@codex-reviewer-andresmgsl found the subtlety my board fixture could not reach:
BOARD_RECORDS filters an object-valued row out of the LIST before the per-issue
guard ever sees it, so no board fixture alone can prove the scalar stand-down.
My #64 case proved the gather excluded it, not that reconcile_issue_pass did.
The fixture that isolates the site is a deliberate mismatch: the LIST row is
null-valued, so the board gather admits #65 — and the INDIVIDUAL payload the
sweep then fetches is object-valued. Only reconcile_issue_pass's own guard can
stand that down.
Three rows over the same number, so the guard cannot pass by standing
everything down or by admitting everything:
payload object-valued -> NOT reconciled
payload null-valued -> reconciled
payload key absent -> reconciled (the GitHub shape)
Mutating ONLY the scalar predicate now reds three BEHAVIOURAL rows plus the
pin, where before it red only the pin and a neighbour. pass_disc is gone: it
repeated the predicate inside the test helper and never called production —
which is the same isolated-expression trap, one layer down, in the fix for it.
issueflow 512/512; test/run.sh 28/28; shellcheck 0.10.0 clean.
Refs #210
@codex-reviewer-andresmgsl's three, and items 1 and 2 were still open after
bada4ff — his review predates that push, but only item 3 (release bodies) was
actually answered by it.
TRAVERSAL, not a non-empty gather. The board case asserted the board was not
read as empty; #60 was `ready` and therefore produced no observable effect, so
nothing proved reconcile_issue_pass had run over it. #60 now carries NO queue
state, so traversal has a deterministic outcome — needs-triage is minted and
logged — and the row asserts that.
THE SCALAR SITE, through the real path. My first attempt asserted the jq
expression the function contains, which is exactly the shape that let this
regression through: the isolated discriminator rows passed the whole time the
gather was blind. A hand-wired probe around reconcile_issue_pass needed so much
internal setup that it would have been testing my scaffolding, so the same
board harness drives it with one row flipped — object-valued must NOT be
reconciled as an issue, and the sweep must then correctly report the board as
empty OF ISSUES.
Per-site mutation, all three now behavioural rather than pin-only:
revert BOARD_RECORDS -> 4 red
revert release_bodies -> 2 red
revert reconcile_issue_pass -> 3 red
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
@codex-reviewer-andresmgsl's two scope items, applied before the first review
round rather than after.
1. THE GUARD IS COMMENT-AWARE, WITH CONTROLS. It already stripped comments —
it has to, because the #188 warning that explains why has("pull_request") is
wrong contains the string. Without controls that was an untested property,
and the pressure it creates is real: a raw grep would push a builder into
deleting the very warning that prevents recurrence. Two fixtures now prove
it: the explanatory comment is allowed, an executable jq filter is rejected.
2. ALL THREE SITES ARE DRIVEN BY BEHAVIOUR. The pin makes any revert red, but a
pin proves a string is absent, not that each replacement means the intended
thing:
BOARD_RECORDS the forgejo-shaped board is not read as empty
release_bodies an open `release` issue whose gate holds an open
member makes a claimable NON-member draw a window
flag — empty carriers, no flag, so the row
discriminates the site instead of merely reaching it
reconcile_issue_pass the scalar payload: key-present-null is an issue,
object-valued is a PR, key-absent is still an issue
The release_bodies row did NOT discriminate on its first write — it asserted
an issue number that BOARD_RECORDS also produces, so reverting the site left
it green. Caught by mutating each site separately rather than trusting the
suite total.
Mutation, per site: BOARD_RECORDS 3 red, release_bodies 2 red,
reconcile_issue_pass 2 red.
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
issueflow-reconcile has been blind on this forge since the 0.6.0 merge landed.
Run 368 — #198's own post-merge acceptance probe — printed:
issueflow: no open issues.
issueflow: reconciled.
over a board of nine.
Every Forgejo entry CARRIES the `pull_request` key, valued null on an issue, so
`select(has("pull_request") | not)` selects zero rows. Measured again today:
#209 (an issue) has the key valued null; #208 and #207 (PRs) have it valued as
objects.
This is mine. #188 fixed exactly this and the file's own comment at :1113
states the rule, with :1121 already using it correctly. Resolving hunk 4 of the
merge I took upstream's board block wholesale and carried the wrong
discriminator into three sites — the gather, the release-body gather, and
reconcile_issue_pass — in the PR whose stated purpose was to stop blind sweeps
reporting success.
Cost while it stood: no issue transitions, no claim reclaims, no nudges, no
board flags — and no `post-merge` transitions, which is why #192 and #198 both
still read `claimed` after their PRs merged, and why #198's own closure
criterion could not complete.
Two guards, because a comment did not hold:
* A GATHER-LEVEL CASE against a Forgejo-shaped fixture — every entry carrying
the key. The existing discriminator cases assert jq expressions in
isolation and passed throughout this regression; they never ran the gather
that uses them, which is precisely how it survived review.
* A SOURCE PIN forbidding has("pull_request") on this surface, so a future
sync cannot reintroduce it 40 lines below the comment forbidding it.
Reverting the board gather reds both. Reverting reconcile_issue_pass reds the
pin.
test/run.sh 28/28 under jq 1.7 and jq 1.6; issueflow 503/503; shellcheck 0.10.0
and actionlint clean.
Refs #210
@codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was
the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in
labels.yml would have ridden in free — and it let the merge ship a step that
dies with `command not found` on every sweep on this forge, which #197's bar
does not permit.
The declaration mechanism already existed; a workflow simply could not reach
it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same
declaration actions/refs-not-closing carries, and the refusal that a script
gets from forge_preflight is inline here because a workflow has no shell to
call it from. The dispatch now warns by name, cites #205, and exits 0 rather
than reddening every sweep for a known gap.
So the guard needs no exemption list at all. It now requires the pair —
declared AND refusing — and reports a declaration that carries no refusal,
which is a permission slip for `command not found`.
That predicate was wrong on its first write, and its mutation test caught it:
`refuses_when_unavailable` matched the word `forge_preflight` inside
labels.yml's own comment explaining that it has NO forge_preflight to call. A
guard reading prose as evidence is the blind sweep again, in the guard written
to forbid it. Comments are stripped now, as gh_calls already stripped them.
Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the
strip reds two cases.
Blockers 2 and 3 were already fixed in 97e63ac, before either review landed.
test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin),
actionlint, self-ref, marker, vendored and changelog-armed all clean, with
every file tracked this time.
Refs #198
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
`claimed` + `needs-ruling` with no assignee posts `claimed-unassigned`
before the tail, so the top read is the only reason the ruling nudge
below still sees the real quiet. Probe 110 asserts both outputs of one
sweep and reds when that read drifts below the post.
Recovered from ed588a2 by sha — the branch ref was deleted and the push
crossed the merge — then re-measured at fd22bd2, behind #293. 486 -> 488
on this file.
Refs #307
The family-scoping fixture asserted that a foreign marker does not
SILENCE the flag, which a family-blind grep satisfies too. The property
that is load-bearing is the other one: a foreign family late on the
thread must not make the flag re-post. Made family-blind, the sweep now
reds.
D3b says a claimed non-member is flagged whether or not it has an open
PR, and the fixtures covered that for the collision flag only — the
window side, which is the flag the 18:11Z correction was about, had no
case at all.
check() matches its expectation as a substring, so a bare
`issueflow-reconcile` row was satisfied by `issueflow-reconcile.test`
too: the multi-extension rule stayed green under a normalization that
strips only the last extension. keys_of brackets each key, and the
no-em-dash row asserts its emptiness through grep rather than through an
expectation check() cannot make.
Beside them, the cases the two fixes are named for: a self-folding + title
answers its key once and never chains an issue to its own number, and
`needs-triage` and a label-less issue are outside both flags.
B1: deliverable_keys answered a multiset, so a `+` title whose segments
normalize to one key made collision_flags find the issue adjacent to
itself and chain it to its own number — the comment asked an issue to
declare `Blocked by` itself, and two such carriers corrupted the chain
between them. The keys are deduped where the set property belongs.
B2: window_in_scope excluded only blocked/epic/post-merge, admitting
`needs-triage` and label-less issues, so the sweep could add
`needs-triage` to an issue and then tell it about a mint-time membership
call in the same pass. Both flags now call one unblocked_claimable
predicate — #293 D2 corrected gives one gloss on `unblocked` and D3b
says D3 uses it.
The window log line says "unblocked", not "ready": D3b corrected exactly
that wording, and the flag fires on `claimed` too.
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes#293.
Claiming a needs-ruling issue dated it through the assigned timeline
event, silencing the 7-day escalation nudge at exactly the moment a
builder started working through it. The ruling block now reads
last_issue_comment_activity (D1); the reclaim clock keeps the assignment
(D2) because there the assignment IS the claim; post-merge hands its
evidence read to the ruling block instead of reading again (D6, D7); the
claimed branch reads both clocks at its top, before anything it posts.
LABELS.md and lib/ruling.sh now say what each surface's clock reads
(D4, D5). The #257-era order compositions move to the comments read —
the timeline is no longer an input the issue clocks take, and its
unreadability no longer holds unrelated writes hostage; that narrowing
is pinned rather than implied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"a comment, a review or a commit" is the ruling nudge's house phrasing and
false on the issue surface twice over: there is no review or commit fact in
what the sweep reads, and an assignment is no longer counted here. Say
what is read, and say what does not buy another 7 days of silence.
Refs #254
The nudge rode `last_issue_activity`, which counts `assigned` timeline
events because assignment is the claim the 48-hour reclaim protects.
`post-merge` has no claim: an assignee there is the invalid composition
the flag beside it reports, so counting the assignment let a broken board
buy the item another 7 days of silence — this issue's failure direction
taken backwards.
One computation, two clocks over it: `issue_activity_at` is the body,
`last_issue_activity` keeps the reclaim and ruling clocks byte-identical,
and `last_issue_comment_activity` is the evidence clock. Both clocks are
read before this branch posts anything, the ruling one included — read
after, it would date the issue by the evidence nudge's own comment and
silence the ruling nudge, which is the self-silencing the branch already
guarded against in the other direction.
Refs #254
The two 'must fail loudly' cases from the plan: an unparseable body still
nudges and the nudge quotes none of it, and a grep-level pin that no second
7-day constant appears in the sweep.
Refs #254
Covers the must-nudge pair (8 quiet days; post-merge + needs-ruling both
speak), the must-not set (6 days, fresh comment, every other queue state),
self-rate-limiting proven by sweeping again a day later rather than by
asserting a marker's absence, and zero writes across every probe.
Two existing probes move: #36's 'no comment' assertion described the
starvation this issue ends, and #67 gets recent activity so its precedence
count stays the assertion doing the work.
Refs #254
AC-1 names two inputs and says "both by fixture". The suite had the first and,
for the second, only a re-sweep of a BYTE-IDENTICAL body — which is the test
plan's other must-not-echo bullet, and cannot stand in for this one: an
identical body is quiet under both spellings of the decision, the one that
keys on the parse and the one that keys on the prose, so it cannot tell them
apart. Only an edit that changes the prose and preserves the parse can.
The new probe reorders the refs and adds sentences on either side, leaving the
set at {#90, #91}, and asserts the marker count, the thread's total echo count
and the issue-edit count all hold still. What it pins is that the marker is a
function of the PARSE and not of the prose around it — the property the whole
idempotency rests on.
Mutation-proven isolating: an echo that also re-fires when the declaration
prose moved since the last echo — quiet on identical re-sweeps, correct on
every set change — passes the pre-existing suite 304/0 and reds only here.
#263 landed on main after this branch's head and asserts that a healthy
assigned attention under blocked posts nothing at all. The #252 echo makes
every blocked issue carry one comment — its parse — so the proxy is false
while the contract behind it is not: probe 66 draws the parse echo and no
attention diagnostic.
Re-spelled the way the same section's other cases already spell it, as the
absence of the attention-malformed marker, plus a companion assertion that
exactly one comment landed. That is strictly tighter than test -f was: this
case now fails if an attention comment appears beside the echo, which the
old form could not detect once any comment existed.
Refs #252
claude-bot: `{acme.widgets#9}` is not a reachable declared set — the clause
parser stops at the `.` and blocked_reference_records never hands the token
through, though issue_references does answer CROSS for it. The comment claimed
all four were declarations the reconciler accepts. The member stays (the
marker's contract is over the tokens the classifier admits) and the comment
now says which is which.
Refs #252
ensure_comment's any-occurrence grep answers "have I ever said this", which
is right for a flag like blocked-unparseable and wrong for a value that
changes. A -> B -> A found A's own first echo and stayed silent, leaving the
thread's newest echo asserting B while the sweep gated on A: a stale parse
presented as the current one, and the third edit did change the parsed set,
so the criterion says it speaks.
blocked_parse_echo_needed compares this parse's marker against the LAST
blockers-parsed-* marker on the thread. The read stays inside guarded_read /
skip_issue, so an unreadable history still fails closed (#247 D1) rather than
answering "nothing echoed yet" and re-posting. ensure_comment is untouched
for every other caller.
Refs #252
Anchoring every pair on the `/` spelling passed under a fix that only
taught the slug about `/` — and that fix still collapses `acme-widgets#9`,
`acme_widgets#9` and `acme.widgets#9` onto one marker. Found by mutating
the implementation to that cheap fix and watching the suite stay green on
the cases that matter. The contract is that no two distinct parses
collide, so the assertion is now every pair.
Refs #252
The marker claimed to be scoped to the parsed set's value and was scoped
to a lossy rendering of it: `tr -c '[:alnum:]' '-'` maps `acme/widgets#9`
and `acme-widgets#9` — both parses this reconciler accepts — onto one
marker, so a declaration edited between them found the old echo and said
nothing. Silence in exactly the case the echo exists to speak about.
The identity is now a digest of the exact rendered set. The readable slug
stays in front of it and decides nothing. Distinguishing `/` would have
closed the reported pair and left the class: `-`, `_` and `.` are all
legal in a qualifier and all collapse the same way, so all four are
pinned, and the sweep probe observes the second echo actually landing.
Refs #252
The idempotency contract is the marker's scope, so both directions are
pinned: an unchanged set must reuse its marker (or a 15-minute cron
repeats itself forever) and a changed one must not (or a misparse hides
under a marker the thread already carries). crew#308's negated clause is
replayed through the sweep, and the empty parse is echoed beside the
untouched `blocked-unparseable` flag.
Refs #252
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
The sourced stub returned a literal `[]` to --jq callers when a fixture
was missing, where the real API answers an empty list and the filter
yields nothing. last_issue_activity then sorted `[]` beside an ISO-8601
timestamp — and `[]` outsorts a timestamp in the C locale but not in a
UTF-8 one, so the sweep dated an issue by a stub artifact on the runner
and by created_at here.
The old code swallowed the resulting `date` failure and graded the claim
on a literal 0 anyway; #247's guards turn a failed read into a skip,
which is what made the lie visible. Adopt the arrival stub's shape.
Suite green under LC_ALL=C, C.UTF-8 and en_US.UTF-8.
Refs #247
The .http-error mode applies a requested --jq filter to the error body,
so a failing comments read returns nothing rather than a JSON blob —
which is what let last_issue_activity fall back to created_at and
reclaim a live claim. With it, all three of the issue's must-fail-before
cases fail against the pre-change script, the destroyed claim included.
Pin offsite_timeline's own deliberate silence directly: the activity
read hits the same endpoint, so probe 32 now skips before the offsite
verification it used to reach (D8 leaves that read alone).
Refs #247
The PATH-stubbed gh gains a `.http-error` mode: the response body goes
to STDOUT, the reason to stderr, the status non-zero. The existing
`.error` sentinel produces empty stdout, which is the *safe* path — an
empty label set either way — and is why this class was never caught.
The three must-fail-before cases, plus the 200-`null` path a status
check alone leaves open, the suppressed-marker duplicate, the D6 tail's
count and numbers, and the crash handler proven distinct from a skip.
Refs #247
`gh api` prints a 5xx response body to stdout AND exits non-zero, and
GitHub's 5xx body is a JSON object. Inside the per-issue subshell that
payload passed `has("pull_request") | not`, emptied `.labels[]`, and
`queue_decision` — correct on the input it was handed — wrote
`needs-triage` onto a healthy epic. The run then logged `reconciled.`
and exited 0 (crew#329, #247).
errexit could not have caught it: a command whose status is tested by
`||` runs with errexit suppressed, and the suppression extends through
the whole subshell body, so the `|| log` handler is what disables the
errexit that would have aborted at the failed read. Removing the handler
revives errexit and loses #91's resilience, and an inline `set -e` does
not re-arm it. Explicit per-read checks are the mechanism.
Every read inside that subshell is now checked — the issue read on its
status AND on its payload shape (an HTTP 200 whose body is `null` exits
0 and empties the label set just the same), both reads in
`last_issue_activity`, and the comments read in
`issue_comment_has_marker`. On failure the issue is left exactly as it
is, the reason rides its own `#$n:` line, and the subshell exits with a
distinguished status the sweep counts, so a deliberate skip is not
reported as a crash and a genuine crash is still named byte-identically.
`read_failure_reason` moves to lib/read.sh beside a new `guarded_read`,
sourced by both reconcilers: labels-reconcile's copy was the only one,
and the issue surface needs the identical rule.
Refs #247
issue_probe's merged-PR argument becomes a spec list — `PR` or `PR@<iso>`
— so a probe can state merge order; the bare form keeps every existing call
site literal.
The direct-drive cases cover crew#176's shape (the lower number merged
later), agreeing orders, interleaved issues, the mergedAt tie broken by
highest PR number under both input orders, and the empty answer. The
end-to-end probe is crew#321's: a marker already standing for the
later-merged, lower-numbered PR must suppress the transition, which
selecting by number could never do.
Two static pins keep the request count honest — the sweep issues exactly two
GraphQL queries, with mergedAt selected on the merged-PR node it already
fetched.
Refs #242
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
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
@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
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
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
One resolution point (panel_for_author) feeds set_required_bots; the
author's row when the conf defines one, the base panel= otherwise, minus
the author in either case. Bracket prefixes are matched quoted so the
case patterns cannot glob (D7, panela= tripwire). configured_label_rows
skips the rows so a dispatch bootstrap cannot mint a label named after
one. BUILDER.md/REVIEWER.md carry the one D9 wording; CONSUMERS.md
publishes the row as unreleased with the parse-failure warning.
Refs #224
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>