@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
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
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
@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
@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
@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
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
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