8cdea3a430
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
134 lines
5.3 KiB
Bash
Executable File
134 lines
5.3 KiB
Bash
Executable File
#!/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/tooling.md section 3.
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat >&2 <<'EOF'
|
|
usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>]
|
|
|
|
--expect <sha> fail if the PR head is not this (prefix ok) at any poll
|
|
--timeout <sec> give up after this long (default 1800)
|
|
--interval <sec> seconds between polls (default 20)
|
|
|
|
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, or mergeability still UNKNOWN,
|
|
or still BLOCKED with every registered check green
|
|
exit 4 usage or environment error
|
|
exit 5 the PR is MERGED or CLOSED, so there is nothing left to wait for
|
|
EOF
|
|
exit 4
|
|
}
|
|
|
|
PR=""; EXPECT=""; TIMEOUT=1800; INTERVAL=20
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--expect) EXPECT="${2:-}"; shift 2 ;;
|
|
--timeout) TIMEOUT="${2:-}"; shift 2 ;;
|
|
--interval) INTERVAL="${2:-}"; shift 2 ;;
|
|
-h|--help) usage ;;
|
|
*) [ -z "$PR" ] || usage; PR="$1"; shift ;;
|
|
esac
|
|
done
|
|
[ -n "$PR" ] || usage
|
|
|
|
# `gh` is shell-aliased to a different tool in this repo's shells, so the name
|
|
# is never trusted: a real gh is located explicitly.
|
|
fetch() {
|
|
if [ -n "${PR_WATCH_FIXTURE_DIR:-}" ]; then
|
|
# The counter lives on disk because fetch is called as "$(fetch)", and a
|
|
# command substitution is a SUBSHELL -- a variable incremented in it is
|
|
# lost, so every poll re-read the first fixture and no test could ever
|
|
# reach the second one.
|
|
local c="$PR_WATCH_FIXTURE_DIR/.poll"
|
|
local n=$(( $(cat "$c" 2>/dev/null || echo 0) + 1 ))
|
|
echo "$n" > "$c"
|
|
local f="$PR_WATCH_FIXTURE_DIR/$n.json"
|
|
[ -f "$f" ] || f="$PR_WATCH_FIXTURE_DIR/last.json"
|
|
cat "$f"
|
|
return
|
|
fi
|
|
if command -v gh >/dev/null 2>&1 && gh --version 2>/dev/null | grep -q '^gh version'; then
|
|
gh pr view "$PR" --json state,headRefOid,mergeable,mergeStateStatus,statusCheckRollup
|
|
else
|
|
nix shell nixpkgs#gh --command gh pr view "$PR" \
|
|
--json state,headRefOid,mergeable,mergeStateStatus,statusCheckRollup
|
|
fi
|
|
}
|
|
|
|
jqf() { jq -r "$1"; }
|
|
|
|
started=$(date +%s)
|
|
pinned=""
|
|
|
|
while :; do
|
|
json="$(fetch)" || { echo "pr-watch: cannot read PR $PR" >&2; exit 4; }
|
|
|
|
head="$(printf '%s' "$json" | jqf '.headRefOid')"
|
|
|
|
# A verdict about a tree that no longer exists reads as a pass. This is not
|
|
# hypothetical here: PR #67 was merged at a head that predated its fix.
|
|
if [ -n "$EXPECT" ] && [ "${head#"$EXPECT"}" = "$head" ]; then
|
|
echo "pr-watch: head is $head, expected $EXPECT*" >&2
|
|
exit 2
|
|
fi
|
|
if [ -z "$pinned" ]; then
|
|
pinned="$head"
|
|
elif [ "$head" != "$pinned" ]; then
|
|
echo "pr-watch: head moved $pinned -> $head during the wait" >&2
|
|
exit 2
|
|
fi
|
|
# A PR no longer open ends the wait (improvise-1qaa), but only after the head
|
|
# checks: a merge at a head never seen is PR #67 again (improvise-24lz).
|
|
pr_state="$(printf '%s' "$json" | jqf '.state')"
|
|
case "$pr_state" in
|
|
MERGED|CLOSED) echo "pr-watch: PR $PR is $pr_state, nothing to wait for" >&2; exit 5 ;;
|
|
esac
|
|
|
|
# total>0 AND nothing still running. "0 pending" alone reads identically for
|
|
# "all finished" and "none registered yet", which is how this exited instantly
|
|
# on a fresh push. And `conclusion` is a STRING, empty while running, never
|
|
# null -- so a null test matches nothing and every check looks finished.
|
|
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 and green-but-BLOCKED are waited on: see context/code/tooling.md,
|
|
# "pr-watch and mergeability" (improvise-edpl, improvise-zlj2).
|
|
if [ "$total" -gt 0 ] && [ "$running" -eq 0 ]; then
|
|
if [ "$failed" -gt 0 ] || [ "$mergeable" = CONFLICTING ]; then break; fi
|
|
case "$mergeable/$state" in
|
|
UNKNOWN/*|*/UNKNOWN|MERGEABLE/BLOCKED) ;;
|
|
*) break ;;
|
|
esac
|
|
fi
|
|
|
|
now=$(date +%s)
|
|
if [ $((now - started)) -ge "$TIMEOUT" ]; then
|
|
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"
|
|
done
|
|
|
|
printf '%s' "$json" | jqf '.statusCheckRollup[]? | select(.conclusion != "SKIPPED") | " \(.conclusion) \(.name)"'
|
|
|
|
echo "pr-watch: head=$head $mergeable/$state $total checks, $failed failed"
|
|
|
|
[ "$failed" -eq 0 ] || exit 1
|
|
# An allowlist, so a state GitHub adds later fails closed. DIRTY and BLOCKED
|
|
# 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
|