From c3b79976a3c9b3032f05673b4c5ac841eb5f0b32 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 5 Aug 2026 22:08:33 +0200 Subject: [PATCH] feat(pr-status): name the unsigned commit instead of saying investigate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A required_signatures ruleset is shut by one commit anywhere on the branch that carries no valid signature. GitHub surfaces that only as mergeStateStatus BLOCKED: no failing check, no rollup entry, nothing in the review or thread state. Every visible signal is green, so the tool fell through to investigate and left the reader to guess. Observed today across eight PRs in this campaign, all reporting BLOCKED with everything green. Diagnosing it by hand took a walk through the ruleset parameters, a diff of required contexts against present ones, and finally a per-commit verification call. The tool now answers it in the first run. The check is last in the chain on purpose: it only matters once the visible gates are satisfied, and it costs one extra field on a query that already runs. The suggested command re-signs every commit on the branch, not just the tip — amending only HEAD leaves an unsigned parent and the gate stays shut. Signed-off-by: Sebastian Mendel --- evals/evals.json | 6 ++++++ skills/git-workflow/scripts/pr-status.sh | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) 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)