From baf4a20571acbba174277e3578f2f3d351512b1b Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Sun, 2 Aug 2026 19:43:58 +0000 Subject: [PATCH] feat(forge): port every reconciler call site onto the shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Term 1 completed. All 52 runtime gh call sites in the three reconcilers and lib/ruling.sh now go through forge_* verbs; the three remaining matches in labels-reconcile are prose in comments. lib/facts.sh is deliberately untouched — it is the release door, and the ruling keeps release.yml out of this issue. The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the sites they described, so the tree is never in a state where the declaration lies. main() now runs forge_preflight then forge_select "". Two sites needed judgment rather than substitution: - labels-scope's write is forge_labels_add, a genuine additive POST on both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on that write not being a read-modify-PUT: the labeler action computed (labels-at-job-start union derived) and PUT the whole set, silently dropping a label applied while the job ran. Routing it through a generic edit verb would have quietly reopened that. - the human-review request is forge_request_reviewer. Contrary to my earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on Forgejo — 422 naming the reviewer's access without it, 201 with it. The earlier 404 was a GET, which the endpoint does not serve, plus a username that did not exist. Test churn, all of it the term-5 boundary move: - the suites select the github backend, so their existing gh() stubs stay the boundary and keep intercepting; - stubs strip the paging the shim injects, so fixtures stay keyed on the logical endpoint (inlined in the PATH stub, which is a standalone executable and cannot see a shell function); - fixtures renamed off the per_page suffix for the same reason; - recorded-mutation assertions now match the verb, not the raw gh line; - gh() stubs carry SC2317: they are reached through the backend now, so shellcheck can no longer see the call path. Refs #188 --- .../issueflow-reconcile.sh | 55 +++++++++++-------- actions/labels-reconcile/labels-reconcile.sh | 45 +++++++++------ actions/labels-scope/labels-scope.sh | 19 +++++-- lib/forge-forgejo.sh | 31 +++++++++++ lib/forge-github.sh | 19 +++++++ lib/ruling.sh | 14 ++--- test/facts.test.sh | 6 ++ test/harness.sh | 10 ++++ test/issueflow-reconcile.test.sh | 31 ++++++++--- test/labels-reconcile.test.sh | 24 +++++++- test/release-chain.test.sh | 6 ++ test/ruling.test.sh | 8 +++ 12 files changed, 204 insertions(+), 64 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 6f18063..4ece7cc 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -275,12 +275,12 @@ offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET ensure_comment() { # $1 issue, $2 marker, $3 message local n="$1" marker="$2" message="$3" if issue_comment_has_marker "$n" "$marker"; then return; fi - run gh issue comment "$n" -R "$REPO" --body " + run forge_issue_comment "$n" " $message" >/dev/null } issue_comment_has_marker() { # $1 issue, $2 marker - gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ + forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ | grep -qF "" } @@ -288,7 +288,7 @@ reference_states() { local ref state while IFS= read -r ref; do [ -n "$ref" ] || continue - state="$(gh api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)" + state="$(forge_api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)" case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac done } @@ -299,23 +299,23 @@ offsite_pr_states() { [ -n "$ref" ] || continue repo="${ref%#*}" number="${ref##*#}" - state="$(gh api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)" + state="$(forge_api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)" case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac done } offsite_timeline() { # unreadable timelines are deliberately silent - gh api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1 + forge_api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1 } last_issue_activity() { local n="$1" created="$2" latest latest="$({ printf '%s\n' "$created" - gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' + forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' # Assignment is the claim itself. Ignoring it would let an old issue be # reclaimed in the seconds between assignment and its required draft PR. - gh api --paginate "repos/$REPO/issues/$n/timeline" \ + forge_api --paginate "repos/$REPO/issues/$n/timeline" \ --jq '.[] | select(.event == "assigned") | .created_at' } \ | sort | tail -n1)" @@ -329,7 +329,7 @@ reconcile_issue() { decision="$(queue_decision <<<"$ISSUE_LABELS")" case "$decision" in ADD_NEEDS_TRIAGE) - run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null + run forge_issue_edit "$n" --add-label needs-triage >/dev/null log "#$n: needs-triage (no queue state)" ;; FLAG_CONFLICT) ensure_comment "$n" queue-conflict \ @@ -362,10 +362,10 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in # would create an impossible parked-for state (#175 D4). has_issue_label attention && remove_claimed=claimed,attention if [ -n "$owners" ]; then - run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \ + run forge_issue_edit "$n" --remove-assignee "$owners" \ --remove-label "$remove_claimed" --add-label post-merge >/dev/null else - run gh issue edit "$n" -R "$REPO" \ + run forge_issue_edit "$n" \ --remove-label "$remove_claimed" --add-label post-merge >/dev/null fi log "#$n: merged Refs PR -> post-merge; claim released" @@ -391,10 +391,10 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in 'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.' owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")" if [ -n "$owners" ]; then - run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \ + run forge_issue_edit "$n" --remove-assignee "$owners" \ --remove-label claimed --add-label ready >/dev/null else - run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null + run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null fi log "#$n: stale claim reclaimed -> ready" ;; esac @@ -433,7 +433,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in READY) ensure_comment "$n" blockers-cleared \ 'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.' - run gh issue edit "$n" -R "$REPO" --remove-label blocked --add-label ready >/dev/null + run forge_issue_edit "$n" --remove-label blocked --add-label ready >/dev/null log "#$n: blockers closed -> ready" ;; esac elif has_issue_label epic; then @@ -455,7 +455,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in # An already-applied stale comes off: waiting on a human is legitimately # quiet (#50 D10), and nothing on the issue side ever puts stale back. if has_issue_label stale; then - run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null + run forge_issue_edit "$n" --remove-label stale >/dev/null log "#$n: unstale (a ruling is pending)" fi [ -n "${age:-}" ] \ @@ -466,7 +466,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in reconcile_opened_issue() { local n="$1" author triage=false labels remove="" label - ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")" + ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")" # The stand-downs return 0 explicitly: a bare return carries the failed # test's status, which under execution is live `set -e` — and it killed the # run on every triage-authored mint, before one issue was reconciled (#91). @@ -480,9 +480,9 @@ reconcile_opened_issue() { done remove="${remove#,}" if [ -n "$remove" ]; then - run gh issue edit "$n" -R "$REPO" --add-label needs-triage --remove-label "$remove" >/dev/null + run forge_issue_edit "$n" --add-label needs-triage --remove-label "$remove" >/dev/null else - run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null + run forge_issue_edit "$n" --add-label needs-triage >/dev/null fi log "#$n: needs-triage (opened by $author)" } @@ -492,7 +492,16 @@ main() { # Forgejo — but with `line 408: gh: command not found`, which names the # symptom and not the cause, and only after the sibling step had already # reported a green blind sweep. - CEREMONY_FORGE_CLIENT="${CEREMONY_FORGE_CLIENT:-gh}" forge_preflight || return 1 + # The forge is decided once, here, before anything reads the board, and + # the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT + # wrapper that stood here died with the call-site port: it declared "this + # code uses gh", which stopped being true the moment every site went + # through the shim, and leaving it would have defaulted the forgejo path + # into the very client its own preflight refuses. + forge_preflight || return 1 + # "" means decide from the environment; forge_select takes an explicit + # forge only in tests. + forge_select "" || return 1 REPO="${REPO:?set REPO to owner/name}" LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" @@ -502,7 +511,7 @@ main() { fi # owner/name split out here until #188 — the GraphQL query took them as # separate variables. REST takes the owner/name path whole, so it is gone. - # Both gathers were `gh api graphql` until #188. Forgejo has NO GraphQL + # Both gathers were `forge_api graphql` until #188. Forgejo has NO GraphQL # API — a real forgejo-runner job even arrives with GITHUB_GRAPHQL_URL set # to the empty string (probe task 278) — so these could not be translated # to a Forgejo endpoint; there is none. They are REST + a parser this repo @@ -514,7 +523,7 @@ main() { # one line and silently loses every declaration after the first. The old # GraphQL gather sidestepped that with `split("\n")[]`; base64 is the same # protection without needing the split to be correct. - OPEN_PR_ISSUES="$(gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" \ + OPEN_PR_ISSUES="$(forge_api --paginate "repos/$REPO/pulls?state=open&per_page=100" \ --jq '.[] | .body // "" | @base64' \ | while IFS= read -r b64; do [ -n "$b64" ] && printf '%s' "$b64" | base64 -d | closes_references @@ -523,7 +532,7 @@ main() { # meant the CLOSING relation specifically, and reading Refs as closing # would make every referenced issue look closeable — the distinction #151 # was reopened by hand over. - MERGED_REF_PR_RECORDS="$(gh api --paginate "repos/$REPO/pulls?state=closed&per_page=100" \ + MERGED_REF_PR_RECORDS="$(forge_api --paginate "repos/$REPO/pulls?state=closed&per_page=100" \ --jq '.[] | select(.merged_at != null) | "\(.number)\t\(.body // "" | @base64)"' \ | while IFS=$'\t' read -r pr b64; do [ -n "$b64" ] || continue @@ -533,10 +542,10 @@ main() { done)" local n - for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \ + for n in $(forge_api --paginate "repos/$REPO/issues?state=open&per_page=100" \ --jq '.[] | select(has("pull_request") | not) | .number'); do ( - ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")" + ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")" jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0 ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" reconcile_issue "$n" diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 9936f7e..9e4ff13 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -516,7 +516,7 @@ $(configured_label_rows "$LABELS_CONF")" fi while IFS='|' read -r name color desc; do [ -n "$name" ] || continue - run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force + run forge_label_create "$name" "$color" "$desc" done <<<"$rows" # LABELS.md publishes the defaults as deleted at bootstrap; until #93 @@ -530,7 +530,7 @@ $(configured_label_rows "$LABELS_CONF")" # the taxonomy it can create. Either way: log the name, keep going. while IFS= read -r name; do [ -n "$name" ] || continue - run gh label delete "$name" -R "$REPO" --yes \ + run forge_label_delete "$name" \ || log "retire: '$name' not deleted (already absent, or refused) — continuing" done <<<"$(retired_label_names)" } @@ -559,10 +559,10 @@ tree_version() { # $1 = ref → that tree's version via the API, or nothing # Every failure path prints nothing: the caller treats "could not read" # as "not release-shaped" rather than warning on a guess. local ref="$1" ver - ver="$(gh api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \ + ver="$(forge_api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \ | base64 -d 2>/dev/null | tr -d '[:space:]')" if [ -z "$ver" ]; then - ver="$(gh api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \ + ver="$(forge_api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \ | base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)" fi [ -z "$ver" ] || printf '%s\n' "$ver" @@ -583,7 +583,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch # concurrency group in labels.yml. With a comment-only bot on the panel # this path stays cold and the AUTHOR requests the human. if [ "$desired" = state:needs-human ] && human_request_needed; then - run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent + run forge_request_reviewer "$n" "$HUMAN" log "#$n: requested $HUMAN (round passed)" fi @@ -645,7 +645,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then args=(--add-label "$desired${add:+,$add}") [ -n "$remove" ] && args+=(--remove-label "$remove") - if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then + if run forge_issue_edit "$n" "${args[@]}" >/dev/null; then log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}" else # a deleted label must not wedge the sweep — dispatch heals the taxonomy @@ -668,7 +668,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch # the moment the PR is no longer the thing a human should merge next, the # claim is removed. Setting it stays with whoever owns the queue. if has_label merge-next && [ "$desired" != state:needs-human ]; then - run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null + run forge_issue_edit "$n" --remove-label merge-next >/dev/null log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)" fi @@ -677,9 +677,9 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch { jq -r '.created_at' <<<"$PR_JSON" jq -r '.[].submitted_at' <<<"$REVIEWS_JSON" - gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' - gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' - gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' + forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' + forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' + forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' } | sort | tail -n1 )" last_activity_epoch="$(date -d "$last_activity" +%s)" @@ -688,11 +688,11 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch # (#50 D10). The 7-day nudge is #52's, once for both surfaces. if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then if has_label stale; then - run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null + run forge_issue_edit "$n" --remove-label stale >/dev/null log "#$n: unstale" fi elif ! has_label stale; then - run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null + run forge_issue_edit "$n" --add-label stale >/dev/null log "#$n: stale ($((age / 3600))h quiet)" fi @@ -711,7 +711,16 @@ main() { # `gh`, so that is what this declares — honestly, which is the point: on # a Forgejo consumer the preflight refuses here instead of letting the # sweep run blind and print "reconciled." over zero PRs (rig run 979). - CEREMONY_FORGE_CLIENT="${CEREMONY_FORGE_CLIENT:-gh}" forge_preflight || return 1 + # The forge is decided once, here, before anything reads the board, and + # the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT + # wrapper that stood here died with the call-site port: it declared "this + # code uses gh", which stopped being true the moment every site went + # through the shim, and leaving it would have defaulted the forgejo path + # into the very client its own preflight refuses. + forge_preflight || return 1 + # "" means decide from the environment; forge_select takes an explicit + # forge only in tests. + forge_select "" || return 1 REPO="${REPO:?set REPO to owner/name}" LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" @@ -725,7 +734,7 @@ main() { # The repo's label set, read ONCE per sweep — reconcile_pr filters every # add against it, because one unknown name fails the whole edit call. - REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")" + REPO_LABELS="$(forge_label_list 2>/dev/null || echo "")" [ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered" missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS" @@ -736,7 +745,7 @@ main() { status=0 output="$( ( - PR_JSON="$(gh api "repos/$REPO/pulls/$n")" + PR_JSON="$(forge_api "repos/$REPO/pulls/$n")" DRAFT="$(jq -r '.draft' <<<"$PR_JSON")" AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")" set_required_bots "$AUTHOR" @@ -745,7 +754,7 @@ main() { LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")" REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")" # PENDING reviews are unsubmitted drafts in someone's browser — not a verdict - REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \ + REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \ | jq -s '[.[] | select(.state != "PENDING")]')" # mergeability + the check rollup, the two facts the state machine was # blind to (#136). `gh pr view` rather than the REST PR object: the API's @@ -761,7 +770,7 @@ main() { # D2), never left to interleave raw into the per-PR output block, # where an unlucky line could collide with a matched string. GH_VIEW_ERR_FILE="$(mktemp)" - GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>"$GH_VIEW_ERR_FILE" || echo '{}')" + GH_VIEW="$(forge_pr_view "$n" 2>"$GH_VIEW_ERR_FILE" || echo '{}')" GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")" rm -f "$GH_VIEW_ERR_FILE" MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")" @@ -791,7 +800,7 @@ main() { elif [ "$status" -ne 0 ]; then log "#$n: reconcile failed — continuing with the remaining PRs" fi - done < <(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number') + done < <(forge_pr_list) blind_sweep_warning "$unreadable" "$total" "$sampled_reason" log "reconciled." } diff --git a/actions/labels-scope/labels-scope.sh b/actions/labels-scope/labels-scope.sh index f72f711..d61ce4c 100644 --- a/actions/labels-scope/labels-scope.sh +++ b/actions/labels-scope/labels-scope.sh @@ -132,7 +132,16 @@ main() { # quietest of the three: an unreadable mapping and an absent one produced # the same "nothing to derive" no-op, so on Forgejo a PR simply got no # scope labels and nothing said why. - CEREMONY_FORGE_CLIENT="${CEREMONY_FORGE_CLIENT:-gh}" forge_preflight || return 1 + # The forge is decided once, here, before anything reads the board, and + # the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT + # wrapper that stood here died with the call-site port: it declared "this + # code uses gh", which stopped being true the moment every site went + # through the shim, and leaving it would have defaulted the forgejo path + # into the very client its own preflight refuses. + forge_preflight || return 1 + # "" means decide from the environment; forge_select takes an explicit + # forge only in tests. + forge_select "" || return 1 REPO="${REPO:?set REPO to owner/name}" PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}" @@ -143,13 +152,13 @@ main() { # No mapping is a consumer that has not adopted scope labels — an # advisory no-op, not a red run (scopes locate, they do not alert). A # mapping that EXISTS but does not parse still fails loudly below. - if ! config="$(gh api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \ + if ! config="$(forge_api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \ --jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive" return 0 fi tsv="$(parse_labeler_config <<<"$config")" - files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')" + files="$(forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')" labels="$(derive_labels "$tsv" "$files")" if [ -z "$labels" ]; then @@ -157,8 +166,8 @@ main() { return 0 fi local args=() - while IFS= read -r label; do args+=(-f "labels[]=$label"); done <<<"$labels" - run gh api "repos/$REPO/issues/$PR_NUMBER/labels" "${args[@]}" --silent + while IFS= read -r label; do args+=("$label"); done <<<"$labels" + run forge_labels_add "$PR_NUMBER" "${args[@]}" log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)" } diff --git a/lib/forge-forgejo.sh b/lib/forge-forgejo.sh index 5d06ad0..b962c5b 100644 --- a/lib/forge-forgejo.sh +++ b/lib/forge-forgejo.sh @@ -407,3 +407,34 @@ forgejo_write() { cat "$body" rm -f "$hdr" "$body" } + +# forge_labels_add — the additive label write (ceremony#128; see +# the github twin). POST /issues/{n}/labels adds the named labels and removes +# nothing, and it takes NAMES — measured, unlike the removal path, which +# needs ids. +forge_labels_add() { + local n="${1:?forge_labels_add: number required}" + shift + [ "$#" -gt 0 ] || return 0 + forgejo_write POST "repos/$REPO/issues/$n/labels" \ + "$(printf '%s\n' "$@" | jq -R . | jq -s '{labels: .}')" >/dev/null +} + +# forge_request_reviewer — ask for a verdict. +# +# This endpoint DOES exist here, contrary to an earlier reading of mine +# (#4698) which recorded requested_reviewers as having no sub-resource at +# all. What is true is narrower: Forgejo serves POST and DELETE on it and no +# GET, so a GET probe answers 404 — and a POST naming a user who does not +# exist answers 404 as well, for a different reason. Measured on a scratch +# repo: POST with a real user who lacks read access is 422 ("Reviewer can't +# read"), and 201 once they have it. +# +# The READ stays retired regardless (term 4): the field is stale here even on +# merged PRs, so outstanding verdicts come from /pulls/{n}/reviews at the +# current head SHA. It is the write that has an answer. +forge_request_reviewer() { + local n="${1:?}" user="${2:?}" + forgejo_write POST "repos/$REPO/pulls/$n/requested_reviewers" \ + "$(jq -n --arg u "$user" '{reviewers: [$u]}')" >/dev/null +} diff --git a/lib/forge-github.sh b/lib/forge-github.sh index 543a5d7..ed180ff 100644 --- a/lib/forge-github.sh +++ b/lib/forge-github.sh @@ -115,3 +115,22 @@ forge_label_delete() { local name="${1:?}" gh label delete "$name" -R "$REPO" --yes } + +# forge_labels_add — an ADDITIVE label write, and deliberately +# not forge_issue_edit --add-label. The distinction is ceremony#128: the +# labeler action computed (labels-at-job-start ∪ derived) and PUT the whole +# set, so a label applied while the job ran was silently removed. This is the +# raw POST, which adds the named labels, ignores ones already present, and +# removes nothing — a concurrent label survives by construction. +forge_labels_add() { + local n="${1:?forge_labels_add: number required}" args=() label + shift + for label in "$@"; do args+=(-f "labels[]=$label"); done + gh api "repos/$REPO/issues/$n/labels" "${args[@]}" --silent +} + +# forge_request_reviewer — ask for a verdict. +forge_request_reviewer() { + local n="${1:?}" user="${2:?}" + gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$user" --silent +} diff --git a/lib/ruling.sh b/lib/ruling.sh index 119a5cf..afe23bd 100644 --- a/lib/ruling.sh +++ b/lib/ruling.sh @@ -209,7 +209,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now # comment, which only these facts identify, and half-verdicts on half-read # facts is the exact shape the reconciler's standing rule forbids. local flags newest setter labeled_at labeled_epoch - if ! flags="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \ + if ! flags="$(forge_api --paginate "repos/$REPO/issues/$n/timeline" \ --jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling") | [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then log "#$n: ruling timeline unreadable — no verdict invented this pass" @@ -230,7 +230,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now # the whole file is line-oriented, so the row format stays TSV and the # body is decoded at its points of use (#73). Do not switch rows to JSON. local comments - if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \ + if ! comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" \ --jq '.[] | [.user.login, .created_at, .html_url, ((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then log "#$n: ruling comments unreadable — no verdict invented this pass" @@ -268,7 +268,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now # ---- the bare-flag check (#50 D4, mechanical proxy) ---- if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then - run gh issue comment "$n" -R "$REPO" --body "$RULING_BARE_MARKER + run forge_issue_comment "$n" "$RULING_BARE_MARKER The ruling flag on this item was set by @$setter with no accompanying escalation comment. Setting it requires the escalation contract — the **question**, the **options**, and a **recommendation** — posted by the @@ -299,7 +299,7 @@ still owed." >/dev/null if [ "$shape" != SHAPED ] \ && [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then local missing="${shape#MALFORMED }" - run gh issue comment "$n" -R "$REPO" --body "$RULING_SHAPE_MARKER + run forge_issue_comment "$n" "$RULING_SHAPE_MARKER @$setter — the [escalation comment]($esc_url) accompanying this ruling flag is missing required field labels: **$missing**. The contract's shape is fixed because this machinery checks for it (heavy-duty/ceremony#50 D12): @@ -327,7 +327,7 @@ enforced." >/dev/null esac if [ "$rung" = RUNG12 ] \ && [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then - run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG12_MARKER + run forge_issue_comment "$n" "$RULING_RUNG12_MARKER @$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's 12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask), heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries @@ -342,7 +342,7 @@ reset on activity; this comment fires once per flag episode." >/dev/null fi if [ "$rung" = RUNG24 ] \ && [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then - run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG24_MARKER + run forge_issue_comment "$n" "$RULING_RUNG24_MARKER @$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's 24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask), heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries @@ -375,7 +375,7 @@ timer." >/dev/null else esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter." fi - run gh issue comment "$n" -R "$REPO" --body "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line + run forge_issue_comment "$n" "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment. diff --git a/test/facts.test.sh b/test/facts.test.sh index 32bea4a..ad2a9e3 100644 --- a/test/facts.test.sh +++ b/test/facts.test.sh @@ -10,6 +10,12 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh . "$ROOT/test/harness.sh" +# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary +# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those +# stubs already intercept (#188). Without this the verbs are simply undefined. +# shellcheck source=lib/forge.sh +. "$ROOT/lib/forge.sh" +forge_select github FACTS="$ROOT/lib/facts.sh" diff --git a/test/harness.sh b/test/harness.sh index 00e05f6..3b8b3bd 100644 --- a/test/harness.sh +++ b/test/harness.sh @@ -34,3 +34,13 @@ summary() { [ "$FAIL" -eq 0 ] } + +# forge_stub_path — strip the paging parameters the forge shim +# injects (#188) so a fixture keyed on the logical endpoint still matches. +# The page size moved OUT of the call sites and into the backend, which means +# every stub now sees "?per_page=100" appended to a paginated read; without +# this, a fixture lookup misses and the stub answers "unreadable", which the +# production code correctly reports as a degraded read. +forge_stub_path() { + printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g' +} diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 05d8d00..83eee06 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -4,6 +4,12 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh source "$ROOT/test/harness.sh" +# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary +# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those +# stubs already intercept (#188). Without this the verbs are undefined. +# shellcheck source=lib/forge.sh +. "$ROOT/lib/forge.sh" +forge_select github # shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh source "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" @@ -182,7 +188,7 @@ chmod +x "$TMP/gh" # shellcheck disable=SC2016 # expansions belong to the isolated bash -c process check "cross-repo warning is idempotent across two sweeps" 0 "" \ env PATH="$TMP:$PATH" GH_COMMENTS="$TMP/comments" bash -c \ - 'source "$1"; REPO=heavy-duty/ceremony + 'source "$1"; forge_select github; REPO=heavy-duty/ceremony ensure_comment 99 blocked-cross-repo "cross-repo warning" ensure_comment 99 blocked-cross-repo "cross-repo warning" test "$(grep -cF "" "$GH_COMMENTS")" -eq 1' \ @@ -234,6 +240,7 @@ check "claimed plus attention is a healthy issue" 0 "KEEP" \ INOW=2000000000 iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } +# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) issue_stub_gh() { if [ "$1" = api ]; then shift @@ -246,6 +253,7 @@ issue_stub_gh() { esac shift done + endpoint="$(forge_stub_path "$endpoint")" file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" printf '%s\n' "$endpoint" >>"$TMP/api-calls" [ ! -f "$file.error" ] || return 1 @@ -286,6 +294,7 @@ issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 open PR, $5 merged PR, $ MERGED_REF_PR_RECORDS="" fi run() { "$@"; } + # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { issue_stub_gh "$@"; } reconcile_issue "$1" 2>&1 ) @@ -531,6 +540,7 @@ jq -n --arg flag "$(iso_at $((INOW - 8 * 86400)))" \ jq -n --arg at "$(iso_at $((INOW - 8 * 86400 - 60)))" \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \ >"$(cfix 24)" +# shellcheck disable=SC2317 # reached through the forge backend (#188) churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; } last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )" check "last activity ignores the 2-day-old label churn" 0 "" \ @@ -566,6 +576,11 @@ if [ "$1" = api ]; then esac shift done + # Inlined, not the suite's helper: this stub is a standalone executable on + # PATH and cannot see a shell function from the test process. Strips the + # paging the forge shim injects so fixtures stay keyed on the logical + # endpoint (#188). + endpoint="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')" file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json" [ ! -f "$file.error" ] || exit 1 if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi @@ -580,8 +595,8 @@ chmod +x "$ARRIVAL/stub/gh" # The two PR gathers were GraphQL until #188; they are REST now, so the # fixtures are the /pulls list both forges return. Empty by default — the # merged-Refs case below fills the closed one. -printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open_per_page_100.json" -printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed_per_page_100.json" +printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" +printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; } # CEREMONY_FORGE=github below, and at the executable-sweep driver further # down: these fixtures ARE a GitHub board (a gh stub on PATH answering @@ -636,9 +651,9 @@ check "...and the sweep still runs" 0 "" \ # return the field, and both return null on a closed-unmerged PR. printf '%s\n' \ '[{"number":400,"body":"Refs #40","merged_at":"2026-07-30T00:00:00Z"},{"number":401,"body":"Refs #40","merged_at":null}]' \ - >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed_per_page_100.json" + >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" printf '[{"number":40}]\n' \ - >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json" + >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json" jq -n --arg at "$(iso_at "$INOW")" \ '{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json" @@ -680,10 +695,10 @@ check "...and performs the release edit from the executable path" 0 "" \ # of a vacuous test. printf '%s\n' \ '[{"number":500,"body":"## Summary\nSome prose about the work.\nCloses #50\n","merged_at":null}]' \ - >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open_per_page_100.json" -printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed_per_page_100.json" + >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json" +printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" printf '[{"number":50},{"number":51}]\n' \ - >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json" + >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json" # Both claims are two hours quiet against a ONE-hour stale bound, so the # reclaim clock has genuinely expired for whichever of them no open PR # rescues. The clock is injected rather than real: INOW is a fixed epoch in diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 071f836..20e45e3 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -13,6 +13,19 @@ export LC_ALL=C cd "$(dirname "$0")/.." # shellcheck source=actions/labels-reconcile/labels-reconcile.sh . actions/labels-reconcile/labels-reconcile.sh +# This suite drives the GITHUB backend: its gh() stubs ARE the forge boundary +# now, and forge_label_delete/forge_issue_comment/... resolve to the gh +# invocations those stubs already intercept (#188). main() selects a backend +# itself, but these probes call the pure functions directly, so the suite has +# to say which forge it is standing in. +forge_select github +# This suite predates test/harness.sh and carries its own expect(), so it does +# not get harness.sh's helper — define it here rather than pulling in a second +# assertion vocabulary. Strips the paging the shim injects so a fixture keyed +# on the logical endpoint still matches (#188). +forge_stub_path() { + printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g' +} load_config .github/labels.conf set_required_bots codex-bot-andresmgsl @@ -512,6 +525,7 @@ reconcile_probe() { # $1 = REPO_LABELS content → the log lines reconcile_pr em MERGEABLE=MERGEABLE CHECKS=SUCCESS PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}' run() { :; } # swallow mutations + # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { :; } # no network reconcile_pr 777 2>&1 ) @@ -610,6 +624,7 @@ ruling_probe() { # $1 = the PR's labels → the log lines reconcile_pr emits MERGEABLE=MERGEABLE CHECKS=SUCCESS PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}' run() { :; } # swallow mutations + # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { :; } # no network reconcile_pr 888 2>&1 ) @@ -659,6 +674,7 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines MERGEABLE=MERGEABLE CHECKS=SUCCESS PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')" run() { "$@"; } # mutations reach the stub and are recorded, not swallowed + # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { if [ "$1" = api ]; then shift @@ -671,6 +687,7 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines esac shift done + endpoint="$(forge_stub_path "$endpoint")" file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json" # A missing fixture is an empty collection — projected through the # caller's --jq exactly like real gh, so '.[].foo' yields no lines. @@ -741,6 +758,7 @@ blind_main_probe() { # This probe IS a GitHub board — say so at the forge boundary rather # than leaving main()'s preflight to infer one (#188). CEREMONY_FORGE=github + # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { if [ "$1" = label ] && [ "$2" = list ]; then core_label_rows | cut -d'|' -f1 @@ -824,10 +842,10 @@ expected_upserts="$({ core_label_rows; configured_label_rows .github/labels.conf ) expect "a dispatch deletes the six in the same run as the upserts" \ "$RETIRED_WANT" \ - "$(sed -n 's/^gh label delete \(.*\) -R owner\/repo --yes$/\1/p' "$BOOT/happy")" + "$(sed -n 's/^forge_label_delete \(.*\)$/\1/p' "$BOOT/happy")" expect "...and the recorded upsert set is unchanged from today's" \ "$expected_upserts" \ - "$(sed -n 's/^gh label create \([^ ]*\) .*/\1/p' "$BOOT/happy")" + "$(sed -n 's/^forge_label_create \([^ ]*\) .*/\1/p' "$BOOT/happy")" # -- a missing label is success: gh exits non-zero with not-found, and the # guard keeps that from aborting the dispatch. Red without the guard. @@ -898,7 +916,7 @@ boot_dry_probe() { } dry_out="$(boot_dry_probe)" expect "DRY_RUN narrates each deletion" \ - 6 "$(grep -c '^labels: DRY_RUN: gh label delete' <<<"$dry_out")" + 6 "$(grep -c '^labels: DRY_RUN: forge_label_delete' <<<"$dry_out")" expect "...and performs none" \ no "$(test -f "$BOOT/dry-real" && echo yes || echo no)" diff --git a/test/release-chain.test.sh b/test/release-chain.test.sh index 8733425..3633824 100644 --- a/test/release-chain.test.sh +++ b/test/release-chain.test.sh @@ -11,6 +11,12 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh . "$ROOT/test/harness.sh" +# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary +# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those +# stubs already intercept (#188). Without this the verbs are simply undefined. +# shellcheck source=lib/forge.sh +. "$ROOT/lib/forge.sh" +forge_select github FACTS="$ROOT/lib/facts.sh" DECIDE="$ROOT/lib/decide.sh" diff --git a/test/ruling.test.sh b/test/ruling.test.sh index 2eaffb7..7150e39 100644 --- a/test/ruling.test.sh +++ b/test/ruling.test.sh @@ -4,6 +4,12 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh source "$ROOT/test/harness.sh" +# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary +# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those +# stubs already intercept (#188). Without this the verbs are undefined. +# shellcheck source=lib/forge.sh +. "$ROOT/lib/forge.sh" +forge_select github # shellcheck source=lib/ruling.sh source "$ROOT/lib/ruling.sh" @@ -123,6 +129,7 @@ run() { "$@"; } iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } +# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { if [ "$1" = api ]; then shift @@ -135,6 +142,7 @@ gh() { esac shift done + endpoint="$(forge_stub_path "$endpoint")" file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" [ -f "$file" ] || return 1 if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi