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
load_config .github/labels.conf
set_required_bots codex-bot-andresmgsl
# 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
BOT1 = " ${ REQUIRED_BOTS [0] } " BOT2 = " ${ REQUIRED_BOTS [1] } " BOT3 = " ${ REQUIRED_BOTS [2] } "
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-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" \
"::warning::labels: every open PR was unreadable; sampled reason: HTTP 403: Resource not accessible by integration — one candidate is missing checks: read and statuses: 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 " ) "
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-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) "
# -- 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 \
" $( rev " $BOT1 " APPROVED head1 "" t1) " \
" $( rev " $BOT2 " APPROVED head1 "" t2) " ) "
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) "
# -- 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 = ""
# -- 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
REVIEWS_JSON = " $( reviews " $( rev " $BOT1 " APPROVED head1 "" t1) " ) "
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 \
" $( rev " $BOT1 " APPROVED oldhead "" t1) " \
" $( rev " $BOT2 " APPROVED oldhead "" t2) " \
" $( rev " $BOT3 " APPROVED oldhead "" t3) " ) "
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}' ; }
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)"
expect "...and the reverse order is not a re-run passing, it is one failing" FAILURE \
" $( 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)"
# -- 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
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) "
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
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.
# ---------------------------------------------------------------------------
RTMP = " $( mktemp -d) "
trap 'rm -rf "$RTMP"' EXIT
iso_at( ) { date -u -d " @ $1 " +%Y-%m-%dT%H:%M:%SZ; }
RNOW = 2000000000
ruling_sweep_probe( ) { # $1 = the PR's labels → reconcile_pr's log lines
(
REPO_LABELS = " $( printf 'state:addressing\nstate:needs-human\nmerge-next\nstale\nneeds-ruling' ) "
REPO = owner/repo NOW = " $RNOW "
LABELS = " $1 "
DRAFT = false HEAD_SHA = head1 REQUESTED = ""
# Approvals submitted 8 days ago — the newest real activity anywhere.
REVIEWS_JSON = " $( reviews \
" $( rev " $BOT1 " APPROVED head1 "" " $( iso_at $(( RNOW - 8 * 86400 )) ) " ) " \
" $( rev " $BOT2 " APPROVED head1 "" " $( iso_at $(( RNOW - 8 * 86400 )) ) " ) " \
" $( rev " $BOT3 " APPROVED head1 "" " $( iso_at $(( RNOW - 8 * 86400 )) ) " ) " ) "
MERGEABLE = MERGEABLE CHECKS = SUCCESS
PR_JSON = " $( jq -n --arg at " $( iso_at $(( RNOW - 10 * 86400 )) ) " '{created_at: $at}' ) "
run( ) { " $@ " ; } # mutations reach the stub and are recorded, not swallowed
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
file = " $RTMP / $( printf '%s' " $endpoint " | tr '/' '_' ) .json "
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
}
reconcile_pr 77 2>& 1
)
}
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-07-23 23:13:24 +00:00
# -- the sweep wiring observes the existing per-PR skip without writing -------
blind_main_probe( ) {
(
GITHUB_EVENT_NAME = schedule
REPO = owner/repo
LABELS_CONF = .github/labels.conf
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-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 " \
" $( sed -n 's/^gh label delete \(.*\) -R owner\/repo --yes$/\1/p' " $BOOT /happy " ) "
expect "...and the recorded upsert set is unchanged from today's" \
" $expected_upserts " \
" $( sed -n 's/^gh label create \([^ ]*\) .*/\1/p' " $BOOT /happy " ) "
# -- 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" \
6 " $( grep -c '^labels: DRY_RUN: gh label delete' <<< " $dry_out " ) "
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 "
exec_env( ) { # $1 = event name → the real script, executed under the PATH stub
: >" $EXEC /record "
env PATH = " $EXEC /stub: $PATH " GH_RECORD = " $EXEC /record " \
REPO = owner/repo LABELS_CONF = " $EXEC /labels.conf " GITHUB_EVENT_NAME = " $1 " \
bash actions/labels-reconcile/labels-reconcile.sh
}
exec_rc = 0
exec_out = " $( exec_env workflow_dispatch 2>& 1) " || exec_rc = $?
expect "an executed dispatch with all six absent completes green" 0 " $exec_rc "
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 " ) "
# -- bootstrap is dispatch-only, deletes included: the cron and
# pull_request_target paths touch no label
for ev in schedule pull_request_target; do
ev_rc = 0
exec_env " $ev " >/dev/null 2>& 1 || ev_rc = $?
expect " the $ev path completes green " 0 " $ev_rc "
expect "...and deletes nothing" \
no " $( grep -q '^delete ' " $EXEC /record " && echo yes || echo no) "
done
2026-07-22 18:19:49 +00:00
printf 'labels-reconcile tests: %d passed, %d failed\n' " $pass " " $fail "
[ " $fail " -eq 0 ]