#!/usr/bin/env bash set -euo pipefail # 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 # 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 # 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' } 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 # 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=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" 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 # 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 # -- a sweep-wide read failure is visible without changing any PR ------------ 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, statuses: read and actions: read in the caller (private repos do not imply them)" \ "$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)" # 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}" # -- a missing core taxonomy row is visible without mutating labels ---------- core_rows="$(core_label_rows)" core_names="$(cut -d'|' -f1 <<<"$core_rows")" 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")" 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")" # -- 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)" # -- 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 "" 2026-08-03T10:00:00Z)" \ "$(rev "$BOT2" APPROVED head1 "" 2026-08-03T10:01:00Z)")" 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 "" 2026-08-03T10:00:00Z)")" 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 "" 2026-08-03T10:00:00Z)" \ "$(rev "$BOT2" APPROVED oldhead "" 2026-08-03T10:01:00Z)" \ "$(rev "$BOT3" APPROVED oldhead "" 2026-08-03T10:02:00Z)")" 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}'; } # 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="" 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)" # 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 \ "$(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)" # -- 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)" # -- 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)" # ...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)" # -- 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 # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) 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)" # --------------------------------------------------------------------------- # 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="" # --------------------------------------------------------------------------- # 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)" 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 # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) 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)" # --------------------------------------------------------------------------- # 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 ruling_sweep_probe() { # $1 labels, $2 PR, $3 assignees, $4 requested, $5 activity age days ( 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"}]' 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="$requested" # Approvals submitted 8 days ago — the newest real activity anywhere. REVIEWS_JSON="$(reviews \ "$(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)))")")" MERGEABLE=MERGEABLE CHECKS=SUCCESS PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" \ --argjson assignees "$assignee_json" '{created_at: $at, assignees: $assignees}')" run() { "$@"; } # mutations reach the stub and are recorded, not swallowed # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) 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 endpoint="$(forge_stub_path "$endpoint")" file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json" printf '%s\n' "$endpoint" >>"$RTMP/api-calls" [ ! -f "$file.error" ] || return 1 # 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; } 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 "$n" 2>&1 ) } # 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)))" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$RTMP/repos_owner_repo_issues_77_timeline.json" 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":"\nrung"}, {"user":{"login":"sweep-bot"},"created_at":$r24,"html_url":"https://x/r24","body":"\nrung"}]' \ >"$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)" # --------------------------------------------------------------------------- # 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 '