ceremony/lib/forge-github.sh
cluade-reviewer-andresmgsl 714a2e0413 feat(forge): the reconciler verb surface on both backends
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:

1. Adding labels takes NAMES; removing one takes a numeric ID.
     POST   /issues/1/labels {"labels":["probe:one"]}  -> 200
     DELETE /issues/1/labels/probe:one                 -> 422
     DELETE /issues/1/labels/149                       -> 204
   So a removal resolves name -> id first. gh hides this; the shim cannot.

2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
   whole list and {"assignees":[]} clears it. --remove-assignee is therefore
   a read-modify-write, not a delete.

3. There is no statusCheckRollup. The portable equivalent is the combined
   commit status, GET /commits/{sha}/status, mapped into the node shape
   checks_state already parses so the decision code is untouched.

gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).

Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.

Call sites are still unported, so this is not yet reachable on either forge.

Refs #188
2026-08-02 19:13:14 +00:00

117 lines
4.4 KiB
Bash

#!/usr/bin/env bash
# lib/forge-github.sh — the GitHub backend (issue #188, term 1). Sourced by
# lib/forge.sh when forge_detect says github; never at the same time as the
# forgejo backend — they define the same verbs on purpose.
#
# This file is the CURRENT call set, extracted 1:1 and nothing more. Term 5
# of the frozen Spec is "GitHub consumers are unchanged", and the cheapest
# way to keep that true is for every verb here to be a thin pass-through to
# the `gh` invocation the call site used before the port. No behaviour is
# added, fixed or tidied on this path; anything that looks like an
# improvement here is a regression risk against a forge nobody is currently
# reporting bugs on.
# forge_api [--paginate] <endpoint> [--jq <expr>]
#
# The one deliberate difference from a pure pass-through: the caller no
# longer names a page size, because the page-size parameter is not portable
# and is therefore the backend's to own (#188).
#
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
#
# Both answer HTTP 200 either way, so a call site that names one is a silent
# truncation waiting for the other forge. per_page=100 is injected here —
# exactly what the call sites said before — so the GitHub path is unchanged
# in behaviour while the parameter stops being a call-site concern.
forge_api() {
local paginate=false endpoint="" jqexpr="" have_jq=false
while [ $# -gt 0 ]; do
case "$1" in
--paginate) paginate=true ;;
--jq) jqexpr="$2"; have_jq=true; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
if [ "$paginate" = true ]; then
endpoint="$(github_page_url "$endpoint")"
if [ "$have_jq" = true ]; then
gh api --paginate "$endpoint" --jq "$jqexpr"
else
gh api --paginate "$endpoint"
fi
else
if [ "$have_jq" = true ]; then
gh api "$endpoint" --jq "$jqexpr"
else
gh api "$endpoint"
fi
fi
}
# github_page_url <endpoint> — pure, so the page-size contract is testable
# without a network. Strips any page-size parameter a caller left behind in
# either dialect, then applies GitHub's own.
github_page_url() {
local endpoint="${1:?github_page_url: endpoint required}" clean
clean="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
case "$clean" in
*\?) printf '%sper_page=100\n' "$clean" ;;
*\?*) printf '%s&per_page=100\n' "$clean" ;;
*) printf '%s?per_page=100\n' "$clean" ;;
esac
}
# --- the verbs the reconcilers use, extracted 1:1 -------------------------
# Every one of these is the exact `gh` invocation the call site carried
# before the port. Term 5 is kept by making this file boring.
# forge_issue_edit <n> <gh-style flags…> — labels and assignees on an issue
# or a PR (gh treats them interchangeably, and so do the call sites).
forge_issue_edit() {
local n="${1:?forge_issue_edit: number required}"
shift
gh issue edit "$n" -R "$REPO" "$@"
}
# forge_issue_comment <n> <body>
forge_issue_comment() {
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
gh issue comment "$n" -R "$REPO" --body "$body"
}
# forge_pr_list — open PR numbers, one per line. Note this used
# `gh pr list --limit 100`: a page size in gh's OWN flag namespace, which no
# URL-parameter strip could have caught, so it moves behind the shim with
# the rest (#188).
forge_pr_list() {
gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'
}
# forge_pr_view <n> — {mergeable, statusCheckRollup} as JSON, or non-zero
# with the reason on stderr. `gh pr view` rather than the REST PR object:
# the API's `mergeable` is a tri-state boolean GitHub computes lazily, while
# this returns the MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
forge_pr_view() {
local n="${1:?forge_pr_view: number required}"
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup
}
# forge_label_list — every label name in the repo.
forge_label_list() {
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'
}
forge_label_create() {
local name="${1:?}" color="${2:?}" desc="${3:-}"
gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
}
forge_label_delete() {
local name="${1:?}"
gh label delete "$name" -R "$REPO" --yes
}