feat(forge): port every reconciler call site onto the shim

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
This commit is contained in:
cluade-reviewer-andresmgsl 2026-08-02 19:43:58 +00:00
parent dce12e0bb5
commit baf4a20571
12 changed files with 204 additions and 64 deletions

View file

@ -275,12 +275,12 @@ offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET
ensure_comment() { # $1 issue, $2 marker, $3 message ensure_comment() { # $1 issue, $2 marker, $3 message
local n="$1" marker="$2" message="$3" local n="$1" marker="$2" message="$3"
if issue_comment_has_marker "$n" "$marker"; then return; fi if issue_comment_has_marker "$n" "$marker"; then return; fi
run gh issue comment "$n" -R "$REPO" --body "<!-- issueflow:$marker --> run forge_issue_comment "$n" "<!-- issueflow:$marker -->
$message" >/dev/null $message" >/dev/null
} }
issue_comment_has_marker() { # $1 issue, $2 marker 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 "<!-- issueflow:$2 -->" | grep -qF "<!-- issueflow:$2 -->"
} }
@ -288,7 +288,7 @@ reference_states() {
local ref state local ref state
while IFS= read -r ref; do while IFS= read -r ref; do
[ -n "$ref" ] || continue [ -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 case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
done done
} }
@ -299,23 +299,23 @@ offsite_pr_states() {
[ -n "$ref" ] || continue [ -n "$ref" ] || continue
repo="${ref%#*}" repo="${ref%#*}"
number="${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 case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
done done
} }
offsite_timeline() { # unreadable timelines are deliberately silent 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() { last_issue_activity() {
local n="$1" created="$2" latest local n="$1" created="$2" latest
latest="$({ latest="$({
printf '%s\n' "$created" 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 # Assignment is the claim itself. Ignoring it would let an old issue be
# reclaimed in the seconds between assignment and its required draft PR. # 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' --jq '.[] | select(.event == "assigned") | .created_at'
} \ } \
| sort | tail -n1)" | sort | tail -n1)"
@ -329,7 +329,7 @@ reconcile_issue() {
decision="$(queue_decision <<<"$ISSUE_LABELS")" decision="$(queue_decision <<<"$ISSUE_LABELS")"
case "$decision" in case "$decision" in
ADD_NEEDS_TRIAGE) 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)" ;; log "#$n: needs-triage (no queue state)" ;;
FLAG_CONFLICT) FLAG_CONFLICT)
ensure_comment "$n" queue-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). # would create an impossible parked-for state (#175 D4).
has_issue_label attention && remove_claimed=claimed,attention has_issue_label attention && remove_claimed=claimed,attention
if [ -n "$owners" ]; then 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 --remove-label "$remove_claimed" --add-label post-merge >/dev/null
else else
run gh issue edit "$n" -R "$REPO" \ run forge_issue_edit "$n" \
--remove-label "$remove_claimed" --add-label post-merge >/dev/null --remove-label "$remove_claimed" --add-label post-merge >/dev/null
fi fi
log "#$n: merged Refs PR -> post-merge; claim released" 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.' '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")" owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
if [ -n "$owners" ]; then 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 --remove-label claimed --add-label ready >/dev/null
else 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 fi
log "#$n: stale claim reclaimed -> ready" ;; log "#$n: stale claim reclaimed -> ready" ;;
esac esac
@ -433,7 +433,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
READY) READY)
ensure_comment "$n" blockers-cleared \ ensure_comment "$n" blockers-cleared \
'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.' '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" ;; log "#$n: blockers closed -> ready" ;;
esac esac
elif has_issue_label epic; then 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 # 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. # quiet (#50 D10), and nothing on the issue side ever puts stale back.
if has_issue_label stale; then 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)" log "#$n: unstale (a ruling is pending)"
fi fi
[ -n "${age:-}" ] \ [ -n "${age:-}" ] \
@ -466,7 +466,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
reconcile_opened_issue() { reconcile_opened_issue() {
local n="$1" author triage=false labels remove="" label 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 # 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 # 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). # run on every triage-authored mint, before one issue was reconciled (#91).
@ -480,9 +480,9 @@ reconcile_opened_issue() {
done done
remove="${remove#,}" remove="${remove#,}"
if [ -n "$remove" ]; then 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 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 fi
log "#$n: needs-triage (opened by $author)" log "#$n: needs-triage (opened by $author)"
} }
@ -492,7 +492,16 @@ main() {
# Forgejo — but with `line 408: gh: command not found`, which names the # 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 # symptom and not the cause, and only after the sibling step had already
# reported a green blind sweep. # 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}" REPO="${REPO:?set REPO to owner/name}"
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
@ -502,7 +511,7 @@ main() {
fi fi
# owner/name split out here until #188 — the GraphQL query took them as # 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. # 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 # 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 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 # 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 # one line and silently loses every declaration after the first. The old
# GraphQL gather sidestepped that with `split("\n")[]`; base64 is the same # GraphQL gather sidestepped that with `split("\n")[]`; base64 is the same
# protection without needing the split to be correct. # 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' \ --jq '.[] | .body // "" | @base64' \
| while IFS= read -r b64; do | while IFS= read -r b64; do
[ -n "$b64" ] && printf '%s' "$b64" | base64 -d | closes_references [ -n "$b64" ] && printf '%s' "$b64" | base64 -d | closes_references
@ -523,7 +532,7 @@ main() {
# meant the CLOSING relation specifically, and reading Refs as closing # meant the CLOSING relation specifically, and reading Refs as closing
# would make every referenced issue look closeable — the distinction #151 # would make every referenced issue look closeable — the distinction #151
# was reopened by hand over. # 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)"' \ --jq '.[] | select(.merged_at != null) | "\(.number)\t\(.body // "" | @base64)"' \
| while IFS=$'\t' read -r pr b64; do | while IFS=$'\t' read -r pr b64; do
[ -n "$b64" ] || continue [ -n "$b64" ] || continue
@ -533,10 +542,10 @@ main() {
done)" done)"
local n 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 --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 jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0
ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
reconcile_issue "$n" reconcile_issue "$n"

View file

@ -516,7 +516,7 @@ $(configured_label_rows "$LABELS_CONF")"
fi fi
while IFS='|' read -r name color desc; do while IFS='|' read -r name color desc; do
[ -n "$name" ] || continue [ -n "$name" ] || continue
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force run forge_label_create "$name" "$color" "$desc"
done <<<"$rows" done <<<"$rows"
# LABELS.md publishes the defaults as deleted at bootstrap; until #93 # 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. # the taxonomy it can create. Either way: log the name, keep going.
while IFS= read -r name; do while IFS= read -r name; do
[ -n "$name" ] || continue [ -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" || log "retire: '$name' not deleted (already absent, or refused) — continuing"
done <<<"$(retired_label_names)" 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" # Every failure path prints nothing: the caller treats "could not read"
# as "not release-shaped" rather than warning on a guess. # as "not release-shaped" rather than warning on a guess.
local ref="$1" ver 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:]')" | base64 -d 2>/dev/null | tr -d '[:space:]')"
if [ -z "$ver" ]; then 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)" | base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)"
fi fi
[ -z "$ver" ] || printf '%s\n' "$ver" [ -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 # concurrency group in labels.yml. With a comment-only bot on the panel
# this path stays cold and the AUTHOR requests the human. # this path stays cold and the AUTHOR requests the human.
if [ "$desired" = state:needs-human ] && human_request_needed; then 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)" log "#$n: requested $HUMAN (round passed)"
fi 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 if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
args=(--add-label "$desired${add:+,$add}") args=(--add-label "$desired${add:+,$add}")
[ -n "$remove" ] && args+=(--remove-label "$remove") [ -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)}" log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
else else
# a deleted label must not wedge the sweep — dispatch heals the taxonomy # 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 # 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. # claim is removed. Setting it stays with whoever owns the queue.
if has_label merge-next && [ "$desired" != state:needs-human ]; then 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)" log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
fi 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 '.created_at' <<<"$PR_JSON"
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON" jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' forge_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/pulls/$n/commits" --jq '.[].commit.committer.date'
} | sort | tail -n1 } | sort | tail -n1
)" )"
last_activity_epoch="$(date -d "$last_activity" +%s)" 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. # (#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 blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
if has_label stale; 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" log "#$n: unstale"
fi fi
elif ! has_label stale; then 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)" log "#$n: stale ($((age / 3600))h quiet)"
fi fi
@ -711,7 +711,16 @@ main() {
# `gh`, so that is what this declares — honestly, which is the point: on # `gh`, so that is what this declares — honestly, which is the point: on
# a Forgejo consumer the preflight refuses here instead of letting the # a Forgejo consumer the preflight refuses here instead of letting the
# sweep run blind and print "reconciled." over zero PRs (rig run 979). # 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}" REPO="${REPO:?set REPO to owner/name}"
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" 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 # 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. # 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" [ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS" missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
@ -736,7 +745,7 @@ main() {
status=0 status=0
output="$( output="$(
( (
PR_JSON="$(gh api "repos/$REPO/pulls/$n")" PR_JSON="$(forge_api "repos/$REPO/pulls/$n")"
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")" DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")" AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")"
set_required_bots "$AUTHOR" set_required_bots "$AUTHOR"
@ -745,7 +754,7 @@ main() {
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")" LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")" REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict # 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")]')" | jq -s '[.[] | select(.state != "PENDING")]')"
# mergeability + the check rollup, the two facts the state machine was # 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 # 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, # D2), never left to interleave raw into the per-PR output block,
# where an unlucky line could collide with a matched string. # where an unlucky line could collide with a matched string.
GH_VIEW_ERR_FILE="$(mktemp)" 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")" GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")"
rm -f "$GH_VIEW_ERR_FILE" rm -f "$GH_VIEW_ERR_FILE"
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")" MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
@ -791,7 +800,7 @@ main() {
elif [ "$status" -ne 0 ]; then elif [ "$status" -ne 0 ]; then
log "#$n: reconcile failed — continuing with the remaining PRs" log "#$n: reconcile failed — continuing with the remaining PRs"
fi 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" blind_sweep_warning "$unreadable" "$total" "$sampled_reason"
log "reconciled." log "reconciled."
} }

View file

@ -132,7 +132,16 @@ main() {
# quietest of the three: an unreadable mapping and an absent one produced # 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 # the same "nothing to derive" no-op, so on Forgejo a PR simply got no
# scope labels and nothing said why. # 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}" REPO="${REPO:?set REPO to owner/name}"
PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}" 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 # 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 # 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. # 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 --jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then
log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive" log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive"
return 0 return 0
fi fi
tsv="$(parse_labeler_config <<<"$config")" 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")" labels="$(derive_labels "$tsv" "$files")"
if [ -z "$labels" ]; then if [ -z "$labels" ]; then
@ -157,8 +166,8 @@ main() {
return 0 return 0
fi fi
local args=() local args=()
while IFS= read -r label; do args+=(-f "labels[]=$label"); done <<<"$labels" while IFS= read -r label; do args+=("$label"); done <<<"$labels"
run gh api "repos/$REPO/issues/$PR_NUMBER/labels" "${args[@]}" --silent run forge_labels_add "$PR_NUMBER" "${args[@]}"
log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)" log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)"
} }

View file

@ -407,3 +407,34 @@ forgejo_write() {
cat "$body" cat "$body"
rm -f "$hdr" "$body" rm -f "$hdr" "$body"
} }
# forge_labels_add <n> <label…> — 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 <n> <user> — ask <user> 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
}

View file

@ -115,3 +115,22 @@ forge_label_delete() {
local name="${1:?}" local name="${1:?}"
gh label delete "$name" -R "$REPO" --yes gh label delete "$name" -R "$REPO" --yes
} }
# forge_labels_add <n> <label…> — 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 <n> <user> — ask <user> for a verdict.
forge_request_reviewer() {
local n="${1:?}" user="${2:?}"
gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$user" --silent
}

View file

@ -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 # comment, which only these facts identify, and half-verdicts on half-read
# facts is the exact shape the reconciler's standing rule forbids. # facts is the exact shape the reconciler's standing rule forbids.
local flags newest setter labeled_at labeled_epoch 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") --jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling")
| [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then | [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then
log "#$n: ruling timeline unreadable — no verdict invented this pass" 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 # 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. # body is decoded at its points of use (#73). Do not switch rows to JSON.
local comments 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, --jq '.[] | [.user.login, .created_at, .html_url,
((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then ((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then
log "#$n: ruling comments unreadable — no verdict invented this pass" 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) ---- # ---- the bare-flag check (#50 D4, mechanical proxy) ----
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then
if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; 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 The ruling flag on this item was set by @$setter with no accompanying
escalation comment. Setting it requires the escalation contract — the escalation comment. Setting it requires the escalation contract — the
**question**, the **options**, and a **recommendation** — posted by the **question**, the **options**, and a **recommendation** — posted by the
@ -299,7 +299,7 @@ still owed." >/dev/null
if [ "$shape" != SHAPED ] \ if [ "$shape" != SHAPED ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then && [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then
local missing="${shape#MALFORMED }" 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 @$setter — the [escalation comment]($esc_url) accompanying this ruling flag
is missing required field labels: **$missing**. The contract's shape is is missing required field labels: **$missing**. The contract's shape is
fixed because this machinery checks for it (heavy-duty/ceremony#50 D12): fixed because this machinery checks for it (heavy-duty/ceremony#50 D12):
@ -327,7 +327,7 @@ enforced." >/dev/null
esac esac
if [ "$rung" = RUNG12 ] \ if [ "$rung" = RUNG12 ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then && [ "$(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 @$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), 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 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 fi
if [ "$rung" = RUNG24 ] \ if [ "$rung" = RUNG24 ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then && [ "$(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 @$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), 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 heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
@ -375,7 +375,7 @@ timer." >/dev/null
else else
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter." esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
fi 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. 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.

View file

@ -10,6 +10,12 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh # shellcheck source=test/harness.sh
. "$ROOT/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" FACTS="$ROOT/lib/facts.sh"

View file

@ -34,3 +34,13 @@ summary() {
[ "$FAIL" -eq 0 ] [ "$FAIL" -eq 0 ]
} }
# forge_stub_path <endpoint> — 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'
}

View file

@ -4,6 +4,12 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh # shellcheck source=test/harness.sh
source "$ROOT/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 # shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
source "$ROOT/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 # shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "cross-repo warning is idempotent across two sweeps" 0 "" \ check "cross-repo warning is idempotent across two sweeps" 0 "" \
env PATH="$TMP:$PATH" GH_COMMENTS="$TMP/comments" bash -c \ 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"
ensure_comment 99 blocked-cross-repo "cross-repo warning" ensure_comment 99 blocked-cross-repo "cross-repo warning"
test "$(grep -cF "<!-- issueflow:blocked-cross-repo -->" "$GH_COMMENTS")" -eq 1' \ test "$(grep -cF "<!-- issueflow:blocked-cross-repo -->" "$GH_COMMENTS")" -eq 1' \
@ -234,6 +240,7 @@ check "claimed plus attention is a healthy issue" 0 "KEEP" \
INOW=2000000000 INOW=2000000000
iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } 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() { issue_stub_gh() {
if [ "$1" = api ]; then if [ "$1" = api ]; then
shift shift
@ -246,6 +253,7 @@ issue_stub_gh() {
esac esac
shift shift
done done
endpoint="$(forge_stub_path "$endpoint")"
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
printf '%s\n' "$endpoint" >>"$TMP/api-calls" printf '%s\n' "$endpoint" >>"$TMP/api-calls"
[ ! -f "$file.error" ] || return 1 [ ! -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="" MERGED_REF_PR_RECORDS=""
fi fi
run() { "$@"; } run() { "$@"; }
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { issue_stub_gh "$@"; } gh() { issue_stub_gh "$@"; }
reconcile_issue "$1" 2>&1 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)))" \ 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"}]' \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \
>"$(cfix 24)" >"$(cfix 24)"
# shellcheck disable=SC2317 # reached through the forge backend (#188)
churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; } churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; }
last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )" last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )"
check "last activity ignores the 2-day-old label churn" 0 "" \ check "last activity ignores the 2-day-old label churn" 0 "" \
@ -566,6 +576,11 @@ if [ "$1" = api ]; then
esac esac
shift shift
done 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" file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json"
[ ! -f "$file.error" ] || exit 1 [ ! -f "$file.error" ] || exit 1
if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi 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 # 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 # fixtures are the /pulls list both forges return. Empty by default — the
# merged-Refs case below fills the closed one. # 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_open.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed_per_page_100.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"; } 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 # CEREMONY_FORGE=github below, and at the executable-sweep driver further
# down: these fixtures ARE a GitHub board (a gh stub on PATH answering # 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. # return the field, and both return null on a closed-unmerged PR.
printf '%s\n' \ printf '%s\n' \
'[{"number":400,"body":"Refs #40","merged_at":"2026-07-30T00:00:00Z"},{"number":401,"body":"Refs #40","merged_at":null}]' \ '[{"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' \ 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")" \ 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"}]}' \ '{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" >"$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. # of a vacuous test.
printf '%s\n' \ printf '%s\n' \
'[{"number":500,"body":"## Summary\nSome prose about the work.\nCloses #50\n","merged_at":null}]' \ '[{"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" >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed_per_page_100.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
printf '[{"number":50},{"number":51}]\n' \ 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 # 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 # 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 # rescues. The clock is injected rather than real: INOW is a fixed epoch in

View file

@ -13,6 +13,19 @@ export LC_ALL=C
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh # shellcheck source=actions/labels-reconcile/labels-reconcile.sh
. 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 load_config .github/labels.conf
set_required_bots codex-bot-andresmgsl 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 MERGEABLE=MERGEABLE CHECKS=SUCCESS
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}' PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
run() { :; } # swallow mutations run() { :; } # swallow mutations
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { :; } # no network gh() { :; } # no network
reconcile_pr 777 2>&1 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 MERGEABLE=MERGEABLE CHECKS=SUCCESS
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}' PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
run() { :; } # swallow mutations run() { :; } # swallow mutations
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { :; } # no network gh() { :; } # no network
reconcile_pr 888 2>&1 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 MERGEABLE=MERGEABLE CHECKS=SUCCESS
PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')" PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')"
run() { "$@"; } # mutations reach the stub and are recorded, not swallowed run() { "$@"; } # mutations reach the stub and are recorded, not swallowed
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { gh() {
if [ "$1" = api ]; then if [ "$1" = api ]; then
shift shift
@ -671,6 +687,7 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines
esac esac
shift shift
done done
endpoint="$(forge_stub_path "$endpoint")"
file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json" file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
# A missing fixture is an empty collection — projected through the # A missing fixture is an empty collection — projected through the
# caller's --jq exactly like real gh, so '.[].foo' yields no lines. # 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 # This probe IS a GitHub board — say so at the forge boundary rather
# than leaving main()'s preflight to infer one (#188). # than leaving main()'s preflight to infer one (#188).
CEREMONY_FORGE=github CEREMONY_FORGE=github
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { gh() {
if [ "$1" = label ] && [ "$2" = list ]; then if [ "$1" = label ] && [ "$2" = list ]; then
core_label_rows | cut -d'|' -f1 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" \ expect "a dispatch deletes the six in the same run as the upserts" \
"$RETIRED_WANT" \ "$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" \ expect "...and the recorded upsert set is unchanged from today's" \
"$expected_upserts" \ "$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 # -- 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. # guard keeps that from aborting the dispatch. Red without the guard.
@ -898,7 +916,7 @@ boot_dry_probe() {
} }
dry_out="$(boot_dry_probe)" dry_out="$(boot_dry_probe)"
expect "DRY_RUN narrates each deletion" \ 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" \ expect "...and performs none" \
no "$(test -f "$BOOT/dry-real" && echo yes || echo no)" no "$(test -f "$BOOT/dry-real" && echo yes || echo no)"

View file

@ -11,6 +11,12 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh # shellcheck source=test/harness.sh
. "$ROOT/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" FACTS="$ROOT/lib/facts.sh"
DECIDE="$ROOT/lib/decide.sh" DECIDE="$ROOT/lib/decide.sh"

View file

@ -4,6 +4,12 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh # shellcheck source=test/harness.sh
source "$ROOT/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 # shellcheck source=lib/ruling.sh
source "$ROOT/lib/ruling.sh" source "$ROOT/lib/ruling.sh"
@ -123,6 +129,7 @@ run() { "$@"; }
iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { gh() {
if [ "$1" = api ]; then if [ "$1" = api ]; then
shift shift
@ -135,6 +142,7 @@ gh() {
esac esac
shift shift
done done
endpoint="$(forge_stub_path "$endpoint")"
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
[ -f "$file" ] || return 1 [ -f "$file" ] || return 1
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi