diff --git a/.github/scripts/labels-reconcile.sh b/.github/scripts/labels-reconcile.sh index 216353e..89ff1cb 100644 --- a/.github/scripts/labels-reconcile.sh +++ b/.github/scripts/labels-reconcile.sh @@ -56,7 +56,14 @@ run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing requested() { grep -qxF "$1" <<<"$REQUESTED"; } -checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE +checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE + # UNREADABLE is the absence of the key itself, which is what a failed fetch + # leaves behind — distinct from a present-but-empty rollup, which honestly + # means this PR has no checks. Collapsing the two let an API hiccup present + # as "nothing is failing", i.e. as mergeable-by-a-human: the same + # unknown-certified-as-green shape as the bug this machine exists to stop. + # The caller skips the PR entirely rather than labelling on facts it did not + # read; blocking on it instead would flap the whole board on one bad call. # 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 @@ -70,6 +77,8 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE # 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 ' + if (has("statusCheckRollup") | not) then "UNREADABLE" else + # 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 @@ -121,7 +130,9 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | if ($latest | length) == 0 then "NONE" elif (($latest - $passing - $waiting) | length) > 0 then "FAILURE" elif (($latest - $passing) | length) > 0 then "PENDING" - else "SUCCESS" end' + else "SUCCESS" end + + end' } bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK @@ -414,6 +425,13 @@ main() { GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>/dev/null || echo '{}')" MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")" CHECKS="$(checks_state <<<"$GH_VIEW")" + # Read failed: leave this PR exactly as it is. Recomputing on facts we + # did not read is how an API hiccup turns into a false "merge me" — + # and the next tick is 15 minutes away, not 15 hours. + if [ "$CHECKS" = UNREADABLE ]; then + log "#$n: could not read mergeability/checks — left alone this pass" + exit 0 + fi reconcile_pr "$n" ) || log "#$n: reconcile failed — continuing with the remaining PRs" done diff --git a/test/labels-reconcile.sh b/test/labels-reconcile.sh index 863e216..c756fb1 100644 --- a/test/labels-reconcile.sh +++ b/test/labels-reconcile.sh @@ -258,6 +258,13 @@ 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 \