From 0a4cde88b0211fde4633b6ba5a3e4267851c012e Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 20 May 2026 21:39:34 +0200 Subject: [PATCH] add hive-forge-tools: shell wrappers for common forge API operations --- nix/packages/hive-forge-tools.nix | 272 ++++++++++++++++++++++++++++++ nix/templates/harness-base.nix | 4 + 2 files changed, 276 insertions(+) create mode 100644 nix/packages/hive-forge-tools.nix diff --git a/nix/packages/hive-forge-tools.nix b/nix/packages/hive-forge-tools.nix new file mode 100644 index 0000000..d8c8d6b --- /dev/null +++ b/nix/packages/hive-forge-tools.nix @@ -0,0 +1,272 @@ +{ pkgs, lib }: +# Small shell wrappers that replace ad-hoc curl pipelines for +# common Forgejo API operations. Every script 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 +# +# All scripts exit non-zero on HTTP errors and print diagnostics +# to stderr. +let + # Common prologue injected into every script: reads token and + # defines forge_get / forge_post / forge_patch / forge_delete. + commonPrologue = '' + : "''${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 + } + ''; + + mkScript = name: text: + pkgs.writeShellApplication { + inherit name; + runtimeInputs = [ pkgs.curl pkgs.jq ]; + text = commonPrologue + "\n" + text; + }; +in +pkgs.symlinkJoin { + name = "hive-forge-tools"; + paths = [ + + # hive-forge-view [repo] + # Dump title + body + all comments for an issue or PR. + (mkScript "hive-forge-view" '' + if [ $# -lt 1 ]; then + echo "usage: hive-forge-view [repo]" >&2; exit 1 + fi + _n="$1" + _repo="''${2:-$HIVE_FORGE_REPO}" + + _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 + + _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 + '') + + # hive-forge-issue [repo] + # Print key fields of an issue as JSON. + (mkScript "hive-forge-issue" '' + if [ $# -lt 1 ]; then + echo "usage: hive-forge-issue [repo]" >&2; exit 1 + fi + _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}' + '') + + # hive-forge-pr [repo] + # Print key fields of a PR as JSON. + (mkScript "hive-forge-pr" '' + if [ $# -lt 1 ]; then + echo "usage: hive-forge-pr [repo]" >&2; exit 1 + fi + _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}' + '') + + # hive-forge-comment [body | -f | - for stdin] + # Post a comment on an issue or PR. Body can be passed as: + # - trailing args (joined with spaces) + # - -f to read from a file + # - - or no args to read from stdin + (mkScript "hive-forge-comment" '' + if [ $# -lt 1 ]; then + echo "usage: hive-forge-comment [body | -f | -]" >&2; exit 1 + fi + _n="$1"; shift + + if [ $# -eq 0 ] || [ "''${1:-}" = "-" ]; then + _body=$(cat) + elif [ "''${1:-}" = "-f" ]; then + _body=$(cat "$2") + else + _body="$*" + fi + + _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}' + '') + + # hive-forge-assign [--remove] + # Assign or unassign a user on an issue or PR. + (mkScript "hive-forge-assign" '' + if [ $# -lt 2 ]; then + echo "usage: hive-forge-assign [--remove]" >&2; exit 1 + fi + _n="$1"; _user="$2"; _remove="''${3:-}" + + _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 + '') + + # hive-forge-close + # Close an issue or PR. + (mkScript "hive-forge-close" '' + 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}' + '') + + # hive-forge-labels [list | add | remove ] + # Manage labels on an issue or PR. Default action is list. + (mkScript "hive-forge-labels" '' + if [ $# -lt 1 ]; then + echo "usage: hive-forge-labels [list|add|remove] [labels...]" >&2; exit 1 + fi + _n="$1"; _action="''${2:-list}" + shift; shift || true + + 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 + '') + + # hive-forge-pr-reviews [repo] + # List reviews on a PR with id/state/user/body. + (mkScript "hive-forge-pr-reviews" '' + if [ $# -lt 1 ]; then + echo "usage: hive-forge-pr-reviews [repo]" >&2; exit 1 + fi + _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}]' + '') + + # hive-forge-branches [pattern] [repo] + # List branches, optionally filtered by a grep pattern. + (mkScript "hive-forge-branches" '' + _pattern="''${1:-}" + _repo="''${2:-$HIVE_FORGE_REPO}" + _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 + '') + + # hive-forge-tree-sha [repo] + # Print the tree SHA of the commit at the given branch or commit SHA. + (mkScript "hive-forge-tree-sha" '' + if [ $# -lt 1 ]; then + echo "usage: hive-forge-tree-sha [repo]" >&2; exit 1 + fi + _ref="$1" + _repo="''${2:-$HIVE_FORGE_REPO}" + # Resolve branch -> commit SHA first, then get the tree SHA + # from the git commit object (tree.sha field). + _commit_sha=$(forge_get "$FORGE_API/repos/$_repo/branches/$_ref" 2>/dev/null \ + | jq -r '.commit.id // empty') || true + if [ -z "$_commit_sha" ]; then + # Assume it's already a commit SHA + _commit_sha="$_ref" + fi + forge_get "$FORGE_API/repos/$_repo/git/commits/$_commit_sha" \ + | jq -r '.tree.sha // .sha' + '') + + ]; +} diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 9ec301b..402e26d 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -273,6 +273,10 @@ jq # curl: HTTP client for forge REST API and other web requests. curl + # hive-forge-*: shell wrappers around the Forgejo REST API + # (hive-forge-view, hive-forge-pr, hive-forge-comment, etc.) + # that replace ad-hoc curl pipelines in agent turns. + (pkgs.callPackage ../packages/hive-forge-tools.nix { }) ]; # One-shot: write tea's config.yml from the seeded forge token so