Skip to content

Commit 3320d13

Browse files
committed
fix(pr-status): make --watch usable on a draft PR
Draft sat near the top of the NEXT ladder and always answered ready, masking conflicts, red checks and open threads on a deliberately parked PR; ready itself was missing from ACTIONABLE, so the watch could neither return on it nor hold through it — --ignore-action refused the name and the loop heartbeated waiting: draft into the timeout. The back-to-draft-on-resume convention (#227) and the watcher were mutually exclusive. Draft now ranks below the real-work branches and above the review and merge advice, mirroring the queue-entry placement: a red check or an open thread surfaces normally, checks actually running report wait (the watch holds), and everything else answers ready — which is ACTIONABLE now, so the watch returns on it and --ignore-action can hold through it. checks_settled is deliberately not the gate for ready: it demands at least one registered context and zero undispatched required ones, which a draft often cannot satisfy (workflows that skip drafts or trigger on ready_for_review, fork runs awaiting approval) — the review round caught that gating on it left ready unreachable there, an endless wait of the very shape this commit removes. investigate joins ACTIONABLE too: it is the terminal check-branch-protection-manually verdict on settled checks, and a watch that heartbeats on it idles into the timeout on a state waiting cannot change (observed 2026-08-26 on #226, where classic-protection CodeQL contexts kept mergeState BLOCKED for over an hour of waiting lines). tests/test_pr_status_draft_watch.sh pins the ladder against a stubbed gh (hold on running checks; return on a thread, a red check, ready; ready reachable with zero registered checks and with a never-dispatched required context; plain status names a red check instead of ready) and pins the action vocabulary against every action literal the script emits. Watched failing against the respective pre-fix states: 9 assertions on the original script, 5 more on the version before the checks_settled correction. Closes #228. Assisted-by: claude-code:claude-fable-5 Agent-Session: https://claude.ai/code/session_01C7S9rbgu5giqCwnzwafrHA Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
1 parent 394c996 commit 3320d13

3 files changed

Lines changed: 233 additions & 5 deletions

File tree

skills/git-workflow/references/pull-request-workflow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1134,7 +1134,7 @@ Draft → Ready for Review → Changes Requested → Approved → Merged
11341134
↑______________|↑_____________________|
11351135
```
11361136
1137-
**Draft is a state the PR returns to, not one it only starts in.** Opening every PR with `--draft` is the well-known half. The half that gets missed: when work resumes on a PR that is already "ready for review" — a rebase, a round of review fixes, another commit of any kind — convert it back *before the first push*, with `gh pr ready --undo <n>` (`glab mr update <iid> --draft`). Mark it ready again as a separate step, once the checks are green and the user has asked for it.
1137+
**Draft is a state the PR returns to, not one it only starts in.** Opening every PR with `--draft` is the well-known half. The half that gets missed: when work resumes on a PR that is already "ready for review" — a rebase, a round of review fixes, another commit of any kind — convert it back *before the first push*, with `gh pr ready --undo <n>` (`glab mr update <iid> --draft`). Mark it ready again as a separate step, once the checks are green and the user has asked for it. Watching the parked PR is compatible with this: `pr-status.sh --watch` holds through the draft while checks run and returns on the first real event — a red check, an open thread — or with `NEXT: ready` once nothing is running any more (with `--ignore-action ready` that same-poll return is labelled `SETTLED` instead); it does not need the PR to leave draft first (#228).
11381138
11391139
The reason is what "ready for review" tells everyone else. It is a standing request for a maintainer's time against a specific head, and mid-work heads do not deserve it: between the first fix commit and the last one, the PR advertises for review a state you already know is incomplete — sometimes one you know is broken, when the work is a response to a reviewer's finding. Reviewers who look during that window spend attention on a diff that is about to change, and a green CI run on an intermediate head reads as an endorsement of work that is not finished. The round-trip is two commands.
11401140

skills/git-workflow/scripts/pr-status.sh

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,12 @@ REPO=""; PR=""; JSON=0; WATCH=0; INTERVAL=20; MAXWAIT=3600; IGNORE=""
132132

133133
# Every action the watch loop returns on. --ignore-action accepts exactly
134134
# these: any other value could never have fired the loop, so accepting one
135-
# would let a typo behave as if the flag were absent.
136-
ACTIONABLE="fix-ci triage-ci resolve-threads request-review rebase resolve-conflicts merge blocked none fix-signatures"
135+
# would let a typo behave as if the flag were absent. The waiting actions
136+
# (wait, await-*, rules-unavailable) heartbeat instead of returning, so they
137+
# are deliberately not in here. tests/test_pr_status_draft_watch.sh pins the
138+
# two lists against every action literal this script can emit — a new action
139+
# must land in one of them.
140+
ACTIONABLE="fix-ci triage-ci resolve-threads request-review rebase resolve-conflicts merge blocked none fix-signatures ready investigate"
137141

138142
die() { printf 'pr-status: %s\n' "$1" >&2; exit 2; }
139143

@@ -585,8 +589,6 @@ evaluate() {
585589
elif ($s.rules_fetched|not) then
586590
{action:"rules-unavailable",
587591
why:"could not read repos/\($s.repo)/rules/branches/\($s.base) — the required-check list is unknown, so no merge verdict is possible from here"}
588-
elif $s.draft then
589-
{action:"ready", why:"draft", cmd:"gh pr ready \($s.number) --repo \($s.repo)"}
590592
elif $s.mergeable == "CONFLICTING" then
591593
{action:"resolve-conflicts", why:"merge conflict with \($s.base)"}
592594
elif $s.mergeState == "BEHIND" then
@@ -601,6 +603,36 @@ evaluate() {
601603
elif $s.unresolved_threads > 0 then
602604
{action:"resolve-threads", why:"\($s.unresolved_threads) unresolved review thread(s)",
603605
threads:$s.threads}
606+
# Draft sits BELOW the branches that report real work — a conflict, a
607+
# stale base, a red check, an open thread all stay worth doing while
608+
# the PR is deliberately parked as draft (the back-to-draft-on-resume
609+
# convention) — and ABOVE every review and merge branch, whose advice
610+
# is meaningless for a draft. While checks still run there is nothing
611+
# to act on yet: report wait, so --watch holds through the parked
612+
# state and returns on the first real event instead of answering
613+
# "ready" on every poll (#228). Only a settled draft is actionable,
614+
# and that action belongs to the operator: mark it ready. (No single
615+
# quotes in this block — the jq program lives in a single-quoted
616+
# shell string, same trap the fix-signatures cmd below notes.)
617+
elif $s.draft then
618+
# checks_settled is deliberately NOT the gate here: it demands at
619+
# least one registered context and zero undispatched required ones,
620+
# which a draft often cannot satisfy — workflows that skip drafts
621+
# or trigger on ready_for_review leave contexts unregistered, and a
622+
# fork draft has runs sitting unapproved. Holding the watch on
623+
# those waits for an event that only readying can produce. So only
624+
# checks actually RUNNING hold the wait; everything else is the
625+
# operator call this state exists for: mark it ready.
626+
(if ($s.checks.pending > 0) then
627+
{action:"wait", why:"draft — \($s.checks.pending) check(s) still running"}
628+
else
629+
{action:"ready",
630+
why:("draft — nothing running, mark ready when the work is done"
631+
+ (if ($s.undispatched|length) > 0
632+
then " (\($s.undispatched|length) required context(s) not reported — dispatch happens on ready, or the runs await approval)"
633+
else "" end)),
634+
cmd:"gh pr ready \($s.number) --repo \($s.repo)"}
635+
end)
604636
# Sits after the branches that report real work (failing checks,
605637
# open threads) — those stay worth doing while queued, and a queue
606638
# entry that fails its own checks is dropped anyway. It sits before
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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

Comments
 (0)