hive-forge: single command with subverbs instead of per-verb scripts

This commit is contained in:
damocles 2026-05-20 21:43:26 +02:00 committed by Mara
parent 0a4cde88b0
commit a95ca22b49
2 changed files with 165 additions and 185 deletions

View file

@ -1,18 +1,21 @@
{ pkgs, lib }: { pkgs, lib }:
# Small shell wrappers that replace ad-hoc curl pipelines for # Single `hive-forge <verb>` CLI wrapping common Forgejo API operations.
# common Forgejo API operations. Every script reads credentials # Reads credentials from the environment:
# from the environment:
# #
# HIVE_FORGE_URL -- base URL, e.g. http://localhost:3000 # HIVE_FORGE_URL -- base URL, e.g. http://localhost:3000
# HIVE_FORGE_REPO -- default repo, e.g. hyperhive/hyperhive # HIVE_FORGE_REPO -- default repo, e.g. hyperhive/hyperhive
# HYPERHIVE_STATE_DIR -- state dir; forge-token lives here # HYPERHIVE_STATE_DIR -- state dir; forge-token lives here
# #
# All scripts exit non-zero on HTTP errors and print diagnostics # Usage: hive-forge <verb> [args...]
# to stderr. # Verbs: view, issue, pr, comment, assign, close, labels, pr-reviews,
let # branches, tree-sha
# Common prologue injected into every script: reads token and pkgs.writeShellApplication {
# defines forge_get / forge_post / forge_patch / forge_delete. name = "hive-forge";
commonPrologue = '' runtimeInputs = [
pkgs.curl
pkgs.jq
];
text = ''
: "''${HIVE_FORGE_URL:=http://localhost:3000}" : "''${HIVE_FORGE_URL:=http://localhost:3000}"
: "''${HIVE_FORGE_REPO:=hyperhive/hyperhive}" : "''${HIVE_FORGE_REPO:=hyperhive/hyperhive}"
_state_dir="''${HYPERHIVE_STATE_DIR:-}" _state_dir="''${HYPERHIVE_STATE_DIR:-}"
@ -30,7 +33,6 @@ let
-H "Accept: application/json" \ -H "Accept: application/json" \
"$1" "$1"
} }
forge_post() { forge_post() {
${pkgs.curl}/bin/curl -sf -X POST \ ${pkgs.curl}/bin/curl -sf -X POST \
-H "Authorization: token $_token" \ -H "Authorization: token $_token" \
@ -38,7 +40,6 @@ let
-H "Accept: application/json" \ -H "Accept: application/json" \
-d "$2" "$1" -d "$2" "$1"
} }
forge_patch() { forge_patch() {
${pkgs.curl}/bin/curl -sf -X PATCH \ ${pkgs.curl}/bin/curl -sf -X PATCH \
-H "Authorization: token $_token" \ -H "Authorization: token $_token" \
@ -46,7 +47,6 @@ let
-H "Accept: application/json" \ -H "Accept: application/json" \
-d "$2" "$1" -d "$2" "$1"
} }
forge_delete() { forge_delete() {
local _url="$1" local _url="$1"
local _body="''${2:-}" local _body="''${2:-}"
@ -63,87 +63,57 @@ let
"$_url" "$_url"
fi fi
} }
'';
mkScript = name: text: cmd_view() {
pkgs.writeShellApplication { # view <number> [repo]
inherit name;
runtimeInputs = [ pkgs.curl pkgs.jq ];
text = commonPrologue + "\n" + text;
};
in
pkgs.symlinkJoin {
name = "hive-forge-tools";
paths = [
# hive-forge-view <number> [repo]
# Dump title + body + all comments for an issue or PR. # Dump title + body + all comments for an issue or PR.
(mkScript "hive-forge-view" '' if [ $# -lt 1 ]; then echo "usage: hive-forge view <number> [repo]" >&2; exit 1; fi
if [ $# -lt 1 ]; then local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}"
echo "usage: hive-forge-view <number> [repo]" >&2; exit 1 local _issue _title _body _state _user _is_pr _kind
fi
_n="$1"
_repo="''${2:-$HIVE_FORGE_REPO}"
_issue=$(forge_get "$FORGE_API/repos/$_repo/issues/$_n") _issue=$(forge_get "$FORGE_API/repos/$_repo/issues/$_n")
_title=$(printf '%s' "$_issue" | jq -r '.title') _title=$(printf '%s' "$_issue" | jq -r '.title')
_body=$(printf '%s' "$_issue" | jq -r '.body // ""') _body=$(printf '%s' "$_issue" | jq -r '.body // ""')
_state=$(printf '%s' "$_issue" | jq -r '.state') _state=$(printf '%s' "$_issue" | jq -r '.state')
_user=$(printf '%s' "$_issue" | jq -r '.user.login') _user=$(printf '%s' "$_issue" | jq -r '.user.login')
_is_pr=$(printf '%s' "$_issue" | jq -r '.pull_request != null') _is_pr=$(printf '%s' "$_issue" | jq -r '.pull_request != null')
_kind="issue" _kind="issue"
if [ "$_is_pr" = "true" ]; then _kind="PR"; fi if [ "$_is_pr" = "true" ]; then _kind="PR"; fi
printf '# %s #%s [%s] by %s\n' "$_kind" "$_n" "$_state" "$_user" printf '# %s #%s [%s] by %s\n' "$_kind" "$_n" "$_state" "$_user"
printf '%s\n' "$_title" printf '%s\n' "$_title"
if [ -n "$_body" ]; then if [ -n "$_body" ]; then printf '\n%s\n' "$_body"; fi
printf '\n%s\n' "$_body" local _comments _count
fi
_comments=$(forge_get "$FORGE_API/repos/$_repo/issues/$_n/comments?limit=50") _comments=$(forge_get "$FORGE_API/repos/$_repo/issues/$_n/comments?limit=50")
_count=$(printf '%s' "$_comments" | jq 'length') _count=$(printf '%s' "$_comments" | jq 'length')
if [ "$_count" -gt 0 ]; then if [ "$_count" -gt 0 ]; then
printf '\n---\n## Comments (%s)\n\n' "$_count" printf '\n---\n## Comments (%s)\n\n' "$_count"
printf '%s' "$_comments" | jq -r '.[] | "**\(.user.login)**: \(.body)\n"' printf '%s' "$_comments" | jq -r '.[] | "**\(.user.login)**: \(.body)\n"'
fi fi
'') }
# hive-forge-issue <number> [repo] cmd_issue() {
# issue <number> [repo]
# Print key fields of an issue as JSON. # Print key fields of an issue as JSON.
(mkScript "hive-forge-issue" '' if [ $# -lt 1 ]; then echo "usage: hive-forge issue <number> [repo]" >&2; exit 1; fi
if [ $# -lt 1 ]; then local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}"
echo "usage: hive-forge-issue <number> [repo]" >&2; exit 1
fi
_n="$1"
_repo="''${2:-$HIVE_FORGE_REPO}"
forge_get "$FORGE_API/repos/$_repo/issues/$_n" \ forge_get "$FORGE_API/repos/$_repo/issues/$_n" \
| jq '{number,title,state,user:.user.login,assignees:[.assignees[]?.login],labels:[.labels[]?.name],body}' | jq '{number,title,state,user:.user.login,assignees:[.assignees[]?.login],labels:[.labels[]?.name],body}'
'') }
# hive-forge-pr <number> [repo] cmd_pr() {
# pr <number> [repo]
# Print key fields of a PR as JSON. # Print key fields of a PR as JSON.
(mkScript "hive-forge-pr" '' if [ $# -lt 1 ]; then echo "usage: hive-forge pr <number> [repo]" >&2; exit 1; fi
if [ $# -lt 1 ]; then local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}"
echo "usage: hive-forge-pr <number> [repo]" >&2; exit 1
fi
_n="$1"
_repo="''${2:-$HIVE_FORGE_REPO}"
forge_get "$FORGE_API/repos/$_repo/pulls/$_n" \ 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}' | jq '{number,title,state,merged,user:.user.login,head_sha:.head.sha,head_branch:.head.label,base_branch:.base.label}'
'') }
# hive-forge-comment <number> [body | -f <file> | - for stdin]
# Post a comment on an issue or PR. Body can be passed as:
# - trailing args (joined with spaces)
# - -f <file> 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 <number> [body | -f <file> | -]" >&2; exit 1
fi
_n="$1"; shift
cmd_comment() {
# comment <number> [body | -f <file> | - for stdin]
# Post a comment on an issue or PR.
if [ $# -lt 1 ]; then echo "usage: hive-forge comment <number> [body | -f <file> | -]" >&2; exit 1; fi
local _n="$1"; shift
local _body
if [ $# -eq 0 ] || [ "''${1:-}" = "-" ]; then if [ $# -eq 0 ] || [ "''${1:-}" = "-" ]; then
_body=$(cat) _body=$(cat)
elif [ "''${1:-}" = "-f" ]; then elif [ "''${1:-}" = "-f" ]; then
@ -151,20 +121,18 @@ pkgs.symlinkJoin {
else else
_body="$*" _body="$*"
fi fi
local _payload
_payload=$(jq -n --arg body "$_body" '{body:$body}') _payload=$(jq -n --arg body "$_body" '{body:$body}')
forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/comments" "$_payload" \ forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/comments" "$_payload" \
| jq '{id,url:.html_url}' | jq '{id,url:.html_url}'
'') }
# hive-forge-assign <number> <user> [--remove] cmd_assign() {
# assign <number> <user> [--remove]
# Assign or unassign a user on an issue or PR. # Assign or unassign a user on an issue or PR.
(mkScript "hive-forge-assign" '' if [ $# -lt 2 ]; then echo "usage: hive-forge assign <number> <user> [--remove]" >&2; exit 1; fi
if [ $# -lt 2 ]; then local _n="$1" _user="$2" _remove="''${3:-}"
echo "usage: hive-forge-assign <number> <user> [--remove]" >&2; exit 1 local _payload
fi
_n="$1"; _user="$2"; _remove="''${3:-}"
_payload=$(jq -n --arg u "$_user" '{assignees:[$u]}') _payload=$(jq -n --arg u "$_user" '{assignees:[$u]}')
if [ "$_remove" = "--remove" ]; then if [ "$_remove" = "--remove" ]; then
forge_delete "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \ forge_delete "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \
@ -173,31 +141,26 @@ pkgs.symlinkJoin {
forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \ forge_post "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/assignees" "$_payload" \
| jq '{number,assignees:[.assignees[]?.login]}' | jq '{number,assignees:[.assignees[]?.login]}'
fi fi
'') }
# hive-forge-close <number> cmd_close() {
# close <number>
# Close an issue or PR. # Close an issue or PR.
(mkScript "hive-forge-close" '' if [ $# -lt 1 ]; then echo "usage: hive-forge close <number>" >&2; exit 1; fi
if [ $# -lt 1 ]; then
echo "usage: hive-forge-close <number>" >&2; exit 1
fi
forge_patch "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$1" '{"state":"closed"}' \ forge_patch "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$1" '{"state":"closed"}' \
| jq '{number,state}' | jq '{number,state}'
'') }
# hive-forge-labels <number> [list | add <label...> | remove <label...>] cmd_labels() {
# labels <number> [list|add|remove] [labels...]
# Manage labels on an issue or PR. Default action is list. # Manage labels on an issue or PR. Default action is list.
(mkScript "hive-forge-labels" '' if [ $# -lt 1 ]; then echo "usage: hive-forge labels <number> [list|add|remove] [labels...]" >&2; exit 1; fi
if [ $# -lt 1 ]; then local _n="$1" _action="''${2:-list}"
echo "usage: hive-forge-labels <number> [list|add|remove] [labels...]" >&2; exit 1
fi
_n="$1"; _action="''${2:-list}"
shift; shift || true shift; shift || true
local _all _ids _id _label
case "$_action" in case "$_action" in
list) list)
forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" \ forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" | jq '[.[].name]'
| jq '[.[].name]'
;; ;;
add) add)
_all=$(forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/labels?limit=100") _all=$(forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/labels?limit=100")
@ -213,60 +176,75 @@ pkgs.symlinkJoin {
forge_delete "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels/$_id" forge_delete "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels/$_id"
fi fi
done done
forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" \ forge_get "$FORGE_API/repos/$HIVE_FORGE_REPO/issues/$_n/labels" | jq '[.[].name]'
| jq '[.[].name]'
;; ;;
*) *)
echo "unknown action: $_action (use list, add, remove)" >&2; exit 1 echo "unknown action: $_action (use list, add, remove)" >&2; exit 1
;; ;;
esac esac
'') }
# hive-forge-pr-reviews <number> [repo] cmd_pr_reviews() {
# pr-reviews <number> [repo]
# List reviews on a PR with id/state/user/body. # 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 <number> [repo]" >&2; exit 1; fi
if [ $# -lt 1 ]; then local _n="$1" _repo="''${2:-$HIVE_FORGE_REPO}"
echo "usage: hive-forge-pr-reviews <number> [repo]" >&2; exit 1
fi
_n="$1"
_repo="''${2:-$HIVE_FORGE_REPO}"
forge_get "$FORGE_API/repos/$_repo/pulls/$_n/reviews" \ forge_get "$FORGE_API/repos/$_repo/pulls/$_n/reviews" \
| jq '[.[] | {id,state,user:.user.login,body,comments_count}]' | jq '[.[] | {id,state,user:.user.login,body,comments_count}]'
'') }
# hive-forge-branches [pattern] [repo] cmd_branches() {
# branches [pattern] [repo]
# List branches, optionally filtered by a grep pattern. # List branches, optionally filtered by a grep pattern.
(mkScript "hive-forge-branches" '' local _pattern="''${1:-}" _repo="''${2:-$HIVE_FORGE_REPO}"
_pattern="''${1:-}" local _result
_repo="''${2:-$HIVE_FORGE_REPO}" _result=$(forge_get "$FORGE_API/repos/$_repo/branches?limit=100" | jq -r '.[].name')
_result=$(forge_get "$FORGE_API/repos/$_repo/branches?limit=100" \
| jq -r '.[].name')
if [ -n "$_pattern" ]; then if [ -n "$_pattern" ]; then
printf '%s\n' "$_result" | grep "$_pattern" || true printf '%s\n' "$_result" | grep "$_pattern" || true
else else
printf '%s\n' "$_result" printf '%s\n' "$_result"
fi fi
'') }
# hive-forge-tree-sha <ref> [repo] cmd_tree_sha() {
# tree-sha <ref> [repo]
# Print the tree SHA of the commit at the given branch or commit SHA. # 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 <ref> [repo]" >&2; exit 1; fi
if [ $# -lt 1 ]; then local _ref="$1" _repo="''${2:-$HIVE_FORGE_REPO}"
echo "usage: hive-forge-tree-sha <ref> [repo]" >&2; exit 1 local _commit_sha
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 \ _commit_sha=$(forge_get "$FORGE_API/repos/$_repo/branches/$_ref" 2>/dev/null \
| jq -r '.commit.id // empty') || true | jq -r '.commit.id // empty') || true
if [ -z "$_commit_sha" ]; then if [ -z "$_commit_sha" ]; then
# Assume it's already a commit SHA
_commit_sha="$_ref" _commit_sha="$_ref"
fi fi
forge_get "$FORGE_API/repos/$_repo/git/commits/$_commit_sha" \ forge_get "$FORGE_API/repos/$_repo/git/commits/$_commit_sha" \
| jq -r '.tree.sha // .sha' | jq -r '.tree.sha // .sha'
'') }
]; VERB="''${1:-}"
if [ -z "$VERB" ]; then
echo "usage: hive-forge <verb> [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
'';
} }

View file

@ -248,7 +248,10 @@
# `nix flake` constantly (devshells, ad-hoc evals, fetching their # `nix flake` constantly (devshells, ad-hoc evals, fetching their
# own MCP-server flakes); without this they hit the "experimental # own MCP-server flakes); without this they hit the "experimental
# feature not enabled" wall on the first try. # 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 # `claude-code` is unfree. Each per-agent container's nixosConfiguration
# evaluates its own `nixpkgs` instance, so the operator's host-level # evaluates its own `nixpkgs` instance, so the operator's host-level
@ -273,9 +276,8 @@
jq jq
# curl: HTTP client for forge REST API and other web requests. # curl: HTTP client for forge REST API and other web requests.
curl curl
# hive-forge-*: shell wrappers around the Forgejo REST API # hive-forge <verb>: CLI wrapping common Forgejo REST API operations
# (hive-forge-view, hive-forge-pr, hive-forge-comment, etc.) # (view, pr, issue, comment, assign, close, labels, branches, etc.)
# that replace ad-hoc curl pipelines in agent turns.
(pkgs.callPackage ../packages/hive-forge-tools.nix { }) (pkgs.callPackage ../packages/hive-forge-tools.nix { })
]; ];