Files
improvise/scripts/pr-watch
T

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