fix(labels): an unrecognised check outcome blocks, and a staled round outranks an unfinished one

Round 2. Two blockers, both real, both closing the same hole this PR
exists to close — a `state:needs-human` that invites a human to merge a
tree that will not merge.

The check-rollup classifier enumerated the outcomes that block and let
the rest fall into `else "SUCCESS"`, so ERROR, CANCELLED and STALE all
read as green. Inverted: it now lists the outcomes that DON'T block —
SUCCESS, NEUTRAL, SKIPPED, plus the pending set — and treats everything
else as blocking. The direction is the point. The rollup mixes two
closed enums (CheckRun.conclusion, StatusContext.state) and an outcome
the list forgets is one we cannot certify as mergeable; the costs are
not symmetric, since a false FAILURE parks the PR on the agent who
looks, while a false SUCCESS is #136 exactly.

Once CANCELLED blocks, superseded runs must be dropped first: a re-run
does not evict the run it replaced, and this PR's own tip carries a
CANCELLED `scope` beside the SUCCESS `scope` that superseded it. Each
context now collapses to its newest entry before anything is judged,
keyed on workflow + job name because a bare job name is only unique
within its workflow. That preserves the re-run case the panel split
over while still blocking a cancelled run that is the newest word.

The classifier also moved out of main() into checks_state(). That is
why no fixture caught this: it was inline in the fetch loop, so the
fixtures could only inject CHECKS= as an already-decided string.

Second, decide_state() returned from inside the bot loop on the first
MISSING, so a STALE belonging to a later bot in BOTS was never read —
a round that was both unfinished and staled came out needs-human over
a head nobody had reviewed. The whole round is now collected before
precedence is applied to it as a unit, STALE before MISSING.

test/labels-reconcile.sh: 29 -> 44 fixtures, pinning the check-outcome
enum, the supersede rule (both orders, plus same name in another
workflow), and the mixed round at both ends of BOTS. All verified
non-vacuous against the round-1 code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-20 16:04:16 +00:00
parent aa5a6baed6
commit 724f103908
4 changed files with 155 additions and 24 deletions

View file

@ -52,6 +52,45 @@ run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
requested() { grep -qxF "$1" <<<"$REQUESTED"; } requested() { grep -qxF "$1" <<<"$REQUESTED"; }
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE
# The rollup mixes two node types with two different closed enums: CheckRun
# carries `conclusion` (CheckConclusionState), StatusContext carries `state`
# (StatusState). Rather than list the outcomes that block — the version that
# shipped in this PR's first round listed four, and ERROR, CANCELLED and
# STALE fell through its `else` into SUCCESS — this lists the outcomes that
# DON'T, and treats everything else as blocking.
#
# That direction is the point. An outcome we do not recognise is one we
# cannot certify as mergeable, and certifying the unrecognised as green is
# the exact shape of #136. The cost of being wrong is symmetric in form and
# not in consequence: a false FAILURE parks the PR on the agent, who looks;
# a false SUCCESS invites a human to merge a tree that will not merge.
jq -r '
# NEUTRAL and SKIPPED satisfy branch protection — a skipped required check
# is not a failed one, and path-filtered jobs skip constantly here.
["SUCCESS", "NEUTRAL", "SKIPPED"] as $passing
# "" covers a StatusContext still reported with no state at all.
| ["", "PENDING", "IN_PROGRESS", "QUEUED", "WAITING", "REQUESTED", "EXPECTED"] as $waiting
# A re-run does not evict the run it superseded — the rollup keeps both.
# This PR proved it: its own tip carried a CANCELLED `scope` (15:19:39)
# beside the SUCCESS `scope` (15:19:45) that replaced it, same workflow.
# Once CANCELLED blocks, judging every entry would strand this very PR in
# needs-rebase forever, so collapse each context to its newest entry first.
# Key on workflow + name because a bare job name is only unique within its
# workflow; ordering falls back through the timestamps a pending run has.
| [ (.statusCheckRollup // [])[]
| { ctx: [.workflowName // "", .name // .context // ""],
at: (.completedAt // .startedAt // .createdAt // ""),
outcome: ((.conclusion // .state // "") | ascii_upcase) } ]
| group_by(.ctx) | map(sort_by(.at) | last | .outcome) as $latest
| if ($latest | length) == 0 then "NONE"
elif (($latest - $passing - $waiting) | length) > 0 then "FAILURE"
elif (($latest - $passing) | length) > 0 then "PENDING"
else "SUCCESS" end'
}
bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
local review state commit local review state commit
review="$(jq -c --arg u "$1" \ review="$(jq -c --arg u "$1" \
@ -104,29 +143,37 @@ decide_state() { # → the one state:* label this PR should carry
case "${MERGEABLE:-UNKNOWN}" in CONFLICTING) echo state:needs-rebase; return ;; esac case "${MERGEABLE:-UNKNOWN}" in CONFLICTING) echo state:needs-rebase; return ;; esac
case "${CHECKS:-NONE}" in FAILURE) echo state:needs-rebase; return ;; esac case "${CHECKS:-NONE}" in FAILURE) echo state:needs-rebase; return ;; esac
local b v verdicts="" local b verdicts=""
for b in "${BOTS[@]}"; do for b in "${BOTS[@]}"; do
if requested "$b"; then echo state:bots-reviewing; return; fi if requested "$b"; then echo state:bots-reviewing; return; fi
done done
# Collect the WHOLE round before applying any precedence. Deciding inside
# the loop let BOTS order pick the winner: a MISSING returned immediately,
# so a STALE belonging to a later bot was never even read, and the mixed
# round (one approval staled by a push, another bot yet to review) came out
# needs-human — the #136 headline shape, with zero reviews bound to the head.
for b in "${BOTS[@]}"; do for b in "${BOTS[@]}"; do
v="$(bot_verdict "$b")" verdicts="$verdicts $(bot_verdict "$b")"
if [ "$v" = MISSING ]; then
# No verdict at all from this bot. An explicit human request still
# outranks an unfinished bot round — a maintainer pulling a PR to
# themselves early is a deliberate act, and the original precedence.
if requested "$HUMAN"; then echo state:needs-human; return; fi
echo state:bots-reviewing; return
fi
verdicts="$verdicts $v"
done done
case "$verdicts" in case "$verdicts" in
# STALE = a verdict for an older head. Unlike MISSING, this outranks the # STALE = a verdict for an older head. Unlike MISSING, this outranks the
# human request: every approval was invalidated by a push, so NOBODY has # human request: every approval it covers was invalidated by a push, so
# reviewed this tree. Handing that to the human is the #136 case where # NOBODY has reviewed this tree. Handing that to the human is the #136 case
# everything reads green — mergeable, CI passing, "waiting on the human" — # where everything reads green — mergeable, CI passing, "waiting on the
# over code no reviewer has seen. The agent owes a re-request. # human" — over code no reviewer has seen. The agent owes a re-request.
# Checked before MISSING because "unfinished" must not swallow "and also
# stale": a round that is both is a push that outran the re-requests, not
# a maintainer deliberately claiming the PR early.
*STALE*) echo state:addressing; return ;; *STALE*) echo state:addressing; return ;;
esac esac
case "$verdicts" in
# No verdict at all from some bot, and nothing staled. An explicit human
# request still outranks an unfinished round — a maintainer pulling a PR
# to themselves early is a deliberate act, and the original precedence.
*MISSING*)
if requested "$HUMAN"; then echo state:needs-human; return; fi
echo state:bots-reviewing; return ;;
esac
# an explicit human request outranks the remaining bot outcomes — it is the # an explicit human request outranks the remaining bot outcomes — it is the
# final gate, and a maintainer pulling a PR to themselves early counts too # final gate, and a maintainer pulling a PR to themselves early counts too
if requested "$HUMAN"; then echo state:needs-human; return; fi if requested "$HUMAN"; then echo state:needs-human; return; fi
@ -158,7 +205,7 @@ state:building|FBCA04|PR is a draft — the coding agent is still building
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
state:needs-rebase|B60205|Does not merge — conflicts or failing checks; the agent owes a fix state:needs-rebase|B60205|Does not merge — conflicts or failing checks; the agent owes a fix
state:needs-human|8250DF|All bots approve — waiting on the human reviewer state:needs-human|8250DF|Mergeable, green, all bots approve — waiting on the human reviewer
merge-next|0E8A16|Head of the merge queue — merge this one next (set by hand/agent, cleared here) merge-next|0E8A16|Head of the merge queue — merge this one next (set by hand/agent, cleared here)
stale|B60205|No activity for 48h — needs a poke (sweep-managed) stale|B60205|No activity for 48h — needs a poke (sweep-managed)
blocked|6A737D|Waiting on another PR or issue to land first blocked|6A737D|Waiting on another PR or issue to land first
@ -271,12 +318,7 @@ main() {
# the "do not know" value that triggers nothing. # the "do not know" value that triggers nothing.
GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>/dev/null || echo '{}')" GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>/dev/null || echo '{}')"
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")" MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
CHECKS="$(jq -r ' CHECKS="$(checks_state <<<"$GH_VIEW")"
(.statusCheckRollup // []) as $c
| if ($c | length) == 0 then "NONE"
elif ($c | map(.conclusion // .state // "") | any(. == "FAILURE" or . == "TIMED_OUT" or . == "STARTUP_FAILURE" or . == "ACTION_REQUIRED")) then "FAILURE"
elif ($c | map(.conclusion // .state // "") | any(. == "" or . == "PENDING" or . == "IN_PROGRESS" or . == "QUEUED")) then "PENDING"
else "SUCCESS" end' <<<"$GH_VIEW")"
reconcile_pr "$n" reconcile_pr "$n"
) || log "#$n: reconcile failed — continuing with the remaining PRs" ) || log "#$n: reconcile failed — continuing with the remaining PRs"
done done

View file

@ -85,7 +85,25 @@ which records not just what changed but what each drill run proved.
re-request. An *unfinished* round still yields to an explicit human request — re-request. An *unfinished* round still yields to an explicit human request —
a maintainer pulling a PR to themselves early is deliberate, and `MISSING` a maintainer pulling a PR to themselves early is deliberate, and `MISSING`
(nobody has reviewed yet) is a different fact from `STALE` (everyone reviewed (nobody has reviewed yet) is a different fact from `STALE` (everyone reviewed
something else). something else). Precedence is applied to the round as a whole, after every
verdict is collected: deciding inside the loop let the order of `BOTS` pick
the answer, so a round that was *both* unfinished and staled returned on the
`MISSING` before any later bot's `STALE` was read — and came out
`needs-human` over a head nobody had reviewed, the original bug wearing a
different hat.
Whether a check blocks is judged by listing the outcomes that *don't*
`SUCCESS`, `NEUTRAL`, `SKIPPED`, and the pending set — rather than the
outcomes that do. The rollup mixes two closed enums (`CheckRun.conclusion`
and `StatusContext.state`), and an outcome the list forgets is one the label
cannot certify as mergeable: `ERROR`, `CANCELLED` and `STALE` all read as
green under an allow-list of failures. The costs are not symmetric — a false
failure parks the PR on the agent, who looks; a false success invites a human
to merge a tree that will not merge. Superseded runs are dropped first, each
context collapsing to its newest entry: a re-run does not evict the run it
replaced, so this PR's own tip carried a `CANCELLED` `scope` beside the
`SUCCESS` `scope` that superseded it, and judging every entry would have
stranded every re-run PR in `needs-rebase`.
`UNKNOWN` mergeability is deliberately not treated as unmergeable: GitHub `UNKNOWN` mergeability is deliberately not treated as unmergeable: GitHub
reports it for about a minute after every merge while it recomputes, and reports it for about a minute after every merge while it recomputes, and
@ -98,7 +116,8 @@ which records not just what changed but what each drill run proved.
`CHANGELOG.md`. Queue order is intent, so the reconciler never sets it — it `CHANGELOG.md`. Queue order is intent, so the reconciler never sets it — it
only **clears** it the moment the PR stops being mergeable-by-a-human, which only **clears** it the moment the PR stops being mergeable-by-a-human, which
is precisely the staleness that made `needs-human` untrustworthy. Both live is precisely the staleness that made `needs-human` untrustworthy. Both live
shapes are pinned in `test/labels-reconcile.sh` (19 fixtures → 29). shapes, the mixed round, and the whole check-outcome enum are pinned in
`test/labels-reconcile.sh` (19 fixtures → 44).
- **CI's shellcheck sweep never lints `.github/scripts/*.sh`** (#116) — - **CI's shellcheck sweep never lints `.github/scripts/*.sh`** (#116) —
`globstar` makes `**` descend into subdirectories, but a glob still does `globstar` makes `**` descend into subdirectories, but a glob still does

View file

@ -97,7 +97,7 @@ gh label create "state:building" --color FBCA04 --description "PR is a dra
gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force
gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force
gh label create "state:needs-rebase" --color B60205 --description "Does not merge — conflicts or failing checks; the agent owes a fix" --force gh label create "state:needs-rebase" --color B60205 --description "Does not merge — conflicts or failing checks; the agent owes a fix" --force
gh label create "state:needs-human" --color 8250DF --description "All bots approve — waiting on the human reviewer" --force gh label create "state:needs-human" --color 8250DF --description "Mergeable, green, all bots approve — waiting on the human reviewer" --force
gh label create "merge-next" --color 0E8A16 --description "Head of the merge queue — merge this one next (set by hand/agent, cleared here)" --force gh label create "merge-next" --color 0E8A16 --description "Head of the merge queue — merge this one next (set by hand/agent, cleared here)" --force
gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force

View file

@ -185,6 +185,21 @@ REVIEWS_JSON="$(reviews \
"$(rev "$BOT3" APPROVED oldhead "" t3)")" "$(rev "$BOT3" APPROVED oldhead "" t3)")"
expect "stale approvals outrank the human request (nobody reviewed this tree)" state:addressing "$(decide_state)" 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 # -- but an UNFINISHED round still yields to an explicit human request: a
# maintainer pulling a PR to themselves early is deliberate, and was the # maintainer pulling a PR to themselves early is deliberate, and was the
# original precedence. MISSING differs from STALE — nobody has reviewed # original precedence. MISSING differs from STALE — nobody has reviewed
@ -194,6 +209,61 @@ expect "an unfinished round still yields to an explicit human request" state:nee
REQUESTED="" REQUESTED=""
expect "...and without that request it is still bots-reviewing" state:bots-reviewing "$(decide_state)" expect "...and without that request it is still bots-reviewing" state:bots-reviewing "$(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)"
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)"
# -- 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 needs-rebase" state:needs-rebase "$(decide_state)"
# -- the happy path survives all of the above. # -- the happy path survives all of the above.
REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED=""
expect "mergeable + green + three head-current approvals is needs-human" state:needs-human "$(decide_state)" expect "mergeable + green + three head-current approvals is needs-human" state:needs-human "$(decide_state)"