diff --git a/evals/evals.json b/evals/evals.json index 9c0cb09..9f63669 100644 --- a/evals/evals.json +++ b/evals/evals.json @@ -156,6 +156,12 @@ "name": "required-check-queued-not-running", "prompt": "My PR has been waiting on CI for forty minutes and the status tool says the required checks are still running. Should I enqueue it for merge so it goes in as soon as they finish?", "expected_output": "Should check whether those checks are actually running or merely queued: queued and in_progress are different API states, and a required check sitting in queued with nothing running means no runner has picked it up, not that CI is slow; should warn that a merge queue drops an entry whose required check does not report inside check_response_timeout_minutes and that the clock covers waiting for a runner, so enqueueing now risks a silent dequeue and each retry leaves more runs holding slots; should not try to look up the concurrency limit, which is not exposed by the API" + }, + { + "id": 27, + "name": "blocked-with-everything-green", + "prompt": "My PR is mergeStateStatus BLOCKED. Every check passes, no review thread is open, the ruleset needs zero approvals and every required context is present. What is holding it?", + "expected_output": "Should suspect an unsigned commit rather than re-reading branch protection: a required_signatures ruleset is shut by one commit anywhere on the branch that carries no valid signature, and GitHub surfaces that only as BLOCKED, never as a failing check or a rollup entry, so nothing visible names it; should check every commit on the branch, not just the head, and re-sign the branch with a rebase that amends each commit rather than only the tip" } ] } diff --git a/skills/git-workflow/scripts/pr-status.sh b/skills/git-workflow/scripts/pr-status.sh index 4151c22..b4ab639 100755 --- a/skills/git-workflow/scripts/pr-status.sh +++ b/skills/git-workflow/scripts/pr-status.sh @@ -76,6 +76,11 @@ collect() { ... on User{login} ... on Bot{login} ... on Team{slug} } } } reviewThreads(first:100){ nodes{ id isResolved isOutdated comments(first:1){ nodes{ databaseId author{login} path } } } } + # One unsigned commit anywhere on the branch shuts a required_signatures + # ruleset, and GitHub surfaces that only as mergeStateStatus BLOCKED — + # no red check, nothing in the rollup. Without this the tool can only + # say "investigate". + allCommits: commits(first:100){ nodes{ commit{ oid signature{ isValid } } } } commits(last:1){ nodes{ commit{ oid statusCheckRollup{ state contexts(first:100){ nodes{ __typename @@ -170,6 +175,9 @@ evaluate() { }, required_contexts: $required, rules_fetched: ($ok == 1), + unsigned: [$p.allCommits.nodes[]?.commit + | select((.signature.isValid // false) | not) + | .oid[0:8]], rulesets: $ruletypes, reviews_on_head: ($head_reviews|map({(.author.login): .state})|add // {}), has_review_on_head: (($head_reviews|length) > 0), @@ -292,6 +300,20 @@ evaluate() { {action:"triage-ci", why:"UNSTABLE: a non-required check is red; the gate stays shut until it is green or the PR is force-merged"} elif $s.checks.pending > 0 then {action:"wait", why:"\($s.checks.pending) check(s) still running (none of them required)"} + # Checked last, because it only matters once everything visible is + # green: an unsigned commit produces no red check and no rollup entry, + # so it surfaces purely as BLOCKED and used to end here as + # "investigate". + elif (($s.unsigned|length) > 0 and (($ruletypes | index("required_signatures")) != null)) then + {action:"fix-signatures", + why:("\($s.unsigned|length) commit(s) on this branch carry no valid signature — \($s.unsigned|join(", "))" + + " — and the required_signatures ruleset is active. GitHub reports this" + + " only as mergeStateStatus \($s.mergeState), never as a failing check"), + # No single quotes in here: the whole jq program lives in a + # single-quoted shell string and one would end it (shellcheck + # SC2026 catches it, but only after the parse has already gone + # wrong further down). + cmd:"git rebase --exec \"git commit --amend --no-edit -S\" $(git merge-base HEAD origin/\($s.base)) ; git push --force-with-lease"} else {action:"investigate", why:"mergeState=\($s.mergeState) with no failing check, no open thread and no missing review — check branch protection manually"} end)