From 714a2e0413f9e2518b74447a7053abfb5894428b Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Sun, 2 Aug 2026 19:13:14 +0000 Subject: [PATCH] feat(forge): the reconciler verb surface on both backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/forge-forgejo.sh | 159 +++++++++++++++++++++++++++++++++++++++++++ lib/forge-github.sh | 50 ++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/lib/forge-forgejo.sh b/lib/forge-forgejo.sh index 0000e33..fd16e99 100644 --- a/lib/forge-forgejo.sh +++ b/lib/forge-forgejo.sh @@ -213,3 +213,162 @@ forgejo_http_ok() { ;; esac } + +# --- the verbs the reconcilers use, over /api/v1 -------------------------- +# Three asymmetries with gh, all measured against this instance on +# 2026-08-02 using a scratch repo (never a live board): +# +# 1. Adding labels takes NAMES POST /issues/{n}/labels {"labels":["x"]} -> 200 +# Removing one takes a numeric ID DELETE /issues/{n}/labels/x -> 422 +# DELETE /issues/{n}/labels/149 -> 204 +# So a removal must resolve name -> id first. gh hides this; the shim +# cannot. +# +# 2. Assignees are SET, not added and removed. PATCH /issues/{n} takes the +# whole list ({"assignees":[]} clears it, 201), so --remove-assignee is +# a read-modify-write rather than a delete. +# +# 3. There is no statusCheckRollup. The portable equivalent is the +# combined commit status, GET /commits/{sha}/status, which returns +# {state, statuses[]}. + +# forgejo_label_ids — nameid for every label in the repo, read once per +# call site that needs it. Paginated through forge_api, so a repo with more +# than one page of labels cannot silently lose the tail (#188). +forgejo_label_ids() { + forge_api --paginate "repos/$REPO/labels" --jq '.[] | "\(.name)\t\(.id)"' +} + +# forge_issue_edit [--add-label X]… [--remove-label X]… [--add-assignee U]… [--remove-assignee U]… +# gh's flag surface, translated. Accepts comma-separated values, as gh does. +forge_issue_edit() { + local n="${1:?forge_issue_edit: number required}" + shift + local add_labels=() rm_labels=() add_assignees=() rm_assignees=() v + while [ $# -gt 0 ]; do + case "$1" in + --add-label) IFS=, read -ra v <<<"$2"; add_labels+=("${v[@]}"); shift ;; + --remove-label) IFS=, read -ra v <<<"$2"; rm_labels+=("${v[@]}"); shift ;; + --add-assignee) IFS=, read -ra v <<<"$2"; add_assignees+=("${v[@]}"); shift ;; + --remove-assignee) IFS=, read -ra v <<<"$2"; rm_assignees+=("${v[@]}"); shift ;; + *) ;; + esac + shift + done + + if [ "${#add_labels[@]}" -gt 0 ]; then + local payload + payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -s '{labels: .}')" + forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1 + fi + + if [ "${#rm_labels[@]}" -gt 0 ]; then + local ids id name + ids="$(forgejo_label_ids)" || return 1 + for name in "${rm_labels[@]}"; do + id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")" + # A label the repo does not have is not an error: the reconcilers call + # --remove-label unconditionally to converge state, and gh's own + # behaviour there is a no-op. + [ -n "$id" ] || continue + forgejo_write DELETE "repos/$REPO/issues/$n/labels/$id" '' >/dev/null || return 1 + done + fi + + if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then + local current want payload + current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1 + want="$( + { + printf '%s\n' "$current" + [ "${#add_assignees[@]}" -gt 0 ] && printf '%s\n' "${add_assignees[@]}" + } | grep -v '^$' | sort -u + )" + if [ "${#rm_assignees[@]}" -gt 0 ]; then + want="$(grep -vxF -f <(printf '%s\n' "${rm_assignees[@]}") <<<"$want" || true)" + fi + payload="$(printf '%s' "$want" | jq -R . | jq -s '{assignees: [.[] | select(. != "")]}')" + forgejo_write PATCH "repos/$REPO/issues/$n" "$payload" >/dev/null || return 1 + fi +} + +forge_issue_comment() { + local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}" + forgejo_write POST "repos/$REPO/issues/$n/comments" "$(jq -n --arg b "$body" '{body: $b}')" >/dev/null +} + +forge_pr_list() { + forge_api --paginate "repos/$REPO/pulls?state=open" --jq '.[].number' +} + +# forge_pr_view — the {mergeable, statusCheckRollup} shape the state +# machine reads, assembled from the two places Forgejo keeps it. The rollup +# is mapped into the node shape checks_state already parses, so the decision +# code is untouched. +forge_pr_view() { + local n="${1:?forge_pr_view: number required}" pr sha status + pr="$(forge_api "repos/$REPO/pulls/$n")" || return 1 + sha="$(jq -r '.head.sha // ""' <<<"$pr")" + [ -n "$sha" ] || { echo "forge_pr_view: PR $n has no head sha" >&2; return 1; } + status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1 + jq -n --argjson pr "$pr" --argjson st "$status" ' + { + mergeable: (if $pr.mergeable == true then "MERGEABLE" + elif $pr.mergeable == false then "CONFLICTING" + else "UNKNOWN" end), + statusCheckRollup: [ + $st.statuses[]? | { + __typename: "StatusContext", + context: .context, + state: (.status | ascii_upcase) + } + ] + }' +} + +forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; } + +forge_label_create() { + local name="${1:?}" color="${2:?}" desc="${3:-}" + forgejo_write POST "repos/$REPO/labels" \ + "$(jq -n --arg n "$name" --arg c "$color" --arg d "$desc" '{name:$n,color:$c,description:$d}')" >/dev/null +} + +forge_label_delete() { + local name="${1:?}" ids id + ids="$(forgejo_label_ids)" || return 1 + id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")" + [ -n "$id" ] || return 0 + forgejo_write DELETE "repos/$REPO/labels/$id" '' >/dev/null +} + +# forgejo_write — every mutation goes through +# here so a non-2xx is named rather than swallowed, the same contract +# forgejo_http_ok gives reads. +forgejo_write() { + local method="$1" endpoint="$2" payload="$3" base token hdr body rc + base="$(forgejo_api_base)" || return 1 + token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}" + hdr="$(mktemp)"; body="$(mktemp)" + if [ -n "$payload" ]; then + curl -sS -X "$method" -D "$hdr" -o "$body" \ + -H "Authorization: token $token" -H 'Content-Type: application/json' \ + -d "$payload" "$base/$endpoint" + else + curl -sS -X "$method" -D "$hdr" -o "$body" \ + -H "Authorization: token $token" "$base/$endpoint" + fi + rc=$? + if [ "$rc" -ne 0 ]; then + rm -f "$hdr" "$body" + echo "forge: $method $endpoint failed to send" >&2 + return 1 + fi + if ! forgejo_http_ok "$hdr" "$method $endpoint"; then + head -c 300 "$body" >&2; echo >&2 + rm -f "$hdr" "$body" + return 1 + fi + cat "$body" + rm -f "$hdr" "$body" +} diff --git a/lib/forge-github.sh b/lib/forge-github.sh index 82230bd..543a5d7 100644 --- a/lib/forge-github.sh +++ b/lib/forge-github.sh @@ -65,3 +65,53 @@ github_page_url() { *) 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 — 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 +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 — {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 +}