diff --git a/nix/packages/hive-forge-tools.nix b/nix/packages/hive-forge-tools.nix new file mode 100644 index 00000000..b5e40a35 --- /dev/null +++ b/nix/packages/hive-forge-tools.nix @@ -0,0 +1,250 @@ +{ pkgs, lib }: +# Single `hive-forge ` CLI wrapping common Forgejo API operations. +# Reads credentials from the environment: +# +# HIVE_FORGE_URL -- base URL, e.g. http://localhost:3000 +# HIVE_FORGE_REPO -- default repo, e.g. hyperhive/hyperhive +# HYPERHIVE_STATE_DIR -- state dir; forge-token lives here +# +# Usage: hive-forge [args...] +# Verbs: view, issue, pr, comment, assign, close, labels, pr-reviews, +# branches, tree-sha +pkgs.writeShellApplication { + name = "hive-forge"; + runtimeInputs = [ + pkgs.curl + pkgs.jq + ]; + text = '' + : "''${HIVE_FORGE_URL:=http://localhost:3000}" + : "''${HIVE_FORGE_REPO:=hyperhive/hyperhive}" + _state_dir="''${HYPERHIVE_STATE_DIR:-}" + _token_file="''${_state_dir:+$_state_dir/}forge-token" + if [ ! -f "$_token_file" ]; then + echo "hive-forge: no forge-token at $_token_file" >&2 + exit 1 + fi + _token=$(cat "$_token_file") + FORGE_API="$HIVE_FORGE_URL/api/v1" + + forge_get() { + ${pkgs.curl}/bin/curl -sf \ + -H "Authorization: token $_token" \ + -H "Accept: application/json" \ + "$1" + } + forge_post() { + ${pkgs.curl}/bin/curl -sf -X POST \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$2" "$1" + } + forge_patch() { + ${pkgs.curl}/bin/curl -sf -X PATCH \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$2" "$1" + } + forge_delete() { + local _url="$1" + local _body="''${2:-}" + if [ -n "$_body" ]; then + ${pkgs.curl}/bin/curl -sf -X DELETE \ + -H "Authorization: token $_token" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$_body" "$_url" + else + ${pkgs.curl}/bin/curl -sf -X DELETE \ + -H "Authorization: token $_token" \ + -H "Accept: application/json" \ + "$_url" + fi + } + + cmd_view() { + # view [repo] + # Dump title + body + all comments for an issue or PR. + if [ $# -lt 1 ]; then echo "usage: hive-forge view [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + local _issue _title _body _state _user _is_pr _kind + _issue=$(forge_get "$FORGE_API/repos/$_repo/issues/$_n") + _title=$(printf '%s' "$_issue" | jq -r '.title') + _body=$(printf '%s' "$_issue" | jq -r '.body // ""') + _state=$(printf '%s' "$_issue" | jq -r '.state') + _user=$(printf '%s' "$_issue" | jq -r '.user.login') + _is_pr=$(printf '%s' "$_issue" | jq -r '.pull_request != null') + _kind="issue" + if [ "$_is_pr" = "true" ]; then _kind="PR"; fi + printf '# %s #%s [%s] by %s\n' "$_kind" "$_n" "$_state" "$_user" + printf '%s\n' "$_title" + if [ -n "$_body" ]; then printf '\n%s\n' "$_body"; fi + local _comments _count + _comments=$(forge_get "$FORGE_API/repos/$_repo/issues/$_n/comments?limit=50") + _count=$(printf '%s' "$_comments" | jq 'length') + if [ "$_count" -gt 0 ]; then + printf '\n---\n## Comments (%s)\n\n' "$_count" + printf '%s' "$_comments" | jq -r '.[] | "**\(.user.login)**: \(.body)\n"' + fi + } + + cmd_issue() { + # issue [repo] + # Print key fields of an issue as JSON. + if [ $# -lt 1 ]; then echo "usage: hive-forge issue [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + forge_get "$FORGE_API/repos/$_repo/issues/$_n" \ + | jq '{number,title,state,user:.user.login,assignees:[.assignees[]?.login],labels:[.labels[]?.name],body}' + } + + cmd_pr() { + # pr [repo] + # Print key fields of a PR as JSON. + if [ $# -lt 1 ]; then echo "usage: hive-forge pr [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + forge_get "$FORGE_API/repos/$_repo/pulls/$_n" \ + | jq '{number,title,state,merged,user:.user.login,head_sha:.head.sha,head_branch:.head.label,base_branch:.base.label}' + } + + cmd_comment() { + # comment [body | -f | - for stdin] + # Post a comment on an issue or PR. + if [ $# -lt 1 ]; then echo "usage: hive-forge comment [body | -f | -]" >&2; exit 1; fi + local _n="$1"; shift + local _body + if [ $# -eq 0 ] || [ "''${1:-}" = "-" ]; then + _body=$(cat) + elif [ "''${1:-}" = "-f" ]; then + _body=$(cat "$2") + else + _body="$*" + fi + local _payload + _payload=$(jq -n --arg body "$_body" '{body:$body}') + forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/comments" "$_payload" \ + | jq '{id,url:.html_url}' + } + + cmd_assign() { + # assign [--remove] + # Assign or unassign a user on an issue or PR. + if [ $# -lt 2 ]; then echo "usage: hive-forge assign [--remove]" >&2; exit 1; fi + local _n="$1" _user="$2" _remove="''${3:-}" + local _payload + _payload=$(jq -n --arg u "$_user" '{assignees:[$u]}') + if [ "$_remove" = "--remove" ]; then + forge_delete "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \ + | jq '{number,assignees:[.assignees[]?.login]}' + else + forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \ + | jq '{number,assignees:[.assignees[]?.login]}' + fi + } + + cmd_close() { + # close + # Close an issue or PR. + if [ $# -lt 1 ]; then echo "usage: hive-forge close " >&2; exit 1; fi + forge_patch "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$1" '{"state":"closed"}' \ + | jq '{number,state}' + } + + cmd_labels() { + # labels [list|add|remove] [labels...] + # Manage labels on an issue or PR. Default action is list. + if [ $# -lt 1 ]; then echo "usage: hive-forge labels [list|add|remove] [labels...]" >&2; exit 1; fi + local _n="$1" _action="''${2:-list}" + shift; shift || true + local _all _ids _id _label + case "$_action" in + list) + forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" | jq '[.[].name]' + ;; + add) + _all=$(forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/labels?limit=100") + _ids=$(printf '%s' "$_all" | jq -r --args '[.[] | select(.name == ($ARGS.positional[])) | .id]' -- "$@") + forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" "{\"labels\":$_ids}" \ + | jq '[.[].name]' + ;; + remove) + _all=$(forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/labels?limit=100") + for _label in "$@"; do + _id=$(printf '%s' "$_all" | jq -r --arg n "$_label" '.[] | select(.name==$n) | .id') + if [ -n "$_id" ]; then + forge_delete "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels/$_id" + fi + done + forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" | jq '[.[].name]' + ;; + *) + echo "unknown action: $_action (use list, add, remove)" >&2; exit 1 + ;; + esac + } + + cmd_pr_reviews() { + # pr-reviews [repo] + # List reviews on a PR with id/state/user/body. + if [ $# -lt 1 ]; then echo "usage: hive-forge pr-reviews [repo]" >&2; exit 1; fi + local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + forge_get "$FORGE_API/repos/$_repo/pulls/$_n/reviews" \ + | jq '[.[] | {id,state,user:.user.login,body,comments_count}]' + } + + cmd_branches() { + # branches [pattern] [repo] + # List branches, optionally filtered by a grep pattern. + local _pattern="''${1:-}" _repo="''${2:-$HIVE_FORGE_REPO}" + local _result + _result=$(forge_get "$FORGE_API/repos/$_repo/branches?limit=100" | jq -r '.[].name') + if [ -n "$_pattern" ]; then + printf '%s\n' "$_result" | grep "$_pattern" || true + else + printf '%s\n' "$_result" + fi + } + + cmd_tree_sha() { + # tree-sha [repo] + # Print the tree SHA of the commit at the given branch or commit SHA. + if [ $# -lt 1 ]; then echo "usage: hive-forge tree-sha [repo]" >&2; exit 1; fi + local _ref="$1" _repo="''${2:-$HIVE_FORGE_REPO}" + local _commit_sha + _commit_sha=$(forge_get "$FORGE_API/repos/$_repo/branches/$_ref" 2>/dev/null \ + | jq -r '.commit.id // empty') || true + if [ -z "$_commit_sha" ]; then + _commit_sha="$_ref" + fi + forge_get "$FORGE_API/repos/$_repo/git/commits/$_commit_sha" \ + | jq -r '.tree.sha // .sha' + } + + VERB="''${1:-}" + if [ -z "$VERB" ]; then + echo "usage: hive-forge [args...]" >&2 + echo "verbs: view, issue, pr, comment, assign, close, labels, pr-reviews, branches, tree-sha" >&2 + exit 1 + fi + shift + + case "$VERB" in + view) cmd_view "$@" ;; + issue) cmd_issue "$@" ;; + pr) cmd_pr "$@" ;; + comment) cmd_comment "$@" ;; + assign) cmd_assign "$@" ;; + close) cmd_close "$@" ;; + labels) cmd_labels "$@" ;; + pr-reviews) cmd_pr_reviews "$@" ;; + branches) cmd_branches "$@" ;; + tree-sha) cmd_tree_sha "$@" ;; + *) + echo "hive-forge: unknown verb '$VERB'" >&2 + echo "verbs: view, issue, pr, comment, assign, close, labels, pr-reviews, branches, tree-sha" >&2 + exit 1 + ;; + esac + ''; +} diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 9ec301bd..fa339f26 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -248,7 +248,10 @@ # `nix flake` constantly (devshells, ad-hoc evals, fetching their # own MCP-server flakes); without this they hit the "experimental # feature not enabled" wall on the first try. - nix.settings.experimental-features = [ "nix-command" "flakes" ]; + nix.settings.experimental-features = [ + "nix-command" + "flakes" + ]; # `claude-code` is unfree. Each per-agent container's nixosConfiguration # evaluates its own `nixpkgs` instance, so the operator's host-level @@ -273,6 +276,9 @@ jq # curl: HTTP client for forge REST API and other web requests. curl + # hive-forge : CLI wrapping common Forgejo REST API operations + # (view, pr, issue, comment, assign, close, labels, branches, etc.) + (pkgs.callPackage ../packages/hive-forge-tools.nix { }) ]; # One-shot: write tea's config.yml from the seeded forge token so @@ -294,57 +300,57 @@ pkgs.coreutils ]; script = '' - # No `set -e`: any subshell failure must not propagate. - # A failed unit aborts `nixos-container update` which blocks rebuilds. - FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} - # Manager bind-mounts state at /state; sub-agents at - # /agents//state. Glob both — each container only sees - # its own mount, so there is exactly one hit (or zero when - # the forge hasn't been seeded yet). - TOKEN_FILE="" - for f in /state/forge-token /agents/*/state/forge-token; do - if [ -f "$f" ]; then - TOKEN_FILE="$f" - break - fi - done - if [ -z "$TOKEN_FILE" ]; then - echo "tea-login: no forge-token found; skipping" - exit 0 - fi - TOKEN=$(cat "$TOKEN_FILE") - # Resolve the agent username from the forge API. - USER=$(curl -sf --max-time 5 \ - -H "Authorization: token $TOKEN" \ - "$FORGE_URL/api/v1/user" \ - | python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \ - 2>/dev/null || true) - if [ -z "$USER" ]; then - echo "tea-login: could not resolve username from forge API; skipping" - exit 0 - fi - # tea reads config from $HOME/.config/tea/config.yml. - # Write it directly so we control default:true and always - # refresh a rotated token — no 'tea login add' interactive dance. - CONFIG="$HOME/.config/tea/config.yml" - mkdir -p "$(dirname "$CONFIG")" || true - cat > "$CONFIG" << EOF -logins: - - name: forge - url: $FORGE_URL - token: $TOKEN - default: true - ssh_host: "" - ssh_key: "" - insecure: false - ssh_agent: false - user: $USER -preferences: - editor: false - flag_defaults: - remote: "" -EOF - echo "tea-login: configured for $FORGE_URL as $USER" + # No `set -e`: any subshell failure must not propagate. + # A failed unit aborts `nixos-container update` which blocks rebuilds. + FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} + # Manager bind-mounts state at /state; sub-agents at + # /agents//state. Glob both — each container only sees + # its own mount, so there is exactly one hit (or zero when + # the forge hasn't been seeded yet). + TOKEN_FILE="" + for f in /state/forge-token /agents/*/state/forge-token; do + if [ -f "$f" ]; then + TOKEN_FILE="$f" + break + fi + done + if [ -z "$TOKEN_FILE" ]; then + echo "tea-login: no forge-token found; skipping" + exit 0 + fi + TOKEN=$(cat "$TOKEN_FILE") + # Resolve the agent username from the forge API. + USER=$(curl -sf --max-time 5 \ + -H "Authorization: token $TOKEN" \ + "$FORGE_URL/api/v1/user" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \ + 2>/dev/null || true) + if [ -z "$USER" ]; then + echo "tea-login: could not resolve username from forge API; skipping" + exit 0 + fi + # tea reads config from $HOME/.config/tea/config.yml. + # Write it directly so we control default:true and always + # refresh a rotated token — no 'tea login add' interactive dance. + CONFIG="$HOME/.config/tea/config.yml" + mkdir -p "$(dirname "$CONFIG")" || true + cat > "$CONFIG" << EOF + logins: + - name: forge + url: $FORGE_URL + token: $TOKEN + default: true + ssh_host: "" + ssh_key: "" + insecure: false + ssh_agent: false + user: $USER + preferences: + editor: false + flag_defaults: + remote: "" + EOF + echo "tea-login: configured for $FORGE_URL as $USER" ''; };