Compare commits

...

20 commits

Author SHA1 Message Date
7fc9afe45f Merge pull request 'actions/* + lib/* — one forge abstraction, two backends (#188)' (#189) from build/188-forge-preflight into main
All checks were successful
CI / test (push) Successful in 1m25s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 5s
CI / action-exercise (push) Successful in 4s
CI / docs-sync-exercise (push) Successful in 4s
release / release (push) Successful in 5s
Reviewed-on: #189
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-04 09:52:22 +00:00
4e929e2083 test(forge): the negative half of the github pass-through pins
Some checks failed
CI / test (pull_request) Successful in 1m25s
CI / release-exercise (pull_request) Successful in 8s
CI / self-guards (pull_request) Successful in 5s
CI / action-exercise (pull_request) Successful in 4s
CI / docs-sync-exercise (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 6s
@grok-reviewer-andresmgsl landed the term-5 pins in ff17d1e while I had the
same two asks in flight locally; theirs is on the branch and I dropped my
duplicate rather than push a competing tip. This adds only what the two
suites did not share.

Their pins are positive: the github twins DO call the right endpoints, and
forge_pr_activity emits all three timestamp sources. Mine had two negatives
they did not, and negatives are what catch the drift a 1:1-extraction path
actually suffers — a positive pin still passes if the github path GAINS
forgejo behaviour, and term 5 is a statement about what must NOT change.

  - the github timeline is never reshaped. That timeline already IS the
    shape ruling.sh selects on, so a projection here would be a second,
    divergent normalizer maintained by nobody.
  - github activity never derives inline comments from reviews. That
    derivation exists on the forgejo path only because the flat endpoint
    404s there; a github twin quietly adopting the workaround is the
    "both backends drift together" failure term 5 forbids.

Mutation-verified: adding a --jq projection to the github timeline, and
swapping the flat PR-comments read for a reviews-derived one, each red their
own case. forge-backends 77 -> 79.

Refs #188
2026-08-03 15:30:28 +00:00
ff17d1ea3f fix(forge): term-5 GitHub pins for timeline/activity + keep activity stderr (#188)
Some checks failed
CI / test (pull_request) Successful in 1m25s
CI / release-exercise (pull_request) Successful in 9s
CI / self-guards (pull_request) Successful in 5s
CI / action-exercise (pull_request) Successful in 4s
CI / docs-sync-exercise (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 6s
Codex 1566 held APPROVE: only Forgejo stubs covered forge_timeline and
forge_pr_activity. Pin the github twins as 1:1 extractions (timeline
paginate; issue comments + flat pulls comments + commits).

Cluade #4879: drop 2>/dev/null on the labels-reconcile activity call site
so a failed read still degrades last_activity but names the failure in the
job log (keep || true).
2026-08-03 15:26:08 +00:00
5c8e4f5b84 feat(forge): timeline normalizer, portable PR activity, shellcheck install (#188)
Some checks failed
CI / test (pull_request) Successful in 1m26s
CI / release-exercise (pull_request) Successful in 8s
CI / self-guards (pull_request) Successful in 5s
CI / action-exercise (pull_request) Successful in 4s
CI / docs-sync-exercise (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 6s
Panel-unanimous batch that was staged unpushed on 57abe15 (#4853):

- forge_timeline: project Forgejo label events into the GitHub shape
  so the ruling ladder fires on this forge (measured mapping #4849)
- forge_pr_activity: stop calling /pulls/{n}/comments (404 here); use
  reviews with comments_count > 0 for inline comments (#4844)
- ci.yml: install shellcheck before lint, mirroring actionlint — the
  act-22.04 runner image does not ship it

Status captured before jq so an unreadable timeline cannot report empty.
2026-08-03 15:13:30 +00:00
57abe15a77 fix(issueflow): the issue/PR discriminator is GitHub-only
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Found by rehearsing DRY_RUN against heavy-duty/rig's live board, not by any
test. issueflow swept ZERO issues on Forgejo and printed "reconciled." — the
blind sweep again, one layer in, and invisible because the log is identical
to a legitimately empty queue.

Measured on the two list endpoints, 2026-08-02:

  GitHub   plain issues OMIT pull_request  -> 0 of 9 carried the key
  Forgejo  every entry HAS it, null on issues -> 10 of 10 carried it

So `select(has("pull_request") | not)` matched nothing here. Replaced with
`.pull_request == null`, which is true for an issue on both forges (an absent
key reads as null) and false for a PR on both. Verified against both live
list endpoints: Forgejo 10 open issues, GitHub 9 — each matching its API.

With the fix the sweep produces real decisions against rig rather than
silence: needs-triage on six issues with no queue state, the merged-Refs
post-merge transition on #133, and the conflicting-queue-labels flag on #129.

The regression test keeps the old expression as a must-fail: it disagrees
with the new one on exactly the Forgejo shape.

Refs #188
2026-08-02 20:44:58 +00:00
9db8317543 fix(labels-scope): jq 1.6 cannot parse $label — the runner image ships 1.6
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Found by running ceremony's own CI at 9357f09 on a real Forgejo runner
rather than reasoning about it.

`label` is a reserved word in jq's grammar (`label $out | … | break $out`),
so jq **1.6** rejects `$label` outright:

  jq: error: syntax error, unexpected label, expecting IDENT

jq 1.7 parses it, which is why this survived: GitHub's hosted ubuntu-latest
ships 1.7, and ghcr.io/catthehacker/ubuntu:act-22.04 — the image this
instance maps ubuntu-latest to — ships 1.6. So parse_labeler_config died on
a compile error before it read a byte of config, and EVERY scope derivation
on this forge failed. Renamed to $lbl in the jq program only; the bash
locals keep their names.

Also makes test/forge.test.sh hermetic. Its "github + gh passes" case
depended on gh being on the HOST's PATH, so it passed on a developer box and
failed in the runner image, which has no gh. The preflight cases now run
against stub binaries, and the missing-binary refusal gets its own arm on a
PATH carrying the shell and text tools but no clients — the condition under
test, rather than whatever the machine happens to have.

Verified in both environments: local (jq 1.7, gh present) and the runner
image (jq 1.6, no gh) — shellcheck 0, 22 files 0 failed in each.

Refs #188
2026-08-02 20:34:02 +00:00
9357f09aea fix: the four findings from the panel round on 2168e4e
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
@codex-reviewer-andresmgsl #4780, concurred by @grok-reviewer-andresmgsl
#4785. All four real.

1. Three issueflow call sites still named per_page=100. The backend
   sanitized it so it worked, but the frozen term and the changelog both say
   no call site names a page size — and a contract that holds only because
   something downstream cleans up is not the contract. Endpoints now carry
   their logical query alone.

2. The suite's summary and `[ "$fail" -eq 0 ]` gate sat in the MIDDLE of
   test/labels-reconcile.test.sh, and the eight outstanding_requests expects
   were appended after them. Proven before fixing: a deliberately broken
   term-4 assertion printed FAIL, was excluded from the totals, and the
   suite still exited 0. Those assertions were decorative. The gate moves to
   the true end, with a note that nothing goes below it; the reported count
   goes 157 -> 164, which is the eight that were never being counted.

3. forge_labels_add and forge_request_reviewer arrived with the port and had
   no boundary pins. Both backends now have them, and the labels_add cases
   pin the property ceremony#128 turns on: an additive POST, never a PUT of
   the whole set, exactly one write so nothing is read-modify-written.
   Mutation-verified — making it RMW/PUT, or routing github through
   `issue edit --add-label`, each red their own cases.

4. The historical comment said the old gathers were `forge_api graphql`. My
   own mechanical port rewrote it; before #188 they were `gh api graphql`
   and the abstraction did not exist.

Refs #188
2026-08-02 19:58:51 +00:00
2168e4ef9a test(forge): hermetic cases for the two forgejo edit asymmetries
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
The coverage owed with the call-site port (@grok-reviewer-andresmgsl #4741
note 2, #4751 item 2). Live scratch-repo evidence proved these work; these
pin the request SHAPE so they keep working.

  - a removal resolves name -> numeric id, and never sends the name as the
    path segment (measured: DELETE .../labels/probe:one -> 422,
    DELETE .../labels/149 -> 204);
  - a removal of a label the repo does not have writes nothing, matching gh:
    the reconcilers call --remove-label unconditionally to converge state;
  - adds take names directly, one request, comma-separated values split as
    gh splits them;
  - an assignee removal PATCHes the SURVIVING list, because Forgejo sets
    assignees rather than adding and removing them — a naive translation
    would have cleared every other assignee as a side effect of removing
    one, which is what the mutation test proves is caught.

Payloads are now compact JSON. They were pretty-printed, which spread a
single write across several lines — harder to read in a log, and it hid the
shape from any assertion matching a line.

Refs #188
2026-08-02 19:50:37 +00:00
f2d5fcd565 feat(forge): derive outstanding review requests from the head, not the field
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Term 4. GitHub clears requested_reviewers when a verdict lands, so the field
answers "who still owes a verdict" by itself. Forgejo never clears it —
measured: rig!140 listed all three panelists with all three verdicts in, and
rig!146 still lists three while MERGED, so the field is stale even on a
closed PR.

Read raw on Forgejo that is not a cosmetic over-count. `requested` drives
three decisions, and a permanently-true field pins a PR at
state:bots-reviewing for life and stops blocker:unrequested from ever being
true: the sweep believes a round is live forever and no staleness can
correct it.

So the requested set is intersected with who has NOT submitted a verdict for
the current head, derived from /pulls/{n}/reviews — the read that is true on
both forges. On GitHub the filter removes nothing, because the field is
already accurate; term 5 holds by construction rather than by care.

A STALE approval — an approval of an older head — still owes a verdict. That
is the case that matters: treating it as answered would let a stale round
read as complete, which is the shape #136 exists to prevent.

Mutation-verified both ways: reading the field raw again reds three cases,
and treating STALE as answered reds two.

Also documents @grok-reviewer-andresmgsl's ask (#4763): every panel= account
must be able to read the repo, or the forge refuses the review request —
422 naming the account on Forgejo. A real failure mode for private
consumers, and it fails loudly rather than sweeping blind.

Refs #188
2026-08-02 19:48:09 +00:00
baf4a20571 feat(forge): port every reconciler call site onto the shim
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
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
2026-08-02 19:43:58 +00:00
dce12e0bb5 fix(test): the second curl stub needed the SC2317 disable too
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
a968e13 was pushed with shellcheck red. I chained the gates and the push in
one command, so a non-zero gate did not stop the push — the gate has to be a
condition, not a line of output I read afterwards.

Refs #188
2026-08-02 19:24:27 +00:00
a968e13ca4 fix(forge): parity gaps in the forgejo verbs — upsert, timestamps, typos
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
@codex-reviewer-andresmgsl's three findings (#4743), all real.

1. forge_label_create is now an UPSERT, matching gh label create --force.
   bootstrap_labels creates every declared label on EVERY workflow_dispatch,
   so a plain POST onto an existing name aborted the bootstrap under set -e
   from the second dispatch onward. Resolves name -> id and PATCHes when it
   exists.

2. forge_pr_view carries createdAt/completedAt. checks_state groups repeated
   contexts and selects the newest by [.startedAt, .createdAt, .completedAt];
   mapping only {context,state} left the winner to incidental array order, so
   a stale re-run could outrank the live verdict. The combined status carries
   created_at and updated_at — measured.

3. forge_issue_edit refuses unknown flags and missing values. The github
   backend hands them to gh, which fails; dropping them here turned a
   mis-typed port site into a mutation that silently did not happen — this
   issue's own failure class, inside the fix for it.

Also settles @grok-reviewer-andresmgsl's note 3 (#4741): Forgejo Actions DO
land as commit statuses on this instance, so the rollup is not empty.
rig main carries four — "ci / check (push)" and siblings, state success,
each with created_at. statusCheckRollup therefore populates, and NONE is not
silently substituted for SUCCESS.

Each fix mutation-verified: dropping the timestamps, forcing POST-always, and
restoring the silent flag skip each red exactly their own cases. The
newest-verdict case drives the real checks_state, not a copy.

Refs #188
2026-08-02 19:22:28 +00:00
adf3299192 test(forge): assert the distinguishing text, not a surviving substring
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
@codex-reviewer-andresmgsl (#4727) and @grok-reviewer-andresmgsl (#4734):
"...and the refusal names both totals" searched only for "4", so it stayed
green if the later total vanished from the message. A case named "names
BOTH" must fail when one goes. Now asserts "4 then 9".

Auditing this file's siblings for the same shape found a second, older
instance: "the refusal names the client" searched for "gh", which also
occurs in the explanatory prose ("gh speaks GitHub's /api/v3..."), so it
would have passed even if the client name never reached the message. Now
asserts "the 'gh' client cannot speak it".

Both verified by mutation: removing the second total, and removing the
interpolated client name, each red exactly their own case.

Refs #188
2026-08-02 19:16:48 +00:00
714a2e0413 feat(forge): the reconciler verb surface on both backends
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:

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

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

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

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

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

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

Refs #188
2026-08-02 19:13:14 +00:00
66e20f12f0 fix(forge): validate the completeness bound itself, on every page
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
@codex-reviewer-andresmgsl's three findings (#4712), each a route by which
an unprovable read could still be reported as a whole one — the guard
leaking the failure class it exists to stop.

1. x-total-count was never validated. `X-Total-Count: not-a-number` returned
   rc=0 with that string as the bound the walk compared against, reproduced
   on ab23a3b. Now required to be a canonical non-negative integer.

2. The total was read once. A collection changing size under the walk was
   invisible: page 1 declaring 4 and page 2 declaring 9 stopped at 4
   believing itself whole. Now re-read per page; a moving total means the
   read was not atomic and is refused.

3. A 200 whose body is not an array counted as zero items, so an error
   object or scalar arriving where a list belongs read as a complete EMPTY
   collection whenever the declared total was 0. Now refused, quoting the
   body. A genuinely empty array is still fine — covered.

Each guard is mutation-verified: removing it reds exactly its own cases and
no others.

Refs #188
2026-08-02 19:07:32 +00:00
87b088114a fix(test): silence the two lint classes the new backend suite introduces
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
SC2016 on the deliberate single-quoted bash -c (the expansion belongs to
the isolated process, as the sibling case in issueflow-reconcile.test.sh
already documents), and SC2317 on the curl stub, which shellcheck cannot
see is invoked indirectly by forge_api.

Found only after committing, because .github/scripts/shellcheck-all.sh
derives its lint set from `git ls-files` — an UNTRACKED file is not linted
at all. "Gates clean" measured before `git add` was measuring a set that
excluded the file just written. Verified from a clean clone at the pushed
SHA, which is what caught it.

Refs #188
2026-08-02 19:03:40 +00:00
ab23a3b1b6 feat(forge): two backends behind one call surface, and the shim owns paging
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.

The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:

  ?per_page=100   GitHub 100 items   Forgejo 30 items  (ignored)
  ?limit=100      GitHub  30 items   Forgejo 50 items  (capped)

Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.

Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.

@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.

Call sites are not ported yet; that is the next commit.

Refs #188
2026-08-02 19:00:58 +00:00
3885437f02 test(forge): cover the open-pull REST gather at main() granularity
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
@codex-reviewer-andresmgsl's draft-stage finding: the closed/merged half of
the term-3 replacement had an executable-path case, the open half did not.
The 27 closes_references cases test the parser, not the
`.body | @base64` -> base64 -d -> closes_references wiring around it.

Both directions in one sweep so neither assertion passes vacuously: #50 is
closed by an open PR and keeps its claim, #51 is closed by nothing and is
reclaimed. `Closes #50` sits on the third line of the body, so the newline
protection is non-vacuous — an @tsv-shaped regression that keeps only the
first line reclaims #50 and reds the case.

Verified by mutation: replacing the decode with `base64 -d | head -1` fails
exactly "a claim closed by an open PR survives the base64 round trip" and
nothing else; reverting restores 148/148.

The clock is injected. INOW is a fixed 2033 epoch, so without ISSUEFLOW_NOW
the subprocess reads its own wall clock, dates both claims in the future and
keeps them on a negative age — green, and proving nothing. Caught while
writing this case.

Also renames the sibling assertion that still said "through GraphQL"; that
gather has been REST since 5797b41.

Refs #188
2026-08-02 18:49:28 +00:00
5797b418b9 feat(forge): replace both gh api graphql sites with REST + a body parser
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Term 3 of #188. Forgejo has no GraphQL API, so these two gathers could not
be translated — there is no endpoint to translate them to. A real
forgejo-runner job says so from the other side: GITHUB_GRAPHQL_URL arrives
set to the empty string (probe task 278).

MERGED_REF_PR_RECORDS was already a body parse; GraphQL was buying
pagination, nothing semantic. OPEN_PR_ISSUES used GitHub's own parse of the
closing keywords, so it becomes lib/closes_references.sh — a sibling of
refs_references, sharing its LOCAL/CROSS classifier so rig#112 can still
never be read as local #112 (#61).

Both gathers now read /pulls, which /api/v3 and /api/v1 return in the same
shape (measured on both). merged_at replaces GraphQL's states: MERGED.
Bodies travel base64: jq's @tsv escapes a newline to a literal backslash-n,
which a line parser reads as one line and loses every declaration after the
first.

The accepted delta, written down rather than rediscovered: GitHub also
records closing links attached through the PR development sidebar, which
live in no body. This family declares links in the body, so the delta is
zero here.

Refs #188
2026-08-02 18:41:03 +00:00
7d52b2cd4a feat(forge): refuse loudly when the client cannot speak the forge
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.

Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:

  labels-scope         exit 0  "no .github/labeler.yml" (the file is HTTP 200)
  labels-reconcile     exit 0  "reconciled."            (zero PRs enumerated)
  issueflow-reconcile  exit 1  "unexpected end of JSON input"

labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.

Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.

Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.

Refs #188
2026-08-02 18:29:08 +00:00
20 changed files with 2319 additions and 105 deletions

View file

@ -15,6 +15,18 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# GitHub-hosted ubuntu-latest ships shellcheck; the Forgejo runner image
# this instance uses (ghcr.io/catthehacker/ubuntu:act-22.04) does not.
# actionlint already self-installs below — the same for shellcheck, so a
# green head is reachable once a ceremony runner is online (#188).
- name: Install shellcheck
env:
SHELLCHECK_VERSION: 0.10.0
run: |
curl -fsSLo shellcheck.tar.xz \
"https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
tar -xJf shellcheck.tar.xz "shellcheck-v${SHELLCHECK_VERSION}/shellcheck"
sudo install "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
- name: Shellcheck - name: Shellcheck
run: bash .github/scripts/shellcheck-all.sh run: bash .github/scripts/shellcheck-all.sh
- name: Install actionlint - name: Install actionlint

View file

@ -27,6 +27,10 @@ TRIAGE_ACTORS=()
# The needs-ruling invariants (#52) — one implementation for both surfaces. # The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh # shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# shellcheck source=lib/closes_references.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
log() { printf 'issueflow: %s\n' "$*"; } log() { printf 'issueflow: %s\n' "$*"; }
run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; } run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; }
@ -271,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 -->"
} }
@ -284,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
} }
@ -295,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)"
@ -325,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 \
@ -358,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"
@ -387,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
@ -429,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
@ -451,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:-}" ] \
@ -462,11 +466,19 @@ 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).
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || return 0 # `.pull_request == null`, NOT `has("pull_request") | not` (#188). The key's
# PRESENCE is a GitHub-only discriminator: GitHub omits it on a plain issue,
# Forgejo emits it on every entry and sets it to null. Measured on the list
# endpoints, 2026-08-02 — GitHub 0 of 9 issues carried the key, Forgejo 10 of
# 10 did. So the old test selected ZERO issues here and the sweep printed
# "reconciled." over an untouched board: the blind sweep again, one layer in.
# The null test is true for an issue on both forges (absent key reads as
# null) and false for a PR on both.
jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || return 0
author="$(jq -r '.user.login' <<<"$ISSUE_JSON")" author="$(jq -r '.user.login' <<<"$ISSUE_JSON")"
is_triage_actor "$author" && triage=true is_triage_actor "$author" && triage=true
labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
@ -476,56 +488,73 @@ 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)"
} }
main() { main() {
local owner name # See labels-reconcile's twin (#188). This one already failed loudly on
# 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.
# 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}"
load_issueflow_config "$LABELS_CONF" load_issueflow_config "$LABELS_CONF"
if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then
reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}" reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}"
fi fi
owner="${REPO%%/*}" # owner/name split out here until #188 — the GraphQL query took them as
name="${REPO#*/}" # separate variables. REST takes the owner/name path whole, so it is gone.
OPEN_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query=' # Both gathers were `gh api graphql` until #188. Forgejo has NO GraphQL
query($owner: String!, $name: String!, $endCursor: String) { # API — a real forgejo-runner job even arrives with GITHUB_GRAPHQL_URL set
repository(owner: $owner, name: $name) { # to the empty string (probe task 278) — so these could not be translated
pullRequests(first: 100, states: OPEN, after: $endCursor) { # to a Forgejo endpoint; there is none. They are REST + a parser this repo
nodes { closingIssuesReferences(first: 100) { nodes { number } } } # owns, over `number` and `body`, which /api/v3 and /api/v1 both return in
pageInfo { hasNextPage endCursor } # the same shape (measured on both, 2026-08-02).
} #
} # Bodies travel base64 because they contain newlines: jq's @tsv escapes a
}' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \ # newline to a literal backslash-n, which a line-oriented parser reads as
| sort -nu)" # one line and silently loses every declaration after the first. The old
MERGED_REF_PR_RECORDS="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query=' # GraphQL gather sidestepped that with `split("\n")[]`; base64 is the same
query($owner: String!, $name: String!, $endCursor: String) { # protection without needing the split to be correct.
repository(owner: $owner, name: $name) { OPEN_PR_ISSUES="$(forge_api --paginate "repos/$REPO/pulls?state=open" \
pullRequests(first: 100, states: MERGED, after: $endCursor) { --jq '.[] | .body // "" | @base64' \
nodes { number body } | while IFS= read -r b64; do
pageInfo { hasNextPage endCursor } [ -n "$b64" ] && printf '%s' "$b64" | base64 -d | closes_references
} done | sort -nu)"
} # closes_references, not refs_references: GitHub's closingIssuesReferences
}' --jq '.data.repository.pullRequests.nodes[] # meant the CLOSING relation specifically, and reading Refs as closing
| .number as $pr | .body | split("\n")[] # would make every referenced issue look closeable — the distinction #151
| [$pr, .] | @tsv' \ # was reopened by hand over.
| while IFS=$'\t' read -r pr body; do MERGED_REF_PR_RECORDS="$(forge_api --paginate "repos/$REPO/pulls?state=closed" \
--jq '.[] | select(.merged_at != null) | "\(.number)\t\(.body // "" | @base64)"' \
| while IFS=$'\t' read -r pr b64; do
[ -n "$b64" ] || continue
while IFS= read -r issue; do while IFS= read -r issue; do
[ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr" [ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr"
done < <(refs_references <<<"$body") done < <(printf '%s' "$b64" | base64 -d | refs_references)
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" \
--jq '.[] | select(has("pull_request") | not) | .number'); do --jq '.[] | select(.pull_request == null) | .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 '.pull_request == null' <<<"$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"
) || log "#$n: reconcile failed — continuing with the remaining issues" ) || log "#$n: reconcile failed — continuing with the remaining issues"

View file

@ -53,6 +53,8 @@ STALE_AFTER=$((48 * 3600))
# The needs-ruling invariants (#52) — one implementation for both surfaces. # The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh # shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
log() { printf 'labels: %s\n' "$*"; } log() { printf 'labels: %s\n' "$*"; }
@ -185,6 +187,35 @@ set_required_bots() { # the PR author is recused by construction
requested() { grep -qxF "$1" <<<"$REQUESTED"; } requested() { grep -qxF "$1" <<<"$REQUESTED"; }
# outstanding_requests <requested-logins> — the portable "who still owes a
# verdict on THIS head" (issue #188, term 4).
#
# GitHub clears requested_reviewers when a verdict lands, so on that forge the
# field already answers this question and the filter below removes nothing.
# **Forgejo does not clear it.** Measured 2026-08-02: rig!140 listed all three
# panelists with all three verdicts in, and rig!146 still lists three while
# MERGED — the field is stale even on a closed PR, so it over-counts forever.
#
# Reading it raw on Forgejo pins a PR at state:bots-reviewing for life and
# stops blocker:unrequested from ever being true: the sweep believes a round
# is permanently live. So the requested set is intersected with "has not
# submitted a verdict for the current head", which is derived from
# /pulls/{n}/reviews — the read that is true on both forges.
#
# Pure over REVIEWS_JSON/HEAD_SHA so the fixtures can drive it; a reviewer
# whose only verdict is STALE still owes one, which is why this asks
# bot_verdict rather than merely "has any review".
outstanding_requests() {
local login
while IFS= read -r login; do
[ -n "$login" ] || continue
case "$(bot_verdict "$login")" in
APPROVE | BLOCK | FEEDBACK) continue ;;
esac
printf '%s\n' "$login"
done <<<"${1-}"
}
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
# UNREADABLE is the absence of the key itself, which is what a failed fetch # UNREADABLE is the absence of the key itself, which is what a failed fetch
# leaves behind — distinct from a present-but-empty rollup, which honestly # leaves behind — distinct from a present-but-empty rollup, which honestly
@ -514,7 +545,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
@ -528,7 +559,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)"
} }
@ -557,10 +588,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"
@ -581,7 +612,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
@ -643,7 +674,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
@ -666,18 +697,24 @@ 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
# ---- stale: real activity only, and blocked is legitimately quiet ---- # ---- stale: real activity only, and blocked is legitimately quiet ----
# forge_pr_activity owns the portable half: issue comments + commits +
# inline review comments. The flat /pulls/{n}/comments endpoint 404s on
# Forgejo; the forgejo backend re-derives it from reviews with
# comments_count > 0 (#188 / #4844). PR created_at and review submitted_at
# stay here — they are already in hand and need no second fetch.
last_activity="$( last_activity="$(
{ {
jq -r '.created_at' <<<"$PR_JSON" jq -r '.created_at' <<<"$PR_JSON"
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON" jq -r '.[].submitted_at // empty' <<<"$REVIEWS_JSON"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' # Non-fatal degrade (pre-#188 same edge), but do NOT swallow stderr —
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' # forge_api names failures loudly, and hiding them re-opens this issue's
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' # silent-green class (#4879 / #101 D5).
forge_pr_activity "$n" || true
} | sort | tail -n1 } | sort | tail -n1
)" )"
last_activity_epoch="$(date -d "$last_activity" +%s)" last_activity_epoch="$(date -d "$last_activity" +%s)"
@ -686,11 +723,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
@ -705,6 +742,21 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
} }
main() { main() {
# BEFORE anything reads the board (#188). Every call site below is still
# `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).
# 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}"
load_config "$LABELS_CONF" load_config "$LABELS_CONF"
@ -717,7 +769,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"
@ -728,17 +780,20 @@ 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"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")" HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")" BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")" 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 # 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")]')"
# Read AFTER the reviews, because the raw field is not portable: Forgejo
# never clears it, so it is intersected with who still owes a verdict on
# this head (#188 term 4). A no-op on GitHub, which clears it itself.
REQUESTED="$(outstanding_requests "$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")")"
# 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
# `mergeable` is a tri-state boolean that GitHub computes lazily, while # `mergeable` is a tri-state boolean that GitHub computes lazily, while
@ -753,7 +808,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")"
@ -783,7 +838,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

@ -6,6 +6,9 @@ else
set -u set -u
fi fi
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# labels-scope.sh — the additive half of the labels automation: derive # labels-scope.sh — the additive half of the labels automation: derive
# scope:* labels from a PR's changed paths and ADD them, touching nothing # scope:* labels from a PR's changed paths and ADD them, touching nothing
# else. This seat belonged to actions/labeler@v5 until #130: even under # else. This seat belonged to actions/labeler@v5 until #130: even under
@ -68,6 +71,14 @@ glob_to_regex() { # $1 = glob (the subset above) → anchored ERE, one line
} }
parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
# The jq variable is $lbl, not $label: **`label` is a reserved keyword in
# jq's grammar** (`label $out | ... | break $out`), and jq 1.6 refuses
# `$label` outright — "syntax error, unexpected label, expecting IDENT".
# jq 1.7 parses it, which is why this survived: GitHub's hosted
# ubuntu-latest ships 1.7, and the Forgejo runner image
# (ghcr.io/catthehacker/ubuntu:act-22.04) ships **1.6**. Measured on both,
# 2026-08-02 (#188). Every scope-label derivation on this forge failed on a
# jq compile error before the config was even read.
# yq only normalizes YAML to JSON; the shape contract is enforced in jq, # yq only normalizes YAML to JSON; the shape contract is enforced in jq,
# where an unsupported key is a loud error naming the label it sits under. # where an unsupported key is a loud error naming the label it sits under.
yq -o=json '.' - | jq -r ' yq -o=json '.' - | jq -r '
@ -75,38 +86,38 @@ parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
error("labeler config: top level must be a map of label -> rules") error("labeler config: top level must be a map of label -> rules")
else . end else . end
| to_entries[] | to_entries[]
| .key as $label | .key as $lbl
| (if (.value | type) != "array" then | (if (.value | type) != "array" then
error("labeler config: \($label): rules must be a list") error("labeler config: \($lbl): rules must be a list")
else .value end)[] else .value end)[]
| (if type != "object" then | (if type != "object" then
error("labeler config: \($label): each rule must be a map") error("labeler config: \($lbl): each rule must be a map")
else . end) else . end)
| ((keys - ["changed-files"]) as $extra | ((keys - ["changed-files"]) as $extra
| if ($extra | length) > 0 then | if ($extra | length) > 0 then
error("labeler config: \($label): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)") error("labeler config: \($lbl): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)")
else . end) else . end)
| .["changed-files"] | .["changed-files"]
| (if type == "object" then [.] | (if type == "object" then [.]
elif type == "array" then . elif type == "array" then .
else error("labeler config: \($label): changed-files must be a list") end)[] else error("labeler config: \($lbl): changed-files must be a list") end)[]
| (if type != "object" then | (if type != "object" then
error("labeler config: \($label): each changed-files entry must be a map") error("labeler config: \($lbl): each changed-files entry must be a map")
else . end) else . end)
| ((keys - ["any-glob-to-any-file"]) as $extra | ((keys - ["any-glob-to-any-file"]) as $extra
| if ($extra | length) > 0 then | if ($extra | length) > 0 then
error("labeler config: \($label): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)") error("labeler config: \($lbl): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)")
else . end) else . end)
| .["any-glob-to-any-file"] | .["any-glob-to-any-file"]
| (if type == "string" then [.] | (if type == "string" then [.]
elif type == "array" then . elif type == "array" then .
else error("labeler config: \($label): any-glob-to-any-file must be a glob or a list of globs") end)[] else error("labeler config: \($lbl): any-glob-to-any-file must be a glob or a list of globs") end)[]
| (if type != "string" then | (if type != "string" then
error("labeler config: \($label): globs must be strings") error("labeler config: \($lbl): globs must be strings")
elif contains("\\") then elif contains("\\") then
error("labeler config: \($label): backslash in glob \(.) — escapes are not supported (#130)") error("labeler config: \($lbl): backslash in glob \(.) — escapes are not supported (#130)")
else . end) else . end)
| [$label, .] | @tsv | [$lbl, .] | @tsv
' '
} }
@ -125,6 +136,21 @@ derive_labels() { # $1 = "label<TAB>glob" lines, $2 = changed files (one per
} }
main() { main() {
# See labels-reconcile's twin (#188). This action's degraded read was the
# 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.
# 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}"
CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}" CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}"
@ -134,13 +160,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
@ -148,8 +174,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)"
} }

56
changelog.d/188.md Normal file
View file

@ -0,0 +1,56 @@
### Added
- `lib/forge.sh` — the forge selector: `forge_detect` names the forge from
the runner's own environment, `forge_client` names the client it needs, and
`forge_preflight` refuses loudly before any sweep when the two disagree
(#188).
- The reconcilers and `labels-scope` run that preflight first, so a
GitHub-shaped client on a Forgejo instance is a named refusal instead of a
sweep that reads nothing and reports success (#188).
- `lib/closes_references.sh` — the closing-keyword parser, sibling of
`refs_references`, so "which issues does this PR close" is answered from a
PR body rather than from GitHub's GraphQL API (#188).
- `lib/forge-github.sh` and `lib/forge-forgejo.sh` — one call surface, two
backends, selected by `forge_select`; no forge branching at the call sites
(#188).
- The forgejo backend proves each paginated gather complete against the
server's `x-total-count` and refuses loudly when it cannot — a missing
header is a refusal, not a pass (#188).
### Changed
- `issueflow-reconcile` gathers open and merged PRs over REST instead of
`gh api graphql`. Forgejo serves no GraphQL at all, so the two queries were
replaced rather than translated; both forges return `number` and `body`
from `/pulls` in the same shape (#188).
- `forge_api` owns the page size, because each forge silently ignores the
other's parameter: `per_page=100` reads 30 items on Forgejo and `limit=100`
reads 30 on GitHub, both HTTP 200. No call site names one (#188).
- Outstanding review requests are derived from the reviews on the current head
rather than from `requested_reviewers`, which Forgejo never clears — read
raw there, a PR would sit at `state:bots-reviewing` forever (#188).
### Fixed
- `labels-scope` no longer fails to compile its jq program on jq 1.6, which
the Forgejo runner image ships: `label` is a reserved word in jq's grammar,
so `$label` is a syntax error there and every scope derivation died before
reading the config (#188).
- `labels-reconcile` and `labels-scope` no longer exit 0 on a Forgejo
consumer having read zero facts — measured on `heavy-duty/rig`, where the
sweep printed `reconciled.` over an empty PR list and scope reported "no
labeler.yml" for a file that exists (#188).
- `forge_timeline` projects Forgejo's label events (`.type` / `.body` /
`.user.login`) into the GitHub shape (`.event` / `.actor.login`) so the
ruling ladder reads the same board on both forges (#188).
- `forge_pr_activity` no longer calls `/pulls/{n}/comments` on Forgejo
(HTTP 404); inline review comments come from reviews with
`comments_count > 0` (#188).
- CI installs shellcheck before linting, matching actionlint — the Forgejo
runner image does not ship it (#188).

View file

@ -396,6 +396,13 @@ never before it and never through mixed refs.
Both actor lists are whitespace-separated. `triage-actors` names the identities Both actor lists are whitespace-separated. `triage-actors` names the identities
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
`name|color|description`; blank lines are ignored and extra pipes are refused. `name|color|description`; blank lines are ignored and extra pipes are refused.
**Every account in `panel=` must be able to read the repository.** Requesting a
review from someone without read access is refused by the forge, not silently
dropped — on Forgejo with `422 Reviewer can't read`, naming the account
(#188). On a public repo this is satisfied already; on a **private** consumer
it is a real failure mode when a panel member is not on the collaborator
list, and the sweep will report it rather than sweep blind.
There are no comment lines: every non-blank line must be the `panel=` There are no comment lines: every non-blank line must be the `panel=`
setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose
is a parse failure, not a comment (rig #13's conversion found this the hard is a parse failure, not a comment (rig #13's conversion found this the hard

79
lib/closes_references.sh Normal file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
# lib/closes_references.sh — "which issues does this PR body close?", parsed
# here rather than asked of a forge (issue #188, term 3).
#
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
# options, as lib/version.sh and lib/forge.sh do.
#
# WHY THIS EXISTS. issueflow-reconcile asked GitHub's GraphQL API for
# `closingIssuesReferences` — GitHub's own parse of the closing keywords in
# a PR body. **Forgejo has no GraphQL API at all**, and the runner confirms
# it from the other side: a real forgejo-runner job arrives with
# GITHUB_GRAPHQL_URL set to the empty string (probe task 278, 2026-08-02).
# So that call site could not be translated to a Forgejo endpoint — there is
# nothing to translate it to. It had to be replaced by a parse this repo
# owns, over a field both forges already return:
# `GET /repos/{owner}/{repo}/pulls` carries `number` and `body` on
# /api/v3 and /api/v1 alike (measured on both).
#
# That the replacement is honest is the point. The sibling half of the same
# GraphQL query, MERGED_REF_PR_RECORDS, was ALREADY a body parse — it pulled
# `number` and `body` and ran them through refs_references. GraphQL was
# buying pagination convenience there, nothing semantic. This file makes the
# other half symmetric: one parser this repo controls and can test, for both
# link kinds, on both forges.
#
# THE ACCEPTED DELTA, stated so it is not rediscovered as a bug: GitHub also
# records closing links attached through the pull request's development
# sidebar, which live in no body and which no body parse can see. This
# family declares its links in the body — that is what BUILDER.md's PR
# template asks for — so the delta is zero in practice here. A consumer that
# links through the sidebar would see those issues go unclosed by the sweep;
# they would need to say so in the body instead.
#
# DEPENDENCY: issue_references, from issueflow-reconcile.sh — the LOCAL /
# CROSS classifier that keeps rig#112 from ever being read as local #112
# (#61). Bash resolves function calls at call time, so the order of sourcing
# does not matter; both must simply be defined before closes_references runs.
# refs_references depends on it exactly the same way.
# closes_references — PR body on stdin -> local issue numbers this body
# declares it CLOSES, sorted, unique.
#
# The keyword set is GitHub's documented one, all three verbs in all three
# tenses. Matching is case-insensitive because bodies are written by humans
# and agents both ("Closes", "closes", "CLOSES").
#
# Deliberately NOT matched: "Refs #N". That is the other relation entirely —
# refs_references owns it, and conflating them would make every referenced
# issue look closeable, which is the post-merge transition #151 had to be
# reopened by hand over.
closes_references() {
awk '
{
line = $0
lower = tolower(line)
# Every occurrence contributes, not just the first: a body that says
# "Closes #1. Closes #2." declares two, and binding to the first
# occurrence dropped the later ones — the same defect #184 fixed in
# blocked_reference_records, kept fixed here by construction.
while (match(lower, /(^|[^[:alnum:]_-])(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]:]+/)) {
# BOTH cursors advance together. Advancing only `lower` left the
# next match offset indexing the ORIGINAL line, so the second
# declaration on a line came back as garbage — caught by the
# "two closes on one line" case, which is why it is a case.
rest = substr(line, RSTART + RLENGTH)
line = rest
lower = tolower(rest)
if (rest ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
token = rest
# Stop at the first thing that cannot be part of a reference, so
# "Closes #12, and more prose" yields #12 and not the sentence.
sub(/[^[:alnum:]_.\/#-].*/, "", token)
print token
}
}
}
' | issue_references \
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
}

483
lib/forge-forgejo.sh Normal file
View file

@ -0,0 +1,483 @@
#!/usr/bin/env bash
# lib/forge-forgejo.sh — the Forgejo backend: /api/v1 over curl + jq
# (issue #188, term 1). Sourced by lib/forge.sh when forge_detect says
# forgejo; never sourced directly, and never at the same time as the github
# backend — they define the same verbs on purpose.
#
# curl+jq rather than a CLI because that is what the runner has. The image
# this instance runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04, probe
# task 278) carries curl, jq and node, and has neither `gh` nor `stoke`.
# forgejo_api_base — the /api/v1 root, from the runner's own environment.
# GITHUB_API_URL already IS the /api/v1 root on a Forgejo runner (measured:
# https://forgejo.heavyduty.builders/api/v1). CEREMONY_FORGE_API overrides
# it for tests and for anyone driving this outside Actions.
forgejo_api_base() {
local base="${CEREMONY_FORGE_API:-${GITHUB_API_URL:-}}"
if [ -z "$base" ]; then
echo "forgejo_api_base: no GITHUB_API_URL or CEREMONY_FORGE_API — cannot reach the forge (#188)" >&2
return 1
fi
printf '%s\n' "${base%/}"
}
# forgejo_page_url <endpoint> <page> — pure, so the page-size contract is
# testable without a network. Returns the endpoint with this backend's OWN
# paging parameters applied.
#
# THE TRAP THIS EXISTS TO REMOVE, measured 2026-08-02 against
# heavy-duty/rig (137 issues and PRs) and heavy-duty/ceremony on GitHub:
#
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
#
# Each forge silently ignores the other's page-size parameter, answers
# HTTP 200 with valid JSON, and says nothing. Every call site in this repo
# was written GitHub-shaped, so a verbatim port would have swept 30 of
# rig's 137 and printed "reconciled." — acceptance criterion 2 failing
# green, and the same "degraded read that does not report it degraded"
# failure class this whole issue exists to kill.
#
# So NO CALL SITE NAMES A PAGE SIZE. The backend owns it. Fixing the
# boundary once beats fixing nine call sites and trusting the tenth — the
# same argument that chose shape C over B, one level down.
#
# 50 is not a preference: Forgejo caps a page at MAX_RESPONSE_ITEMS (50 on
# this instance) whatever you ask for, so asking for more cannot help and
# pagination is mandatory rather than an optimisation.
forgejo_page_url() {
local endpoint="${1:?forgejo_page_url: endpoint required}" page="${2:?forgejo_page_url: page required}"
# Strip any page-size parameter a caller left behind, in either dialect,
# rather than trusting that none did: this function is the one place that
# decides paging, and a stray per_page= would be exactly the silent
# truncation above.
local clean="$endpoint"
clean="$(printf '%s' "$clean" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
case "$clean" in
*\?) printf '%slimit=50&page=%s\n' "$clean" "$page" ;;
*\?*) printf '%s&limit=50&page=%s\n' "$clean" "$page" ;;
*) printf '%s?limit=50&page=%s\n' "$clean" "$page" ;;
esac
}
# forge_api [--paginate] <endpoint> [--jq <expr>]
#
# --paginate walks page= until a short page, then PROVES the walk was
# complete by comparing what it collected against the server's declared
# x-total-count. @kimi-reviewer-andresmgsl's hardening (#4699): a MISSING
# header is a loud refusal, not a pass. Header exposure is a server setting
# (access-control-expose-headers), and an instance that withholds it would
# make the completeness check compare null to a number — the guard itself
# degrading silently, which is the failure class re-entering through the
# door built to stop it.
forge_api() {
local paginate=false endpoint="" jqexpr="" have_jq=false
while [ $# -gt 0 ]; do
case "$1" in
--paginate) paginate=true ;;
--jq) jqexpr="$2"; have_jq=true; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
local base token
base="$(forgejo_api_base)" || return 1
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
local hdr body
hdr="$(mktemp)"; body="$(mktemp)"
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
trap "rm -f '$hdr' '$body'" RETURN
if [ "$paginate" = false ]; then
if ! curl -sS -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Accept: application/json' \
"$base/$endpoint"; then
echo "forge_api: request failed: $endpoint" >&2
return 1
fi
forgejo_http_ok "$hdr" "$endpoint" || return 1
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi
return 0
fi
# Paginated: accumulate into ONE array and apply --jq once at the end.
# gh --paginate applies --jq per page and concatenates; for the `.[] | …`
# shapes every call site here uses, the two are identical, and merging
# first is what makes the completeness assert possible at all.
local page=1 total="" got=0 n all="[]" pagejson
while :; do
if ! curl -sS -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Accept: application/json' \
"$base/$(forgejo_page_url "$endpoint" "$page")"; then
echo "forge_api: request failed: $endpoint (page $page)" >&2
return 1
fi
forgejo_http_ok "$hdr" "$endpoint" || return 1
# Re-read on EVERY page, not once (#4712). A board that changes size
# under the walk was invisible: page 1 declaring 4 and page 2 declaring
# 9 stopped at 4 believing itself whole. A moving total means the read
# cannot have been atomic, so it is refused rather than reconciled.
local page_total
page_total="$(forgejo_total_count "$hdr")" || return 1
if [ -z "$total" ]; then
total="$page_total"
elif [ "$page_total" != "$total" ]; then
cat >&2 <<EOF
forge_api: the declared total for '$endpoint' changed between pages — $total then $page_total (#188).
The collection moved under the walk, so no page set can be proven whole.
Refusing rather than reconciling a board that is already out of date.
EOF
return 1
fi
pagejson="$(cat "$body")"
# A 200 whose body is not a collection counted as zero items (#4712),
# so an error object or a scalar arriving where a list belongs read as
# a complete EMPTY collection whenever the declared total was 0.
if [ "$(jq -r 'type' <<<"$pagejson" 2>/dev/null)" != array ]; then
cat >&2 <<EOF
forge_api: '$endpoint' did not return a collection (#188).
Expected a JSON array; got: $(head -c 200 <<<"$pagejson")
Refusing: a body this shim cannot count must not be counted as empty.
EOF
return 1
fi
n="$(jq 'length' <<<"$pagejson")"
[ "$n" -gt 0 ] || break
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
got=$((got + n))
[ "$got" -lt "$total" ] || break
page=$((page + 1))
done
# The assert. A short read here is the silent-truncation bug arriving by
# another route, so it is fatal rather than a warning.
if [ "$got" -ne "$total" ]; then
cat >&2 <<EOF
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
Refusing rather than reconciling a partial board: a sweep over part of the
queue that reports success is the failure this shim exists to prevent.
EOF
return 1
fi
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <<<"$all"; else printf '%s\n' "$all"; fi
}
# forgejo_total_count <header-file> — the declared size of the collection.
# Absent is fatal (#4699): without it the completeness assert cannot run,
# and an assert that cannot run must not silently pass.
forgejo_total_count() {
local hdr="$1" total
total="$(tr -d '\r' <"$hdr" | awk 'tolower($1) == "x-total-count:" { print $2 }' | tail -n1)"
if [ -z "$total" ]; then
cat >&2 <<EOF
forge_api: this forge did not send x-total-count — cannot prove the gather is complete (#188).
The header is exposed by a server setting (access-control-expose-headers).
Refusing: an unprovable read must not be reported as a whole one.
EOF
return 1
fi
# Validate before it reaches arithmetic (#4712). `X-Total-Count:
# not-a-number` used to sail through and become the bound the walk was
# compared against — a guard whose own input was never checked.
case "$total" in
'' | *[!0-9]*)
cat >&2 <<EOF
forge_api: x-total-count is not a non-negative integer: '$total' (#188).
Refusing: the completeness bound must be a number, or the assert that
uses it proves nothing.
EOF
return 1
;;
esac
printf '%s\n' "$total"
}
# forgejo_http_ok <header-file> <endpoint> — a non-2xx is named, not
# swallowed. gh exits non-zero on HTTP failure; curl does not without -f,
# and -f would throw away the body that says why.
forgejo_http_ok() {
local hdr="$1" endpoint="$2" code
code="$(tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }')"
case "$code" in
2*) return 0 ;;
*)
echo "forge_api: HTTP $code from '$endpoint'" >&2
return 1
;;
esac
}
# --- the verbs the reconcilers use, over /api/v1 --------------------------
# Three asymmetries with gh, all measured against this instance on
# 2026-08-02 using a scratch repo (never a live board):
#
# 1. Adding labels takes NAMES POST /issues/{n}/labels {"labels":["x"]} -> 200
# Removing one takes a numeric ID DELETE /issues/{n}/labels/x -> 422
# DELETE /issues/{n}/labels/149 -> 204
# So a removal must resolve name -> id first. gh hides this; the shim
# cannot.
#
# 2. Assignees are SET, not added and removed. PATCH /issues/{n} takes the
# whole list ({"assignees":[]} clears it, 201), so --remove-assignee is
# a read-modify-write rather than a delete.
#
# 3. There is no statusCheckRollup. The portable equivalent is the
# combined commit status, GET /commits/{sha}/status, which returns
# {state, statuses[]}.
# forgejo_label_ids — name<TAB>id for every label in the repo, read once per
# call site that needs it. Paginated through forge_api, so a repo with more
# than one page of labels cannot silently lose the tail (#188).
forgejo_label_ids() {
forge_api --paginate "repos/$REPO/labels" --jq '.[] | "\(.name)\t\(.id)"'
}
# forge_issue_edit <n> [--add-label X]… [--remove-label X]… [--add-assignee U]… [--remove-assignee U]…
# gh's flag surface, translated. Accepts comma-separated values, as gh does.
forge_issue_edit() {
local n="${1:?forge_issue_edit: number required}"
shift
local add_labels=() rm_labels=() add_assignees=() rm_assignees=() v
# Unknown flags REFUSE (#4743). The github backend forwards whatever it is
# given to `gh`, which fails on a flag it does not know; dropping it here
# instead would turn a port typo into a green no-op — a mutation that
# silently did not happen, which is precisely this issue's failure class
# arriving inside the fix for it.
while [ $# -gt 0 ]; do
case "$1" in
--add-label | --remove-label | --add-assignee | --remove-assignee)
if [ "$#" -lt 2 ]; then
echo "forge_issue_edit: $1 requires a value (#188)" >&2
return 1
fi
IFS=, read -ra v <<<"$2"
case "$1" in
--add-label) add_labels+=("${v[@]}") ;;
--remove-label) rm_labels+=("${v[@]}") ;;
--add-assignee) add_assignees+=("${v[@]}") ;;
--remove-assignee) rm_assignees+=("${v[@]}") ;;
esac
shift
;;
*)
echo "forge_issue_edit: unknown flag '$1' — refusing rather than silently skipping the edit (#188)" >&2
return 1
;;
esac
shift
done
if [ "${#add_labels[@]}" -gt 0 ]; then
local payload
payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -sc '{labels: .}')"
forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
fi
if [ "${#rm_labels[@]}" -gt 0 ]; then
local ids id name
ids="$(forgejo_label_ids)" || return 1
for name in "${rm_labels[@]}"; do
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
# A label the repo does not have is not an error: the reconcilers call
# --remove-label unconditionally to converge state, and gh's own
# behaviour there is a no-op.
[ -n "$id" ] || continue
forgejo_write DELETE "repos/$REPO/issues/$n/labels/$id" '' >/dev/null || return 1
done
fi
if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then
local current want payload
current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1
want="$(
{
printf '%s\n' "$current"
[ "${#add_assignees[@]}" -gt 0 ] && printf '%s\n' "${add_assignees[@]}"
} | grep -v '^$' | sort -u
)"
if [ "${#rm_assignees[@]}" -gt 0 ]; then
want="$(grep -vxF -f <(printf '%s\n' "${rm_assignees[@]}") <<<"$want" || true)"
fi
payload="$(printf '%s' "$want" | jq -R . | jq -sc '{assignees: [.[] | select(. != "")]}')"
forgejo_write PATCH "repos/$REPO/issues/$n" "$payload" >/dev/null || return 1
fi
}
forge_issue_comment() {
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
forgejo_write POST "repos/$REPO/issues/$n/comments" "$(jq -nc --arg b "$body" '{body: $b}')" >/dev/null
}
forge_pr_list() {
forge_api --paginate "repos/$REPO/pulls?state=open" --jq '.[].number'
}
# forge_pr_view <n> — the {mergeable, statusCheckRollup} shape the state
# machine reads, assembled from the two places Forgejo keeps it. The rollup
# is mapped into the node shape checks_state already parses, so the decision
# code is untouched.
forge_pr_view() {
local n="${1:?forge_pr_view: number required}" pr sha status
pr="$(forge_api "repos/$REPO/pulls/$n")" || return 1
sha="$(jq -r '.head.sha // ""' <<<"$pr")"
[ -n "$sha" ] || { echo "forge_pr_view: PR $n has no head sha" >&2; return 1; }
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
jq -n --argjson pr "$pr" --argjson st "$status" '
{
mergeable: (if $pr.mergeable == true then "MERGEABLE"
elif $pr.mergeable == false then "CONFLICTING"
else "UNKNOWN" end),
statusCheckRollup: [
$st.statuses[]? | {
__typename: "StatusContext",
context: .context,
state: (.status | ascii_upcase),
# checks_state groups repeated contexts and takes the NEWEST by
# [.startedAt, .createdAt, .completedAt]. Without a timestamp the
# winner would be decided by incidental array order, so a stale
# re-run could outrank the live verdict (#4743). The combined
# status carries both fields; measured on this instance.
createdAt: .created_at,
completedAt: .updated_at
}
]
}'
}
forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
# forge_label_create — an UPSERT, matching `gh label create --force` (#4743).
# bootstrap_labels creates every declared label on every workflow_dispatch, so
# the second dispatch must update rather than conflict; a plain POST onto an
# existing name aborts the bootstrap under set -e.
forge_label_create() {
local name="${1:?}" color="${2:?}" desc="${3:-}" ids id payload
payload="$(jq -nc --arg n "$name" --arg c "$color" --arg d "$desc" '{name:$n,color:$c,description:$d}')"
ids="$(forgejo_label_ids)" || return 1
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
if [ -n "$id" ]; then
forgejo_write PATCH "repos/$REPO/labels/$id" "$payload" >/dev/null
else
forgejo_write POST "repos/$REPO/labels" "$payload" >/dev/null
fi
}
forge_label_delete() {
local name="${1:?}" ids id
ids="$(forgejo_label_ids)" || return 1
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
[ -n "$id" ] || return 0
forgejo_write DELETE "repos/$REPO/labels/$id" '' >/dev/null
}
# forgejo_write <method> <endpoint> <json-body> — every mutation goes through
# here so a non-2xx is named rather than swallowed, the same contract
# forgejo_http_ok gives reads.
forgejo_write() {
local method="$1" endpoint="$2" payload="$3" base token hdr body rc
base="$(forgejo_api_base)" || return 1
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
hdr="$(mktemp)"; body="$(mktemp)"
if [ -n "$payload" ]; then
curl -sS -X "$method" -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Content-Type: application/json' \
-d "$payload" "$base/$endpoint"
else
curl -sS -X "$method" -D "$hdr" -o "$body" \
-H "Authorization: token $token" "$base/$endpoint"
fi
rc=$?
if [ "$rc" -ne 0 ]; then
rm -f "$hdr" "$body"
echo "forge: $method $endpoint failed to send" >&2
return 1
fi
if ! forgejo_http_ok "$hdr" "$method $endpoint"; then
head -c 300 "$body" >&2; echo >&2
rm -f "$hdr" "$body"
return 1
fi
cat "$body"
rm -f "$hdr" "$body"
}
# 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 -sc '{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 -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null
}
# forge_timeline <n> — JSON array of timeline events projected into the
# GitHub shape the reconcilers already select on. Measured mapping (#4849):
#
# | | GitHub | Forgejo |
# | event kind | .event == "labeled"/"unlabeled"| .type == "label" |
# | add vs remove | the two event names | .body "1" / "" |
# | actor | .actor.login (no .user) | .user.login (no .actor) |
#
# Status is captured BEFORE jq so an unreadable read cannot report as an
# empty timeline — the two states the ruling ladder must tell apart (#4853).
forge_timeline() {
local n="${1:?forge_timeline: number required}" raw
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1
jq '
[.[]
| select(.type == "label")
| {
event: (if .body == "1" then "labeled" else "unlabeled" end),
actor: {login: (.user.login // "")},
label: {name: (.label.name // "")},
created_at: .created_at
}
]
' <<<"$raw"
}
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity.
# Forgejo has no flat /pulls/{n}/comments (HTTP 404, measured #4844); inline
# review comments live under /pulls/{n}/reviews/{id}/comments. Only reviews
# with comments_count > 0 are fetched, so a board with none costs zero
# extra requests.
forge_pr_activity() {
local n="${1:?forge_pr_activity: number required}" reviews rid
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
reviews="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews")" || return 1
while IFS= read -r rid; do
[ -n "$rid" ] || continue
forge_api --paginate "repos/$REPO/pulls/$n/reviews/$rid/comments" \
--jq '.[].created_at' || return 1
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
}

158
lib/forge-github.sh Normal file
View file

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

218
lib/forge.sh Normal file
View file

@ -0,0 +1,218 @@
#!/usr/bin/env bash
# lib/forge.sh — one forge abstraction, two backends (issue #188).
#
# Sourced, never executed: no set -e/-u here — the sourcing script owns its
# own shell options, exactly as lib/version.sh does. This file is the
# selector only; the backends live beside it in lib/forge-github.sh and
# lib/forge-forgejo.sh, and nothing here talks to a network.
#
# WHY THIS FILE EXISTS, stated once. Until #188 the reconcilers were `gh`
# all the way down — 61 runtime call sites, no indirection, no forge check.
# Pointed at a Forgejo instance (heavy-duty/rig, which moved here and runs
# its CI on a Forgejo Actions runner) they did not fail usefully. Measured
# against forgejo.heavyduty.builders on 2026-08-02, at ceremony 84bb1a4:
#
# labels-scope exit 0 "no .github/labeler.yml at main — nothing
# to derive" — the file exists (HTTP 200)
# labels-reconcile exit 0 "reconciled." — having enumerated ZERO PRs
# issueflow-reconcile exit 1 "unexpected end of JSON input"
#
# Two of the three reported SUCCESS having read nothing. labels-reconcile's
# own blind-sweep warning (#96) could not fire, because it counts unreadable
# PRs against a list `gh pr list` never produced — and a process
# substitution's failure does not trip set -e, so `total` stayed 0 and the
# sweep called itself reconciled. rig run 979 is the log.
#
# The tempting fix — install gh on the runner — makes it WORSE. gh speaks
# GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1 and no
# GraphQL at all. With gh present and GH_HOST set to the Forgejo host, the
# one loud failure goes quiet (`gh pr list` hits /api/graphql -> HTTP 405,
# prints nothing, exits into the same empty loop) and all three actions go
# green while reading nothing. That is this repo's own doctrine — an
# unreadable rollup reads as "nothing is failing" — being violated by the
# repo that wrote it.
#
# So: the forge is decided ONCE, before any sweep, and a client that cannot
# speak it refuses loudly. Never "probably github".
# forge_detect — print "github" or "forgejo"; exit 1 loudly when it cannot
# tell. Order matters and every signal below was measured, not read from
# docs: a real forgejo-runner v6.3.1 job on forgejo.heavyduty.builders
# (probe task 278, 2026-08-02) dumped its environment, and a GitHub-hosted
# runner's is the control.
#
# The trap that makes this non-obvious: **the Forgejo runner populates the
# whole GITHUB_* namespace.** GITHUB_ACTIONS=true, GITHUB_REPOSITORY,
# GITHUB_SHA, GITHUB_TOKEN — all set, all correct-looking. Detecting on
# "GITHUB_ACTIONS is set" would answer "github" on both forges, which is
# precisely the bug. What actually differs:
#
# signal GitHub Forgejo (measured)
# GITHUB_API_URL https://api.github.com https://<host>/api/v1
# GITHUB_GRAPHQL_URL https://api.github.com/… (empty)
# GITEA_ACTIONS (unset) true
#
# GITHUB_GRAPHQL_URL being empty on Forgejo is not a curiosity — it is the
# forge telling us the two `gh api graphql` sites #188 retired can never
# work here. It is deliberately NOT a detection signal, though: an empty
# variable is also what a hand-rolled harness leaves behind, and a signal
# that fires on absence is a signal that fires by accident.
forge_detect() {
# 1. The explicit override outranks every probe — the escape hatch for a
# forge this file has not met, and the handle the tests drive. A typo
# in it is fatal on purpose: the operator said something and it was
# wrong, and falling through to a probe that guesses right by accident
# would hide that until the guess was wrong too.
if [ -n "${CEREMONY_FORGE:-}" ]; then
case "$CEREMONY_FORGE" in
github | forgejo) printf '%s\n' "$CEREMONY_FORGE"; return 0 ;;
*)
echo "forge_detect: unknown forge: CEREMONY_FORGE=$CEREMONY_FORGE (expected github or forgejo)" >&2
return 1
;;
esac
fi
# 2. Forgejo's and Gitea's own positive marker. Unambiguous where a
# hand-set GITHUB_API_URL might not be, so it is read first.
if [ "${GITEA_ACTIONS:-}" = true ] || [ "${FORGEJO_ACTIONS:-}" = true ]; then
printf 'forgejo\n'
return 0
fi
# 3. The API URL's shape. /api/v3 is GitHub's (github.com and GitHub
# Enterprise Server alike — GHES is a github backend on a non-github.com
# host, and routing it to the forgejo backend would regress term 5's
# "GitHub consumers are unchanged"). /api/v1 is the Gitea shape Forgejo
# serves.
case "${GITHUB_API_URL:-}" in
https://api.github.com | https://api.github.com/*) printf 'github\n'; return 0 ;;
*/api/v3 | */api/v3/*) printf 'github\n'; return 0 ;;
*/api/v1 | */api/v1/*) printf 'forgejo\n'; return 0 ;;
esac
# 4. Last resort, the server host. Only github.com itself is conclusive
# here: a bare hostname says nothing about which API it serves.
case "${GITHUB_SERVER_URL:-}" in
https://github.com | https://github.com/*) printf 'github\n'; return 0 ;;
esac
# 5. Refuse. "Nothing to read" is not "probably github" — guessing here
# reinstates the exact blind sweep this file exists to end. Name what
# was inspected and the escape hatch, so the log answers "why" without
# a second run (#101 D5, one layer up: report, do not diagnose).
cat >&2 <<EOF
forge_detect: cannot determine which forge this is — refusing to guess (#188).
GITHUB_API_URL='${GITHUB_API_URL:-}'
GITHUB_SERVER_URL='${GITHUB_SERVER_URL:-}'
GITEA_ACTIONS='${GITEA_ACTIONS:-}'
Set CEREMONY_FORGE=github or CEREMONY_FORGE=forgejo to say so explicitly.
EOF
return 1
}
# Where the backends live. Captured at source time, not call time: a
# function that resolves BASH_SOURCE later would resolve its own file, not
# this one.
FORGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# forge_select [forge] — source the backend for this forge, so the forge_*
# verbs exist. Exactly one backend is ever loaded; both define the same
# names, which is what keeps the branching out of the 61 call sites (term 1)
# and the single-forge assumption from growing back.
#
# Idempotent, because the actions call it once and the tests call it per
# case. Pass a forge explicitly to load a specific backend; omit it and the
# environment decides via forge_detect.
forge_select() {
local forge="${1:-}"
if [ -z "$forge" ]; then
forge="$(forge_detect)" || return 1
fi
case "$forge" in
github | forgejo) ;;
*)
echo "forge_select: unknown forge: $forge (expected github or forgejo)" >&2
return 1
;;
esac
# shellcheck source=/dev/null
. "$FORGE_LIB_DIR/forge-$forge.sh" || return 1
# Read by callers and tests to assert which backend is loaded, so the
# choice is inspectable rather than implied by which functions exist.
# shellcheck disable=SC2034 # consumed by sourcing scripts, not this file
FORGE="$forge"
}
# forge_client <forge> — print the client that backend requires.
#
# github -> gh the current call set, extracted 1:1 (term 5)
# forgejo -> rest /api/v1 over curl+jq
#
# forgejo is "rest" by MEASUREMENT, not preference. The image the Forgejo
# instance actually runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04,
# probe task 278) carries curl, jq and node — and has neither `gh` NOR
# `stoke` on PATH. That second absence is what retired option A from the
# ruling: porting the call sites to the stoke CLI would have put a binary
# on the critical path that the runner does not have and that would need
# installing before every job.
forge_client() {
case "${1:?forge_client: forge required}" in
github) printf 'gh\n' ;;
forgejo) printf 'rest\n' ;;
*)
echo "forge_client: unknown forge: $1 (expected github or forgejo)" >&2
return 1
;;
esac
}
# forge_preflight — the gate. Run it BEFORE any sweep: it decides the forge
# and proves the client can speak it, or exits non-zero with a named reason.
#
# CEREMONY_FORGE_CLIENT declares the client the caller will actually use —
# how a call site that still hard-codes `gh` announces itself honestly while
# the backends are being ported. Two checks run, in order:
#
# 1. the declaration, when made, must match what this forge needs;
# 2. that client's binaries must actually be on PATH — checked whether or
# not a declaration was made, because a call site that declares the
# right client on a runner that lacks it is still a blind sweep waiting
# to happen.
forge_preflight() {
local forge want
forge="$(forge_detect)" || return 1
want="$(forge_client "$forge")" || return 1
if [ -n "${CEREMONY_FORGE_CLIENT:-}" ] && [ "$CEREMONY_FORGE_CLIENT" != "$want" ]; then
cat >&2 <<EOF
forge_preflight: this is a '$forge' forge and the '$CEREMONY_FORGE_CLIENT' client cannot speak it (#188).
gh speaks GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1
and has no GraphQL surface at all. Pointing one at the other does not
fail usefully — it reads nothing and reports success.
This forge needs the '$want' client.
EOF
return 1
fi
# Then prove the tools are actually here — declared or not. A missing
# binary is the rig failure verbatim, "line 692: gh: command not found",
# and it must be a refusal before the sweep, not a 127 halfway through
# one. Checked on BOTH paths deliberately: a call site that declares the
# right client on a runner that lacks it is still a blind sweep waiting
# to happen.
local missing_bins=() bin
case "$want" in
gh) command -v gh >/dev/null 2>&1 || missing_bins+=(gh) ;;
rest) for bin in curl jq; do command -v "$bin" >/dev/null 2>&1 || missing_bins+=("$bin"); done ;;
esac
if [ "${#missing_bins[@]}" -gt 0 ]; then
cat >&2 <<EOF
forge_preflight: this is a '$forge' forge, which needs the '$want' client, and ${missing_bins[*]} is not installed (#188).
Refusing before the sweep: a reconciler that cannot read the board must
not report that it reconciled one.
EOF
return 1
fi
return 0
}

View file

@ -208,13 +208,20 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
# skips BOTH checks — the nudge's specified content links the escalation # skips BOTH checks — the nudge's specified content links the escalation
# 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 # forge_timeline projects both forges into the GitHub event shape
if ! flags="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \ # (.event / .actor.login). Capture its status BEFORE jq: a pipeline's
--jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling") # status is the last command's, so `forge_timeline | jq` would collapse
| [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then # an unreadable timeline into an empty one — the two states this function
# exists to tell apart (#188 / #4853).
local flags newest setter labeled_at labeled_epoch timeline
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
log "#$n: ruling timeline unreadable — no verdict invented this pass" log "#$n: ruling timeline unreadable — no verdict invented this pass"
return 0 return 0
fi fi
flags="$(jq -r '
.[] | select(.event == "labeled" and .label.name == "needs-ruling")
| [.actor.login, .created_at] | @tsv
' <<<"$timeline")"
if [ -z "$flags" ]; then if [ -z "$flags" ]; then
# The label is on the item but no labeled event is visible (a timeline # The label is on the item but no labeled event is visible (a timeline
# hiccup, or an import). Same treatment as unreadable: do nothing. # hiccup, or an import). Same treatment as unreadable: do nothing.
@ -230,7 +237,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 +275,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 +306,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 +334,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 +349,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 +382,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

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Contract tests for lib/closes_references.sh (issue #188, term 3).
# set -u, not -e: failing commands are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# issue_references (the LOCAL/CROSS classifier) lives here; closes_references
# calls it, exactly as refs_references does.
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
. "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
# shellcheck source=lib/closes_references.sh
. "$ROOT/lib/closes_references.sh"
# closes <want-newline-separated> <body> — the parse of <body> is exactly
# <want>. Exact, not substring: "12" is contained in "123".
closes() {
local want="$1" body="$2" got
got="$(printf '%s' "$body" | closes_references)"
[ "$got" = "$want" ]
}
# --- the three verbs, the three tenses ----------------------------------
# GitHub's documented keyword set. All of them, because a body that says
# "Fixed #4" and goes unclosed is a silent failure of the post-merge
# transition, not a loud one.
check "closes" 0 "" closes 1 'Closes #1'
check "close" 0 "" closes 1 'Close #1'
check "closed" 0 "" closes 1 'Closed #1'
check "fixes" 0 "" closes 2 'Fixes #2'
check "fix" 0 "" closes 2 'Fix #2'
check "fixed" 0 "" closes 2 'Fixed #2'
check "resolves" 0 "" closes 3 'Resolves #3'
check "resolve" 0 "" closes 3 'Resolve #3'
check "resolved" 0 "" closes 3 'Resolved #3'
check "case-insensitive" 0 "" closes 4 'CLOSES #4'
check "lowercase" 0 "" closes 4 'closes #4'
check "colon form" 0 "" closes 5 'Closes: #5'
# --- Refs is NOT a closing link -----------------------------------------
# The relation this file must not swallow. refs_references owns Refs, and
# conflating them makes every referenced issue look closeable — the
# post-merge transition #151 was reopened by hand over exactly that
# distinction.
check "Refs is not a close" 0 "" closes '' 'Refs #7'
check "Refs and Closes in one body keeps only the close" 0 "" \
closes 8 $'Refs #7\nCloses #8'
# --- cross-repo references stay out (#61) -------------------------------
# rig#112 must never be read as local #112. The classifier is shared with
# refs_references precisely so this rule has one implementation.
check "qualified reference is not local" 0 "" closes '' 'Closes rig#112'
check "owner-qualified reference is not local" 0 "" \
closes '' 'Closes heavy-duty/rig#112'
check "a local and a cross reference keep only the local" 0 "" \
closes 9 $'Closes rig#112\nCloses #9'
# --- every occurrence contributes ---------------------------------------
# Binding to the first occurrence is the defect #184 fixed in
# blocked_reference_records; this parser must not reintroduce it.
check "two closes on one line" 0 "" closes $'1\n2' 'Closes #1. Closes #2.'
check "two closes on two lines" 0 "" closes $'1\n2' $'Closes #1\nCloses #2'
check "sorted and deduplicated" 0 "" closes $'2\n10' $'Closes #10\nCloses #2\nCloses #10'
# --- prose must not be swallowed ----------------------------------------
check "trailing prose is not part of the reference" 0 "" \
closes 12 'Closes #12, and adds the guard'
check "a sentence terminator ends the reference" 0 "" closes 13 'Closes #13.'
check "no reference means no output" 0 "" closes '' 'Closes the door behind it'
check "a bare issue mention is not a close" 0 "" closes '' 'See #14 for context'
# "unclosed" contains "close" — a naive word match would fire on it.
check "a word merely containing a verb does not fire" 0 "" \
closes '' 'This left #15 unclosed'
# --- the shapes a real PR body carries ----------------------------------
check "the template's leading declaration" 0 "" \
closes 188 $'Closes #188\n\n## Acceptance criteria\n\n- [ ] a thing'
check "an empty body yields nothing" 0 "" closes '' ''
summary

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"

619
test/forge-backends.test.sh Normal file
View file

@ -0,0 +1,619 @@
#!/usr/bin/env bash
# Contract tests for lib/forge-github.sh and lib/forge-forgejo.sh
# (issue #188, term 1). set -u, not -e.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
eq() {
local want="$1" got
shift
got="$("$@")" || return 1
[ "$got" = "$want" ]
}
# --- forge_select: exactly one backend, chosen deliberately -------------
check "select github loads the github backend" 0 "" \
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select github; declare -f github_page_url >/dev/null'
check "select forgejo loads the forgejo backend" 0 "" \
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select forgejo; declare -f forgejo_page_url >/dev/null'
check "select refuses an unknown forge" 1 "unknown forge" \
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select gitlab'
# shellcheck disable=SC2016 # $FORGE expands in the isolated bash -c process
check "select with no argument reads the environment" 0 "" \
bash -c 'CEREMONY_FORGE=forgejo; . '"$ROOT"'/lib/forge.sh; forge_select; [ "$FORGE" = forgejo ]'
# --- the page-size contract, both dialects ------------------------------
# The trap, measured 2026-08-02: each forge silently ignores the OTHER's
# page-size parameter and answers HTTP 200 with fewer items.
#
# ?per_page=100 GitHub 100 Forgejo 30 (ignored)
# ?limit=100 GitHub 30 Forgejo 50 (capped)
#
# So no call site names one, and these two functions are the only places
# that decide. Pure on purpose: the contract is testable without a network.
. "$ROOT/lib/forge-github.sh"
. "$ROOT/lib/forge-forgejo.sh"
check "github: a bare path gets a query" 0 "" \
eq 'repos/o/r/issues?per_page=100' github_page_url 'repos/o/r/issues'
check "github: an existing query is preserved" 0 "" \
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open'
check "forgejo: a bare path gets a query" 0 "" \
eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues' 1
check "forgejo: an existing query is preserved" 0 "" \
eq 'repos/o/r/issues?state=open&limit=50&page=2' forgejo_page_url 'repos/o/r/issues?state=open' 2
# A caller that names a page size anyway must not be able to reintroduce the
# truncation — the parameter is stripped in BOTH dialects, on both backends,
# because the whole point is that the boundary decides and the call site
# cannot override it by accident.
check "github strips a stray per_page" 0 "" \
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&per_page=30'
check "github strips a stray limit" 0 "" \
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&limit=100'
check "forgejo strips a stray per_page" 0 "" \
eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&per_page=100' 1
check "forgejo strips a stray limit" 0 "" \
eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&limit=100' 1
check "stripping the only parameter leaves a clean query" 0 "" \
eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues?per_page=100' 1
# --- the forgejo gather: complete, or loudly refused --------------------
# curl is stubbed as a function so these are hermetic. Each case writes the
# headers and body a real Forgejo would.
# fake_forge <total-spec> <pages…> — install a curl stub serving <pages> as
# successive page bodies, declaring <total-spec> in x-total-count. An empty
# string omits the header entirely (@kimi's #4699 case). A comma-separated
# spec declares a DIFFERENT total per page ("4,9"), which is
# @codex-reviewer-andresmgsl's changing-between-pages case (#4700 / #4712):
# a server whose count moves under the walk cannot have been read whole.
fake_forge() {
FAKE_TOTAL="$1"; shift
FAKE_PAGES=("$@")
FAKE_CALLS=0
# shellcheck disable=SC2317 # the stub is invoked indirectly, by forge_api
curl() {
local hdr="" out="" url=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;;
-o) out="$2"; shift ;;
-H) shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
local page=1
case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac
local total="$FAKE_TOTAL"
case "$FAKE_TOTAL" in
*,*)
total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f"$page")"
[ -n "$total" ] || total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f1)"
;;
esac
{
printf 'HTTP/1.1 200 OK\r\n'
[ -n "$total" ] && printf 'X-Total-Count: %s\r\n' "$total"
printf '\r\n'
} >"$hdr"
if [ "$page" -le "${#FAKE_PAGES[@]}" ]; then
printf '%s' "${FAKE_PAGES[$((page - 1))]}" >"$out"
else
printf '[]' >"$out"
fi
FAKE_CALLS=$((FAKE_CALLS + 1))
return 0
}
}
export CEREMONY_FORGE_API=https://forge.example/api/v1
# One page, and the count agrees with the declared total.
fake_forge 2 '[{"number":1},{"number":2}]'
check "a complete single-page gather returns its items" 0 "" \
eq $'1\n2' forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# Two pages that add up. The walk must not stop at the first page merely
# because it came back non-empty — rig has 137 issues across 3 pages, which
# is the case this models.
fake_forge 4 '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]'
check "a multi-page gather walks every page" 0 "" \
eq $'1\n2\n3\n4' forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# The whole reason the assert exists: a server that declares more than it
# hands over must not produce a "successful" partial sweep.
fake_forge 137 '[{"number":1},{"number":2}]'
check "a short gather is refused, not reconciled" 1 "incomplete gather" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
check "...and the refusal names both counts" 1 "collected 2 of 137" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# @kimi-reviewer-andresmgsl's hardening (#4699): the guard must not be able
# to degrade silently either. A Forgejo that does not expose x-total-count
# leaves the assert with nothing to compare, and an assert that cannot run
# must refuse rather than pass.
fake_forge '' '[{"number":1},{"number":2}]'
check "a missing x-total-count refuses" 1 "did not send x-total-count" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
check "...and says why it cannot prove completeness" 1 "cannot prove the gather is complete" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# @codex-reviewer-andresmgsl's #4712 findings. Each one is a route by which
# an unprovable read could still have been reported as a whole one — the
# guard leaking the failure class it was built to stop, which is why they
# are refusals rather than warnings.
# A total that is not a number went straight into arithmetic. Reproduced on
# ab23a3b: `X-Total-Count: not-a-number` returned rc=0 with that string as
# the total.
fake_forge 'not-a-number' '[{"number":1}]'
check "a non-numeric total is refused" 1 "not a non-negative integer" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
check "...and the refusal quotes what arrived" 1 "not-a-number" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
fake_forge '12x' '[{"number":1}]'
check "a partly-numeric total is refused" 1 "not a non-negative integer" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
fake_forge '-3' '[{"number":1}]'
check "a negative total is refused" 1 "not a non-negative integer" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# A total that MOVES under the walk. The loop read it once, so a board
# changing size mid-gather was invisible: page 1 said 4, page 2 said 9, and
# the walk stopped at 4 believing itself complete.
fake_forge '4,9' '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]'
check "a total that changes between pages is refused" 1 "changed between pages" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# The distinguishing text, not a substring that survives losing half the
# message: "4" alone stayed green if the later total vanished, which is what
# @codex-reviewer-andresmgsl (#4727) and @grok-reviewer-andresmgsl (#4734)
# both caught. A test named "names BOTH totals" must fail when one goes.
check "...and the refusal names both totals" 1 "4 then 9" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# A 200 whose body is not a collection. `length` on a non-array counted 0,
# so an object or a scalar arriving where a list belongs read as a complete
# EMPTY collection when the declared total was 0 — silence dressed as a
# clean sweep.
fake_forge 0 '{"message":"Not found"}'
check "a non-array body is refused" 1 "did not return a collection" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
fake_forge 0 '"a string"'
check "a scalar body is refused" 1 "did not return a collection" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# A genuinely empty collection is still fine — the refusal must not fire on
# a repo that legitimately has nothing.
fake_forge 0 '[]'
check "an empty collection is not an error" 0 "" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# --- HTTP failures are named, not swallowed -----------------------------
# gh exits non-zero on an HTTP error; curl does not without -f, and -f
# discards the body that explains why. So the status is read explicitly.
fake_forge 1 '[{"number":1}]'
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
curl() {
local hdr="" out=""
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; esac
shift
done
printf 'HTTP/1.1 404 Not Found\r\n\r\n' >"$hdr"
printf '{"message":"Not found"}' >"$out"
return 0
}
check "a 404 is a named failure" 1 "HTTP 404" forge_api 'repos/o/r/issues/9999'
check "a 404 names the endpoint" 1 "repos/o/r/issues/9999" forge_api 'repos/o/r/issues/9999'
# --- forge_issue_edit: a typo must not become a green no-op --------------
# @codex-reviewer-andresmgsl (#4743). The github backend hands whatever it is
# given to `gh`, which fails on a flag it does not know. Dropping it here
# instead turned a mis-typed port site into a mutation that silently did not
# happen — this issue's own failure class, arriving inside the fix for it.
check "an unknown edit flag refuses" 1 "unknown flag" forge_issue_edit 1 --typo value
check "...and names the flag it refused" 1 "--typo" forge_issue_edit 1 --typo value
check "a flag with no value refuses" 1 "requires a value" forge_issue_edit 1 --add-label
# --- forge_label_create: an upsert, like gh's --force --------------------
# bootstrap_labels creates every declared label on EVERY workflow_dispatch,
# so a plain POST onto an existing name aborts the bootstrap under set -e
# from the second dispatch onward (#4743).
WRITES="$TMP/writes"
stub_writes() {
: >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
curl() {
local hdr="" out="" method=GET url="" payload=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;;
-o) out="$2"; shift ;;
-X) method="$2"; shift ;;
-d) payload="$2"; shift ;;
-H) shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_LABEL_N:-1}" >"$hdr"
case "$url" in
*"/labels?"* | */labels) printf '%s' "${FAKE_LABELS:-[]}" >"$out" ;;
*) printf '{}' >"$out" ;;
esac
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
return 0
}
}
# The label does not exist yet -> POST (create).
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_label_create ready 0e8a16 'in the queue'
check "creating a new label POSTs" 0 "" grep -q '^POST repos/o/r/labels ' "$WRITES"
# The label already exists -> PATCH (update), which is what --force does.
FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 stub_writes
FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 REPO=o/r forge_label_create ready 0e8a16 'new text'
check "recreating an existing label PATCHes it" 0 "" \
grep -q '^PATCH repos/o/r/labels/7 ' "$WRITES"
check "...and does not POST a duplicate" 1 "" grep -q '^POST repos/o/r/labels ' "$WRITES"
check "...carrying the updated description" 0 "" grep -q 'new text' "$WRITES"
# --- forge_issue_edit on forgejo: the two asymmetries, hermetically ------
# Promised with the call-site port (@grok-reviewer-andresmgsl #4741 note 2,
# #4751 item 2). Live scratch-repo evidence proved these work; these prove
# they keep working, and pin the SHAPE of the requests.
# Removal resolves name -> id, because Forgejo takes names on add and only a
# numeric id on remove. Measured: DELETE .../labels/probe:one -> 422,
# DELETE .../labels/149 -> 204.
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 stub_writes
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 REPO=o/r forge_issue_edit 5 --remove-label stale
check "removing a label resolves its numeric id" 0 "" grep -q '^DELETE repos/o/r/issues/5/labels/11 ' "$WRITES"
check "...and never sends the name as the path segment" 1 "" grep -q 'labels/stale' "$WRITES"
# A label the repo does not have is a no-op, matching gh: the reconcilers
# call --remove-label unconditionally to converge state.
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 stub_writes
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 REPO=o/r forge_issue_edit 5 --remove-label nonexistent
check "removing an absent label writes nothing" 0 "" test ! -s "$WRITES"
# Adding takes names directly — no lookup, one request.
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_issue_edit 5 --add-label "ready,stale"
check "adding labels posts them by name" 0 "" grep -q '^POST repos/o/r/issues/5/labels .*"ready"' "$WRITES"
check "...comma-separated values are split, as gh splits them" 0 "" grep -q '"stale"' "$WRITES"
# Assignees are SET, not added/removed: PATCH takes the whole list. So a
# removal is a read-modify-write, and a naive translation would have cleared
# every OTHER assignee as a side effect of removing one.
assignee_stub() {
: >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by forge_issue_edit
curl() {
local hdr="" out="" method=GET url="" payload=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;; -o) out="$2"; shift ;;
-X) method="$2"; shift ;; -d) payload="$2"; shift ;;
-H) shift ;; -*) ;; *) url="$1" ;;
esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 0\r\n\r\n' >"$hdr"
printf '{"assignees":[{"login":"alice"},{"login":"bob"}]}' >"$out"
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
return 0
}
}
assignee_stub
REPO=o/r forge_issue_edit 5 --remove-assignee alice
check "removing one assignee PATCHes the surviving list" 0 "" grep -q '^PATCH repos/o/r/issues/5 .*"bob"' "$WRITES"
check "...and the removed one is gone from it" 1 "" grep -q '"alice"' "$WRITES"
assignee_stub
REPO=o/r forge_issue_edit 5 --add-assignee carol
check "adding an assignee keeps the existing ones" 0 "" grep -qE '^PATCH repos/o/r/issues/5 .*"alice".*"bob".*"carol"|^PATCH repos/o/r/issues/5 .*"alice".*"carol".*"bob"' "$WRITES"
# --- forge_labels_add / forge_request_reviewer, both backends ------------
# @codex-reviewer-andresmgsl #4780 item 3. These two writes came in with the
# call-site port and had no boundary pins of their own.
# ceremony#128 is the whole reason forge_labels_add exists as its own verb.
# The labeler action computed (labels-at-job-start union derived) and PUT the
# whole set, so a label applied while the job ran was silently removed —
# ceremony#128 lost its `release` label, the merge door's declared-intent
# read, two seconds after the builder set it. This write must therefore be an
# ADDITIVE POST and must never read-modify-write.
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 stub_writes
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 \
REPO=o/r forge_labels_add 7 scope:docs scope:cli
check "labels_add POSTs to the issue labels collection" 0 "" \
grep -q '^POST repos/o/r/issues/7/labels ' "$WRITES"
check "...carrying every name in one request" 0 "" \
grep -q '"scope:docs","scope:cli"' "$WRITES"
# The regression that would reopen ceremony#128: any PUT, or a GET-then-write.
check "...and never PUTs the whole set (ceremony#128)" 1 "" grep -q '^PUT ' "$WRITES"
check "...exactly one write, so nothing is read-modify-written" 0 "" \
test "$(wc -l <"$WRITES")" -eq 1
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_labels_add 7
check "labels_add with no labels writes nothing" 0 "" test ! -s "$WRITES"
# The reviewer payload shape. Measured against this instance: the endpoint
# serves post and delete only, and takes {"reviewers":[...]}.
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_request_reviewer 9 danmt
check "request_reviewer POSTs to requested_reviewers" 0 "" \
grep -q '^POST repos/o/r/pulls/9/requested_reviewers ' "$WRITES"
check "...with the reviewers array payload" 0 "" \
grep -q '{"reviewers":\["danmt"\]}' "$WRITES"
# The github twin is a 1:1 gh pass-through (term 5), so its parity is pinned
# by the command it builds rather than by an HTTP shape.
gh_calls="$TMP/ghcalls"
: >"$gh_calls"
# shellcheck disable=SC2317 # invoked indirectly, by the github verbs
gh() { printf '%s\n' "$*" >>"$gh_calls"; }
# A subshell so the github backend does not stay loaded over the forgejo
# cases below; REPO is deliberately scoped to it for the same reason.
(
forge_select github
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
REPO=o/r
forge_labels_add 7 scope:docs scope:cli
forge_request_reviewer 9 danmt
)
check "github labels_add uses the additive api POST, not issue edit" 0 "" \
grep -q 'api repos/o/r/issues/7/labels -f labels\[\]=scope:docs -f labels\[\]=scope:cli' "$gh_calls"
check "...and never routes through issue edit --add-label" 1 "" \
grep -q 'issue edit' "$gh_calls"
check "github request_reviewer posts the reviewer" 0 "" \
grep -q 'api repos/o/r/pulls/9/requested_reviewers -f reviewers\[\]=danmt' "$gh_calls"
# --- term-5 pins for the batch verbs (codex 1566) -------------------------
# The forgejo twins have hermetic coverage below; these pin that the github
# twins stay 1:1 extractions of the pre-port endpoints, not silent rewrites.
: >"$gh_calls"
# shellcheck disable=SC2317 # invoked indirectly, by the github verbs
gh() {
printf '%s\n' "$*" >>"$gh_calls"
if [ "$1" = api ]; then
shift
local jqexpr="" endpoint=""
while [ $# -gt 0 ]; do
case "$1" in
--jq) jqexpr="$2"; shift ;;
--paginate) ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
local body='[]'
case "$endpoint" in
*'/issues/'*'/timeline'*)
body='[{"event":"labeled","actor":{"login":"alice"},"label":{"name":"ready"},"created_at":"2026-08-01T09:00:00Z"}]'
;;
*'/issues/'*'/comments'*)
body='[{"created_at":"2026-08-01T10:00:00Z"}]'
;;
*'/pulls/'*'/comments'*)
body='[{"created_at":"2026-08-01T10:30:00Z"}]'
;;
*'/pulls/'*'/commits'*)
body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]'
;;
esac
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$body"; else printf '%s\n' "$body"; fi
return 0
fi
return 0
}
gh_tl="$(
forge_select github
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
REPO=o/r
forge_timeline 42
)"
check "github forge_timeline paginates the issue timeline endpoint" 0 "" \
grep -qE 'api --paginate repos/o/r/issues/42/timeline|api repos/o/r/issues/42/timeline' "$gh_calls"
check "github forge_timeline is a pass-through of the GitHub event shape" 0 "" \
jq -e '.[0].event == "labeled" and .[0].actor.login == "alice"' <<<"$gh_tl" >/dev/null
# The NEGATIVE half of "pass-through". A positive pin still passes if the
# github path GAINS forgejo behaviour, and term 5 is a statement about what
# must NOT change — so the drift these two catch is the only kind a
# 1:1-extraction path realistically suffers.
#
# The GitHub timeline already IS the shape ruling.sh selects on, so a
# projection here would be a second, divergent normalizer maintained by
# nobody.
check "...and never reshapes it, as the forgejo backend must" 1 "" \
grep -q 'issues/42/timeline.*--jq' "$gh_calls"
: >"$gh_calls"
gh_act="$(
forge_select github
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
REPO=o/r
forge_pr_activity 9 | sort
)"
check "github forge_pr_activity hits issue comments" 0 "" \
grep -q 'repos/o/r/issues/9/comments' "$gh_calls"
check "github forge_pr_activity hits the flat /pulls/{n}/comments endpoint" 0 "" \
grep -q 'repos/o/r/pulls/9/comments' "$gh_calls"
check "github forge_pr_activity hits commits" 0 "" \
grep -q 'repos/o/r/pulls/9/commits' "$gh_calls"
check "github forge_pr_activity emits all three timestamp sources" 0 "" \
test "$(printf '%s\n' "$gh_act")" = "$(printf '%s\n' '2026-08-01T10:00:00Z' '2026-08-01T10:30:00Z' '2026-08-01T11:00:00Z')"
# The forgejo backend derives inline comments from reviews with
# comments_count > 0, because the flat endpoint 404s there. That derivation
# must never appear on this path: GitHub serves the flat endpoint, and a
# github twin quietly adopting the workaround is the exact "both backends
# drift together" failure term 5 forbids.
check "...never deriving them from reviews, as forgejo must" 1 "" \
grep -q 'pulls/[0-9]*/reviews' "$gh_calls"
unset -f gh
. "$ROOT/lib/forge-forgejo.sh"
# --- forge_pr_view: newest verdict per context must win ------------------
# checks_state groups repeated contexts and selects the newest by
# [.startedAt, .createdAt, .completedAt]. Mapping only {context,state} left
# the winner to incidental array order, so a stale re-run could outrank the
# live one (#4743). Forgejo's combined status carries created_at/updated_at
# — measured on this instance, where Actions DO land as commit statuses
# (rig main: "ci / check (push)" success, with created_at).
pr_view_stub() {
# shellcheck disable=SC2317 # invoked indirectly, by forge_pr_view
curl() {
local hdr="" out="" url=""
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 1\r\n\r\n' >"$hdr"
case "$url" in
*/status) printf '%s' "$FAKE_STATUS" >"$out" ;;
*) printf '{"head":{"sha":"abc"},"mergeable":true}' >"$out" ;;
esac
return 0
}
}
# The FAILURE is older but listed second — array order would pick it.
FAKE_STATUS='{"state":"failure","statuses":[
{"context":"ci / check","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"ci / check","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"}]}'
pr_view_stub
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view maps createdAt" 0 "" \
grep -q '"createdAt": "2026-08-02T10:00:00Z"' <<<"$view_json"
check "pr_view maps completedAt" 0 "" \
grep -q '"completedAt":' <<<"$view_json"
check "pr_view maps mergeable to the UI string" 0 "" \
grep -q '"mergeable": "MERGEABLE"' <<<"$view_json"
# The real proof: feed it to the production classifier and confirm the newer
# SUCCESS wins over the older FAILURE regardless of array order.
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
. "$ROOT/actions/labels-reconcile/labels-reconcile.sh"
classified="$(checks_state <<<"$view_json")"
check "the newest verdict per context wins, not the array order" 0 "" \
test "$classified" = SUCCESS
# --- the api base must be known -----------------------------------------
check "no api base refuses" 1 "cannot reach the forge" \
bash -c 'unset CEREMONY_FORGE_API GITHUB_API_URL; . '"$ROOT"'/lib/forge-forgejo.sh; forgejo_api_base'
# --- forge_timeline: project Forgejo labels into the GitHub event shape -
# Mapping measured #4849: .type=="label", .body "1"/"" -> labeled/unlabeled,
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
# emitting .user instead of .actor each reds its own case (#4853).
timeline_stub() {
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
curl() {
local hdr="" out="" url=""
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr"
case "$url" in
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
*) printf '[]' >"$out" ;;
esac
return 0
}
}
FAKE_TIMELINE='[
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
{"type":"comment","body":"noise","user":{"login":"other"},"created_at":"2026-08-02T15:00:00Z"}
]'
FAKE_TL_N=3
timeline_stub
tl="$(REPO=o/r forge_timeline 188)"
check "forge_timeline projects body=1 to labeled" 0 "" \
jq -e '.[] | select(.event == "labeled" and .label.name == "needs-ruling" and .actor.login == "setter")' <<<"$tl" >/dev/null
check "forge_timeline projects body=\"\" to unlabeled" 0 "" \
jq -e '.[] | select(.event == "unlabeled" and .label.name == "needs-ruling")' <<<"$tl" >/dev/null
check "forge_timeline drops non-label events" 0 "" \
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
# Unreadable: curl fails. Status must surface through forge_timeline itself
# (not a later jq), or the ruling ladder invents a verdict on a half-read.
# shellcheck disable=SC2317
curl() { return 22; }
tl_unreadable() { REPO=o/r forge_timeline 188; }
check "forge_timeline fails when the gather fails" 1 "" tl_unreadable
# --- forge_pr_activity: no flat /pulls/{n}/comments on Forgejo -----------
# Only reviews with comments_count > 0 are fetched (#4844).
activity_calls="$TMP/activity_calls"
: >"$activity_calls"
activity_stub() {
# shellcheck disable=SC2317
curl() {
local hdr="" out="" url="" total=1 body='[]'
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf '%s\n' "$url" >>"$activity_calls"
case "$url" in
*'/issues/'*'/comments'*)
total=1
body='[{"created_at":"2026-08-01T10:00:00Z"}]'
;;
*'/pulls/'*'/commits'*)
total=1
body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]'
;;
*'/reviews/'*'/comments'*)
total=1
body='[{"created_at":"2026-08-01T12:00:00Z"}]'
;;
*'/pulls/'*'/reviews'*)
total=2
body="$FAKE_REVIEWS"
;;
*) total=0; body='[]' ;;
esac
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "$total" >"$hdr"
printf '%s' "$body" >"$out"
return 0
}
}
FAKE_REVIEWS='[{"id":7,"comments_count":1},{"id":8,"comments_count":0}]'
activity_stub
: >"$activity_calls"
act="$(REPO=o/r forge_pr_activity 9 | sort)"
check "forge_pr_activity emits issue-comment timestamps" 0 "" \
grep -qx '2026-08-01T10:00:00Z' <<<"$act"
check "forge_pr_activity emits commit timestamps" 0 "" \
grep -qx '2026-08-01T11:00:00Z' <<<"$act"
check "forge_pr_activity emits inline review-comment timestamps" 0 "" \
grep -qx '2026-08-01T12:00:00Z' <<<"$act"
check "forge_pr_activity fetches only reviews with comments_count>0" 0 "" \
grep -q '/reviews/7/comments' "$activity_calls"
check "...and never fetches a zero-comment review" 1 "" \
grep -q '/reviews/8/comments' "$activity_calls"
check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
grep -E '/pulls/[0-9]+/comments(\?|$)' "$activity_calls"
summary

175
test/forge.test.sh Normal file
View file

@ -0,0 +1,175 @@
#!/usr/bin/env bash
# Contract tests for lib/forge.sh (issue #188). set -u, not -e: failing
# commands are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# A PATH with the shell and the text tools lib/forge.sh itself uses, but with
# NO forge clients on it — that is the condition under test. It cannot be a
# genuinely empty directory: `env -i PATH=…` would then fail to find `bash`,
# and the heredoc refusals use `cat`.
mkdir -p "$TMP/empty"
for _t in bash cat sed awk tr printf; do
_p="$(command -v "$_t" 2>/dev/null)" && ln -sf "$_p" "$TMP/empty/$_t"
done
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
# eq <want> <cmd...> — succeeds AND prints exactly <want>. check()'s
# substring match cannot prove "forgejo" was not printed as "forgejox".
eq() {
local want="$1" got
shift
got="$("$@")" || return 1
[ "$got" = "$want" ]
}
# detect_in <env-assignments…> — run forge_detect in a clean environment
# carrying only the named vars, so a leaked GITHUB_* from the CI running
# THIS suite cannot decide the answer. Every case below is hermetic.
detect_in() {
env -i PATH="$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_detect'
}
# A PATH carrying stub binaries for every client the preflight can require.
# Without this the "passes" cases depend on whatever the HOST happens to have,
# which is not hermetic and is wrong in the only place it matters: the Forgejo
# runner image (ghcr.io/catthehacker/ubuntu:act-22.04) has **no gh**, so
# "github + gh passes" failed there while passing on a developer box. Measured
# 2026-08-02 (#188) — the same class of hosted-image assumption this issue
# exists to find.
STUBBIN="$TMP/bin"
mkdir -p "$STUBBIN"
for _b in gh curl jq; do printf '#!/bin/sh\nexit 0\n' >"$STUBBIN/$_b"; chmod +x "$STUBBIN/$_b"; done
preflight_in() {
env -i PATH="$STUBBIN:$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
}
# ...and one with NO clients at all, for the missing-binary refusal.
preflight_bare() {
env -i PATH="$TMP/empty" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
}
# --- forge_detect: the explicit override --------------------------------
# CEREMONY_FORGE outranks every probe. It is the escape hatch for a forge
# whose env this file has not met yet, and the handle the tests below use
# to drive the backends without a live instance.
check "override: github" 0 "" eq github detect_in CEREMONY_FORGE=github
check "override: forgejo" 0 "" eq forgejo detect_in CEREMONY_FORGE=forgejo
check "override refuses an unknown forge" 1 "unknown forge" \
detect_in CEREMONY_FORGE=gitlab
# A typo must not silently fall through to a probe that guesses right by
# accident: the operator said something, and it was wrong.
check "override outranks the env" 1 "unknown forge" \
detect_in CEREMONY_FORGE=gitlab GITHUB_API_URL=https://api.github.com
# --- forge_detect: GITHUB_API_URL, the load-bearing signal ---------------
# Measured on forgejo.heavyduty.builders 2026-08-02 with a real
# forgejo-runner v6.3.1 job (probe run, task 278). The Forgejo runner
# populates the GITHUB_* namespace — GITHUB_ACTIONS=true and all — so
# "GITHUB_ACTIONS is set" proves nothing at all. What differs is where
# those URLs point:
#
# GitHub GITHUB_API_URL=https://api.github.com
# Forgejo GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
#
# That is the whole bug this issue exists for, in one variable: gh speaks
# /api/v3 against api.github.com, and neither half is true here.
check "api url: api.github.com is github" 0 "" \
eq github detect_in GITHUB_API_URL=https://api.github.com
check "api url: /api/v1 is forgejo" 0 "" \
eq forgejo detect_in GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
# GitHub Enterprise Server: a self-hosted GitHub still speaks /api/v3, and
# it is a github backend on a non-github.com host. Getting this wrong would
# route a GHES consumer to the forgejo backend and break term 5.
check "api url: GHES /api/v3 is github" 0 "" \
eq github detect_in GITHUB_API_URL=https://ghe.example.com/api/v3
# --- forge_detect: GITEA_ACTIONS, the positive marker --------------------
# The Forgejo runner also exports GITEA_ACTIONS=true (measured, task 278),
# which GitHub never sets. It is checked BEFORE the URL shape because it is
# unambiguous where a hand-set GITHUB_API_URL might not be.
check "gitea marker alone is enough" 0 "" eq forgejo detect_in GITEA_ACTIONS=true
check "gitea marker outranks a github-shaped api url" 0 "" \
eq forgejo detect_in GITEA_ACTIONS=true GITHUB_API_URL=https://api.github.com
# --- forge_detect: refusing to guess ------------------------------------
# Nothing to read is NOT "probably github". A wrong guess here is exactly
# the silent blind sweep #188 measured; the whole point of this file is
# that an unknown forge is loud.
check "bare environment refuses" 1 "cannot determine which forge" detect_in
check "refusal names what it looked at" 1 "GITHUB_API_URL" detect_in
check "refusal names the escape hatch" 1 "CEREMONY_FORGE" detect_in
# --- forge_preflight: the must-fail case --------------------------------
# The Test plan's named must-fail: "point it at a Forgejo instance with a
# GitHub-shaped client and assert it refuses loudly rather than sweeping
# blind."
#
# Measured before this guard existed, against this instance:
# labels-scope exit 0 "no .github/labeler.yml — nothing to derive" (it exists)
# labels-reconcile exit 0 "reconciled." (zero PRs read)
# issueflow-reconcile exit 1 "unexpected end of JSON input"
# Two of three swept blind and reported success. gh present made it WORSE:
# it silenced the one loud failure. Hence: refuse before the sweep, not
# after — and say which forge and which client, so the log answers "why"
# without a second run (#101 D5's report-do-not-diagnose, one layer up).
check "forgejo + gh-only client refuses" 1 "cannot speak" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
check "the refusal names the forge" 1 "forgejo" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# The interpolated client, not the bare string "gh" — which also appears in
# the explanatory prose ("gh speaks GitHub's /api/v3…"), so the old assertion
# stayed green even if the client name never reached the message. Same class
# as the "names both totals" weakness the panel caught in the backend suite
# (#4727 / #4734); found by auditing this file for the same shape.
check "the refusal names the client" 1 "the 'gh' client cannot speak it" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# The refusal must be actionable, not merely loud: #188's whole cost was a
# red check that told nobody what to do.
check "the refusal names the issue" 1 "#188" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# --- forge_preflight: the passing pairs ---------------------------------
check "github + gh passes" 0 "" preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=gh
check "forgejo + rest passes" 0 "" preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=rest
# The mirror of the must-fail: a Forgejo client against GitHub is just as
# wrong, and symmetric refusal is cheaper than explaining why only one
# direction is checked.
check "github + rest refuses" 1 "cannot speak" \
preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=rest
# --- forge_preflight: it refuses when the forge itself is unknown --------
# Detection failure must not be swallowed into a pass — that would restore
# the blind sweep through the back door.
check "unknown forge fails the preflight" 1 "cannot determine which forge" preflight_in
# --- forge_client: what each backend actually needs ----------------------
# Measured in the runner image the Forgejo instance actually uses
# (ghcr.io/catthehacker/ubuntu:act-22.04, task 278): gh ABSENT, stoke
# ABSENT, curl and jq present. So the forgejo backend is REST-over-curl by
# necessity, not preference — this is the measurement that retired option
# A (port to stoke) as well: the CLI is not on the runner either.
check "github backend wants gh" 0 "" eq gh forge_client github
check "forgejo backend wants rest" 0 "" eq rest forge_client forgejo
check "forge_client refuses an unknown backend" 1 "unknown forge" forge_client gitlab
# The missing-binary arm, hermetically: an empty PATH has no client at all.
check "a forge whose client is not installed refuses" 1 "is not installed" \
preflight_bare CEREMONY_FORGE=github
check "...and names the missing binary" 1 "gh" preflight_bare CEREMONY_FORGE=github
check "...the forgejo arm names its own tools" 1 "curl" preflight_bare CEREMONY_FORGE=forgejo
summary

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
@ -577,13 +592,22 @@ echo "gh stub: unexpected call: gh $*" >&2
exit 97 exit 97
EOF EOF
chmod +x "$ARRIVAL/stub/gh" chmod +x "$ARRIVAL/stub/gh"
printf '%s\n' \ # The two PR gathers were GraphQL until #188; they are REST now, so the
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ # fixtures are the /pulls list both forges return. Empty by default — the
>"$ARRIVAL/fixtures/graphql.json" # merged-Refs case below fills the closed one.
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"; } 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
# /api/v3 shapes), so the suite says so at the forge boundary rather than
# letting main()'s preflight infer a forge from whatever env the CI job
# leaked (#188). Stubbing `gh` and staying silent about the forge is the
# boundary this issue moved.
arrival_run() { arrival_run() {
: >"$ARRIVAL/fixtures/edits" : >"$ARRIVAL/fixtures/edits"
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
CEREMONY_FORGE=github \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \ EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
@ -619,13 +643,17 @@ check "...and the sweep still runs" 0 "" \
grep -qF 'issueflow: reconciled.' <<<"$pr_out" grep -qF 'issueflow: reconciled.' <<<"$pr_out"
# The merged-Refs transition must survive the executable's set -e path too. # The merged-Refs transition must survive the executable's set -e path too.
# Keep this at main() granularity: the GraphQL gather and loop are the code # Keep this at main() granularity: the PR gather and loop are the code a
# a sourced decision probe cannot exercise (#91's lesson). # sourced decision probe cannot exercise (#91's lesson).
#
# merged_at is what makes this PR merged rather than merely closed — the
# REST replacement for GraphQL's states: MERGED filter (#188). Both forges
# return the field, and both return null on a closed-unmerged PR.
printf '%s\n' \ printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ '[{"number":400,"body":"Refs #40","merged_at":"2026-07-30T00:00:00Z"},{"number":401,"body":"Refs #40","merged_at":null}]' \
>"$ARRIVAL/fixtures/graphql.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"
@ -633,18 +661,95 @@ printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json"
: >"$ARRIVAL/fixtures/edits" : >"$ARRIVAL/fixtures/edits"
subprocess_out="$( subprocess_out="$(
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
CEREMONY_FORGE=github \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)" )"
subprocess_rc=$? subprocess_rc=$?
check "executable sweep transitions merged Refs work" 0 "" \ check "executable sweep transitions merged Refs work" 0 "" \
test "$subprocess_rc" -eq 0 test "$subprocess_rc" -eq 0
check "...reaches the transition through GraphQL and the issue loop" 0 "" \ check "...reaches the transition through the REST gather and the issue loop" 0 "" \
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out" grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out"
check "...and performs the release edit from the executable path" 0 "" \ check "...and performs the release edit from the executable path" 0 "" \
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \ grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
"$ARRIVAL/fixtures/edits" "$ARRIVAL/fixtures/edits"
# -- the issue/PR discriminator is not `has("pull_request")` ---------------
# Measured on the two list endpoints, 2026-08-02 (#188):
#
# GitHub plain issues OMIT the key -> 0 of 9 carried it
# Forgejo every entry HAS the key -> 10 of 10, valued null on issues
#
# So `select(has("pull_request") | not)` selected ZERO issues on Forgejo and
# the sweep printed "reconciled." over an untouched board — the blind sweep
# again, one layer in, and invisible because the log looks identical to a
# legitimately empty queue. Caught by rehearsing DRY_RUN against rig's live
# board, not by any unit test. `.pull_request == null` is true for an issue on
# both forges (an absent key reads as null) and false for a PR on both.
disc() { jq -e "$1" >/dev/null 2>&1 && echo issue || echo pr; }
check "github-shaped issue (key absent) reads as an issue" 0 "issue" \
bash -c 'echo "{\"number\":1}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
check "forgejo-shaped issue (key present, null) reads as an issue" 0 "issue" \
bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
check "a PR reads as a PR on either shape" 0 "pr" \
bash -c 'echo "{\"number\":1,\"pull_request\":{\"url\":\"x\"}}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
# The old test, kept as the must-fail: it disagrees with the new one on the
# forgejo shape, which is exactly the bug.
check "the old has() test misreads a forgejo issue as a PR" 0 "pr" \
bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e "has(\"pull_request\") | not" >/dev/null && echo issue || echo pr'
# -- the OPEN-pull gather, at main() granularity ----------------------------
# The closed/merged half above proves one REST path; this proves the other,
# which is a DIFFERENT pipeline: `.body | @base64` -> base64 -d ->
# closes_references -> OPEN_PR_ISSUES. The 27 parser cases in
# test/closes_references.test.sh cannot reach it — they test the parser, not
# the encoding and wiring around it (#188).
#
# Both directions in ONE sweep, so neither assertion can pass vacuously:
# #50 IS closed by an open PR -> the claim is KEPT, no reclaim edit
# #51 is closed by nothing -> the claim is RECLAIMED
# A break anywhere in the pipeline reclaims #50 too, and the first check
# fails. A break that reclaims nothing fails the second.
#
# `Closes #50` sits on the THIRD line of the body on purpose. jq's @tsv
# escapes a newline to a literal backslash-n, so a line-oriented parser
# reading an @tsv-encoded body sees one line and drops everything after the
# first — with the declaration on line 3, that defect reclaims #50 and this
# case goes red. On line 1 it would pass either way, which is the definition
# 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.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.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
# 2033, so without ISSUEFLOW_NOW the subprocess reads its own wall clock,
# dates these claims in the future, and both survive on a negative age —
# which is a green test proving nothing.
for n in 50 51; do
jq -n --arg at "$(iso_at $((INOW - 7200)))" --argjson n "$n" \
'{number:$n,user:{login:"triage-one"},created_at:$at,body:"- [x] built",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_$n.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_${n}_comments.json"
done
: >"$ARRIVAL/fixtures/edits"
open_pr_out="$(
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
CEREMONY_FORGE=github ISSUEFLOW_NOW="$INOW" ISSUEFLOW_STALE_HOURS=1 \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)"
check "the open-pull gather completes" 0 "" \
grep -qF 'issueflow: reconciled.' <<<"$open_pr_out"
check "a claim closed by an open PR survives the base64 round trip" 1 "" \
grep -qE 'issue edit 50 .*--remove-label claimed' "$ARRIVAL/fixtures/edits"
check "...while the claim no open PR closes is reclaimed in the same sweep" 0 "" \
grep -qE 'issue edit 51 .*--remove-label claimed --add-label ready' \
"$ARRIVAL/fixtures/edits"
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on # D2 preserved: only the deliberate stand-downs changed; a genuine failure on
# the arrival path still kills the run loudly. # the arrival path still kills the run loudly.
: >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error" : >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error"

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.
@ -738,6 +755,10 @@ blind_main_probe() {
GITHUB_EVENT_NAME=schedule GITHUB_EVENT_NAME=schedule
REPO=owner/repo REPO=owner/repo
LABELS_CONF=.github/labels.conf LABELS_CONF=.github/labels.conf
# 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() { 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
@ -821,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.
@ -895,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)"
@ -922,6 +943,7 @@ printf 'panel=bot-a bot-b bot-c\n' >"$EXEC/labels.conf"
exec_env() { # $1 = event name → the real script, executed under the PATH stub exec_env() { # $1 = event name → the real script, executed under the PATH stub
: >"$EXEC/record" : >"$EXEC/record"
env PATH="$EXEC/stub:$PATH" GH_RECORD="$EXEC/record" \ env PATH="$EXEC/stub:$PATH" GH_RECORD="$EXEC/record" \
CEREMONY_FORGE=github \
REPO=owner/repo LABELS_CONF="$EXEC/labels.conf" GITHUB_EVENT_NAME="$1" \ REPO=owner/repo LABELS_CONF="$EXEC/labels.conf" GITHUB_EVENT_NAME="$1" \
bash actions/labels-reconcile/labels-reconcile.sh bash actions/labels-reconcile/labels-reconcile.sh
} }
@ -946,5 +968,51 @@ for ev in schedule pull_request_target; do
expect "...and deletes nothing" \ expect "...and deletes nothing" \
no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)" no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)"
done done
# ---------------------------------------------------------------------------
# outstanding_requests — the portable "who still owes a verdict" (#188 term 4)
#
# GitHub clears requested_reviewers when a verdict lands; Forgejo never does.
# Measured 2026-08-02: rig!140 listed all three panelists with all three
# verdicts in, and rig!146 still lists three while MERGED. Read raw on
# Forgejo, that pins a PR at state:bots-reviewing for life and stops
# blocker:unrequested from ever being true.
# ---------------------------------------------------------------------------
HEAD_SHA=head1
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" 2026-08-01T00:00:00Z)" \
"$(rev "$BOT2" CHANGES_REQUESTED head1 "" 2026-08-01T00:00:00Z)" \
"$(rev "$BOT3" APPROVED head0 "" 2026-07-01T00:00:00Z)")"
expect "a head-current approval is no longer outstanding" "" \
"$(outstanding_requests "$BOT1")"
expect "a blocking verdict is not outstanding either — it is answered" "" \
"$(outstanding_requests "$BOT2")"
# The one that matters: an approval of an OLDER head is not a verdict on this
# head, so that reviewer still owes one. Treating STALE as answered would let
# a stale round read as complete.
expect "a stale approval still owes a verdict" "$BOT3" \
"$(outstanding_requests "$BOT3")"
expect "a reviewer who never reviewed still owes one" "nobody" \
"$(outstanding_requests "nobody")"
# The Forgejo shape, end to end: the field lists all three long after every
# verdict landed. Only the stale one may survive the filter.
expect "the never-cleared forgejo field collapses to who actually owes" \
"$BOT3" "$(outstanding_requests "$BOT1
$BOT2
$BOT3")"
# The GitHub shape: the field is already accurate, so the filter is a no-op
# on the set GitHub would have produced (term 5 — behaviour unchanged).
expect "on a github-shaped field the filter removes nothing" "nobody" \
"$(outstanding_requests "nobody")"
expect "an empty request list stays empty" "" "$(outstanding_requests "")"
# The summary and the gate belong at the TRUE end of the file. They sat in the
# middle until #188: eight outstanding_requests expects were appended after
# them, so a failure there printed FAIL, was left out of the totals, and the
# suite still exited 0 (@codex-reviewer-andresmgsl #4780 item 2). Anything
# appended below this line is ungated — so nothing goes below it.
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail" printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]

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