2026-07-22 18:19:49 +00:00
#!/usr/bin/env bash
set -euo pipefail
2026-07-23 12:22:43 +00:00
# Byte-wise collation, matching CI: the probes sort ISO timestamps, and the
# verdict must not flip with the runner's ambient locale.
export LC_ALL = C
2026-07-22 18:19:49 +00:00
# Fixture tests for the labels-reconcile state machine: a comment is a
# non-verdict whatever its body says (the AUTHOR escalates by requesting the
# human), a stale approval does not promote unreviewed code, and an explicit
# human request outranks everything.
# Dependency-free beyond jq; no network, no daemon — pure decide_state.
cd " $( dirname " $0 " ) /.. "
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
. actions/labels-reconcile/labels-reconcile.sh
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
# 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'
}
2026-07-22 18:19:49 +00:00
2026-08-04 17:04:09 +00:00
RTMP = " $( mktemp -d) "
trap 'rm -rf "$RTMP"' EXIT
# The fixture roster is the test's own, and deliberately not the shipped one
# (#304). The state machine is roster-agnostic — it needs three distinct
# required logins, not THESE three — so binding the fixtures to
# .github/labels.conf by slot bought nothing and cost the file: when the
# operator shrank panel= from four members to three, the recused author left
# two, the third slot came up unbound, and set -u aborted this file before
# its first assertion. 217 assertions became 0, on main and on every branch cut
# from it, and no fixture here was about the panel's size. The shape below is
# test/labels.test.sh's, which has always written its own conf.
FIXTURE_CONF = " $RTMP /fixture-labels.conf "
FIXTURE_AUTHOR = fixture-builder
2026-07-22 18:19:49 +00:00
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
# machine's inputs, consumed inside the sourced decide_state — not unused.
# shellcheck disable=SC2034
2026-08-04 17:04:09 +00:00
BOT1 = fixture-bot-one BOT2 = fixture-bot-two BOT3 = fixture-bot-three
printf 'panel=%s %s %s %s\n' " $BOT1 " " $BOT2 " " $BOT3 " " $FIXTURE_AUTHOR " \
>" $FIXTURE_CONF "
load_config " $FIXTURE_CONF "
set_required_bots " $FIXTURE_AUTHOR "
2026-07-22 18:19:49 +00:00
pass = 0 fail = 0
expect( ) { # $1 = description, $2 = want, $3 = got
if [ " $2 " = " $3 " ] ; then
pass = $(( pass + 1 ))
else
fail = $(( fail + 1 ))
printf 'FAIL: %s — want %s, got %s\n' " $1 " " $2 " " $3 "
fi
}
rev( ) { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object
jq -n --arg u " $1 " --arg s " $2 " --arg c " $3 " --arg b " $4 " --arg t " $5 " \
'{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}'
}
reviews( ) { jq -s '.' <<< " $* " ; } # collect review objects into an array
2026-08-24 12:42:11 +00:00
# The approximation replaced by #238, retained only to make crew!96's
# before/after regression explicit. Production reads the forge's live set.
inferred_requests_before_238( ) {
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 - } "
}
2026-08-03 17:18:46 +00:00
# The blocker:unrequested quiescence inputs (#236 D2). Every fixture below
# inherits a readable, settled world — a head commit an hour before this
# sweep's clock — so the cases written before #236 assert exactly what they
# always asserted. The #236 block sets both per case.
#
# One consequence a new fixture has to know: a case that means to raise
# blocker:unrequested needs a REAL submitted_at on its reviews, because the
# grace dates the round's newest review. The symbolic stamps this file uses
# elsewhere (`t1`, `t2`, …) are not unreadable — GNU date reads `t1` as 01:00
# in military timezone T, i.e. a time on WHATEVER day the suite runs — which is
# worse: the verdict would flip with the calendar, the hazard the LC_ALL pin at
# the top of this file guards on the other axis. Hence a fixed NOW here and
# real timestamps on the three stall fixtures below.
NOW = " $( date -d 2026-08-03T12:00:00Z +%s) "
HEAD_COMMIT_AT = 2026-08-03T11:00:00Z
2026-07-23 23:12:30 +00:00
# -- a sweep-wide read failure is visible without changing any PR ------------
test: pin the two-line degrade, the bounded reason, and the demoted diagnosis
The unit block now feeds blind_sweep_warning a sampled reason and asserts
the new lead plus two must-fail guards: the disproven 'grant checks: read
and statuses: read' diagnosis stated as fact goes red, and so does any
drift in the counted line's whole-line shape (exactly the blind PRs match,
no more, no less — a reason line that matched would double-count, a folded
reason would undercount). read_failure_reason is covered pure: D4 wording
for empty stderr, multi-line collapse to one line, 400 chars truncated to
300 plus ellipsis within the 304-byte bound, 300 passing through whole.
blind_main_probe's gh pr view stub now fails with a denial on stderr, the
way real gh fails.
Part of #101.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:49:22 +00:00
warning = " $( blind_sweep_warning 3 3 "HTTP 403: Resource not accessible by integration" ) "
expect "a wholly blind sweep warns, leading with the observed reason" \
2026-07-24 22:57:22 +00:00
"::warning::labels: every open PR was unreadable; sampled reason: HTTP 403: Resource not accessible by integration — one candidate is missing checks: read, statuses: read and actions: read in the caller (private repos do not imply them)" \
2026-07-23 23:12:30 +00:00
" $warning "
expect "the blind warning names checks: read" named \
" $( grep -qF "checks: read" <<< " $warning " && echo named || echo missing) "
expect "the blind warning names statuses: read" named \
" $( grep -qF "statuses: read" <<< " $warning " && echo named || echo missing) "
test: pin the two-line degrade, the bounded reason, and the demoted diagnosis
The unit block now feeds blind_sweep_warning a sampled reason and asserts
the new lead plus two must-fail guards: the disproven 'grant checks: read
and statuses: read' diagnosis stated as fact goes red, and so does any
drift in the counted line's whole-line shape (exactly the blind PRs match,
no more, no less — a reason line that matched would double-count, a folded
reason would undercount). read_failure_reason is covered pure: D4 wording
for empty stderr, multi-line collapse to one line, 400 chars truncated to
300 plus ellipsis within the 304-byte bound, 300 passing through whole.
blind_main_probe's gh pr view stub now fails with a denial on stderr, the
way real gh fails.
Part of #101.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:49:22 +00:00
# must-fail (#101 D5): the #95 inference — disproven on incubator while the
# run held the evidence — must never again be stated as the cause
expect "the warning no longer asserts the permissions diagnosis as fact" no \
" $( grep -qF "grant checks: read and statuses: read" <<< " $warning " && echo yes || echo no) "
warning = " $( blind_sweep_warning 3 3 "" ) "
expect "with no reason captured the warning says exactly that" yes \
" $( grep -qF "no reason was captured" <<< " $warning " && echo yes || echo no) "
expect "...and keeps the permissions candidate" named \
" $( grep -qF "checks: read" <<< " $warning " && echo named || echo missing) "
expect "a partially blind sweep does not warn" "" " $( blind_sweep_warning 1 3 "x" ) "
expect "a sweep with no open PRs does not warn" "" " $( blind_sweep_warning 0 0 "" ) "
# -- the reason helper: facts in, one bounded line out (#101 D3/D4) ----------
expect "empty stderr is reported as its own fact" "no error output" \
" $( read_failure_reason "" ) "
expect "multi-line stderr collapses to one line" \
"GraphQL: Resource not accessible by integration (repository.pullRequest.mergeable) Resource not accessible by integration (repository.pullRequest.statusCheckRollup)" \
" $( read_failure_reason $'GraphQL: Resource not accessible by integration (repository.pullRequest.mergeable)\nResource not accessible by integration (repository.pullRequest.statusCheckRollup)' ) "
long_reason = " $( printf 'e%.0s' { 1..400} ) "
short_reason = " $( read_failure_reason " $long_reason " ) "
expect "400 chars of stderr truncate to 300 plus an ellipsis, one line" \
" $( printf 'e%.0s' { 1..300} ) … " " $short_reason "
expect "...within the 304-byte bound" yes \
" $( [ " ${# short_reason } " -le 304 ] && echo yes || echo no) "
exact_reason = " $( read_failure_reason " $( printf 'e%.0s' { 1..300} ) " ) "
expect "a 300-char reason passes through whole" 300 " ${# exact_reason } "
2026-07-23 23:12:30 +00:00
2026-07-24 00:49:35 +00:00
# -- a missing core taxonomy row is visible without mutating labels ----------
core_rows = " $( core_label_rows) "
core_names = " $( cut -d'|' -f1 <<< " $core_rows " ) "
2026-07-25 00:14:23 +00:00
expect "post-merge core row is byte-exact" \
"post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion" \
" $( grep '^post-merge|' <<< " $core_rows " ) "
2026-07-24 00:49:35 +00:00
expect "a complete core taxonomy does not warn" "" \
" $( missing_core_labels_warning " $core_rows " " $core_names " ) "
expect "one missing core label is named exactly" \
"::warning::labels: missing core label(s): attention; bump the ceremony pin, then re-dispatch workflow_dispatch to bootstrap the taxonomy" \
" $( missing_core_labels_warning " $core_rows " " $( grep -vxF attention <<< " $core_names " ) " ) "
expect "three missing core labels are named in table order" \
"::warning::labels: missing core label(s): offsite, needs-ruling, attention; bump the ceremony pin, then re-dispatch workflow_dispatch to bootstrap the taxonomy" \
" $( missing_core_labels_warning " $core_rows " " $( grep -vxF -e offsite -e needs-ruling -e attention <<< " $core_names " ) " ) "
expect "an unreadable empty label set does not report the taxonomy missing" "" \
" $( missing_core_labels_warning " $core_rows " "" ) "
expect "unrelated scope labels do not affect a complete core taxonomy" "" \
" $( missing_core_labels_warning " $core_rows " " $core_names
scope:consumer-one
scope:consumer-two")"
2026-07-24 11:54:49 +00:00
# -- the release-shape guard warns, never writes (#130; the #128 incident) ----
# The caller gates on NOT has_label release and NOT draft; these fix the
# version matrix. The warning is one line per call — reconcile_pr runs once
# per PR per sweep, so "exactly one warning per sweep" is by construction.
shape_warning = " $( release_shape_warning 41 2.0.0 2.0.0-dev) "
expect "bare head over a -dev base warns" yes \
" $( grep -qF '::warning::' <<< " $shape_warning " && echo yes || echo no) "
expect "...naming the PR and both versions" yes \
" $( grep -qF '#41 is release-shaped (version 2.0.0-dev -> 2.0.0' <<< " $shape_warning " && echo yes || echo no) "
expect "...and pointing at the release label, not setting it" yes \
" $( grep -qF 'apply release' <<< " $shape_warning " && echo yes || echo no) "
expect "an ordinary -dev head is silent" "" \
" $( release_shape_warning 41 2.0.1-dev 2.0.0-dev) "
expect "a bare head equal to the base is silent" "" \
" $( release_shape_warning 41 2.0.0 2.0.0) "
expect "an rc head is silent — pre-releases are not the merge door's shape" "" \
" $( release_shape_warning 41 2.0.0-rc1 2.0.0-dev) "
expect "an unreadable head version is silent — never nag on a guess" "" \
" $( release_shape_warning 41 "" 2.0.0-dev) "
expect "a bare head over an unreadable base still warns" yes \
" $( release_shape_warning 41 2.0.0 "" | grep -qF '::warning::' && echo yes || echo no) "
2026-08-31 21:04:41 +00:00
# The pure matrix above proves the warning predicate. These two fixtures drive
2026-08-31 21:20:35 +00:00
# the sweep boundary that chooses WHICH base tree feeds it (#275): the
# reporting Forgejo payload exposes the PR's moving base tip beside its fixed
# merge base, and using the former manufactures a downgrade after an
# intervening release. A missing merge-base field deliberately keeps the
# specified base-tip fallback for backends that do not supply that fact.
release_shape_ref_probe( ) { # $1 = phantom | bump | fallback
2026-08-31 21:04:41 +00:00
(
2026-08-31 21:11:30 +00:00
# shellcheck disable=SC2030 # this probe intentionally isolates its repository fixture
2026-08-31 21:04:41 +00:00
REPO = owner/repo
LABELS_CONF = " $FIXTURE_CONF "
CEREMONY_FORGE = github
mode = " $1 "
refs = " $RTMP /release-shape- $mode -refs "
: >" $refs "
case " $mode " in
phantom)
2026-08-31 21:20:35 +00:00
head_ver = 1.3.0 base_tip_ver = 1.4.0 merge_base_ver = 1.3.0
merge_base_json = '"merge-base"' ; ;
2026-08-31 21:04:41 +00:00
bump)
2026-08-31 21:20:35 +00:00
head_ver = 1.4.0 base_tip_ver = 1.3.0 merge_base_ver = 1.3.0
merge_base_json = '"merge-base"' ; ;
fallback)
head_ver = 1.4.0 base_tip_ver = 1.4.0 merge_base_ver = unused
merge_base_json = null ; ;
2026-08-31 21:04:41 +00:00
*) return 2 ; ;
esac
# shellcheck disable=SC2317 # reached through the GitHub backend selected above
gh( ) {
if [ " $1 " = label ] && [ " $2 " = list ] ; then
core_label_rows | cut -d'|' -f1
return 0
fi
if [ " $1 " = pr ] && [ " $2 " = list ] ; then
printf '701\n'
return 0
fi
if [ " $1 " = pr ] && [ " $2 " = view ] ; then
jq -n '{mergeable:"MERGEABLE",statusCheckRollup:[]}'
return 0
fi
if [ " $1 " = issue ] && [ " $2 " = edit ] ; then return 0; fi
case " $( forge_stub_path " $* " ) " in
*'repos/owner/repo/pulls/701 --jq .requested_reviewers' *) return 0 ; ;
*repos/owner/repo/pulls/701/reviews*) return 0 ; ;
*repos/owner/repo/pulls/701)
2026-08-31 21:20:35 +00:00
jq -n --argjson merge_base " $merge_base_json " \
' { draft:false,user:{ login:"fixture-builder" } ,
head:{ sha:"head" } ,base:{ sha:"base-tip" } ,merge_base:$merge_base ,
2026-08-31 21:04:41 +00:00
labels:[ { name:"state:addressing" } ,{ name:"blocked" } ] ,
requested_reviewers:[ ] ,created_at:"2026-08-31T20:00:00Z" } ' ; ;
*repos/owner/repo/commits/head*) printf '2026-08-31T20:00:00Z\n' ; ;
*repos/owner/repo/contents/VERSION\? ref = head*)
printf 'head\n' >>" $refs "
printf '%s' " $head_ver " | base64 ; ;
*repos/owner/repo/contents/VERSION\? ref = base-tip*)
printf 'base-tip\n' >>" $refs "
printf '%s' " $base_tip_ver " | base64 ; ;
*repos/owner/repo/contents/VERSION\? ref = merge-base*)
printf 'merge-base\n' >>" $refs "
printf '%s' " $merge_base_ver " | base64 ; ;
*) printf '[]\n' ; ;
esac
}
main
)
}
phantom_shape = " $( release_shape_ref_probe phantom) "
expect "an unchanged branch cut before a later base release emits no release-shape warning" \
no " $( grep -q 'release-shaped' <<< " $phantom_shape " && echo yes || echo no) "
expect "the unchanged-branch guard compares head with the PR merge base" \
$'head\nmerge-base' " $( cat " $RTMP /release-shape-phantom-refs " ) "
bump_shape = " $( release_shape_ref_probe bump) "
expect "a genuine version bump relative to the merge base keeps the warning text" \
yes " $( grep -qF '#701 is release-shaped (version 1.3.0 -> 1.4.0 at its head)' \
<<< " $bump_shape " && echo yes || echo no) "
expect "the genuine-bump guard also compares head with the PR merge base" \
$'head\nmerge-base' " $( cat " $RTMP /release-shape-bump-refs " ) "
2026-08-31 21:20:35 +00:00
fallback_shape = " $( release_shape_ref_probe fallback) "
expect "a null merge base falls back to the base tip without guessing a warning" \
no " $( grep -q 'release-shaped' <<< " $fallback_shape " && echo yes || echo no) "
expect "the null merge-base fallback compares head with the base tip" \
$'head\nbase-tip' " $( cat " $RTMP /release-shape-fallback-refs " ) "
2026-07-22 18:19:49 +00:00
# -- drafts are building, whoever is requested --------------------------------
DRAFT = true HEAD_SHA = head1 REQUESTED = "" REVIEWS_JSON = '[]'
expect "draft PR is building" state:building " $( decide_state) "
# -- fresh ready PR with bots requested ---------------------------------------
DRAFT = false REQUESTED = " $BOT1
$BOT2
$BOT3 " REVIEWS_JSON='[]'
expect "requested bots mean bots-reviewing" state:bots-reviewing " $( decide_state) "
2026-08-24 12:42:11 +00:00
# Forgejo materializes each live request as a REQUEST_REVIEW row. The backend
# returns those three logins directly; the rows are not submitted verdicts
# (#235), so an opening round stays with the panel rather than falling through
# to the builder as three comment-only answers.
2026-08-23 23:11:11 +00:00
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " REQUEST_REVIEW "" "" t1) " \
" $( rev " $BOT2 " REQUEST_REVIEW "" "" t2) " \
" $( rev " $BOT3 " REQUEST_REVIEW "" "" t3) " ) "
2026-08-24 12:42:11 +00:00
REQUESTED = " $BOT1
2026-08-23 23:11:11 +00:00
$BOT2
2026-08-24 12:42:11 +00:00
$BOT3 "
2026-08-23 23:11:11 +00:00
expect "three Forgejo request rows keep the opening round with the panel" \
state:bots-reviewing " $( round_state) "
2026-08-24 12:42:11 +00:00
# crew!96 after a fix push: every submitted verdict belongs to the older head,
# and the forge carries no live request row. The old intersection mistakes the
# two stale approvals for requests; the exact empty set correctly gives the
# incomplete round back to the builder (#238).
HEAD_SHA = head2
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED head1 "" 2026-08-22T23:05:01Z) " \
" $( rev " $BOT2 " APPROVED head1 "" 2026-08-22T23:14:45Z) " \
" $( rev " $BOT3 " REQUEST_CHANGES head1 "" 2026-08-22T23:03:39Z) " ) "
REQUESTED = " $( inferred_requests_before_238 " $BOT1
$BOT2
$BOT3 ")"
expect "crew!96 old inferred requests hand the fix round to the panel" \
state:bots-reviewing " $( round_state) "
REQUESTED = ""
expect "crew!96 exact empty requests return the fix round to the builder" \
state:addressing " $( round_state) "
HEAD_SHA = head1
2026-07-22 18:19:49 +00:00
# -- a bot that never reviewed keeps the round open ---------------------------
# With a live request that is the bots' ball; with NO request outstanding it
# is the agent's, because nothing is coming until somebody asks.
REQUESTED = " $BOT3 " REVIEWS_JSON = " $( reviews \
2026-08-03 17:18:46 +00:00
" $( rev " $BOT1 " APPROVED head1 "" 2026-08-03T10:00:00Z) " \
" $( rev " $BOT2 " APPROVED head1 "" 2026-08-03T10:01:00Z) " ) "
2026-07-22 18:19:49 +00:00
expect "a missing bot WITH a live request is bots-reviewing" state:bots-reviewing " $( decide_state) "
REQUESTED = ""
expect "...but with nobody asked it is the agent's ball" state:addressing " $( decide_state) "
expect "...and the blocker names the stall" blocker:unrequested " $( blockers) "
# -- a comment is a non-verdict, agreement body or not: the author escalates --
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " ) "
expect "comment-only agreement still parks on the author" state:addressing " $( decide_state) "
# ...and the author's escalation — requesting the human — flips it
REQUESTED = " $HUMAN "
expect "author escalation flips to needs-human" state:needs-human " $( decide_state) "
REQUESTED = ""
# -- three formal approvals need no author judgment ---------------------------
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED head1 "" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " ) "
expect "three formal approvals reach needs-human" state:needs-human " $( decide_state) "
# -- a comment WITHOUT a verdict parks the PR on the agent --------------------
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " ) "
expect "comment without verdict is addressing" state:addressing " $( decide_state) "
# -- changes requested blocks, at any head ------------------------------------
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " CHANGES_REQUESTED old1 "blockers below" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " ) "
expect "changes-requested blocks even from an old head" state:addressing " $( decide_state) "
2026-08-23 23:11:11 +00:00
# bot_verdict grades submitted states from both forges (#235). Each direct
# assertion names one arm so a later vocabulary regression cannot hide behind
# round_state's shared BLOCK/FEEDBACK handling.
expect "GitHub CHANGES_REQUESTED grades as a block" BLOCK \
" $( bot_verdict " $BOT1 " ) "
REVIEWS_JSON = " $( reviews " $( rev " $BOT1 " REQUEST_CHANGES old1 "blockers below" t1) " ) "
expect "Forgejo REQUEST_CHANGES grades as a block" BLOCK \
" $( bot_verdict " $BOT1 " ) "
REVIEWS_JSON = " $( reviews " $( rev " $BOT1 " COMMENT head1 "non-blocking note" t1) " ) "
expect "Forgejo COMMENT grades as feedback" FEEDBACK \
" $( bot_verdict " $BOT1 " ) "
REVIEWS_JSON = " $( reviews " $( rev " $BOT1 " FUTURE_FORGE_STATE head1 "" t1) " ) "
bot_verdict " $BOT1 " >" $RTMP /unknown-verdict " 2>" $RTMP /unknown-verdict-log "
expect "an unrecognised review state is conservatively missing" MISSING \
" $( cat " $RTMP /unknown-verdict " ) "
expect "an unrecognised review state logs the login and spelling" yes \
" $( grep -qF " $BOT1 : unrecognised review state FUTURE_FORGE_STATE " \
" $RTMP /unknown-verdict-log " && echo yes || echo no) "
2026-07-22 18:19:49 +00:00
# -- a stale approval must not promote unreviewed code ------------------------
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED old1 "" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " ) "
expect "stale approval is addressing (agent owes re-request)" state:addressing " $( decide_state) "
# -- a re-requested bot reopens the round even with an old approval on file ---
REQUESTED = " $BOT1 "
expect "re-requested bot means bots-reviewing" state:bots-reviewing " $( decide_state) "
REQUESTED = ""
# -- only the LATEST review per bot counts ------------------------------------
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " CHANGES_REQUESTED head1 "blockers" t1) " \
" $( rev " $BOT1 " APPROVED head1 "" t2) " \
" $( rev " $BOT2 " APPROVED head1 "" t3) " \
" $( rev " $BOT3 " APPROVED head1 "" t4) " ) "
expect "later approval supersedes earlier block" state:needs-human " $( decide_state) "
# -- an explicit human request outranks the bot rounds ------------------------
REQUESTED = " $HUMAN " REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " COMMENTED head1 "feedback, no verdict" t1) " ) "
expect "human requested outranks bots" state:needs-human " $( decide_state) "
REQUESTED = ""
# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED head1 "" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " \
" $( rev " $HUMAN " CHANGES_REQUESTED head1 "not yet" t4) " ) "
expect "human block with bots approving is addressing" state:addressing " $( decide_state) "
# ...and re-requesting the human hands it back to them
REQUESTED = " $HUMAN "
expect "re-requested human is needs-human again" state:needs-human " $( decide_state) "
REQUESTED = ""
2026-08-23 23:11:11 +00:00
# Forgejo's human-block spelling carries the same meaning (#235). This is
# independently observable because only BLOCK prevents state:needs-human once
# every bot approves.
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED head1 "" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " \
" $( rev " $HUMAN " REQUEST_CHANGES head1 "not yet" t4) " ) "
expect "Forgejo human request-changes with bots approving is addressing" \
state:addressing " $( decide_state) "
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED head1 "" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " \
" $( rev " $HUMAN " APPROVED head1 "" t4) " ) "
expect "the same Forgejo-shaped fixture with human approval reaches needs-human" \
state:needs-human " $( decide_state) "
2026-07-22 18:19:49 +00:00
# -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
REVIEWS_JSON = " $( reviews \
" $( rev " $HUMAN " COMMENTED old1 "early thoughts" t0) " \
" $( rev " $BOT1 " APPROVED head1 "" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " ) "
expect "old human comment + three approvals is needs-human" state:needs-human " $( decide_state) "
expect "old human comment still needs a fresh request" needed " $( human_request_needed && echo needed || echo not-needed) "
# ...a stale human APPROVAL likewise needs a re-request for the new head
REVIEWS_JSON = " $( reviews \
" $( rev " $HUMAN " APPROVED old1 "" t0) " \
" $( rev " $BOT1 " APPROVED head1 "" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " ) "
expect "stale human approval needs a fresh request" needed " $( human_request_needed && echo needed || echo not-needed) "
# ...a HEAD-CURRENT human approval needs nothing more
REVIEWS_JSON = " $( reviews \
" $( rev " $HUMAN " APPROVED head1 "" t0) " \
" $( rev " $BOT1 " APPROVED head1 "" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " ) "
expect "head-current human approval needs no request" not-needed " $( human_request_needed && echo needed || echo not-needed) "
# ...and a live request suppresses re-requesting
REQUESTED = " $HUMAN "
expect "live human request suppresses re-request" not-needed " $( human_request_needed && echo needed || echo not-needed) "
REQUESTED = ""
# ---------------------------------------------------------------------------
# #136: state:needs-human must mean "a human could merge this RIGHT NOW".
# Both cases below were observed live in this repo on 2026-07-20, and both
# showed state:needs-human while being unmergeable in different ways.
# ---------------------------------------------------------------------------
ALL_APPROVE = " $( reviews \
" $( rev " $BOT1 " APPROVED head1 "" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " \
" $( rev " $BOT3 " APPROVED head1 "" t3) " ) "
# -- flavour 1: not mergeable. The merge button is disabled, yet the board
# said "your turn" on #119/#120/#127 for hours. The branch fact now rides
# the blocker axis; the state says whose ball it is, which is the agent's.
DRAFT = false HEAD_SHA = head1 REQUESTED = "" REVIEWS_JSON = " $ALL_APPROVE " MERGEABLE = CONFLICTING CHECKS = SUCCESS
expect "a CONFLICTING PR is the agent's, not the human's" state:addressing " $( decide_state) "
expect "...and says WHY on the blocker axis" blocker:conflict " $( blockers) "
REQUESTED = " $HUMAN "
expect "...even with the human explicitly requested" state:addressing " $( decide_state) "
# -- red CI is the same claim, but NOT the same work: a rebase does not fix a
# failing test. Collapsing both into one needs-rebase label told the agent
# to do the wrong thing, which is why the axis split exists.
REQUESTED = "" MERGEABLE = MERGEABLE CHECKS = FAILURE
expect "a red PR is the agent's" state:addressing " $( decide_state) "
expect "...and is distinguishable from a conflict" blocker:ci-red " $( blockers) "
REQUESTED = " $HUMAN "
expect "...and a human request does not override red CI" state:addressing " $( decide_state) "
# -- both at once. The single-axis design could not say this at all: one label
# had to win, and the loser silently vanished off the board.
REQUESTED = "" MERGEABLE = CONFLICTING CHECKS = FAILURE
expect "a conflicted AND red PR reports both blockers" " blocker:conflict
blocker:ci-red" " $( blockers) "
expect "...and is still just the agent's ball" state:addressing " $( decide_state) "
# -- UNKNOWN is NOT unmergeable. GitHub reports it for ~a minute after every
# merge while it recomputes; treating it as broken would flap every open PR
# on each merge — worse than the bug being fixed.
REQUESTED = "" MERGEABLE = UNKNOWN CHECKS = PENDING
expect "UNKNOWN mergeability blocks nothing" state:needs-human " $( decide_state) "
expect "...and raises no blocker" "" " $( blockers) "
# -- blocker:unrequested — the stalled round. Nobody owes an answer because
# nobody was ever asked, yet the board read "waiting on the bots" until
# `stale` noticed 48h later.
MERGEABLE = MERGEABLE CHECKS = SUCCESS REQUESTED = "" REVIEWS_JSON = '[]'
expect "ready, nobody asked, nothing reviewed raises unrequested" blocker:unrequested " $( blockers) "
# ...the partial case is equally stalled: one verdict in, nobody asked for the rest
2026-08-03 17:18:46 +00:00
REVIEWS_JSON = " $( reviews " $( rev " $BOT1 " APPROVED head1 "" 2026-08-03T10:00:00Z) " ) "
2026-07-22 18:19:49 +00:00
expect "one bot in, none requested is still unrequested" blocker:unrequested " $( blockers) "
# ...a STALE round with nobody asked is the same debt, and arguably worse: the
# page carries approvals that no longer describe the tree. Guarding on
# MISSING alone let this one through with no blocker at all.
REVIEWS_JSON = " $( reviews \
2026-08-03 17:18:46 +00:00
" $( rev " $BOT1 " APPROVED oldhead "" 2026-08-03T10:00:00Z) " \
" $( rev " $BOT2 " APPROVED oldhead "" 2026-08-03T10:01:00Z) " \
" $( rev " $BOT3 " APPROVED oldhead "" 2026-08-03T10:02:00Z) " ) "
2026-07-22 18:19:49 +00:00
expect "a stale round with nobody asked is unrequested too" blocker:unrequested " $( blockers) "
expect "...and is still the agent's ball" state:addressing " $( decide_state) "
# ...but a live request means an answer IS coming
REVIEWS_JSON = " $( reviews " $( rev " $BOT1 " APPROVED head1 "" t1) " ) "
REQUESTED = " $BOT2 "
expect "a live bot request is not a stalled round" "" " $( blockers) "
# ...and a draft is exempt: the bots ignore drafts by design
DRAFT = true REQUESTED = "" REVIEWS_JSON = '[]'
expect "a draft with nobody asked is not stalled" "" " $( blockers) "
# ...as is an explicit human request — claiming a PR early is deliberate
DRAFT = false REQUESTED = " $HUMAN "
expect "an early human claim is not a stalled round" "" " $( blockers) "
REQUESTED = "" REVIEWS_JSON = " $ALL_APPROVE " MERGEABLE = MERGEABLE CHECKS = SUCCESS
# -- flavour 2 (the dangerous one): mergeable, green, human requested, and
# NOBODY has reviewed this head. Observed on #119 after a rebase: every
# signal read "merge me" and nothing on the page contradicted it.
MERGEABLE = MERGEABLE CHECKS = SUCCESS REQUESTED = " $HUMAN "
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED oldhead "" t1) " \
" $( rev " $BOT2 " APPROVED oldhead "" t2) " \
" $( rev " $BOT3 " APPROVED oldhead "" t3) " ) "
expect "stale approvals outrank the human request (nobody reviewed this tree)" state:addressing " $( decide_state) "
# -- ...and a round that is BOTH unfinished and staled is still the agent's.
# Deciding inside the bot loop made this depend on BOTS order: the MISSING
# returned before any later bot's STALE was read, so the mixed round came
# out needs-human with nothing bound to the head. Pinned at both ends of
# the array, because the whole failure was one of ordering.
MERGEABLE = MERGEABLE CHECKS = SUCCESS REQUESTED = " $HUMAN "
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED oldhead "" t1) " \
" $( rev " $BOT2 " APPROVED oldhead "" t2) " ) "
expect "stale approvals + a bot yet to review is addressing, not needs-human" \
state:addressing " $( decide_state) "
REVIEWS_JSON = " $( reviews " $( rev " $BOT3 " APPROVED oldhead "" t3) " ) "
expect "...and the same when the stale verdict is the LAST bot in BOTS" \
state:addressing " $( decide_state) "
# -- but an UNFINISHED round still yields to an explicit human request: a
# maintainer pulling a PR to themselves early is deliberate, and was the
# original precedence. MISSING differs from STALE — nobody has reviewed
# YET, versus everyone reviewed something else.
REVIEWS_JSON = " $( reviews " $( rev " $BOT1 " APPROVED head1 "" t1) " ) "
expect "an unfinished round still yields to an explicit human request" state:needs-human " $( decide_state) "
REQUESTED = ""
expect "...and without that request the agent owes the ask" state:addressing " $( decide_state) "
# ---------------------------------------------------------------------------
# checks_state: the rollup classifier. It lived inline in main() for the first
# round of this PR, which is why nothing here caught it calling ERROR,
# CANCELLED and STALE green. Extracted so the enum can be pinned down.
# ---------------------------------------------------------------------------
rollup( ) { jq -n --argjson c " $1 " '{statusCheckRollup: $c}' ; }
run_( ) { jq -n --arg n " $1 " --arg o " $2 " --arg t " ${ 3 :- 2026 -07-20T15 : 00 : 00Z } " \
'{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o, completedAt:$t}' ; }
ctx_( ) { jq -n --arg n " $1 " --arg s " $2 " --arg t " ${ 3 :- 2026 -07-20T15 : 00 : 00Z } " \
'{__typename:"StatusContext", context:$n, state:$s, createdAt:$t}' ; }
2026-08-01 16:12:17 +00:00
# Pinned empty for every fixture below except the #208 block, which sets its
# own. The script defaults SELF_WORKFLOW from the ambient GITHUB_WORKFLOW —
# present in any CI run of this suite — and an inherited name that happened
# to match a fixture's workflowName ("ci", "labels") would silently drop
# entries these fixtures rely on. The verdicts must not flip with the runner.
SELF_WORKFLOW = ""
2026-07-22 18:19:49 +00:00
expect "no checks at all is NONE" NONE " $( rollup '[]' | checks_state) "
# A failed fetch leaves no rollup KEY; a PR with no checks leaves an empty
# ARRAY. Collapsing the two let an API hiccup read as "nothing is failing" —
# the same unknown-certified-as-green shape as #136, in the one place that
# fix did not look. The caller skips an UNREADABLE PR rather than relabelling.
expect "a failed read is UNREADABLE, not NONE" UNREADABLE " $( echo '{}' | checks_state) "
expect "...and a real empty rollup is still NONE" NONE \
" $( echo '{"mergeable":"MERGEABLE","statusCheckRollup":[]}' | checks_state) "
expect "all green is SUCCESS" SUCCESS \
" $( rollup " [ $( run_ a SUCCESS) , $( run_ b SUCCESS) ] " | checks_state) "
expect "a queued run is PENDING" PENDING \
" $( rollup " [ $( run_ a SUCCESS) , $( run_ b QUEUED) ] " | checks_state) "
expect "a plain failure is FAILURE" FAILURE \
" $( rollup " [ $( run_ a SUCCESS) , $( run_ b FAILURE) ] " | checks_state) "
# -- the round-1 gap: outcomes that are neither success nor pending, and that
# leave a required check unsatisfied. All three reached the old `else`.
expect "a commit status ERROR blocks" FAILURE \
" $( rollup " [ $( run_ a SUCCESS) , $( ctx_ lint ERROR) ] " | checks_state) "
expect "a CANCELLED run blocks" FAILURE \
" $( rollup " [ $( run_ a SUCCESS) , $( run_ b CANCELLED) ] " | checks_state) "
expect "a STALE run blocks" FAILURE \
" $( rollup " [ $( run_ a SUCCESS) , $( run_ b STALE) ] " | checks_state) "
expect "an outcome the enum does not know blocks, it does not pass" FAILURE \
" $( rollup " [ $( run_ a SUCCESS) , $( run_ b SOME_FUTURE_STATE) ] " | checks_state) "
# -- NEUTRAL and SKIPPED satisfy branch protection; path-filtered jobs skip
# constantly, and calling that red would park every PR on the agent.
expect "NEUTRAL and SKIPPED are not failures" SUCCESS \
" $( rollup " [ $( run_ a SUCCESS) , $( run_ b NEUTRAL) , $( run_ c SKIPPED) ] " | checks_state) "
# -- latest-wins. The rollup keeps superseded runs, so this PR's own tip
# carried a CANCELLED `scope` beside the SUCCESS `scope` that replaced it.
# Without collapsing, making CANCELLED block would strand it forever.
expect "a re-run supersedes the cancelled original" SUCCESS \
" $( rollup " [ $( run_ scope CANCELLED 2026-07-20T15:19:39Z) ,\
$( run_ scope SUCCESS 2026-07-20T15:19:45Z) ] " | checks_state)"
2026-07-24 12:34:51 +00:00
# The reverse order once pinned FAILURE — "the reverse order is not a re-run
# passing, it is one failing". That fixture imagined a cancelled run REPLACING
# a success; #139 recorded a cancelled run that replaced NOTHING: the
# repo-global reconcile queue keeps one pending run per group, so a sibling
# PR's event evicts the queued duplicate after it has already attached a
# check to this head. A run that never executed a step said nothing about
# these bytes — it is not a verdict, and the success beside it is. The
# survivor decides, whatever order the two arrived in (#139).
expect "a cancelled entry beside a success is not a verdict, the success is" SUCCESS \
2026-07-22 18:19:49 +00:00
" $( rollup " [ $( run_ scope SUCCESS 2026-07-20T15:19:39Z) ,\
$( run_ scope CANCELLED 2026-07-20T15:19:45Z) ] " | checks_state)"
# same job name in a different workflow is a different context, not a re-run
expect "same name in another workflow does not supersede" FAILURE \
" $( rollup " [ $( jq -n '{__typename:"CheckRun",workflowName:"labels",name:"scope",conclusion:"FAILURE",completedAt:"2026-07-20T15:00:00Z"}' ) ,\
$( run_ scope SUCCESS 2026-07-20T15:19:45Z) ] " | checks_state)"
2026-07-24 12:34:51 +00:00
# -- the #139 carve-out, pinned by the recorded shape that bought it. PR #136
# head a17e497: the reconcile succeeded 12:16:17→12:17:06, and the queued
# duplicate — evicted by ANOTHER PR's run in the same repo-global group —
# attached CANCELLED at 12:16:41, started_at == completed_at, no step ever
# ran. Recorded payloads, not live fetches: the heads have moved on.
rec_( ) { jq -n --arg o " $1 " --arg s " $2 " --arg c " $3 " \
' { __typename:"CheckRun" , workflowName:"labels" , name:"reconcile" ,
conclusion:$o , startedAt:$s , completedAt:$c } ' ; }
expect "a queue-cancelled duplicate beside the success that did its work (a17e497)" SUCCESS \
" $( rollup " [ $( rec_ SUCCESS 2026-07-24T12:16:17Z 2026-07-24T12:17:06Z) ,\
$( rec_ CANCELLED 2026-07-24T12:16:41Z 2026-07-24T12:16:41Z) ] " | checks_state)"
# ...but the discard needs a surviving verdict. A context that is ONLY
# cancelled never reported at all — a killed or timed-out required job — and
# certifying that green is the unknown-as-green shape checks_state refuses.
expect "a context whose only entry is CANCELLED still blocks" FAILURE \
" $( rollup " [ $( run_ b CANCELLED) ] " | checks_state) "
expect "...and so does a context of two cancelled entries" FAILURE \
" $( rollup " [ $( rec_ CANCELLED 2026-07-24T12:16:17Z 2026-07-24T12:16:20Z) ,\
$( rec_ CANCELLED 2026-07-24T12:16:41Z 2026-07-24T12:16:41Z) ] " | checks_state)"
# ...and discarding the cancelled entry must never discard a real red: the
# survivor rule keeps the FAILURE, it does not resurrect anything green.
expect "a cancelled newest over an earlier FAILURE is still that failure" FAILURE \
" $( rollup " [ $( rec_ FAILURE 2026-07-24T12:16:17Z 2026-07-24T12:17:06Z) ,\
$( rec_ CANCELLED 2026-07-24T12:16:41Z 2026-07-24T12:16:41Z) ] " | checks_state)"
2026-08-01 16:12:17 +00:00
# -- the #208 exclusion: the label machine never grades its own runs. The
# shared concurrency group displaces queued sweeps as CANCELLED, and the
# displaced run's successor was triggered by a DIFFERENT PR or an issues
# event — so on the victim PR the #139 carve-out's premise (a surviving
# sibling on the same head) fails structurally: the newest self entry
# stays CANCELLED, and the sweep set blocker:ci-red off its own corpse,
# re-affirming it every cadence. Proven on crew#227: every real check
# green, the only red rollup entry the sweep's own displaced run. rec_
# already builds entries under workflowName "labels"; naming that as
# self must drop them whole, before the newest-per-context collapse.
SELF_WORKFLOW = "labels"
expect "a displaced self CANCELLED beside green others is no verdict (crew#227)" SUCCESS \
" $( rollup " [ $( run_ a SUCCESS) , $( run_ b SUCCESS) ,\
$( rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z) ] " | checks_state)"
expect "a FAILED self run surfaces on the Actions tab, not as the PR's red" SUCCESS \
" $( rollup " [ $( run_ a SUCCESS) , $( run_ b SUCCESS) ,\
$( rec_ FAILURE 2026-08-01T15:00:00Z 2026-08-01T15:01:00Z) ] " | checks_state)"
expect "a rollup of ONLY self entries is honestly NONE, never SUCCESS" NONE \
" $( rollup " [ $( rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z) ] " | checks_state) "
# must-fail: the filter keys on the self workflow ALONE. Widening it — any
# cancelled entry, any labels-shaped name — certifies a genuine foreign
# failure green, which is #136's unknown-as-green shape all over again.
expect "a genuine foreign FAILURE still blocks beside a cancelled self entry" FAILURE \
" $( rollup " [ $( run_ a FAILURE) ,\
$( rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z) ] " | checks_state)"
2026-08-25 08:41:27 +00:00
# Forgejo exposes Actions checks only as commit statuses. Drive the real
# backend mapper here: fixtures that hand-write workflowName repeat the field
# omission that hid #243 instead of proving the adapter supplies it.
forgejo_rollup( ) {
local statuses = " $1 "
(
# shellcheck source=lib/forge-forgejo.sh
. lib/forge-forgejo.sh
forge_api( ) {
case " $1 " in
repos/o/r/pulls/5)
printf '%s\n' '{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":true}'
; ;
repos/o/r/commits/abc/status)
printf '%s\n' " $statuses "
; ;
*) return 1 ; ;
esac
}
REPO = o/r forge_pr_view 5
)
}
forgejo_self_red = ' { "state" :"failure" ,"statuses" :[
{ "context" :"labels / labels (pull_request)" ,"status" :"failure" ,"created_at" :"2026-08-23T18:32:00Z" ,"updated_at" :"2026-08-23T18:32:00Z" } ,
{ "context" :"CI / test (pull_request)" ,"status" :"pending" ,"created_at" :"2026-08-23T18:32:00Z" ,"updated_at" :"2026-08-23T18:32:00Z" } ] } '
expect "Forgejo StatusContexts exclude a self FAILURE and retain pending work" PENDING \
" $( forgejo_rollup " $forgejo_self_red " | checks_state) "
forgejo_self_only = ' { "state" :"failure" ,"statuses" :[
{ "context" :"labels / labels (pull_request)" ,"status" :"failure" ,"created_at" :"2026-08-23T18:32:00Z" ,"updated_at" :"2026-08-23T18:32:00Z" } ] } '
expect "Forgejo StatusContexts containing only the self workflow are NONE" NONE \
" $( forgejo_rollup " $forgejo_self_only " | checks_state) "
forgejo_foreign_red = ' { "state" :"failure" ,"statuses" :[
{ "context" :"labels / labels (pull_request)" ,"status" :"failure" ,"created_at" :"2026-08-23T18:32:00Z" ,"updated_at" :"2026-08-23T18:32:00Z" } ,
{ "context" :"CI / test (pull_request)" ,"status" :"failure" ,"created_at" :"2026-08-23T18:32:00Z" ,"updated_at" :"2026-08-23T18:32:00Z" } ] } '
expect "Forgejo StatusContexts retain a genuine foreign FAILURE" FAILURE \
" $( forgejo_rollup " $forgejo_foreign_red " | checks_state) "
forgejo_bare_red = ' { "state" :"failure" ,"statuses" :[
{ "context" :"codecov" ,"status" :"failure" ,"created_at" :"2026-08-23T18:32:00Z" ,"updated_at" :"2026-08-23T18:32:00Z" } ] } '
2026-08-25 08:53:52 +00:00
expect "a bare Forgejo context survives a named self-workflow exclusion" FAILURE \
" $( forgejo_rollup " $forgejo_bare_red " | checks_state) "
forgejo_nested_name = ' { "state" :"failure" ,"statuses" :[
{ "context" :"labels / nested / check" ,"status" :"failure" ,"created_at" :"2026-08-23T18:32:00Z" ,"updated_at" :"2026-08-23T18:32:00Z" } ] } '
SELF_WORKFLOW = "labels / nested"
expect "a Forgejo workflow name containing a separator is never guessed" FAILURE \
" $( forgejo_rollup " $forgejo_nested_name " | checks_state) "
SELF_WORKFLOW = ""
expect "a bare Forgejo context also survives an empty self-workflow exclusion" FAILURE \
2026-08-25 08:41:27 +00:00
" $( forgejo_rollup " $forgejo_bare_red " | checks_state) "
2026-08-01 16:12:17 +00:00
# ...and an empty self filters NOTHING: outside Actions no workflow name is
# ambient, and the exclusion must never drop entries on a guess — the same
# displaced-self rollup keeps blocking there, all-cancelled context intact.
SELF_WORKFLOW = ""
expect "an empty SELF_WORKFLOW filters nothing — the same rollup still blocks" FAILURE \
" $( rollup " [ $( run_ a SUCCESS) , $( run_ b SUCCESS) ,\
$( rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z) ] " | checks_state)"
2026-07-22 18:19:49 +00:00
# -- a run still IN FLIGHT. `run_()` cannot express this: it always carries a
# real completedAt, which is exactly why the supersede rule shipped dating
# runs by completion and nothing caught it. Both spellings of "no
# completion" are pinned, because `gh` emits the zero sentinel (a string,
# which `//` does not fall through) while the API emits null.
inflight_( ) { jq -n --arg n " $1 " --arg t " $2 " --arg c " ${ 3 :- 0001 -01-01T00 : 00 : 00Z } " \
' { __typename:"CheckRun" , workflowName:"ci" , name:$n , status:"IN_PROGRESS" ,
conclusion:"" , startedAt:$t , completedAt:( if $c = = "null" then null else $c end) } ' ; }
expect "a re-run in flight beats the success it superseded (zero sentinel)" PENDING \
" $( rollup " [ $( run_ build SUCCESS 2026-07-20T15:00:00Z) ,\
$( inflight_ build 2026-07-20T15:10:00Z) ] " | checks_state)"
expect "...and the same when the absent completion is null" PENDING \
" $( rollup " [ $( run_ build SUCCESS 2026-07-20T15:00:00Z) ,\
$( inflight_ build 2026-07-20T15:10:00Z null) ] " | checks_state)"
expect "a replacement in flight for a CANCELLED run is pending, not failed" PENDING \
" $( rollup " [ $( run_ build CANCELLED 2026-07-20T15:00:00Z) ,\
$( inflight_ build 2026-07-20T15:10:00Z) ] " | checks_state)"
# an entry carrying no usable timestamp is treated as newest, not oldest —
# ambiguity resolves toward "not settled" rather than toward a stale success.
# Guarded by the sort tiebreak rather than the dating expression: reverting
# only `at:` leaves this passing, so the two changes are separately pinned.
expect "an undateable in-flight run is not discarded for a stale success" PENDING \
" $( rollup " [ $( run_ build SUCCESS 2026-07-20T15:00:00Z) ,\
$( jq -n '{__typename:"CheckRun",workflowName:"ci",name:"build",conclusion:"",startedAt:null,completedAt:null}' ) ] " \
| checks_state) "
# ...and the reverse direction, which stops "in flight sorts last" being
# widened into "in flight always wins": a run that FINISHED after an earlier
# in-flight entry is the newer word, and the context is settled.
expect "a finished re-run supersedes an earlier in-flight run" SUCCESS \
" $( rollup " [ $( inflight_ build 2026-07-20T15:19:00Z) ,\
$( run_ build SUCCESS 2026-07-20T15:19:45Z) ] " | checks_state)"
# -- the wind-down window. A predecessor cancelled by the concurrency group
# does not stop the instant its replacement starts, so its completion
# routinely lands AFTER the successor's start — on box's aa5a6ba the
# replacement started 15:19:38 and the run it cancelled finished 15:19:51.
# Dating by "newest stamp of any kind" compares the dead run's completion
# against the live run's start, which is not an ordering on runs, and the
# predecessor wins. Every fixture above spaces completion before start, so
# none of them can see it. run_() cannot express the overlap either — it
# carries no startedAt — hence the explicit payloads.
overlap_( ) { jq -n --arg n " $1 " --arg o " $2 " --arg s " $3 " --arg c " $4 " \
' { __typename:"CheckRun" , workflowName:"ci" , name:$n , conclusion:$o ,
startedAt:$s , completedAt:$c } ' ; }
expect "a predecessor finishing after its replacement started is still older (CANCELLED)" PENDING \
" $( rollup " [ $( overlap_ scope CANCELLED 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z) ,\
$( inflight_ scope 2026-07-20T15:19:38Z) ] " | checks_state)"
expect "...and the same when it finished green — mid-flight is not mergeable" PENDING \
" $( rollup " [ $( overlap_ build SUCCESS 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z) ,\
$( inflight_ build 2026-07-20T15:19:38Z) ] " | checks_state)"
# -- the classifier feeds the state machine: a cancelled required check must
# take the PR off the human's plate, which is the whole point of #136.
DRAFT = false HEAD_SHA = head1 REQUESTED = " $HUMAN " REVIEWS_JSON = " $ALL_APPROVE " MERGEABLE = MERGEABLE
CHECKS = " $( rollup " [ $( run_ a SUCCESS) , $( run_ b CANCELLED) ] " | checks_state) "
expect "a cancelled check reaches decide_state as the agent's ball" state:addressing " $( decide_state) "
expect "...via blocker:ci-red, not a conflict" blocker:ci-red " $( blockers) "
# -- the happy path survives all of the above.
REVIEWS_JSON = " $ALL_APPROVE " MERGEABLE = MERGEABLE CHECKS = SUCCESS REQUESTED = ""
expect "mergeable + green + three head-current approvals is needs-human" state:needs-human " $( decide_state) "
# -- and a draft outranks everything, including a conflict.
DRAFT = true MERGEABLE = CONFLICTING
expect "a draft is building even when conflicted" state:building " $( decide_state) "
DRAFT = false MERGEABLE = MERGEABLE CHECKS = SUCCESS REQUESTED = "" REVIEWS_JSON = '[]'
# ---------------------------------------------------------------------------
# reconcile_pr's cold-start path. Everything above tests pure functions, which
# is exactly why a per-PR `return` in the label pre-flight got through review:
# the fixtures could not reach it. A missing state:* label must skip the label
# EDIT only — merge-next clearing and the stale sweep are independent of the
# taxonomy, and stranding them reintroduced the false-invitation bug (a
# `merge-next` claim surviving on a PR the board had moved to the agent).
# ---------------------------------------------------------------------------
reconcile_probe( ) { # $1 = REPO_LABELS content → the log lines reconcile_pr emits
(
REPO_LABELS = " $1 " REPO = owner/repo NOW = " $( date +%s) "
LABELS = "merge-next" # the PR carries a queue claim
DRAFT = false HEAD_SHA = head1 REQUESTED = "" REVIEWS_JSON = '[]'
MERGEABLE = MERGEABLE CHECKS = SUCCESS
PR_JSON = '{"created_at":"2020-01-01T00:00:00Z"}'
run( ) { :; } # swallow mutations
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
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
2026-07-22 18:19:49 +00:00
gh( ) { :; } # no network
reconcile_pr 777 2>& 1
)
}
cold = " $( reconcile_probe "merge-next" ) " # state:* labels absent entirely
expect "a cold-start repo still clears merge-next" \
yes " $( grep -q 'cleared merge-next' <<< " $cold " && echo yes || echo no) "
expect "...and still runs the stale sweep" \
yes " $( grep -q 'stale (' <<< " $cold " && echo yes || echo no) "
expect "...while warning that the state label is missing" \
yes " $( grep -q "state label 'state:addressing' does not exist" <<< " $cold " && echo yes || echo no) "
warm = " $( reconcile_probe " $( printf 'state:addressing\nmerge-next\nstale\nblocker:unrequested' ) " ) "
expect "a bootstrapped repo converges the state as well" \
yes " $( grep -q 'state -> state:addressing' <<< " $warm " && echo yes || echo no) "
2026-07-23 00:47:59 +00:00
# ---------------------------------------------------------------------------
# needs-ruling (#51): a pending human decision. Hand-set intent the machine
# reads and never writes — an EXCLUSION on needs-human, never a blocker and
# never a latch. #50's D8 by construction: needs-ruling and state:needs-human
# can never share a PR.
# ---------------------------------------------------------------------------
DRAFT = false HEAD_SHA = head1 REQUESTED = "" REVIEWS_JSON = " $ALL_APPROVE " MERGEABLE = MERGEABLE CHECKS = SUCCESS
LABELS = ""
expect "the ruling-free fixture hands off (control)" state:needs-human " $( decide_state) "
LABELS = "needs-ruling"
expect "a pending ruling excludes needs-human" state:addressing " $( decide_state) "
LABELS = ""
expect "...and clearing it hands off again — an exclusion, not a latch" state:needs-human " $( decide_state) "
LABELS = "needs-ruling" DRAFT = true
expect "a draft with a ruling pending is still building" state:building " $( decide_state) "
DRAFT = false
# blockers() must not know the label exists: it is not a branch fact, and the
# converge loop strips every BLOCKERS entry the facts do not re-derive —
# emitting it there is exactly the trap #51 names.
MERGEABLE = CONFLICTING
LABELS = ""
expect "conflict fixture emits its blocker (control)" blocker:conflict " $( blockers) "
LABELS = "needs-ruling"
expect "needs-ruling adds nothing to blockers()" blocker:conflict " $( blockers) "
2026-07-25 09:08:54 +00:00
MERGEABLE = MERGEABLE LABELS = ""
# ---------------------------------------------------------------------------
# blocked (#180): a directed hold. Same shape as needs-ruling — hand-set
# intent the machine reads and never writes, an EXCLUSION on needs-human,
# never a blocker and never a latch. During the #111 freeze rig#126/#128
# carried blocked beside state:needs-human, and rig#126 was merged seven
# minutes after the reconciler wrote the green label.
# ---------------------------------------------------------------------------
DRAFT = false HEAD_SHA = head1 REQUESTED = "" REVIEWS_JSON = " $ALL_APPROVE " MERGEABLE = MERGEABLE CHECKS = SUCCESS
LABELS = ""
expect "the hold-free fixture hands off (control)" state:needs-human " $( decide_state) "
LABELS = "blocked"
expect "a directed hold excludes needs-human" state:addressing " $( decide_state) "
LABELS = ""
expect "...and clearing it hands off again — an exclusion, not a latch" state:needs-human " $( decide_state) "
LABELS = "blocked" DRAFT = true
expect "a draft carrying blocked is still building" state:building " $( decide_state) "
DRAFT = false
# blockers() must not know this label exists either: it is not a branch fact,
# and the converge loop strips every BLOCKERS entry the facts do not
# re-derive — emitting it there would strip a live hold on the next tick.
MERGEABLE = CONFLICTING
LABELS = ""
expect "conflict fixture emits its blocker (control)" blocker:conflict " $( blockers) "
LABELS = "blocked"
expect "blocked adds nothing to blockers()" blocker:conflict " $( blockers) "
2026-07-23 00:47:59 +00:00
MERGEABLE = MERGEABLE LABELS = ""
# The guard the other fixtures cannot see: an UNGUARDED has_label read under
# set -u does not go red — bash treats the unset expansion inside the
# herestring redirection as a redirection error (bash 5.2: rc 127, the shell
# survives), so has_label fails OPEN, answering "label absent" with only a
# stderr complaint. For needs-ruling that would wave a live escalation
# through to needs-human in any caller that never set LABELS. Pinned by
# re-sourcing in a clean shell: the LABELS="" init keeps the read silent,
# and deleting the init turns this red.
guard_noise = " $( bash -uc ' . actions/labels-reconcile/labels-reconcile.sh
DRAFT = false HEAD_SHA = h REQUESTED = "" REVIEWS_JSON = "[]" MERGEABLE = MERGEABLE CHECKS = SUCCESS
decide_state' 2>& 1 >/dev/null) "
expect "a fresh source reads LABELS cleanly (no unbound complaint)" "" " $guard_noise "
# The full-sweep probes ride the needs-human-otherwise fixture (three
# head-current approvals, mergeable, green). reconcile_probe cannot serve
# here — its empty round lands on addressing for its own reasons, and the
# exclusion must be the ONLY thing moving the state.
ruling_probe( ) { # $1 = the PR's labels → the log lines reconcile_pr emits
(
REPO_LABELS = " $( printf 'state:addressing\nstate:needs-human\nmerge-next\nstale\nneeds-ruling' ) "
REPO = owner/repo NOW = " $( date +%s) "
LABELS = " $1 "
DRAFT = false HEAD_SHA = head1 REQUESTED = "" REVIEWS_JSON = " $ALL_APPROVE "
MERGEABLE = MERGEABLE CHECKS = SUCCESS
PR_JSON = '{"created_at":"2020-01-01T00:00:00Z"}'
run( ) { :; } # swallow mutations
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
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
2026-07-23 00:47:59 +00:00
gh( ) { :; } # no network
reconcile_pr 888 2>& 1
)
}
ruled = " $( ruling_probe " $( printf 'needs-ruling\nmerge-next' ) " ) "
expect "the exclusion drives the full sweep to addressing" \
yes " $( grep -q 'state -> state:addressing' <<< " $ruled " && echo yes || echo no) "
expect "...retracting merge-next: a PR awaiting a ruling is not merge-me-next" \
yes " $( grep -q 'cleared merge-next' <<< " $ruled " && echo yes || echo no) "
expect "...and the sweep never touches needs-ruling itself" \
no " $( grep -q 'needs-ruling' <<< " $ruled " && echo yes || echo no) "
# Staleness: waiting on a human is legitimately quiet (#50 D10) — same
# treatment as blocked, including taking an already-applied stale back off.
quiet = " $( ruling_probe "needs-ruling" ) "
expect "quiet under a pending ruling is never stale" \
no " $( grep -q 'stale (' <<< " $quiet " && echo yes || echo no) "
unstale = " $( ruling_probe " $( printf 'needs-ruling\nstale' ) " ) "
expect "...and an already-applied stale comes off" \
yes " $( grep -q 'unstale' <<< " $unstale " && echo yes || echo no) "
2026-07-23 11:57:20 +00:00
# ---------------------------------------------------------------------------
# The ruling pass on the PR surface (#52): the bare-flag check and the 7-day
# nudge ride reconcile_pr behind the flag, on the same real-activity
# computation the stale sweep reads. A recording stub serves the API facts:
# fixture JSON per endpoint (with the caller's --jq applied by real jq),
# posted comments appended back into the fixture so a second sweep sees the
# first one's writes, and every label edit recorded.
# ---------------------------------------------------------------------------
iso_at( ) { date -u -d " @ $1 " +%Y-%m-%dT%H:%M:%SZ; }
RNOW = 2000000000
2026-08-03 19:53:25 +00:00
ruling_sweep_probe( ) { # $1 labels, $2 PR, $3 assignees, $4 requested, $5 activity age days
2026-07-23 11:57:20 +00:00
(
2026-08-03 19:53:25 +00:00
local n = " ${ 2 :- 77 } " assignees = " ${ 3 :- 0 } " requested = " ${ 4 :- } "
local activity_days = " ${ 5 :- 8 } "
local assignee_json = '[]'
[ " $assignees " -eq 0 ] || assignee_json = '[{"login":"owner-bot"}]'
2026-07-23 11:57:20 +00:00
REPO_LABELS = " $( printf 'state:addressing\nstate:needs-human\nmerge-next\nstale\nneeds-ruling' ) "
REPO = owner/repo NOW = " $RNOW "
LABELS = " $1 "
2026-08-03 19:53:25 +00:00
DRAFT = false HEAD_SHA = head1 REQUESTED = " $requested "
2026-07-23 11:57:20 +00:00
# Approvals submitted 8 days ago — the newest real activity anywhere.
REVIEWS_JSON = " $( reviews \
2026-08-03 19:53:25 +00:00
" $( rev " $BOT1 " APPROVED head1 "" " $( iso_at $(( RNOW - activity_days * 86400 )) ) " ) " \
" $( rev " $BOT2 " APPROVED head1 "" " $( iso_at $(( RNOW - activity_days * 86400 )) ) " ) " \
" $( rev " $BOT3 " APPROVED head1 "" " $( iso_at $(( RNOW - activity_days * 86400 )) ) " ) " ) "
2026-07-23 11:57:20 +00:00
MERGEABLE = MERGEABLE CHECKS = SUCCESS
2026-08-03 19:53:25 +00:00
PR_JSON = " $( jq -n --arg at " $( iso_at $(( RNOW - 10 * 86400 )) ) " \
--argjson assignees " $assignee_json " '{created_at: $at, assignees: $assignees}' ) "
2026-07-23 11:57:20 +00:00
run( ) { " $@ " ; } # mutations reach the stub and are recorded, not swallowed
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
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
2026-07-23 11:57:20 +00:00
gh( ) {
if [ " $1 " = api ] ; then
shift
local jqexpr = "" endpoint = "" file
while [ $# -gt 0 ] ; do
case " $1 " in
--jq) jqexpr = " $2 " ; shift ; ;
-*) ; ;
*) [ -n " $endpoint " ] || endpoint = " $1 " ; ;
esac
shift
done
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
endpoint = " $( forge_stub_path " $endpoint " ) "
2026-07-23 11:57:20 +00:00
file = " $RTMP / $( printf '%s' " $endpoint " | tr '/' '_' ) .json "
2026-08-03 19:53:25 +00:00
printf '%s\n' " $endpoint " >>" $RTMP /api-calls "
[ ! -f " $file .error " ] || return 1
2026-07-23 12:22:43 +00:00
# A missing fixture is an empty collection — projected through the
# caller's --jq exactly like real gh, so '.[].foo' yields no lines.
[ -f " $file " ] || { printf '[]\n' | jq -r " ${ jqexpr :- . } " ; return 0; }
2026-07-23 11:57:20 +00:00
if [ -n " $jqexpr " ] ; then jq -r " $jqexpr " " $file " ; else cat " $file " ; fi
elif [ " $1 " = issue ] && [ " $2 " = comment ] ; then
local n = " $3 " body = "" file
shift 3
while [ $# -gt 0 ] ; do
case " $1 " in --body) body = " $2 " ; shift ; ; esac
shift
done
printf '%s\n----\n' " $body " >>" $RTMP /posted- $n "
file = " $RTMP /repos_owner_repo_issues_ ${ n } _comments.json "
[ -f " $file " ] || printf '[]\n' >" $file "
jq --arg b " $body " --arg at " $( iso_at " $RNOW " ) " \
'. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \
" $file " >" $file .tmp " && mv " $file .tmp " " $file "
elif [ " $1 " = issue ] && [ " $2 " = edit ] ; then
printf '%s\n' " $* " >>" $RTMP /edits "
fi
}
2026-08-03 19:53:25 +00:00
reconcile_pr " $n " 2>& 1
2026-07-23 11:57:20 +00:00
)
}
test: cover the shape check and the ladder's rungs
Pure decisions (shape presence, rung boundaries, Default: parse for wording
only), sweep probes for every AC path (malformed-once, conforming silence,
rungs despite activity, cron progression, missed-moment skip, re-flag
episode, unreadable comment list, malformed+rung same pass), and the
existing nudge fixtures updated to conforming escalations with pre-seeded
rung markers so each probe observes one behavior alone.
Part of #73.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:49:31 +00:00
# The flag went up 10 days ago with its escalation posted seconds earlier;
# the newest activity is the reviews at 8 days. The escalation is conforming
# and the rung markers are pre-seeded — by now both rungs fired long ago
# (#73), older than the reviews so the quiet window still reads 8 days —
# and this probe observes the nudge wiring alone; shape and rung behavior
# have their own probes in test/ruling.test.sh.
jq -n --arg at " $( iso_at $(( RNOW - 10 * 86400 )) ) " \
2026-07-23 11:57:20 +00:00
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \
>" $RTMP /repos_owner_repo_issues_77_timeline.json "
test: cover the shape check and the ladder's rungs
Pure decisions (shape presence, rung boundaries, Default: parse for wording
only), sweep probes for every AC path (malformed-once, conforming silence,
rungs despite activity, cron progression, missed-moment skip, re-flag
episode, unreadable comment list, malformed+rung same pass), and the
existing nudge fixtures updated to conforming escalations with pre-seeded
rung markers so each probe observes one behavior alone.
Part of #73.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:49:31 +00:00
jq -n --arg at " $( iso_at $(( RNOW - 10 * 86400 - 60 )) ) " \
--arg b $'Options: A — x B — y\nRecommend: A, because x.\nBlocked: z\nDefault: none — hard block' \
--arg r12 " $( iso_at $(( RNOW - 10 * 86400 + 13 * 3600 )) ) " \
--arg r24 " $( iso_at $(( RNOW - 10 * 86400 + 25 * 3600 )) ) " \
' [ { "user" :{ "login" :"setter" } ,"created_at" :$at ,"html_url" :"https://x/esc77" ,"body" :$b } ,
{ "user" :{ "login" :"sweep-bot" } ,"created_at" :$r12 ,"html_url" :"https://x/r12" ,"body" :"<!-- ceremony:needs-ruling-rung12 -->\nrung" } ,
{ "user" :{ "login" :"sweep-bot" } ,"created_at" :$r24 ,"html_url" :"https://x/r24" ,"body" :"<!-- ceremony:needs-ruling-rung24 -->\nrung" } ] ' \
2026-07-23 11:57:20 +00:00
>" $RTMP /repos_owner_repo_issues_77_comments.json "
wired = " $( ruling_sweep_probe "needs-ruling" ) "
expect "8 quiet days under a ruling nudges on the PR surface" \
yes " $( grep -q 'ruling nudge' <<< " $wired " && echo yes || echo no) "
expect "...while the quiet stays stale-free (#51's skip intact)" \
no " $( grep -q 'stale (' <<< " $wired " && echo yes || echo no) "
expect "...the accompanied flag is not called bare" \
no " $( grep -q 'ruling flag is bare' <<< " $wired " && echo yes || echo no) "
expect "the nudge addressed the decider and linked the escalation" \
yes " $( grep -qF '@danmt' " $RTMP /posted-77 " && grep -qF 'https://x/esc77' " $RTMP /posted-77 " && echo yes || echo no) "
again = " $( ruling_sweep_probe "needs-ruling" ) "
expect "the sweep right after the nudge holds its silence — the comment reset the window" \
no " $( grep -q 'ruling nudge' <<< " $again " && echo yes || echo no) "
expect "exactly one nudge across both sweeps" \
1 " $( grep -c '^----$' " $RTMP /posted-77 " ) "
expect "no label edit across both sweeps names the ruling flag" \
no " $( grep -q 'needs-ruling' " $RTMP /edits " 2>/dev/null && echo yes || echo no) "
2026-08-03 19:53:25 +00:00
# ---------------------------------------------------------------------------
# The attention pass on the PR surface (#232): every PR target is malformed,
# assigned or not. The episode marker makes the comment once-per-labeling;
# every other board mutation remains the ordinary state machine's concern.
# ---------------------------------------------------------------------------
attention_pr_fixture( ) { # $1 PR, $2 labeled timestamp
jq -n --arg at " $2 " \
'[{"event":"labeled","label":{"name":"attention"},"actor":{"login":"setter"},"created_at":$at}]' \
>" $RTMP /repos_owner_repo_issues_ ${ 1 } _timeline.json "
printf '[]\n' >" $RTMP /repos_owner_repo_issues_ ${ 1 } _comments.json "
}
attention_pr_fixture 78 " $( iso_at $(( RNOW - 120 )) ) "
attention_mutations_before = " $( wc -l <" $RTMP /edits " ) "
attention_pr = " $( ruling_sweep_probe $'attention\nstate:needs-human' 78 0 danmt 1) "
expect "attention on an unassigned PR is diagnosed" yes \
" $( grep -q 'malformed attention (pr)' <<< " $attention_pr " && echo yes || echo no) "
expect "the PR comment points to the assigned claim issue" yes \
" $( grep -qF 'assigned issue that owns the claim' " $RTMP /posted-78 " && echo yes || echo no) "
expect "the PR comment does not guess a target issue number" no \
" $( grep -Eq '#[0-9]+' " $RTMP /posted-78 " && echo yes || echo no) "
ruling_sweep_probe $'attention\nstate:needs-human' 78 0 danmt 1 >/dev/null
expect "two PR sweeps in one attention episode post once" 1 \
" $( grep -cF '<!-- ceremony:attention-malformed:' " $RTMP /posted-78 " ) "
jq --arg at " $( iso_at $(( RNOW - 30 )) ) " \
'. + [{"event":"labeled","label":{"name":"attention"},"actor":{"login":"setter"},"created_at":$at}]' \
" $RTMP /repos_owner_repo_issues_78_timeline.json " \
>" $RTMP /repos_owner_repo_issues_78_timeline.json.tmp " \
&& mv " $RTMP /repos_owner_repo_issues_78_timeline.json.tmp " \
" $RTMP /repos_owner_repo_issues_78_timeline.json "
ruling_sweep_probe $'attention\nstate:needs-human' 78 0 danmt 1 >/dev/null
expect "a re-set PR flag receives a second episode comment" 2 \
" $( grep -cF '<!-- ceremony:attention-malformed:' " $RTMP /posted-78 " ) "
attention_pr_fixture 79 " $( iso_at $(( RNOW - 60 )) ) "
ruling_sweep_probe $'attention\nstate:needs-human' 79 1 danmt 1 >/dev/null
expect "attention on an assigned PR is still diagnosed" 1 \
" $( grep -cF '<!-- ceremony:attention-malformed:' " $RTMP /posted-79 " ) "
attention_pr_fixture 80 " $( iso_at $(( RNOW - 60 )) ) "
: >" $RTMP /repos_owner_repo_issues_80_timeline.json.error "
unreadable_attention = " $( ruling_sweep_probe $'attention\nstate:needs-human' 80 0 danmt 1) "
expect "an unreadable PR attention timeline posts nothing" no \
" $( [ -f " $RTMP /posted-80 " ] && echo yes || echo no) "
expect "the unreadable fact is logged without a verdict" yes \
" $( grep -qF 'attention timeline unreadable' <<< " $unreadable_attention " && echo yes || echo no) "
: >" $RTMP /api-calls "
ruling_sweep_probe state:needs-human 81 0 danmt 1 >/dev/null
expect "a flag-free PR performs no attention timeline read" no \
" $( grep -qF 'repos/owner/repo/issues/81/timeline' " $RTMP /api-calls " && echo yes || echo no) "
expect "attention diagnosis caused no PR mutation" " $attention_mutations_before " \
" $( wc -l <" $RTMP /edits " ) "
2026-07-23 23:13:24 +00:00
# -- the sweep wiring observes the existing per-PR skip without writing -------
blind_main_probe( ) {
(
REPO = owner/repo
LABELS_CONF = .github/labels.conf
feat(forge): refuse loudly when the client cannot speak the forge
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
# 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
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
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
2026-07-23 23:13:24 +00:00
gh( ) {
if [ " $1 " = label ] && [ " $2 " = list ] ; then
2026-07-24 00:50:48 +00:00
core_label_rows | cut -d'|' -f1
2026-07-23 23:13:24 +00:00
elif [ " $1 " = pr ] && [ " $2 " = list ] ; then
printf '101\n102\n'
elif [ " $1 " = pr ] && [ " $2 " = view ] ; then
test: pin the two-line degrade, the bounded reason, and the demoted diagnosis
The unit block now feeds blind_sweep_warning a sampled reason and asserts
the new lead plus two must-fail guards: the disproven 'grant checks: read
and statuses: read' diagnosis stated as fact goes red, and so does any
drift in the counted line's whole-line shape (exactly the blind PRs match,
no more, no less — a reason line that matched would double-count, a folded
reason would undercount). read_failure_reason is covered pure: D4 wording
for empty stderr, multi-line collapse to one line, 400 chars truncated to
300 plus ellipsis within the 304-byte bound, 300 passing through whole.
blind_main_probe's gh pr view stub now fails with a denial on stderr, the
way real gh fails.
Part of #101.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:49:22 +00:00
# a denial with its reason on stderr, the way real gh fails (#101)
printf 'GraphQL: Resource not accessible by integration (repository.pullRequest.statusCheckRollup)\n' >& 2
return 1
2026-07-23 23:13:24 +00:00
elif [ " $1 " = api ] && [ [ " $* " = *"/reviews" * ] ] ; then
return 0
elif [ " $1 " = api ] ; then
jq -n --arg n " ${ * : -1 } " \
'{draft:false,user:{login:"author"},head:{sha:"head"},labels:[],requested_reviewers:[],created_at:"2026-07-23T00:00:00Z"}'
elif [ " $1 " = issue ] && [ " $2 " = edit ] ; then
printf 'MUTATION: %s\n' " $* "
fi
}
main
)
}
blind_main = " $( blind_main_probe) "
test: pin the two-line degrade, the bounded reason, and the demoted diagnosis
The unit block now feeds blind_sweep_warning a sampled reason and asserts
the new lead plus two must-fail guards: the disproven 'grant checks: read
and statuses: read' diagnosis stated as fact goes red, and so does any
drift in the counted line's whole-line shape (exactly the blind PRs match,
no more, no less — a reason line that matched would double-count, a folded
reason would undercount). read_failure_reason is covered pure: D4 wording
for empty stderr, multi-line collapse to one line, 400 chars truncated to
300 plus ellipsis within the 304-byte bound, 300 passing through whole.
blind_main_probe's gh pr view stub now fails with a denial on stderr, the
way real gh fails.
Part of #101.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:49:22 +00:00
expect "a wholly blind main sweep emits exactly one annotation" 1 \
" $( grep -c '^::warning::' <<< " $blind_main " ) "
expect "...leading with the reason the sweep actually observed" 1 \
" $( grep -c '^::warning::.*Resource not accessible by integration' <<< " $blind_main " ) "
expect "...still naming the permissions candidate" 1 \
2026-07-23 23:13:24 +00:00
" $( grep -c '^::warning::.*checks: read.*statuses: read' <<< " $blind_main " ) "
test: pin the two-line degrade, the bounded reason, and the demoted diagnosis
The unit block now feeds blind_sweep_warning a sampled reason and asserts
the new lead plus two must-fail guards: the disproven 'grant checks: read
and statuses: read' diagnosis stated as fact goes red, and so does any
drift in the counted line's whole-line shape (exactly the blind PRs match,
no more, no less — a reason line that matched would double-count, a folded
reason would undercount). read_failure_reason is covered pure: D4 wording
for empty stderr, multi-line collapse to one line, 400 chars truncated to
300 plus ellipsis within the 304-byte bound, 300 passing through whole.
blind_main_probe's gh pr view stub now fails with a denial on stderr, the
way real gh fails.
Part of #101.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:49:22 +00:00
# must-fail (#101 D5): red if the disproven diagnosis is re-asserted as fact
expect "...never as a stated cause" 0 \
" $( grep -c 'grant checks: read and statuses: read' <<< " $blind_main " || true ) "
2026-07-23 23:13:24 +00:00
expect "a wholly blind main sweep leaves every PR untouched" no \
" $( grep -q '^MUTATION:' <<< " $blind_main " && echo yes || echo no) "
test: pin the two-line degrade, the bounded reason, and the demoted diagnosis
The unit block now feeds blind_sweep_warning a sampled reason and asserts
the new lead plus two must-fail guards: the disproven 'grant checks: read
and statuses: read' diagnosis stated as fact goes red, and so does any
drift in the counted line's whole-line shape (exactly the blind PRs match,
no more, no less — a reason line that matched would double-count, a folded
reason would undercount). read_failure_reason is covered pure: D4 wording
for empty stderr, multi-line collapse to one line, 400 chars truncated to
300 plus ellipsis within the 304-byte bound, 300 passing through whole.
blind_main_probe's gh pr view stub now fails with a denial on stderr, the
way real gh fails.
Part of #101.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:49:22 +00:00
expect "each blind PR keeps its counted line, matched by the sweep's own grep -qxF" yes \
" $( grep -qxF 'labels: #101: could not read mergeability/checks — left alone this pass' <<< " $blind_main " \
&& grep -qxF 'labels: #102: could not read mergeability/checks — left alone this pass' <<< " $blind_main " \
&& echo yes || echo no) "
expect "each blind PR logs its reason as its own line beside the counted one" 2 \
" $( grep -c '^labels: #10[12]: read failed: GraphQL: Resource not accessible by integration' <<< " $blind_main " ) "
# must-fail (#101 D1): red if a reason line whole-line-matches the counted
# string (the counter would double-count) or the counted line changed (the
# counter would miss it and the warning never fire)
expect "exactly the blind PRs match the counted shape whole-line — no more, no less" 2 \
" $( grep -c '^labels: #[0-9]*: could not read mergeability/checks — left alone this pass$' <<< " $blind_main " ) "
2026-07-23 23:13:24 +00:00
2026-08-03 17:23:11 +00:00
# -- the grace's own wiring: the fixtures above prove the predicate, and only a
# sweep can prove the fetch that feeds it (#236 D2). The fixture-only version
# of this change would have passed with the global never assigned — the #91
# shape, where the probes could not reach the per-PR path at all.
unrequested_main_probe( ) { # $1 = read | denied, the head-commit read's outcome
(
REPO = owner/repo
LABELS_CONF = .github/labels.conf
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#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
2026-08-05 11:56:23 +00:00
# main() preflights the forge before it reads anything, so a probe that
# drives main() has to say which forge it is standing in rather than
# leaving the preflight to infer one from an empty environment (#188).
# The gh() stub below IS the github backend's boundary.
CEREMONY_FORGE = github
2026-08-03 17:23:11 +00:00
UMODE = " $1 "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#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
2026-08-05 11:56:23 +00:00
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
2026-08-03 17:23:11 +00:00
gh( ) {
if [ " $1 " = label ] && [ " $2 " = list ] ; then core_label_rows | cut -d'|' -f1; return 0; fi
if [ " $1 " = pr ] && [ " $2 " = list ] ; then printf '303\n' ; return 0; fi
if [ " $1 " = pr ] && [ " $2 " = view ] ; then
# green, so the D1 gate is open and D2 is the only question left
jq -n ' { mergeable:"MERGEABLE" ,
statusCheckRollup:[ { __typename:"CheckRun" ,workflowName:"ci" ,
name:"check" ,conclusion:"SUCCESS" ,
startedAt:"2026-07-01T00:00:00Z" } ] } '
return 0
fi
# recorded to a file, not to stdout: reconcile_pr sends the edit call's
# stdout to /dev/null, so a narrating stub would look like no edit at all
if [ " $1 " = issue ] && [ " $2 " = edit ] ; then printf '%s\n' " $* " >>" $RTMP /uedits- $UMODE " ; return 0; fi
[ " $1 " = api ] || return 0
shift
local jqexpr = "" endpoint = ""
while [ $# -gt 0 ] ; do
case " $1 " in
--jq) jqexpr = " $2 " ; shift ; ;
-*) ; ;
*) [ -n " $endpoint " ] || endpoint = " $1 " ; ;
esac
shift
done
case " $endpoint " in
*/commits/*) # the head-commit read; ordered before the commit LIST below
if [ " $UMODE " = denied ] ; then
printf 'gh: Not Found (HTTP 404)\n' >& 2
return 1
fi
jq -n '{commit:{committer:{date:"2026-07-01T00:00:00Z"}}}' | jq -r " ${ jqexpr :- . } " ; ;
*/pulls/303)
jq -n ' { draft:false,user:{ login:"author" } ,head:{ sha:"headsha" } ,
base:{ sha:"basesha" } ,labels:[ ] ,requested_reviewers:[ ] ,
created_at:"2026-07-01T00:00:00Z" } ' ; ;
*) printf '[]\n' | jq -r " ${ jqexpr :- . } " ; ; # every collection empty
esac
}
main
)
}
read_sweep = " $( unrequested_main_probe read ) "
expect "the sweep reads the head's date and writes the stall it now dates" yes \
" $( grep -q 'blocker:unrequested' " $RTMP /uedits-read " && echo yes || echo no) "
expect "...saying nothing about a degraded read" no \
" $( grep -q "could not read the head commit's date" <<< " $read_sweep " && echo yes || echo no) "
denied_sweep = " $( unrequested_main_probe denied) "
expect "a denied head-commit read names the denial (#101's shape)" yes \
" $( grep -q "^labels: #303: could not read the head commit's date: gh: Not Found (HTTP 404)" <<< " $denied_sweep " \
&& echo yes || echo no) "
expect "...and writes no blocker it could not date" no \
" $( grep -q 'blocker:unrequested' " $RTMP /uedits-denied " && echo yes || echo no) "
# ...while the PR is still converged: this read narrows one blocker, it does not
# skip the PR the way an unreadable rollup does
expect "...while the state still converges — one blocker unjudged, not a skip" yes \
" $( grep -q 'state:addressing' " $RTMP /uedits-denied " && echo yes || echo no) "
expect "...and the sweep does not report it as a blind pass" 0 \
" $( grep -c 'could not read mergeability/checks' <<< " $denied_sweep " || true ) "
2026-07-23 21:13:07 +00:00
# ---------------------------------------------------------------------------
# bootstrap_labels retires the GitHub defaults (#93). LABELS.md published
# them as deleted at bootstrap; nothing deleted them — incubator's first
# dispatch (run 30041309187) ran green and left `good first issue` standing,
# the first honest read of the machine since the older repos were cleaned by
# hand. One registry beside the taxonomy, dispatch-only, and never fatal:
# absence is the NORMAL case from the second dispatch on (#91's set -e
# shape), and a 403 refusal must not cost the taxonomy the token CAN create.
# ---------------------------------------------------------------------------
BOOT = " $RTMP /bootstrap "
mkdir -p " $BOOT "
RETIRED_WANT = ' duplicate
invalid
question
wontfix
help wanted
good first issue'
expect "the retired registry is exactly the six, no seventh" \
" $RETIRED_WANT " " $( retired_label_names) "
# the sentence and the registry must not drift apart again: parse the names
# out of LABELS.md's own parenthetical and demand identity, name for name
2026-07-23 21:18:46 +00:00
# shellcheck disable=SC2016 # the backticks are LABELS.md literals, not expansions
2026-07-23 21:13:07 +00:00
doctrine = " $( sed -n '/Default GitHub labels/,/are deleted at/p' LABELS.md \
| tr '\n' ' ' | sed 's/.*(//;s/).*//' | grep -o '`[^`]*`' | tr -d '`' ) "
expect "...and matches LABELS.md name for name" " $doctrine " " $( retired_label_names) "
expected_upserts = " $( { core_label_rows; configured_label_rows .github/labels.conf; } | cut -d'|' -f1) "
# -- happy path: the deletes ride the same dispatch, after an unchanged upsert set
(
REPO = owner/repo LABELS_CONF = .github/labels.conf
run( ) { printf '%s\n' " $* " >>" $BOOT /happy " ; }
bootstrap_labels
)
expect "a dispatch deletes the six in the same run as the upserts" \
" $RETIRED_WANT " \
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
" $( sed -n 's/^forge_label_delete \(.*\)$/\1/p' " $BOOT /happy " ) "
2026-07-23 21:13:07 +00:00
expect "...and the recorded upsert set is unchanged from today's" \
" $expected_upserts " \
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
" $( sed -n 's/^forge_label_create \([^ ]*\) .*/\1/p' " $BOOT /happy " ) "
2026-07-23 21:13:07 +00:00
# -- 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.
2026-07-23 21:18:46 +00:00
boot_missing_probe( ) {
2026-07-23 21:13:07 +00:00
(
REPO = owner/repo LABELS_CONF = .github/labels.conf
run( ) { " $@ " ; }
2026-07-23 21:18:46 +00:00
# shellcheck disable=SC2317 # reached through run's "$@", opaque to shellcheck
2026-07-23 21:13:07 +00:00
gh( ) {
if [ " $1 " = label ] && [ " $2 " = delete ] ; then
printf '%s\n' " $3 " >>" $BOOT /missing-deletes "
echo "could not delete label: HTTP 404: Not Found" >& 2
return 1
fi
}
bootstrap_labels
) 2>& 1
2026-07-23 21:18:46 +00:00
}
missing_rc = 0
missing_out = " $( boot_missing_probe) " || missing_rc = $?
2026-07-23 21:13:07 +00:00
expect "an already-absent label does not abort the dispatch" 0 " $missing_rc "
expect "...every deletion still ran" " $RETIRED_WANT " " $( cat " $BOOT /missing-deletes " ) "
expect "...and each absence is logged at most once per name" \
1 " $( grep -c "retire: 'question'" <<< " $missing_out " ) "
# -- a refusal is tolerated: the blocker:drill-pending 403 shape, on a delete.
# The other five still go, the taxonomy still lands, the log says who.
2026-07-23 21:18:46 +00:00
boot_refusal_probe( ) {
2026-07-23 21:13:07 +00:00
(
REPO = owner/repo LABELS_CONF = .github/labels.conf
run( ) { " $@ " ; }
2026-07-23 21:18:46 +00:00
# shellcheck disable=SC2317 # reached through run's "$@", opaque to shellcheck
2026-07-23 21:13:07 +00:00
gh( ) {
if [ " $1 " = label ] && [ " $2 " = delete ] ; then
if [ " $3 " = question ] ; then
echo "HTTP 403: Resource not accessible by integration" >& 2
return 1
fi
printf '%s\n' " $3 " >>" $BOOT /refusal-deletes "
elif [ " $1 " = label ] && [ " $2 " = create ] ; then
printf '%s\n' " $3 " >>" $BOOT /refusal-creates "
fi
}
bootstrap_labels
) 2>& 1
2026-07-23 21:18:46 +00:00
}
refusal_rc = 0
refusal_out = " $( boot_refusal_probe) " || refusal_rc = $?
2026-07-23 21:13:07 +00:00
expect "a refused delete does not abort the dispatch" 0 " $refusal_rc "
expect "...the other five still deleted" " duplicate
invalid
wontfix
help wanted
good first issue" " $( cat " $BOOT /refusal-deletes " ) "
expect "...the taxonomy still upserted whole" \
" $expected_upserts " " $( cat " $BOOT /refusal-creates " ) "
expect "...and the log names the refused label" \
yes " $( grep -q "retire: 'question'" <<< " $refusal_out " && echo yes || echo no) "
# -- DRY_RUN narrates the deletions like every other mutation, and does none
2026-07-23 21:18:46 +00:00
boot_dry_probe( ) {
2026-07-23 21:13:07 +00:00
(
REPO = owner/repo LABELS_CONF = .github/labels.conf DRY_RUN = 1
2026-07-23 21:18:46 +00:00
# shellcheck disable=SC2317 # reached through run's "$@", opaque to shellcheck
2026-07-23 21:13:07 +00:00
gh( ) { printf '%s\n' " $* " >>" $BOOT /dry-real " ; }
bootstrap_labels
)
2026-07-23 21:18:46 +00:00
}
dry_out = " $( boot_dry_probe) "
2026-07-23 21:13:07 +00:00
expect "DRY_RUN narrates each deletion" \
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
6 " $( grep -c '^labels: DRY_RUN: forge_label_delete' <<< " $dry_out " ) "
2026-07-23 21:13:07 +00:00
expect "...and performs none" \
no " $( test -f " $BOOT /dry-real " && echo yes || echo no) "
# -- the case a sourced probe cannot see (#91): the script EXECUTED, set -e
# live, every delete failing the way the second dispatch of every repo
# fails. The run must end green with the taxonomy created whole.
EXEC = " $RTMP /bootstrap-exec "
mkdir -p " $EXEC /stub "
cat >" $EXEC /stub/gh " <<'EOF'
#!/usr/bin/env bash
case " $1 $2 " in
"label delete" )
printf 'delete %s\n' " $3 " >>" $GH_RECORD "
echo "could not delete label: HTTP 404: Not Found (owner/repo)" >& 2
exit 1 ; ;
"label create" )
printf 'create %s\n' " $3 " >>" $GH_RECORD " ; ;
esac
exit 0
EOF
chmod +x " $EXEC /stub/gh "
printf 'panel=bot-a bot-b bot-c\n' >" $EXEC /labels.conf "
2026-08-05 21:32:21 +00:00
exec_env( ) { # $1 = event name, $2 = BOOTSTRAP value ("" = unset)
2026-07-23 21:13:07 +00:00
: >" $EXEC /record "
env PATH = " $EXEC /stub: $PATH " GH_RECORD = " $EXEC /record " \
feat(forge): refuse loudly when the client cannot speak the forge
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
CEREMONY_FORGE = github \
2026-07-23 21:13:07 +00:00
REPO = owner/repo LABELS_CONF = " $EXEC /labels.conf " GITHUB_EVENT_NAME = " $1 " \
2026-08-05 21:32:21 +00:00
${ 2 : +BOOTSTRAP= " $2 " } \
2026-07-23 21:13:07 +00:00
bash actions/labels-reconcile/labels-reconcile.sh
}
exec_rc = 0
2026-08-05 21:32:21 +00:00
exec_out = " $( exec_env workflow_dispatch yes 2>& 1) " || exec_rc = $?
expect "an executed bootstrap=yes with all six absent completes green" 0 " $exec_rc "
2026-07-23 21:13:07 +00:00
expect "...reaching the end of the sweep" \
yes " $( grep -q 'reconciled.' <<< " $exec_out " && echo yes || echo no) "
expect "...having attempted all six deletions" \
6 " $( grep -c '^delete ' " $EXEC /record " ) "
expect "...and created the full taxonomy" \
" $( core_label_rows | cut -d'|' -f1) " \
" $( sed -n 's/^create //p' " $EXEC /record " ) "
2026-08-05 21:32:21 +00:00
# -- the bootstrap keys on the INPUT, never the event (ceremony#215): from
# #209 on, EVERY machine wake is a workflow_dispatch event, so an event
# gate made bootstrap=no inert — runs 459/523 and venue drill runs 16/17
# bootstrapped on a delivered "no". The regression case is exactly that
# pair: dispatch event, no.
for pair in "workflow_dispatch:no" "workflow_dispatch:" "schedule:no" "schedule:" "pull_request_target:" ; do
ev = " ${ pair %% : * } " ; bs = " ${ pair #* : } "
2026-07-23 21:13:07 +00:00
ev_rc = 0
2026-08-05 21:32:21 +00:00
exec_env " $ev " " $bs " >/dev/null 2>& 1 || ev_rc = $?
expect " the $ev event with BOOTSTRAP=' ${ bs :- unset } ' completes green " 0 " $ev_rc "
expect "...and creates nothing" \
no " $( grep -q '^create ' " $EXEC /record " && echo yes || echo no) "
2026-07-23 21:13:07 +00:00
expect "...and deletes nothing" \
no " $( grep -q '^delete ' " $EXEC /record " && echo yes || echo no) "
done
2026-08-02 13:25:59 +00:00
# -- a re-drafted fix round is not a build (#205) ----------------------------
# Draft used to short-circuit decide_state before the round was consulted, so
# a PR carrying a standing CHANGES_REQUESTED that its builder converted back
# to draft read state:building — and the staleness sweep read a dropped fix
# round as a build in progress.
2026-08-04 17:04:09 +00:00
load_config " $FIXTURE_CONF "
set_required_bots " $FIXTURE_AUTHOR "
2026-08-02 13:25:59 +00:00
MERGEABLE = MERGEABLE CHECKS = SUCCESS LABELS = "" HEAD_SHA = head1
DRAFT = true REQUESTED = "" REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " CHANGES_REQUESTED head1 no t1) " \
" $( rev " $BOT2 " APPROVED head1 ok t2) " \
" $( rev " $BOT3 " APPROVED head1 ok t3) " ) "
expect "a re-drafted PR with a standing block is addressing, not building" \
state:addressing " $( decide_state) "
REVIEWS_JSON = " $( reviews " $( rev " $BOT1 " COMMENTED head1 thoughts t1) " ) "
expect "a re-drafted PR owing a round-reply is addressing" \
state:addressing " $( decide_state) "
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED head0 ok t1) " \
" $( rev " $BOT2 " APPROVED head0 ok t2) " \
" $( rev " $BOT3 " APPROVED head0 ok t3) " ) "
expect "a re-drafted PR whose approvals a push staled is addressing" \
state:addressing " $( decide_state) "
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED head1 ok t1) " \
" $( rev " $BOT2 " APPROVED head1 ok t2) " \
" $( rev " $BOT3 " APPROVED head1 ok t3) " \
" $( rev " $HUMAN " CHANGES_REQUESTED head1 no t4) " ) "
expect "the human's standing changes-requested outranks draft too" \
state:addressing " $( decide_state) "
# Approvals do NOT outrank draft: a re-draft after a passed round is
# deliberately building again — and a draft must never read needs-human.
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED head1 ok t1) " \
" $( rev " $BOT2 " APPROVED head1 ok t2) " \
" $( rev " $BOT3 " APPROVED head1 ok t3) " ) "
expect "a re-draft after a passed round is building again" \
state:building " $( decide_state) "
REQUESTED = " $HUMAN "
expect "...even with the human requested — a draft never reads needs-human" \
state:building " $( decide_state) "
2026-08-02 14:13:25 +00:00
# Round 1's 224-case hole (claude's differential): a draft with a LIVE HUMAN
# REQUEST plus a standing block or comment fell through to round_state,
# whose human-request precedence sits above BLOCK/FEEDBACK — and read
# needs-human on a PR GitHub cannot merge. These are the same inputs as the
# addressing rows above with REQUESTED="$HUMAN", which is where the
# criterion can actually fail.
REQUESTED = " $HUMAN " REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " CHANGES_REQUESTED head1 no t1) " \
" $( rev " $BOT2 " APPROVED head1 ok t2) " \
" $( rev " $BOT3 " APPROVED head1 ok t3) " ) "
expect "a draft with a human request and a standing block is addressing" \
state:addressing " $( decide_state) "
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " COMMENTED head1 thoughts t1) " \
" $( rev " $BOT2 " APPROVED head1 ok t2) " \
" $( rev " $BOT3 " APPROVED head1 ok t3) " ) "
expect "a draft with a human request and an owed reply is addressing" \
state:addressing " $( decide_state) "
2026-08-02 13:25:59 +00:00
# The must-not-paper-over combination: a live panel request on a draft is a
# board defect (the bots ignore drafts by design) and stays VISIBLE as
# bots-reviewing rather than being absorbed into building.
REQUESTED = " $BOT2 " REVIEWS_JSON = '[]'
expect "a live panel request on a draft surfaces as bots-reviewing" \
state:bots-reviewing " $( decide_state) "
# The byte-identical baseline: a virgin draft still reads building.
REQUESTED = "" REVIEWS_JSON = '[]'
expect "a draft with no round history still reads building" \
state:building " $( decide_state) "
2026-08-03 17:18:46 +00:00
# ---------------------------------------------------------------------------
# blocker:unrequested knows when the ask is permitted (#236). The blocker
# demands an act — request the panel — that BUILDER.md forbids under a head
# whose checks have not answered, so the predicate that flags the omission has
# to read CHECKS and has to let a round in motion finish moving. Two guards,
# each proved load-bearing by a mutation at the end of the block.
# ---------------------------------------------------------------------------
DRAFT = false HEAD_SHA = head1 REQUESTED = "" MERGEABLE = MERGEABLE LABELS = ""
NOW = " $( date -d 2026-08-03T12:00:00Z +%s) "
# the genuine #26/#39 debt: three approvals of a head a push staled, nobody
# asked for the re-verdicts, and every fact hours old
OWED_QUIET_ROUND = " $( reviews \
" $( rev " $BOT1 " APPROVED oldhead "" 2026-08-03T10:00:00Z) " \
" $( rev " $BOT2 " APPROVED oldhead "" 2026-08-03T10:01:00Z) " \
" $( rev " $BOT3 " APPROVED oldhead "" 2026-08-03T10:02:00Z) " ) "
REVIEWS_JSON = " $OWED_QUIET_ROUND " HEAD_COMMIT_AT = 2026-08-03T11:00:00Z
CHECKS = SUCCESS
expect "green, quiescent, owed and unasked is the stall (the control)" \
blocker:unrequested " $( blockers) "
# D1 — the gate. crew#318's shape: the same debt under a running check, where
# requesting is the one thing the builder must not do.
CHECKS = PENDING
expect "a pending head is CI's move, not a dropped ask" "" " $( blockers) "
CHECKS = FAILURE
expect "a red head raises ci-red alone — the two never co-occur" \
blocker:ci-red " $( blockers) "
CHECKS = NONE
expect "no checks configured is nothing to wait for, so the stall still shows" \
blocker:unrequested " $( blockers) "
# D2 — the grace. ceremony#235's shape: a sweep landing in the ~90 seconds
# between a round-answer push and the author's re-request.
CHECKS = SUCCESS HEAD_COMMIT_AT = 2026-08-03T11:57:30Z
expect "a head pushed inside the grace is a round in motion, not a stall" \
"" " $( blockers) "
HEAD_COMMIT_AT = 2026-08-03T11:55:00Z
expect "...and exactly at the grace it flags — the boundary is inclusive" \
blocker:unrequested " $( blockers) "
HEAD_COMMIT_AT = 2026-08-03T11:50:00Z
expect "...and a later pass flags it with nothing else changed" \
blocker:unrequested " $( blockers) "
# a verdict is the other supporting fact, and an old head does not license
# flagging a round whose newest verdict landed a minute ago
HEAD_COMMIT_AT = 2026-08-03T10:00:00Z
REVIEWS_JSON = " $( reviews " $( rev " $BOT1 " APPROVED oldhead "" 2026-08-03T11:59:00Z) " ) "
expect "a verdict submitted inside the grace is motion too" "" " $( blockers) "
# no verdicts at all is not an unreadable round — it is the first-ask stall,
# and the head's clock is the whole of it
REVIEWS_JSON = '[]' HEAD_COMMIT_AT = 2026-08-03T11:00:00Z
expect "nothing reviewed and nobody asked flags off the head's clock alone" \
blocker:unrequested " $( blockers) "
# an unreadable fact never invents a verdict — the standing rule, applied to
# both timestamps
REVIEWS_JSON = " $OWED_QUIET_ROUND " HEAD_COMMIT_AT = ""
expect "an unread head date leaves the blocker unjudged" "" " $( blockers) "
HEAD_COMMIT_AT = null
expect "...and jq's literal null is unread, not epoch zero" "" " $( blockers) "
HEAD_COMMIT_AT = 2026-08-03T11:00:00Z
REVIEWS_JSON = " $( reviews " $( rev " $BOT1 " APPROVED oldhead "" not-a-timestamp) " ) "
expect "a round whose newest verdict cannot be dated is unread, not quiescent" \
"" " $( blockers) "
# the constant is overridable the way this file's others are
REVIEWS_JSON = " $OWED_QUIET_ROUND " HEAD_COMMIT_AT = 2026-08-03T11:57:30Z
RECONCILE_UNREQUESTED_GRACE = 60
expect "a shorter configured grace flags the same facts" \
blocker:unrequested " $( blockers) "
RECONCILE_UNREQUESTED_GRACE = 300
# the timestamp reader, directly: the three unreadable spellings it must refuse
expect "iso_epoch reads a real stamp" \
" $( date -d 2026-08-03T12:00:00Z +%s) " " $( iso_epoch 2026-08-03T12:00:00Z) "
expect "iso_epoch refuses an absent stamp" "" " $( iso_epoch "" ) "
expect "iso_epoch refuses jq's null" "" " $( iso_epoch null) "
expect "iso_epoch refuses a stamp date cannot read" "" " $( iso_epoch not-a-timestamp) "
# ...and the trap it does NOT catch, recorded because a fixture author will
# reach for it: `t1` is a valid date to GNU date — 01:00 in military timezone T,
# on the day the suite runs — so it reads as a moving stamp rather than as an
# unreadable one. Real timestamps in any fixture the grace touches.
expect "a symbolic stamp is readable, and moves with the run's day" \
" $( date -d t1 +%s) " " $( iso_epoch t1) "
# -- the mutation proofs: both guards are load-bearing, and this runs them ----
# A guard the fixtures cannot see removed is a guard nobody is testing, so each
# is deleted from a COPY of the script and the fixture that covers it must flip.
# The sed programs target one token each, so a refactor that moves a guard
# fails here loudly instead of passing silently.
mutant_blockers( ) { # $1 = sed program → blockers() from a copy of the script
# The copy keeps its position in the tree — the script sources lib/ruling.sh
# relative to its own path, and a copy dropped anywhere else would source
# nothing and say so on stderr instead of failing.
local root = " $RTMP /mutant " mutated
mutated = " $root /actions/labels-reconcile/labels-reconcile.sh "
mkdir -p " $root /actions/labels-reconcile "
ln -sfn " $PWD /lib " " $root /lib "
sed " $1 " actions/labels-reconcile/labels-reconcile.sh >" $mutated "
DRAFT = " $DRAFT " HEAD_SHA = " $HEAD_SHA " REQUESTED = " $REQUESTED " \
REVIEWS_JSON = " $REVIEWS_JSON " MERGEABLE = " $MERGEABLE " CHECKS = " $CHECKS " \
NOW = " $NOW " HEAD_COMMIT_AT = " $HEAD_COMMIT_AT " \
RECONCILE_UNREQUESTED_GRACE = " $RECONCILE_UNREQUESTED_GRACE " \
bash -u -c '
. " $1 "
2026-08-04 17:04:09 +00:00
load_config " $2 "
set_required_bots " $3 "
2026-08-03 17:18:46 +00:00
blockers
2026-08-04 17:04:09 +00:00
' bash " $mutated " " $FIXTURE_CONF " " $FIXTURE_AUTHOR "
2026-08-03 17:18:46 +00:00
}
# the harness itself, unmutated: it must reproduce the verdict the sourced
# functions give, or a "flip" below proves nothing about the guard
REVIEWS_JSON = " $OWED_QUIET_ROUND " HEAD_COMMIT_AT = 2026-08-03T11:00:00Z CHECKS = SUCCESS
expect "the mutation harness reproduces the control verdict" \
blocker:unrequested " $( mutant_blockers 's/^#no-such-line$//' ) "
CHECKS = PENDING
expect "...and the pending fixture is green in the unmutated copy" \
"" " $( mutant_blockers 's/^#no-such-line$//' ) "
expect "removing the green gate reds the pending fixture" \
blocker:unrequested \
" $( mutant_blockers 's/checks_permit_the_ask=false/checks_permit_the_ask=true/' ) "
CHECKS = SUCCESS HEAD_COMMIT_AT = 2026-08-03T11:57:30Z
expect "removing the grace reds the inside-the-window fixture" \
blocker:unrequested \
" $( mutant_blockers 's/ \&\& unrequested_quiescent//' ) "
HEAD_COMMIT_AT = 2026-08-03T11:00:00Z
2026-08-02 13:23:41 +00:00
# -- per-author panels (#224): the required set flows from the one ----------
# resolution point, and convergence counts the effective set — never the
# base panel beside a reduced request set (the must-fail the issue names)
PANEL_DIR = " $RTMP /panel-author "
mkdir -p " $PANEL_DIR "
printf '%s\n' 'panel=bot-a bot-b bot-c bot-d' \
'panel[builder-z]=bot-b bot-c bot-d' >" $PANEL_DIR /labels.conf "
load_config " $PANEL_DIR /labels.conf "
set_required_bots builder-z
expect "a bracketed author requires exactly its configured row" \
"bot-b bot-c bot-d" " ${ REQUIRED_BOTS [*] } "
set_required_bots bot-a
expect "an unbracketed author beside a bracketed row requires panel minus self" \
"bot-b bot-c bot-d" " ${ REQUIRED_BOTS [*] } "
set_required_bots outsider
expect "an unbracketed non-panelist author requires the whole base panel" \
"bot-a bot-b bot-c bot-d" " ${ REQUIRED_BOTS [*] } "
# The engine shape: the three configured reviewers approving the head IS the
# whole round for a bracketed author — bot-a's absent verdict must not hold
# convergence, or the request side and the convergence side disagree forever
# (the deadlock crew#285 was filed over).
set_required_bots builder-z
THREE_APPROVE = " $( reviews \
" $( rev bot-b APPROVED head1 ok 2026-08-02T10:00:00Z) " \
" $( rev bot-c APPROVED head1 ok 2026-08-02T10:01:00Z) " \
" $( rev bot-d APPROVED head1 ok 2026-08-02T10:02:00Z) " ) "
DRAFT = false HEAD_SHA = head1 REQUESTED = "" REVIEWS_JSON = " $THREE_APPROVE " \
MERGEABLE = MERGEABLE CHECKS = SUCCESS LABELS = ""
expect "the bracketed author's round converges on its three approvals" \
state:needs-human " $( decide_state) "
expect "...with no blocker standing" "" " $( blockers) "
# The control: the same three approvals under the base panel are NOT a full
# round — the fourth verdict is owed and unrequested. If this pair ever
# reads the same, one side stopped consulting the resolution point.
printf '%s\n' 'panel=bot-a bot-b bot-c bot-d' >" $PANEL_DIR /labels.conf "
load_config " $PANEL_DIR /labels.conf "
set_required_bots builder-z
expect "without the row the same approvals leave the round incomplete" \
state:addressing " $( decide_state) "
expect "...and the owed, unasked verdict is named" \
blocker:unrequested " $( blockers) "
2026-08-04 17:04:09 +00:00
# -- the shipped roster, as a property rather than a slot (#304 D2) ----------
# The one case that reads the real .github/labels.conf, and it asserts only
# what that file can honestly prove here: it parses, and recusal removes the
# author from whatever it names. No index, no expected size — the panel is the
# operator's to resize (D3), and the fixtures above no longer care. What this
# does catch is a shipped conf that stopped parsing, which must never be
# reported as a green suite.
#
# The probe runs in a subshell so a refusal cannot leave this file's globals
# half-loaded, and it quantifies over every member rather than sampling one:
# there is no member whose recusal is special. load_config's stderr is dropped
# because the exit status is the assertion; the broken-conf case below would
# otherwise print its (correct) complaint into a passing run.
live_panel_probe( ) { # $1 = conf → PARSE:<rc> [RECUSED:<yes|no> SHRANK:<yes|no>]
bash -u -c '
. actions/labels-reconcile/labels-reconcile.sh
rc = 0
load_config " $1 " 2>/dev/null || rc = $?
printf "PARSE:%s" " $rc "
[ " $rc " -eq 0 ] || { printf "\n" ; exit 0; }
recused = yes shrank = yes
for author in " ${ BOTS [@] } " ; do
set_required_bots " $author "
for bot in ${ REQUIRED_BOTS [@]+ " ${ REQUIRED_BOTS [@] } " } ; do
[ " $bot " != " $author " ] || recused = no
done
[ " ${# REQUIRED_BOTS [@] } " -eq " $(( ${# BOTS [@] } - 1 )) " ] || shrank = no
done
printf " RECUSED:%s SHRANK:%s\n" " $recused " " $shrank "
' bash " $1 "
}
expect "the shipped labels.conf parses, and recuses each member from its own panel" \
"PARSE:0 RECUSED:yes SHRANK:yes" " $( live_panel_probe .github/labels.conf) "
# ...and the teeth: the same probe on a copy whose panel= line names nobody.
# A roster edit that empties the line is the shape this catches — the file
# still looks like a conf, and every panel in the repo would resolve to
# nothing.
BROKEN_CONF = " $RTMP /broken-labels.conf "
sed 's/^panel=.*/panel=/' .github/labels.conf >" $BROKEN_CONF "
expect "...and a malformed panel= line in that same file is refused, not passed" \
PARSE:1 " $( live_panel_probe " $BROKEN_CONF " ) "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#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
2026-08-05 11:56:23 +00:00
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
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
# ---------------------------------------------------------------------------
# A WRITE THAT DID NOT HAPPEN FAILS THE SWEEP (#192)
#
# The defect this replaces: forge_issue_edit returned non-zero, reconcile_pr
# logged `WARNING: label edit failed`, fell through, and main printed
# `reconciled.` and exited 0. On this forge that was every removal — the
# workflow token gets HTTP 500 on DELETE .../labels/{id} — so the board could
# only ever gain labels, and the sweep said it had reconciled.
#
# Driven through main() rather than the pure functions, because the swallow
# was in the LOOP: reconcile_pr's non-zero became a log line and the loop
# finished. A fixture-level probe cannot reach that (#91's lesson).
# ---------------------------------------------------------------------------
write_fail_probe( ) { # $1 = ok | fail — whether the label edit write succeeds
(
REPO = owner/repo
LABELS_CONF = .github/labels.conf
CEREMONY_FORGE = github
WMODE = " $1 "
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh( ) {
if [ " $1 " = label ] && [ " $2 " = list ] ; then core_label_rows | cut -d'|' -f1; return 0; fi
if [ " $1 " = pr ] && [ " $2 " = list ] ; then printf '%s\n' 401 402; return 0; fi
if [ " $1 " = pr ] && [ " $2 " = view ] ; then
jq -n ' { mergeable:"MERGEABLE" ,
statusCheckRollup:[ { __typename:"CheckRun" ,workflowName:"ci" ,
name:"check" ,conclusion:"SUCCESS" ,
startedAt:"2026-07-01T00:00:00Z" } ] } '
return 0
fi
if [ " $1 " = issue ] && [ " $2 " = edit ] ; then
# #401's write is the one that fails; #402's succeeds, so the probe
# also proves the sweep KEPT GOING rather than aborting on the first.
if [ " $WMODE " = fail ] && [ " $3 " = 401 ] ; then
printf 'forge_api: HTTP 500 from DELETE repos/owner/repo/issues/401/labels/93\n' >& 2
return 1
fi
printf '%s\n' " $* " >>" $RTMP /wedits "
return 0
fi
case " $* " in
*/pulls/401) jq -n ' { draft:false,user:{ login:"author" } ,head:{ sha:"h" } ,base:{ sha:"b" } ,
labels:[ { name:"blocker:ci-red" } ] ,requested_reviewers:[ ] ,
created_at:"2026-07-01T00:00:00Z" } ' ; ;
*/pulls/402) jq -n ' { draft:false,user:{ login:"author" } ,head:{ sha:"h" } ,base:{ sha:"b" } ,
labels:[ ] ,requested_reviewers:[ ] ,
created_at:"2026-07-01T00:00:00Z" } ' ; ;
*) printf '[]\n' ; ;
esac
}
main
)
}
: >" $RTMP /wedits "
wf_rc = 0
wf_out = " $( write_fail_probe fail 2>& 1) " || wf_rc = $?
expect "a sweep whose label write failed exits non-zero" 1 " $wf_rc "
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
expect "...and the output contains no 'reconciled.' token anywhere" \
no " $( grep -qF 'reconciled.' <<< " $wf_out " && echo yes || echo no) "
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
expect "...saying instead that the write did not happen" \
yes " $( grep -q 'did not happen' <<< " $wf_out " && echo yes || echo no) "
expect "...naming what it attempted, not a cause it has not established" \
yes " $( grep -q 'attempted: forge_issue_edit 401' <<< " $wf_out " && echo yes || echo no) "
expect "...and asserting no bootstrap diagnosis it cannot support" \
no " $( grep -qi 'bootstrap' <<< " $wf_out " && echo yes || echo no) "
# The per-PR tolerance is deliberately KEPT: one bad PR must not blind the
# board. #402 is reconciled in the same pass that #401 failed in.
expect "...while still reconciling the rest of the board" \
yes " $( grep -q '^issue edit 402' " $RTMP /wedits " && echo yes || echo no) "
: >" $RTMP /wedits "
ok_rc = 0
ok_out = " $( write_fail_probe ok 2>& 1) " || ok_rc = $?
expect "the control: the same sweep with writes working exits 0" 0 " $ok_rc "
expect "...and does print reconciled." \
yes " $( grep -q 'labels: reconciled\.' <<< " $ok_out " && echo yes || echo no) "
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
# The tally must catch EVERY label mutation, not only the primary state edit.
# `stale` is written from a different call site; before the one-helper change
# a failure there fell into the generic per-PR branch and the sweep still
# exited 0 (@codex-reviewer-andresmgsl, #192 review).
stale_fail_probe( ) {
(
2026-08-05 21:35:55 +00:00
# No event name: the script stopped reading GITHUB_EVENT_NAME when the
# bootstrap moved to the BOOTSTRAP input (#215); default no is the
# non-bootstrap sweep this probe simulates.
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
REPO = owner/repo
LABELS_CONF = .github/labels.conf
CEREMONY_FORGE = github
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh( ) {
if [ " $1 " = label ] && [ " $2 " = list ] ; then core_label_rows | cut -d'|' -f1; return 0; fi
if [ " $1 " = pr ] && [ " $2 " = list ] ; then printf '%s\n' 501; return 0; fi
if [ " $1 " = pr ] && [ " $2 " = view ] ; then
jq -n ' { mergeable:"MERGEABLE" ,
statusCheckRollup:[ { __typename:"CheckRun" ,workflowName:"ci" ,
name:"check" ,conclusion:"SUCCESS" ,
startedAt:"2026-07-01T00:00:00Z" } ] } '
return 0
fi
if [ " $1 " = issue ] && [ " $2 " = edit ] ; then
# only the `stale` write fails; the primary state edit succeeds, so
# this probe proves the NON-primary site reaches the tally
case " $* " in
*stale*) printf 'forge_api: HTTP 500 from PUT repos/owner/repo/issues/501/labels\n' >& 2; return 1 ; ;
esac
return 0
fi
case " $* " in
*/pulls/501) jq -n ' { draft:false,user:{ login:"author" } ,head:{ sha:"h" } ,base:{ sha:"b" } ,
labels:[ { name:"stale" } ,{ name:"blocked" } ] ,requested_reviewers:[ ] ,
created_at:"2026-07-01T00:00:00Z" } ' ; ;
*) printf '[]\n' ; ;
esac
}
main
)
}
sf_rc = 0
sf_out = " $( stale_fail_probe 2>& 1) " || sf_rc = $?
expect "a failed NON-primary label write also fails the sweep" 1 " $sf_rc "
expect "...with no 'reconciled.' token in the output" \
no " $( grep -qF 'reconciled.' <<< " $sf_out " && echo yes || echo no) "
expect "...naming the attempt that did not happen" \
yes " $( grep -q 'label edit FAILED' <<< " $sf_out " && echo yes || echo no) "
2026-08-23 23:11:11 +00:00
# Drive the ingestion expression through main(), independently of
2026-08-24 12:42:11 +00:00
# bot_verdict (#235). Capturing REVIEWS_JSON at the review-request read that
# follows it proves REQUEST_REVIEW never reaches the grader; the COMMENT and
2026-08-23 23:29:09 +00:00
# APPROVED controls prove both gradeable Forgejo states and rows generally
# survive the filter.
2026-08-23 23:11:11 +00:00
review_filter_probe( ) {
(
REPO = owner/repo
LABELS_CONF = " $FIXTURE_CONF "
CEREMONY_FORGE = github
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh( ) {
if [ " $1 " = label ] && [ " $2 " = list ] ; then core_label_rows | cut -d'|' -f1; return 0; fi
if [ " $1 " = pr ] && [ " $2 " = list ] ; then printf '%s\n' 601; return 0; fi
if [ " $1 " = pr ] && [ " $2 " = view ] ; then
jq -n '{mergeable:"MERGEABLE",statusCheckRollup:[]}'
return 0
fi
if [ " $1 " = issue ] && [ " $2 " = edit ] ; then return 0; fi
case " $( forge_stub_path " $* " ) " in
*repos/owner/repo/pulls/601/reviews*)
jq -nc \
' { user:{ login:"fixture-bot-one" } ,state:"REQUEST_REVIEW" ,commit_id:"" ,submitted_at:"2026-08-22T00:46:05Z" } ,
2026-08-23 23:29:09 +00:00
{ user:{ login:"fixture-bot-three" } ,state:"COMMENT" ,commit_id:"head1" ,submitted_at:"2026-08-22T00:46:35Z" } ,
2026-08-23 23:11:11 +00:00
{ user:{ login:"fixture-bot-two" } ,state:"APPROVED" ,commit_id:"head1" ,submitted_at:"2026-08-22T00:47:05Z" } ' ; ;
*/pulls/601)
jq -n ' { draft:true,user:{ login:"fixture-builder" } ,head:{ sha:"head1" } ,base:{ sha:"base1" } ,
labels:[ { name:"state:building" } ] ,requested_reviewers:[ ] ,
created_at:"2026-08-22T00:45:00Z" } ' ; ;
*) printf '[]\n' ; ;
esac
}
2026-08-24 12:42:11 +00:00
# main normally re-sources the selected backend. This probe already runs
# with the GitHub backend selected at suite startup; keeping that selection
# stable lets the verb override below observe the post-REVIEWS_JSON boundary.
forge_select( ) { return 0; }
2026-08-23 23:17:14 +00:00
# shellcheck disable=SC2317 # main invokes the probe override indirectly
2026-08-24 12:42:11 +00:00
forge_pr_review_requests( ) {
2026-08-23 23:11:11 +00:00
printf '%s\n' " $REVIEWS_JSON " >" $RTMP /gradeable-reviews.json "
}
main >/dev/null
)
}
review_filter_probe
expect "REQUEST_REVIEW is removed before REVIEWS_JSON reaches the grader" \
2026-08-23 23:29:09 +00:00
COMMENT,APPROVED " $( jq -r 'map(.state) | join(",")' " $RTMP /gradeable-reviews.json " ) "
2026-08-23 23:11:11 +00:00
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
# The summary and the gate belong at the TRUE end of the file. They sat in the
2026-08-24 12:42:11 +00:00
# middle until #188, so a later failure 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.
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
printf 'labels-reconcile tests: %d passed, %d failed\n' " $pass " " $fail "
[ " $fail " -eq 0 ]