|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Regression test for #228: --watch must be usable on a draft PR. |
| 3 | +# |
| 4 | +# Before the fix, `draft` sat near the top of the NEXT ladder and always |
| 5 | +# answered `ready` — masking conflicts, red checks and open threads — while |
| 6 | +# `ready` was missing from ACTIONABLE, so the watch could neither return on it |
| 7 | +# nor hold through it via --ignore-action: it heartbeated "waiting: draft" |
| 8 | +# into the timeout. Now draft ranks below the real-work branches, reports |
| 9 | +# `wait` while checks run, and a settled draft returns `ready` (ignorable). |
| 10 | +# |
| 11 | +# The last section pins the action vocabulary: every action literal the script |
| 12 | +# can emit must be in ACTIONABLE (watch returns on it, --ignore-action takes |
| 13 | +# it) or in the waiting set (heartbeats). A new action landing in neither is |
| 14 | +# how #228 happened. |
| 15 | +# |
| 16 | +# Runs pr-status.sh against a stubbed `gh`, so it needs no network and no repo. |
| 17 | + |
| 18 | +set -euo pipefail |
| 19 | + |
| 20 | +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/skills/git-workflow/scripts/pr-status.sh" |
| 21 | +STUB_DIR="$(mktemp -d)" |
| 22 | +trap 'rm -rf "$STUB_DIR"' EXIT |
| 23 | + |
| 24 | +fail=0 |
| 25 | +check() { # check <name> <expected> <actual> |
| 26 | + if [ "$2" = "$3" ]; then |
| 27 | + echo " ok $1" |
| 28 | + else |
| 29 | + echo " FAIL $1: expected '$2', got '$3'" |
| 30 | + fail=1 |
| 31 | + fi |
| 32 | +} |
| 33 | +check_contains() { # check_contains <name> <needle> <haystack> |
| 34 | + case "$3" in |
| 35 | + *"$2"*) echo " ok $1" ;; |
| 36 | + *) echo " FAIL $1: no '$2' in output"; fail=1 ;; |
| 37 | + esac |
| 38 | +} |
| 39 | +check_absent() { # check_absent <name> <needle> <haystack> |
| 40 | + case "$3" in |
| 41 | + *"$2"*) echo " FAIL $1: unexpected '$2' in output"; fail=1 ;; |
| 42 | + *) echo " ok $1" ;; |
| 43 | + esac |
| 44 | +} |
| 45 | + |
| 46 | +export XDG_CACHE_HOME="$STUB_DIR/cache" |
| 47 | + |
| 48 | +# Stub `gh`: a draft PR, green unless told otherwise. |
| 49 | +# PENDING_CHECK=1 make_stub — adds an IN_PROGRESS check (checks_settled false) |
| 50 | +# FAIL_CHECK=1 make_stub — adds a COMPLETED/FAILURE check |
| 51 | +# OPEN_THREAD=1 make_stub — adds one unresolved review thread |
| 52 | +# NO_CHECKS=1 make_stub — zero registered check contexts |
| 53 | +# GHOST_REQUIRED=1 make_stub — a required context no check run ever reports |
| 54 | +make_stub() { |
| 55 | + if [ "${GHOST_REQUIRED:-0}" = "1" ]; then |
| 56 | + printf '%s\n' '[{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"ghost"}]}}]' > "$STUB_DIR/rules.json" |
| 57 | + else |
| 58 | + printf '%s\n' '[]' > "$STUB_DIR/rules.json" |
| 59 | + fi |
| 60 | + cat > "$STUB_DIR/gh" <<STUB |
| 61 | +#!/usr/bin/env bash |
| 62 | +for a in "\$@"; do |
| 63 | + case "\$a" in |
| 64 | + repos/*/rules/branches/*) cat "$STUB_DIR/rules.json"; exit 0 ;; |
| 65 | + esac |
| 66 | +done |
| 67 | +cat "$STUB_DIR/graphql.json" |
| 68 | +STUB |
| 69 | + chmod +x "$STUB_DIR/gh" |
| 70 | + python3 - "$STUB_DIR/graphql.json" <<'PY' |
| 71 | +import sys, json, os |
| 72 | +out = sys.argv[1] |
| 73 | +head = "deadbeefcafe" |
| 74 | +checks = [{"__typename": "CheckRun", "name": "CI", "conclusion": "SUCCESS", |
| 75 | + "status": "COMPLETED", "detailsUrl": "u", |
| 76 | + "startedAt": "2026-01-01T00:00:00Z"}] |
| 77 | +if os.environ.get("NO_CHECKS", "0") == "1": |
| 78 | + checks = [] |
| 79 | +if os.environ.get("PENDING_CHECK", "0") == "1": |
| 80 | + checks.append({"__typename": "CheckRun", "name": "slow", "conclusion": None, |
| 81 | + "status": "IN_PROGRESS", "detailsUrl": "u", |
| 82 | + "startedAt": "2026-01-01T00:00:00Z"}) |
| 83 | +if os.environ.get("FAIL_CHECK", "0") == "1": |
| 84 | + checks.append({"__typename": "CheckRun", "name": "broken", "conclusion": "FAILURE", |
| 85 | + "status": "COMPLETED", "detailsUrl": "u", |
| 86 | + "startedAt": "2026-01-01T00:00:00Z"}) |
| 87 | +threads = [] |
| 88 | +if os.environ.get("OPEN_THREAD", "0") == "1": |
| 89 | + threads.append({"id": "T1", "isResolved": False, |
| 90 | + "comments": {"nodes": [{"databaseId": 1, "path": "f", |
| 91 | + "author": {"login": "rev"}, |
| 92 | + "body": "please fix"}]}}) |
| 93 | +reviews = [{"author": {"login": "rev"}, "state": "APPROVED", |
| 94 | + "commit": {"oid": head}, "body": ""}] |
| 95 | +json.dump({"data": {"repository": { |
| 96 | + "nameWithOwner": "o/r", |
| 97 | + "mergeCommitAllowed": True, "rebaseMergeAllowed": False, "squashMergeAllowed": False, |
| 98 | + "pullRequest": { |
| 99 | + "number": 1, "title": "t", "state": "OPEN", "isDraft": True, |
| 100 | + "mergeable": "MERGEABLE", "mergeStateStatus": "BLOCKED", "reviewDecision": None, |
| 101 | + "author": {"login": "someone"}, |
| 102 | + "baseRefName": "main", "headRefName": "f", "headRefOid": head, |
| 103 | + "isCrossRepository": False, |
| 104 | + "reviews": {"nodes": reviews}, |
| 105 | + "reviewRequests": {"nodes": []}, |
| 106 | + "reviewThreads": {"nodes": threads}, |
| 107 | + "commits": {"nodes": [{"commit": {"oid": head, "statusCheckRollup": { |
| 108 | + "state": "SUCCESS", "contexts": {"nodes": checks}}}}]}, |
| 109 | + "allCommits": {"nodes": [{"commit": {"oid": head, |
| 110 | + "signature": {"isValid": True}}}]}, |
| 111 | + }}}}, open(out, "w")) |
| 112 | +PY |
| 113 | +} |
| 114 | + |
| 115 | +watch() { PATH="$STUB_DIR:$PATH" bash "$SCRIPT" -R o/r 1 --json --watch "$@"; } |
| 116 | +status() { PATH="$STUB_DIR:$PATH" bash "$SCRIPT" -R o/r 1 --json; } |
| 117 | + |
| 118 | +echo "case: draft with running checks -> wait, watch holds" |
| 119 | +PENDING_CHECK=1 make_stub |
| 120 | +rc=0; out=$(watch --interval 1 --max-wait 2) || rc=$? |
| 121 | +check "exits 1 (timeout, still waiting)" "1" "$rc" |
| 122 | +check_contains "why names the draft and the run" "draft — 1 check(s) still running" "$out" |
| 123 | +check_absent "no ACTIONABLE return" "ACTIONABLE" "$out" |
| 124 | + |
| 125 | +echo "case: draft with an unresolved thread -> the thread outranks the draft" |
| 126 | +OPEN_THREAD=1 make_stub |
| 127 | +rc=0; out=$(watch --interval 1 --max-wait 4) || rc=$? |
| 128 | +check "exits 0" "0" "$rc" |
| 129 | +check_contains "returns on the thread" "ACTIONABLE: resolve-threads" "$out" |
| 130 | + |
| 131 | +echo "case: draft with a red check -> returns, draft does not mask it" |
| 132 | +FAIL_CHECK=1 make_stub |
| 133 | +rc=0; out=$(watch --interval 1 --max-wait 4) || rc=$? |
| 134 | +check "exits 0" "0" "$rc" |
| 135 | +check_contains "returns on the failure" "ACTIONABLE" "$out" |
| 136 | +# The watch would return here even pre-fix (the check-failure early-exit is |
| 137 | +# ladder-independent); the reorder shows in plain status, where draft used to |
| 138 | +# mask the red check behind NEXT: ready. |
| 139 | +rc=0; out=$(status) || rc=$? |
| 140 | +check "plain status names the red check" "triage-ci" "$(jq -r '.next.action' <<<"$out")" |
| 141 | + |
| 142 | +echo "case: draft with no registered checks -> ready, not an endless wait" |
| 143 | +NO_CHECKS=1 make_stub |
| 144 | +rc=0; out=$(watch --interval 1 --max-wait 4) || rc=$? |
| 145 | +check "exits 0" "0" "$rc" |
| 146 | +check_contains "returns ready" "ACTIONABLE: ready" "$out" |
| 147 | + |
| 148 | +echo "case: draft whose required context never dispatched -> ready, names it" |
| 149 | +GHOST_REQUIRED=1 make_stub |
| 150 | +rc=0; out=$(watch --interval 1 --max-wait 4) || rc=$? |
| 151 | +check "exits 0" "0" "$rc" |
| 152 | +check_contains "returns ready" "ACTIONABLE: ready" "$out" |
| 153 | +check_contains "why names the missing context" "required context(s) not reported" "$out" |
| 154 | + |
| 155 | +echo "case: settled green draft -> ready is the actionable event" |
| 156 | +make_stub |
| 157 | +rc=0; out=$(watch --interval 1 --max-wait 4) || rc=$? |
| 158 | +check "exits 0" "0" "$rc" |
| 159 | +check_contains "returns ready" "ACTIONABLE: ready" "$out" |
| 160 | +check_contains "hands over the command" "gh pr ready" "$out" |
| 161 | + |
| 162 | +echo "case: settled green draft, --ignore-action ready -> SETTLED, not ACTIONABLE" |
| 163 | +make_stub |
| 164 | +rc=0; out=$(watch --ignore-action ready --interval 1 --max-wait 4) || rc=$? |
| 165 | +check "exits 0" "0" "$rc" |
| 166 | +check_contains "reports SETTLED" "SETTLED: NEXT is still the ignored action -> ready" "$out" |
| 167 | +check_absent "no ACTIONABLE return" "ACTIONABLE" "$out" |
| 168 | + |
| 169 | +echo "case: plain status on a settled draft still says ready" |
| 170 | +make_stub |
| 171 | +rc=0; out=$(status) || rc=$? |
| 172 | +check "exits 0" "0" "$rc" |
| 173 | +check "NEXT is ready" "ready" "$(jq -r '.next.action' <<<"$out")" |
| 174 | + |
| 175 | +echo "vocabulary: every emitted action is either actionable or waiting" |
| 176 | +# The waiting set heartbeats instead of returning; everything else must be in |
| 177 | +# ACTIONABLE so the watch can return on it and --ignore-action can name it. |
| 178 | +WAITING="wait await-checks await-review await-capacity rules-unavailable" |
| 179 | +actionable=$(sed -n 's/^ACTIONABLE="\(.*\)"$/\1/p' "$SCRIPT") |
| 180 | +check "ACTIONABLE was found in the script" "yes" "$([ -n "$actionable" ] && echo yes || echo no)" |
| 181 | +emitted=$(grep -oE 'action:"[a-z-]+"' "$SCRIPT" | sed 's/action:"//; s/"$//' | sort -u) |
| 182 | +for a in $emitted; do |
| 183 | + case " $actionable $WAITING " in |
| 184 | + *" $a "*) echo " ok emitted action '$a' is classified" ;; |
| 185 | + *) echo " FAIL emitted action '$a' is neither ACTIONABLE nor waiting"; fail=1 ;; |
| 186 | + esac |
| 187 | +done |
| 188 | +emitted_padded=" ${emitted//$'\n'/ } " |
| 189 | +for a in $actionable; do |
| 190 | + case "$emitted_padded" in |
| 191 | + *" $a "*) echo " ok actionable '$a' is actually emitted" ;; |
| 192 | + *) echo " FAIL actionable '$a' is never emitted — dead vocabulary"; fail=1 ;; |
| 193 | + esac |
| 194 | +done |
| 195 | + |
| 196 | +exit "$fail" |
0 commit comments