Commit graph

540 commits

Author SHA1 Message Date
Andriujose
a9b3f4d766 test: cover attention target diagnostics 2026-08-03 19:53:25 +00:00
Andriujose
e7750c0c8f feat: diagnose malformed attention targets 2026-08-03 19:48:15 +00:00
Daniel Marin
5626a4ef1e
Merge pull request #259 from andriujoseba/build/230-attention-target
docs: clarify the attention target at triage write time
2026-08-03 20:40:48 +01:00
cndgrr
5cfb69e104 test(issueflow): the parse echo, mutation-proven in both directions
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
2026-08-03 19:28:31 +00:00
cndgrr
374005ef77 feat(issueflow): echo the parsed blocker set when it changes
The clause parse is exact and unforgiving, and its output was invisible:
every incident in this class was found by a human running the parser by
hand, hours or days late. The sweep now states what it read — one marker
comment per distinct parsed set, comment-only, no label writes.

Refs #252
2026-08-03 19:24:59 +00:00
Daniel Marin
f73facea6e
Merge pull request #256 from cndgrr/build/247-read-guards
fix(issueflow): a failed read must never reach a decision function
2026-08-03 20:19:35 +01:00
Andriujose
dbb0554288 docs: clarify attention target 2026-08-03 19:07:08 +00:00
Daniel Marin
2e5aa52e29
Merge pull request #255 from andriujoseba/build/248-release-doctrine
docs: add release-management doctrine
2026-08-03 20:00:30 +01:00
cndgrr
6217798e14 fix(issueflow): a per-issue pass commits its whole effect, or none of it
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
2026-08-03 18:55:52 +00:00
Andriujose
8c29424519 docs: correct release gate mechanics 2026-08-03 18:26:58 +00:00
cndgrr
f965e8404c test(issueflow): an absent fixture answers nothing to a --jq read, as gh does
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
2026-08-03 18:14:19 +00:00
cndgrr
24f62cc8e9 test(issueflow): the failing --jq read yields no timestamps, as gh does
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
2026-08-03 18:08:19 +00:00
cndgrr
865d5bd1df test(issueflow): drive the real 5xx — a JSON error body on stdout
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
2026-08-03 18:06:13 +00:00
cndgrr
13e8f54d60 fix(issueflow): a failed read never reaches a decision function
`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
2026-08-03 18:01:58 +00:00
Daniel Marin
d8a70657eb
Merge pull request #250 from cndgrr/build/236-unrequested-green-gate
fix(labels): blocker:unrequested waits for green, and for the round to settle
2026-08-03 18:48:22 +01:00
Andriujose
a74ebb9876 docs: align release triggers with shipped flow 2026-08-03 17:33:39 +00:00
Andriujose
ab60709f49 docs: add release-management doctrine 2026-08-03 17:29:15 +00:00
cndgrr
d4512a1e82 test(labels): drive the fetch that feeds the grace, at the sweep level
The predicate's fixtures cannot see the read that sets HEAD_COMMIT_AT, so a
sweep probe drives it both ways: read, and the blocker is written off a dated
head; denied, and the denial is named on its own line while the state still
converges — this read narrows one blocker, it does not skip the PR the way an
unreadable rollup does. Renaming the assignment reds the probe.

The read also moves after the mergeability/checks skip: a PR the sweep walks
away from must not pay for a call whose only consumer is a blocker that pass
will never decide.

Refs #236
2026-08-03 17:23:11 +00:00
Daniel Marin
d225c68fa4
Merge pull request #243 from andriujoseba/build/241-open-pr-refs
fix: preserve claims with open Refs PRs
2026-08-03 18:21:21 +01:00
cndgrr
8459a9255b test(labels): drive the green gate and the grace, and mutate both to prove them
The six cases the issue names, plus the boundary (the grace is inclusive), a
verdict inside the window against an old head, both unreadable timestamps, and
the configured-grace override.

Two proofs run rather than asserted in prose: a copy of the script with the
gate removed must flag the PENDING fixture, and a copy with the grace removed
must flag the inside-the-window one. The harness checks itself against the
unmutated copy first, or a flip would prove nothing.

The pre-#236 stall fixtures gain real timestamps. Their symbolic stamps are
not unreadable — GNU date reads `t1` as 01:00 in military timezone T, a time on
whatever day the suite runs — so a grace measured against a fixed NOW would
flip with the calendar. Every assertion is byte-identical.

Refs #236
2026-08-03 17:18:46 +00:00
cndgrr
b5ec236b42 fix(labels): blocker:unrequested waits for green, and for the round to settle
`blocker:unrequested` is the one blocker that names an act the author must
perform, and it never knew whether performing it was permitted. BUILDER.md's
review round requires a green check at the head before requesting, so a
builder waiting out a pending run is complying — and the blocker fired on
compliance (crew#318 ~12:44Z, ceremony#235 12:30Z, both 2026-08-03).

Gate the branch on CHECKS ∈ SUCCESS | NONE (D1): PENDING is CI's move, which
state:addressing already says, and FAILURE belongs to blocker:ci-red rather
than to a second label on the same stall. Then require the supporting facts —
the head's own date and the round's newest submitted review — to have stood
for RECONCILE_UNREQUESTED_GRACE (default 300s, D2), measured off those
timestamps because this sweep is stateless per pass. A timestamp that cannot
be read refuses the blocker.

Refs #236
2026-08-03 17:12:38 +00:00
Andriujose
071ac49cc2 test: retain executable transition control 2026-08-03 17:08:16 +00:00
Andriujose
702ec5fc5d test: model both open PR linkage paths 2026-08-03 17:08:16 +00:00
Andriujose
e133924887 fix: preserve claims linked by open Refs PRs 2026-08-03 17:07:40 +00:00
Andriujose
19ae4aedd1 test: reproduce open Refs claim loss 2026-08-03 17:07:40 +00:00
Daniel Marin
78a86198d2
Merge pull request #245 from cndgrr/build/242-post-merge-pr-order
fix(issueflow): the deliverable PR is the last merged, not the highest numbered
2026-08-03 18:03:10 +01:00
cndgrr
544d4a0603 style(test): separate the merge-order block from the offsite decisions
Refs #242
2026-08-03 16:29:21 +00:00
cndgrr
9c690f02b7 test(issueflow): drive the merge-order selection, and the spent-marker shape end to end
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
2026-08-03 16:27:43 +00:00
cndgrr
f4afaa1346 fix(issueflow): the deliverable PR is the last merged, not the highest numbered
post_merge_pr_for_issue answered "which merged Refs PR is this issue's
deliverable?" with sort -n | tail -n1. Merge order is not number order:
crew#176's two Refs PRs merged #184 at 19:05:16Z and #182 at 19:05:18Z.

MERGED_REF_PR_RECORDS gains mergedAt as a third column — a field on the
merged-PR node set already fetched, so no additional GraphQL request — and
the selection sorts on it, breaking ties by highest PR number so the answer
never depends on input order.

Refs #242
2026-08-03 16:23:15 +00:00
Daniel Marin
46329f5993
Merge pull request #244 from cndgrr/build/231-labels-attention-absolute
docs(labels): the `attention` absolute stops denying the shipped reconciler
2026-08-03 17:17:27 +01:00
cndgrr
bded7d0b54 docs(labels): the attention absolute stops denying the shipped reconciler
LABELS.md asserted 'nothing in actions/ sets, clears, reads, or validates
it' and then documented two exceptions to itself four sentences later. The
sentence is false on two of the four verbs: issueflow-reconcile.sh clears a
carried attention on the derived claimed -> post-merge transition and reads
it to gate the post-merge-assigned diagnostic. Keep the hand-set intent,
drop the absolute (#231).
2026-08-03 15:48:05 +00:00
4e929e2083 test(forge): the negative half of the github pass-through pins
@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)
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)
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
github-actions[bot]
985ee7e6e4 chore: bump main to 0.5.1-dev — a dev install must not impersonate 0.5.0 2026-08-03 12:40:50 +00:00
Daniel Marin
ee75c2aba3
Merge pull request #235 from dan-claude-bot/build/233-release-0-5-0
release: cut 0.5.0
2026-08-03 13:40:35 +01:00
dan-claude-bot
d48374ff00 drill: probe 5 states suite coverage, not live dogfood
The #226 delta sits behind reconcile_ruling's needs-ruling gate and this
board has no such item — the post-merge sweeps ran the file, never the
delta. The record now says what was observed and why the live claim is
unreachable (round 1, claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:28:40 +00:00
dan-claude-bot
e5e7555184 release: cut 0.5.0
Five fragments assembled into '## 0.5.0 — 2026-08-03' (#205 #216 #221
#224 #226); VERSION to bare 0.5.0; the three CEREMONY_SELF_REF carriers
stamped "0.5.0" in this one commit; the panel-rows unreleased marker in
docs/CONSUMERS.md cleared to name 0.5.0; drills/0.5.0.md records the
doors-unchanged ruling with the measurements as they are at 0ac3a6f.

Refs #233.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:01:05 +00:00
Daniel Marin
0ac3a6ff7e
Merge pull request #234 from dan-claude-bot/build/226-best-shaped-escalation
fix: select the best-shaped escalation, not the earliest
2026-08-03 12:57:42 +01:00
dan-claude-bot
0df40f8d08 fix: select the best-shaped escalation, not the earliest
Closes-adjacent contract in the PR body; authorizing issue #226.

ruling_escalation_row scored every setter in-window row 0-4 by the shared
field matcher; highest wins, equal scores break to the earliest epoch, an
undecodable body scores 0. ruling_shape_decision now grades through the
same matcher, so the selector and the check cannot drift (crew#293).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:55:45 +00:00
57abe15a77 fix(issueflow): the issue/PR discriminator is GitHub-only
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
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
@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
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
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
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
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
@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
@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
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