pr-watch: wait out UNKNOWN mergeability; pass only an allowlisted state (improvise-edpl)

GitHub reports UNKNOWN for a while after the base moves, and PRs #172 and
#179 both exited 0 there. UNKNOWN is now waited on (exit 3 on timeout), a
failed check or CONFLICTING ends the wait, and the pass condition is
MERGEABLE with CLEAN, UNSTABLE or HAS_HOOKS. BLOCKED, DIRTY, BEHIND, DRAFT
and any unshipped state fail closed. The suite enumerates the whole table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-22 21:34:07 -07:00
parent e0550fc51f
commit d1a9ef9add
3 changed files with 102 additions and 15 deletions
+23 -15
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Waits for a PR's checks to finish and says what happened. Every rule in here
# is one this repo got wrong first; see context/code/pr-watch.md.
# is one this repo got wrong first; see context/code/tooling.md section 3.
set -euo pipefail
usage() {
@@ -11,10 +11,11 @@ usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>
--timeout <sec> give up after this long (default 1800)
--interval <sec> seconds between polls (default 20)
exit 0 every check COMPLETED and none failed
exit 1 a check failed, or the PR is not mergeable
exit 0 every check COMPLETED, none failed, and GitHub says MERGEABLE with a
state of CLEAN, UNSTABLE or HAS_HOOKS
exit 1 a check failed, or the PR is not mergeable (any other known state)
exit 2 the head moved during the wait, or is not --expect
exit 3 timed out with checks still running
exit 3 timed out with checks still running or mergeability still UNKNOWN
exit 4 usage or environment error
EOF
exit 4
@@ -86,13 +87,25 @@ while :; do
total="$(printf '%s' "$json" | jqf '[.statusCheckRollup[]?] | length')"
running="$(printf '%s' "$json" | jqf '[.statusCheckRollup[]? | select(.status != "COMPLETED")] | length')"
failed="$(printf '%s' "$json" | jqf \
'[.statusCheckRollup[]? | select(.conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length')"
mergeable="$(printf '%s' "$json" | jqf '.mergeable')"
state="$(printf '%s' "$json" | jqf '.mergeStateStatus')"
# UNKNOWN is GitHub not having computed mergeability yet, so it is waited on;
# a red check or CONFLICTING is already an answer (improvise-edpl).
if [ "$total" -gt 0 ] && [ "$running" -eq 0 ]; then
break
if [ "$failed" -gt 0 ] || [ "$mergeable" = CONFLICTING ]; then break; fi
if [ "$mergeable" != UNKNOWN ] && [ "$state" != UNKNOWN ]; then break; fi
fi
now=$(date +%s)
if [ $((now - started)) -ge "$TIMEOUT" ]; then
echo "pr-watch: timed out after ${TIMEOUT}s with $running of $total still running" >&2
if [ "$total" -gt 0 ] && [ "$running" -eq 0 ]; then
echo "pr-watch: timed out after ${TIMEOUT}s with mergeability $mergeable/$state" >&2
else
echo "pr-watch: timed out after ${TIMEOUT}s with $running of $total still running" >&2
fi
exit 3
fi
sleep "$INTERVAL"
@@ -100,15 +113,10 @@ done
printf '%s' "$json" | jqf '.statusCheckRollup[]? | select(.conclusion != "SKIPPED") | " \(.conclusion) \(.name)"'
failed="$(printf '%s' "$json" | jqf \
'[.statusCheckRollup[]? | select(.conclusion != "SUCCESS" and .conclusion != "SKIPPED" and .conclusion != "NEUTRAL")] | length')"
mergeable="$(printf '%s' "$json" | jqf '.mergeable')"
state="$(printf '%s' "$json" | jqf '.mergeStateStatus')"
echo "pr-watch: head=$head $mergeable/$state $total checks, $failed failed"
[ "$failed" -eq 0 ] || exit 1
# UNSTABLE means a non-required check is unhappy; it is not a merge blocker on
# its own, so only an outright non-mergeable state fails here.
[ "$mergeable" != "CONFLICTING" ] || exit 1
exit 0
# An allowlist, so a state GitHub adds later fails closed. BLOCKED and DIRTY
# can wear a MERGEABLE flag: context/code/tooling.md, "pr-watch and mergeability".
[ "$mergeable" = MERGEABLE ] || exit 1
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) exit 0 ;; *) exit 1 ;; esac