v1.91.266.0 feat(pr-wait): gstack-pr-wait event watcher (tstack pr-wait design port) and /land-and-deploy Step 3 wiring (#3495)

* feat(pr-wait): gstack-pr-wait event watcher (tstack pr-wait design port) and /land-and-deploy Step 3 wiring

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pr-wait): keep the result codes in lib/result-codes-release.ts so fixtures that copy the module list keep working

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pr-wait): function declarations for the fake-gh helpers so the pregate TSX import scanner maps the lib files

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pr-wait): /ship review pass 1 — every check gates green, --timeout, bot identity, signal disposition

Red Team and specialist findings on #3495, all fixed in the diff:

- green gates on every check on the head, as gstack-ci-gate does, so
  land-and-deploy Step 2 and Step 3 can no longer disagree while a
  non-required check pends; required checks still gate before they appear
- --timeout MINUTES (default 60, 0 = none) ends a watch that met nothing as
  PR_WAIT_BLOCKED naming the still-pending checks; restores Step 3's cap
- a bot owns logins that start with its name (greptile-apps[bot]), not any
  login containing it; a failed gating-bot check is a failed check, never a
  completed review
- ready: green alone when no gating bot is configured (pr_wait_gating_bots
  none no longer polls forever); held while merge state is UNKNOWN
- BEHIND and DIRTY announced once per head, with the short head in the line
- the bin installs no signal handlers: the synchronous loop never ran them
  and they made the process ignore SIGTERM/SIGINT; default disposition kills
  it at once and the next watcher takes over the dead pid's lock
- a gh spawn timeout (ETIMEDOUT) is retried like any transient failure
- a local fault (unwritable state root) is PR_WAIT_REFUSED exit 2, so exit 1
  always carries a PR_WAIT_BLOCKED line
- control characters in GitHub text never start a second event line

Tests: 21 new cases including a real SIGTERM against the bin, gstack-config
set-time validation and default parity, and the 14 state-matrix cells the
state-machines specialist listed (gstack-state-matrix check: ok, 33/33).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pr-wait): /ship review pass 2 — advisory checks gate too, BEHIND keyed on the head, verdict survives a failed save

- an advisory bot's review is never waited for, but its check gates like any
  other, so gstack-ci-gate (Step 2) and gstack-pr-wait --until green (Step 3)
  read the same check set with no exception
- BEHIND and DIRTY are keyed on the head alone; the pass-1 key also held the
  base oid, so a base that kept moving re-announced BEHIND to a restarted
  watcher on every commit
- finish() keeps the printed verdict's exit code when the state file cannot
  be written; the save error goes to stderr
- PR_WAIT_BLOCKED and PR_WAIT_REFUSED summaries name every exit-1 and exit-2
  path (--timeout, a local error); emitBots docstring matches the gating rule
- CHANGELOG says what a timeout means at land-and-deploy Step 3: a STOP with
  the pending checks named, no wait-more question

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-27 04:45:33 +00:00
1 parent a50cf58965
commit 6bbd0a536f
20 files changed
+1643 -21

No files matched your search

+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [1.91.266.0] - 2026-10-27
### Added
- **`bin/gstack-pr-wait`, `lib/pr-wait.ts`, `lib/pr-wait-github.ts`**: an event-driven pull-request watcher (design ported from tstack's pr-wait, MIT, Theo Browne; NOTICE.md, `licenses/MIT-tstack.txt`). `gstack-pr-wait <pr> [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES]` prints one stdout line per real event, prefixed `#<pr>`, and progress on stderr: a check failed (name and link), all checks finished on the head, a comment, review, thread or reply from someone other than the PR author or the gh user, a review bot finished on the head or a gating bot's summary changed, the base branch moved and its new commits touch this PR's files, merge state BEHIND or DIRTY, merged or closed. `green` gates on every check on the head, as `gstack-ci-gate` does, so Step 2 and Step 3 of `/land-and-deploy` never disagree; a check the base branch requires (rulesets and classic branch protection) is pending until it appears, never green by omission, and two identical polls confirm the check set. `ready` is green plus a gating bot's review of the head (green alone when no gating bot is configured), no unresolved thread, and a known merge state (GitHub's `UNKNOWN` right after a push holds it); a gating bot's failed check is a failed check, never a review. A bot owns the logins that start with its name (`greptile-apps[bot]`, not `notgreptile`) and the checks that mention it. `--timeout` (default 60 minutes, 0 for none) ends a watch that met nothing as blocked, naming the still-pending checks; at `/land-and-deploy` Step 3 that is a STOP showing the line, and the user decides whether to start again with a larger `--timeout` or `--timeout 0`. Exit 0 when the condition is met or the PR merged, 1 when it needs action (`PR_WAIT_BLOCKED`), 2 on bad input, an unreadable PR, a second watcher for the same PR or a local error (`PR_WAIT_REFUSED`; a lock whose pid is dead is taken over), so exit 1 always carries a `PR_WAIT_BLOCKED` line. The loop is synchronous and installs no signal handlers, so Ctrl-C and `kill` stop it at once. State lives under `<state root>/pr-wait/` (lib/state-root.ts chain), and a baseline older than 6 hours is discarded. Transient gh failures (5xx, 429, network, gh's own 60 s spawn timeout) retry with backoff; ten failed polls in a row end the watch as blocked. BEHIND and DIRTY are announced once per head (keyed on the head alone, so a base that keeps moving does not repeat them), with the short head in the line. A verdict whose state file cannot be written keeps its exit code; the save error goes to stderr.
- **`gstack-config` keys `pr_wait_gating_bots` (default `greptile,macroscope`) and `pr_wait_advisory_bots` (default `coderabbit`)**: which review bots `--until ready` waits for and which it only reports; comma-separated names matched against bot logins and check names, `none` empties a list, anything else is rejected with the value kept.
- **Result codes `PR_WAIT_BLOCKED` and `PR_WAIT_REFUSED`** (`lib/result-codes-release.ts`) with their [troubleshooting anchors](docs/troubleshooting.md#gstack-pr-wait); a `gstack-pr-wait` row in docs/standalone-binaries.md.
### Changed
- **`/land-and-deploy` Step 3**: the 4-minute `gstack-ci-gate --wait 240` polling rounds with a 15-minute cap and a wait-more/exclude/stop question are replaced by one `gstack-pr-wait "$PR_NUMBER" --repo "$REPO" --until green` call; exit 0 reruns Step 2's gate on the pinned head, exit 1 stops with the watcher's lines (a red `required=n` check stays the one case Step 3.5 may approve), exit 2 shows the refusal. The Important Rules line about polling intervals names the watcher. The old 15-minute cap becomes the watcher's `--timeout` default of 60 minutes; the wait-more question is gone, a timeout stops Step 3 with the pending checks named. The generated skill is the same size on every pinned host, so no pin moved.
### Tests
- `test/pr-wait.test.ts` (new): the fake-gh scripts for the baseline poll, the six-hour staleness and the restart carry, author/gh-user/bot/edit filtering, merged and closed, a required check pending by omission, a failed required check on the baseline poll, an unknown bucket as failed, two identical polls before green, ready with a gating bot and an advisory bot, unresolved threads and BEHIND blocking ready, the base-overlap event and its silent negative, BEHIND once per head and DIRTY, the configured bot lists and `none`, the per-PR lock (live holder refused, dead holder taken over, released on exit), argument parsing, transient retry and the 403 on classic protection, and the bin's shebang; from the /ship review pass: every check gating green (a pending or red non-required check), a failed gating-bot check, prefix-matched bot logins, `ready` with no gating bot and while the merge state is UNKNOWN, `--timeout`, the ten-failure and non-transient `run()` endings, a gh spawn timeout retried, a local fault as exit 2, control characters in check names, `gstack-config` rejecting malformed bot lists and its defaults matching the lib constants, a real SIGTERM against the bin, and the fourteen state-matrix cells the state-machines specialist listed (ready × fail, any-change × pass/pending/fail, BEHIND and DIRTY × any-change, DIRTY × ready, UNKNOWN × green/ready/any-change, the free and dead-pid unlocks, a restarted watcher on a failed required check and on merged). `test/land-and-deploy-ci-gate.test.ts` asserts Step 3 is one watcher call with the three exit outcomes and no polling prose.
### Gate integrity
- No threshold, timeout, trial count or case kind changed. `test/fixtures/module-size-ratchet.json` registers `lib/pr-wait.ts` and `lib/pr-wait-github.ts` under the existing 800-line cap. `test/fixtures/skill-md-size-allowlist.json` is unchanged: the land-and-deploy render is byte-for-byte the pinned size on every host.
## [1.91.265.0] - 2026-10-27
**Four more principles, and three places gstack stops asking what it could observe (pstack absorb, lane C3, GSTA-124).** Ideas from Lauren Tan's pstack (MIT), written in gstack's words; NOTICE.md and the ETHOS.md credit line say so.
+9
View File
@@ -105,6 +105,15 @@ grew the diff cuts first; the end of the budget is not approval) in
are written in gstack's words and mapped onto gstack's blocking floor. No sentence is copied,
so no license text is carried; this entry is a courtesy.
Design port (`skills/babysit-pr/scripts/pr-wait`, at 7de017b3; license text:
`licenses/MIT-tstack.txt`): `bin/gstack-pr-wait`, `lib/pr-wait.ts` and
`lib/pr-wait-github.ts` follow pr-wait's design (one stdout line per real event,
`--until green|ready|any-change`, exit 0/1/2, required checks from rulesets and
classic protection, two identical polls before green, the base-moved-and-touches-
this-PR event, BEHIND and DIRTY, a per-PR lock, a 6-hour baseline), rewritten in
TypeScript on gstack's bun shebang, state root, result codes and config keys;
no Python line is carried. Each file names the source in its header.
## pstack — Copyright Lauren Tan — MIT License
https://github.com/cursor/plugins (`pstack/skills/interrogate`, `lead-judgment.md`). Ideas,
+1 -1
View File
@@ -202,7 +202,7 @@ The same `/qa` and `/qa-only` commands test CLIs, APIs, jobs, workers and webhoo
| `/ios-qa` | **iOS Live-Device QA (v1.43.0.0+)** — drive a real iPhone or iPad over USB CoreDevice via an embedded `StateServer` in the app. Read Swift source, codegen typed `@Observable` accessors, run the agent loop. Optional `--tailnet` flag exposes the device to OpenClaw or any HTTP-capable agent on your Tailscale tailnet so remote agents can run iOS QA without ever touching the hardware. Capability-tier allowlist (observe/interact/mutate/restore), per-device session lock, audit log. |
| `/ios-fix`, `/ios-design-review`, `/ios-clean`, `/ios-sync` | iOS bug-fix loop, designer's-eye HIG audit, debug-bridge cleanup, and accessor resync. See `docs/skills.md`. End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
Beyond the slash commands, gstack ships standalone CLIs (`gstack-egress`, `gstack-context-bill`, `gstack-model-benchmark`, the review and evidence ledgers, the iOS QA daemon): [docs/standalone-binaries.md](docs/standalone-binaries.md). The fallback browser's per-site [domain skills](docs/domain-skills.md) and raw CDP escape hatch, and the [voice-input aliases](docs/voice-input.md) many skills carry, have their own pages.
Beyond the slash commands, gstack ships standalone CLIs (`gstack-egress`, `gstack-context-bill`, `gstack-model-benchmark`, `gstack-pr-wait`, the review and evidence ledgers, the iOS QA daemon): [docs/standalone-binaries.md](docs/standalone-binaries.md). The fallback browser's per-site [domain skills](docs/domain-skills.md) and raw CDP escape hatch, and the [voice-input aliases](docs/voice-input.md) many skills carry, have their own pages.
**[Deep dives with examples and philosophy for every skill →](docs/skills.md)**
+1 -1
View File
@@ -1 +1 @@
1.91.265.0
1.91.266.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.265.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.266.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+18
View File
@@ -270,6 +270,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
# # so Greptile reviews in parallel (a public repo
# # asks once). false keeps the old order.
# # Invalid values are REJECTED (value kept).
# pr_wait_gating_bots: greptile,macroscope # review bots gstack-pr-wait
# # --until ready waits for (comma-separated; a
# # bot owns logins starting with its name and
# # checks mentioning it).
# pr_wait_advisory_bots: coderabbit # review bots gstack-pr-wait reports but
# # never waits for. Both lists: lowercase names,
# # letters, digits, . _ - only; none empties a list.
# # Invalid values are REJECTED (value kept).
#
# ─── Workspace-aware ship ────────────────────────────────────────────
# workspace_root: $HOME/conductor/workspaces # Where /ship looks for sibling
@@ -353,6 +361,8 @@ lookup_default() {
model_frontier_claude|model_frontier_openai|model_smart_claude|model_smart_openai) echo "" ;; # empty = shipped catalog ID (gstack-models list)
codex_effort) echo "" ;; # empty = each gate's own effort; /codex --effort, then GSTACK_CODEX_EFFORT, win (gstack-codex-probe run-with-timeout --effort)
ship_greptile_early) echo "true" ;; # true | false — /ship's early Greptile PR
pr_wait_gating_bots) echo "greptile,macroscope" ;; # review bots gstack-pr-wait --until ready waits for
pr_wait_advisory_bots) echo "coderabbit" ;; # review bots gstack-pr-wait reports but never waits for
# Unknown key: exit non-zero instead of printing "". The fallback pattern
# the preambles use,
# VAR=$(gstack-config get <key> 2>/dev/null || echo "<default>")
@@ -857,6 +867,14 @@ $_gstack_legacy_root"
echo "Error: ship_greptile_early '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2
exit 1
fi ;;
pr_wait_gating_bots|pr_wait_advisory_bots)
# Comma-separated bot names gstack-pr-wait matches against logins and
# check names. Reject anything else so a stray token never widens or
# empties the gate by accident; the literal none is the empty list.
if ! printf '%s' "$VALUE" | LC_ALL=C grep -qE '^[a-z0-9._-]+(,[a-z0-9._-]+)*$'; then
echo "Error: $KEY '$VALUE' is not a comma-separated list of bot names (lowercase letters, digits, . _ -; e.g. greptile,macroscope) or none. Existing value left unchanged." >&2
exit 1
fi ;;
ship_rerun_backend|test_backend)
if [ "$VALUE" != "local" ] && [ "$VALUE" != "ubicloud" ]; then
echo "Error: $KEY '$VALUE' not recognized. Valid values: local, ubicloud. Existing value left unchanged." >&2
+2
View File
@@ -305,6 +305,8 @@ gstack_result_anchor() {
REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;;
RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;;
RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;;
PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;;
PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;;
APPROVAL_RECORD_INVALID) printf '%s' 'approval-record-invalid' ;;
APPROVAL_RECORD_SECRET) printf '%s' 'approval-record-secret' ;;
APPROVAL_RECORD_NO_BRANCH) printf '%s' 'approval-record-no-branch' ;;
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-pr-wait — watch one pull request and print one line per real event.
*
* gstack-pr-wait <pr> [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES]
*
* Exit 0 when the --until condition is met or the PR merged, 1 when it needs
* action (PR_WAIT_BLOCKED), 2 on bad input, a duplicate watcher or a local
* error (PR_WAIT_REFUSED). The loop is synchronous (spawnSync, Bun.sleepSync),
* so signals keep their default disposition: Ctrl-C and kill end the process
* at once and the next watcher takes over the dead pid's lock. Review-bot lists: `gstack-config set pr_wait_gating_bots`
* and `pr_wait_advisory_bots`. Logic: lib/pr-wait.ts and lib/pr-wait-github.ts.
*
* Design ported from tstack's pr-wait (t3dotgg/tstack, MIT, Copyright (c) 2026
* Theo Browne; NOTICE.md). Docs: docs/troubleshooting.md#gstack-pr-wait
*/
import { spawnGh } from '../lib/ci-gate';
import { prWaitMain } from '../lib/pr-wait';
const code = prWaitMain(process.argv.slice(2), {
gh: spawnGh,
vars: process.env,
pid: process.pid,
now: () => Date.now(),
sleep: ms => Bun.sleepSync(ms),
out: text => process.stdout.write(text),
err: text => process.stderr.write(text),
pidAlive: pid => {
try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; }
},
});
process.exit(code);
+1
View File
@@ -16,6 +16,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
| `gstack-review-log` | **Review-pass receipts** — `--start <skill>` captures the working-tree fingerprint before a diff review; `'<JSON>' --finish <token>` consumes that single-use, repository/branch/skill-scoped receipt. Binding requires matching start/end content and reviewer-reported `completed:true` and `converged:true`; it is not independent proof that a model read the source. `--check-shared-libs <token>` reads a current finding as JSON on stdin and checks prior Skip decisions against the actual branch, capture and eligible raw source blobs. It returns `reusable:false` when proof is missing or unsafe, including older records without logger-versioned coverage. Final review logging computes shared-code fingerprints and coverage rather than trusting supplied proof. |
| `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](skills.md#review-readiness-dashboard). |
| `gstack-evidence` | **Verification-evidence ledger** — `run --label <lane> -- <cmd>` transparently wraps any test command (the child's exit code always passes through) and records what ran against which working-tree fingerprint; `check` grades each label FRESH/STALE/MISSING with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. /ship and /land-and-deploy cite fresh evidence instead of re-running suites. Per-run logs are 0600, capped at 2MB, pruned after 30 days; the ledger and logs stay machine-local by design. |
| `gstack-pr-wait` | **PR event watcher** — `gstack-pr-wait <pr> [--repo owner/name] [--until green\|ready\|any-change] [--interval 20] [--timeout 60]` polls one pull request through `gh` and prints one stdout line per real event (a check failed with its link, all checks finished, a comment or review from someone other than the author, a review bot finished on the head, the base moved and its new commits touch this PR's files, BEHIND or DIRTY, merged or closed), with progress on stderr, so a monitor wakes the agent once per event instead of on a timer. `green` gates on every check on the head (an advisory bot's review is never waited for, its checks gate like any other), as `gstack-ci-gate` does; a check the base branch requires (rulesets and classic branch protection) is pending until it appears, never green by omission; two identical polls confirm a check set is complete. Exit 0 when the condition is met, 1 when it needs action or `--timeout` passes (`PR_WAIT_BLOCKED`), 2 on bad input, a second watcher for the same PR or a local error (`PR_WAIT_REFUSED`). State under `<state root>/pr-wait/`, baseline discarded after 6 hours. Review bots are configuration: `gstack-config set pr_wait_gating_bots greptile,macroscope`, `pr_wait_advisory_bots coderabbit` (advisory bots are reported, never waited on). `/land-and-deploy` Step 3 is one call of it. Design ported from tstack's pr-wait (MIT, Theo Browne; NOTICE.md). [Exit codes](troubleshooting.md#gstack-pr-wait). |
| `gstack-issue-guard` | **Tracker-text trust envelope** — fetches GitHub issue/PR text (`issue <n>`, `pr-body`, `pr-comments`, or `--stdin`) and wraps it in a labeled envelope so agents treat it as data: injection-shaped lines get labeled even through fullwidth and invisible-character evasion, and forged envelope banners are defused. Every tracker-text ingress in gstack routes through it, enforced by a CI scanner. |
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone or iPad over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](howto-ios-testing-with-gstack.md). |
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
+61
View File
@@ -4640,6 +4640,67 @@ conflict-only base merge, a test-only change or release metadata, which the
`carry` object in the carried receipt records with the product patch-id and
the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)).
<a id="gstack-pr-wait"></a>
## gstack-pr-wait
`gstack-pr-wait <pr> [--repo owner/name] [--until green|ready|any-change]
[--interval SECONDS] [--timeout MINUTES]` watches one pull request and prints
one stdout line per real event, prefixed `#<pr>`; progress and recoverable
errors go to stderr. `/land-and-deploy` Step 3 runs it with `--until green`.
Exit 0 means the `--until` condition is met or the PR merged; exit 1 and 2
end with one of the two codes below. `green` gates on every check on the
head, as `gstack-ci-gate` does, so the two never disagree; a check the base
branch requires (rulesets and classic branch protection) counts as pending
until it appears. A 403 on the classic
protection read (a token without admin scope) is logged and skipped, and the
rulesets read still names the required checks. The review-bot lists are
configuration: `gstack-config set pr_wait_gating_bots greptile,macroscope`
and `pr_wait_advisory_bots coderabbit` (`none` empties a list); a bot owns
the logins that start with its name and the checks that mention it, and an
advisory bot's review is never waited for while its checks gate like any
other. The loop
is synchronous, so Ctrl-C and `kill` stop it at once; the lock it leaves is
taken over by the next watcher for that PR once the pid is gone.
<a id="pr-wait-blocked"></a>
### `#<pr> stopped: action needed (PR_WAIT_BLOCKED)`
**Meaning.** Exit 1. The condition cannot be met without a change to the PR:
the lines above it say which. A check failed (name and link; `required check
failed` when the base branch requires it); the merge state is DIRTY (the branch
conflicts with its base); the PR was closed without merging; `--until ready` is
blocked by unresolved review threads or a BEHIND branch; GitHub stayed
unreachable for ten polls, or answered with a non-transient error
(`gstack-pr-wait stopped: <gh message>`); or `--timeout` passed (default 60
minutes) with the condition unmet (`gstack-pr-wait stopped: no green after 60
minutes; still pending: <checks>`).
**Fix.** Act on the named line: fix the failing check and push, rebase or
update the branch, resolve the threads, or read the gh message. After a
timeout, decide whether the pending checks are worth waiting for and start
again, with `--timeout 0` to wait without limit. Then start `gstack-pr-wait`
again; its state file remembers what it already reported, so only new events
print. A watcher that stopped on a gh error restarts cleanly once `gh auth
status` passes.
<a id="pr-wait-refused"></a>
### `gstack-pr-wait: the watcher did not start, or a local error stopped it before a verdict — ... (PR_WAIT_REFUSED)`
**Meaning.** Exit 2, never a verdict on the PR. The target is not a PR number
or URL, `--repo` is not `owner/name`, no repository could be read from the
checkout, the PR cannot be read with the current `gh` login, an option is
unknown, another `gstack-pr-wait` process (the line names its pid) already
watches this PR from the same state root, so a second one would print
duplicate events, or a local error (`a local error stopped the watch before a
verdict: <message>`, an unwritable state root for example) ended the process.
Exit 1 is reserved for `PR_WAIT_BLOCKED`, so a local fault never reads as a
PR that needs action.
**Fix.** Pass the number or URL and `--repo owner/name`, check `gh auth
status`, fix the named local error, or let the running watcher finish. If no
events reach you from the named pid, `kill <pid>` and start again; a lock
whose pid is no longer alive is taken over without that step.
## gstack-error-capture
`gstack-error-capture` runs one target CLI for `/devex-review` Step 3 with no
+7 -6
View File
@@ -694,15 +694,16 @@ readiness is not established.
## Step 3: Wait for CI (if pending)
Poll in 4-minute rounds (300 s shell timeout), 15 minutes max; record the wait:
One watcher call, never your own poll loop; a line per real event, else
silence:
```bash
~/.claude/skills/gstack/bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD" --wait 240
~/.claude/skills/gstack/bin/gstack-pr-wait "$PR_NUMBER" --repo "$REPO" --until green
```
Other verdicts follow Step 2. PENDING at 15 minutes: list the still-pending checks;
ask A) wait 15 more minutes, B) exclude named `required=n` checks (Step 3.5
approves each), or C) stop.
Exit 0: green; rerun Step 2's gate (a moved head is a new push: restart at
Step 1), then Step 3.4. Exit 1 (`PR_WAIT_BLOCKED`): **STOP**, show its lines;
only a red `required=n` check can pass Step 3.5. Exit 2 (`PR_WAIT_REFUSED`): show it.
---
@@ -1032,7 +1033,7 @@ Recheck read-only evidence; never redo a merge/deploy because a section was miss
- Never force-push, bypass CI, replay a confirmed merge, or hide missing evidence.
- Auto-detect facts; ask when unknown or when an explicit approval gate applies.
- Poll at each step's stated interval and deadline (4-minute CI rounds in Step 3; 30 seconds for merge readback and deploy status), with its progress messages.
- Step 3 is one `gstack-pr-wait` call; elsewhere poll at the stated interval (30 s for merge readback and deploy) with progress lines.
- After merge failures, offer approved rollback when appropriate; never revert a rollback automatically.
- Verify once; `/canary` provides extended monitoring. Rechecks require the user's choice.
- Use `--delete-branch`; reconcile failed cleanup non-destructively with confirmation.
+7 -6
View File
@@ -177,15 +177,16 @@ readiness is not established.
## Step 3: Wait for CI (if pending)
Poll in 4-minute rounds (300 s shell timeout), 15 minutes max; record the wait:
One watcher call, never your own poll loop; a line per real event, else
silence:
```bash
~/.claude/skills/gstack/bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD" --wait 240
~/.claude/skills/gstack/bin/gstack-pr-wait "$PR_NUMBER" --repo "$REPO" --until green
```
Other verdicts follow Step 2. PENDING at 15 minutes: list the still-pending checks;
ask A) wait 15 more minutes, B) exclude named `required=n` checks (Step 3.5
approves each), or C) stop.
Exit 0: green; rerun Step 2's gate (a moved head is a new push: restart at
Step 1), then Step 3.4. Exit 1 (`PR_WAIT_BLOCKED`): **STOP**, show its lines;
only a red `required=n` check can pass Step 3.5. Exit 2 (`PR_WAIT_REFUSED`): show it.
---
@@ -513,7 +514,7 @@ Recheck read-only evidence; never redo a merge/deploy because a section was miss
- Never force-push, bypass CI, replay a confirmed merge, or hide missing evidence.
- Auto-detect facts; ask when unknown or when an explicit approval gate applies.
- Poll at each step's stated interval and deadline (4-minute CI rounds in Step 3; 30 seconds for merge readback and deploy status), with its progress messages.
- Step 3 is one `gstack-pr-wait` call; elsewhere poll at the stated interval (30 s for merge readback and deploy) with progress lines.
- After merge failures, offer approved rollback when appropriate; never revert a rollback automatically.
- Verify once; `/canary` provides extended monitoring. Rechecks require the user's choice.
- Use `--delete-branch`; reconcile failed cleanup non-destructively with confirmation.
+233
View File
@@ -0,0 +1,233 @@
/**
* pr-wait-github — the GitHub read side of gstack-pr-wait: a gh runner that
* retries transient failures, the one GraphQL read of a pull request, its
* checks from `gh pr checks`, the required-check names from rulesets and
* classic branch protection, and the base-branch comparisons. The event logic
* is lib/pr-wait.ts; the two are split so each stays under the module-size
* ratchet.
*
* Design ported from tstack's `pr-wait` (t3dotgg/tstack,
* skills/babysit-pr/scripts/pr-wait at 7de017b, MIT, Copyright (c) 2026 Theo
* Browne; NOTICE.md and licenses/MIT-tstack.txt), rewritten in TypeScript on
* gstack's gh runner shape (lib/ci-gate.ts).
*/
import type { GhResult, GhRunner } from './ci-gate';
export class GhError extends Error {
constructor(message: string, readonly transient: boolean) {
super(message);
this.name = 'GhError';
}
}
export interface GhEnv {
gh: GhRunner;
sleep(ms: number): void;
log(message: string): void;
}
export type BucketClass = 'pass' | 'pending' | 'fail';
export interface Actor { login: string; bot: boolean }
export interface Comment { id: string; url: string; body: string; author: Actor }
export interface Review { id: string; url: string; state: string; body: string; commitOid: string | null; author: Actor }
export interface Thread { isResolved: boolean; path: string; line: number | null; comments: Comment[] }
export interface PullRequest {
url: string;
state: string;
headRefOid: string;
baseRefName: string;
mergeStateStatus: string;
baseOid: string | null;
author: string;
viewer: string;
comments: Comment[];
reviews: Review[];
threads: Thread[];
}
export interface Check { name: string; bucket: string; link: string; description: string }
const TRANSIENT = /HTTP (5\d\d|429)|rate limit|timed? ?out|connection (reset|refused)|EOF|temporar|could not resolve host|TLS handshake|Something went wrong/i;
const RETRIES = 5;
const PASS_BUCKETS = new Set(['pass', 'skipping', 'neutral']);
/** Worst bucket first, for collapsing duplicate check names (push and pull_request runs of one job). */
const RANK: Record<BucketClass, number> = { fail: 2, pending: 1, pass: 0 };
export const PR_QUERY = `
query($owner: String!, $name: String!, $number: Int!) {
viewer { login }
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
url state headRefOid baseRefName mergeStateStatus
author { login }
baseRef { target { oid } }
comments(last: 100) { nodes { id url body author { login __typename } } }
reviews(last: 100) {
nodes { id url state body commit { oid } author { login __typename } }
}
reviewThreads(last: 100) {
nodes {
isResolved path line
comments(first: 100) { nodes { id url body author { login __typename } } }
}
}
}
}
}
`;
export function bucketClass(bucket: string): BucketClass {
if (PASS_BUCKETS.has(bucket)) return 'pass';
return bucket === 'pending' ? 'pending' : 'fail';
}
function lastLine(text: string): string {
return text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? '';
}
/** Run gh. A transient failure (HTTP 5xx or 429, network) is retried with doubling backoff. */
export function runGh(env: GhEnv, args: string[], allow: number[] = [0]): GhResult {
let delay = 2_000;
for (let attempt = 1; ; attempt++) {
const result = env.gh(args);
// gh missing is fatal; any other spawn failure (its 60 s timeout, a signal) is a slow API, so retry.
if (result.error && /not installed/.test(result.error)) throw new GhError(result.error, false);
const status = result.status ?? -1;
const transient = status !== 0 && (result.error !== undefined || result.status === null || TRANSIENT.test(result.stderr));
if (!transient && allow.includes(status)) return result;
const message = result.error ?? (lastLine(result.stderr) || `gh exited ${status}`);
if (!transient || attempt === RETRIES) throw new GhError(message, transient);
env.log(`gh ${args.slice(0, 2).join(' ')}: ${message}; retry in ${delay / 1000}s`);
env.sleep(delay);
delay *= 2;
}
}
function ghLines(env: GhEnv, args: string[]): string[] {
return runGh(env, args).stdout.split(/\r?\n/).filter(Boolean);
}
type Json = Record<string, unknown>;
const obj = (value: unknown): Json => (value && typeof value === 'object' ? (value as Json) : {});
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
const nodes = (value: unknown): Json[] => {
const list = obj(value).nodes;
return Array.isArray(list) ? list.map(obj) : [];
};
const actor = (node: Json): Actor => {
const author = obj(node.author);
return { login: str(author.login) || 'ghost', bot: author.__typename === 'Bot' };
};
const comment = (node: Json): Comment => ({ id: str(node.id), url: str(node.url), body: str(node.body), author: actor(node) });
/** The current owner/name of the checkout's GitHub repository. */
export function currentRepo(env: GhEnv): string {
return runGh(env, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']).stdout.trim();
}
/** Confirms the PR exists and is readable; throws GhError otherwise. */
export function confirmPullRequest(env: GhEnv, repo: string, pr: number): void {
runGh(env, ['pr', 'view', String(pr), '-R', repo, '--json', 'number']);
}
export function fetchPullRequest(env: GhEnv, repo: string, pr: number): PullRequest {
const [owner, name] = repo.split('/');
const result = runGh(env, ['api', 'graphql', '-f', `query=${PR_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${pr}`]);
let parsed: Json;
try { parsed = obj(JSON.parse(result.stdout)); } catch {
throw new GhError('gh api graphql printed output that is not JSON', false);
}
const node = obj(obj(obj(parsed.data).repository).pullRequest);
if (!str(node.headRefOid)) {
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(e => str(obj(e).message)).filter(Boolean).join('; ') : '';
throw new GhError(errors || `${repo}#${pr} is not a pull request GitHub returned`, false);
}
return {
url: str(node.url),
state: str(node.state),
headRefOid: str(node.headRefOid),
baseRefName: str(node.baseRefName),
mergeStateStatus: str(node.mergeStateStatus) || 'UNKNOWN',
baseOid: str(obj(obj(node.baseRef).target).oid) || null,
author: str(obj(node.author).login) || 'ghost',
viewer: str(obj(obj(parsed.data).viewer).login),
comments: nodes(node.comments).map(comment),
reviews: nodes(node.reviews).map(r => ({ ...comment(r), state: str(r.state), commitOid: str(obj(r.commit).oid) || null })),
threads: nodes(node.reviewThreads).map(t => ({
isResolved: t.isResolved === true,
path: str(t.path),
line: typeof t.line === 'number' ? t.line : null,
comments: nodes(t.comments).map(comment),
})),
};
}
/**
* Every check on the PR head by name, duplicates collapsed to the worst
* bucket. `gh pr checks` exits 8 while checks run and 1 when one failed:
* those are states, so the JSON is read whatever the exit status.
*/
export function fetchChecks(env: GhEnv, repo: string, pr: number): Map<string, Check> {
const result = runGh(env, ['pr', 'checks', String(pr), '-R', repo, '--json', 'name,bucket,link,description'], [0, 1, 8]);
const checks = new Map<string, Check>();
if (!result.stdout.trim()) {
if (/no checks reported/.test(result.stderr)) return checks;
throw new GhError(lastLine(result.stderr) || `gh pr checks exited ${result.status}`, false);
}
let rows: unknown;
try { rows = JSON.parse(result.stdout); } catch {
throw new GhError('gh pr checks printed output that is not JSON', false);
}
if (!Array.isArray(rows)) throw new GhError('gh pr checks JSON is not a list', false);
for (const raw of rows.map(obj)) {
const name = str(raw.name);
if (!name) continue;
const row: Check = { name, bucket: str(raw.bucket) || 'pending', link: str(raw.link), description: str(raw.description) };
const current = checks.get(name);
if (!current || RANK[bucketClass(row.bucket)] > RANK[bucketClass(current.bucket)]) checks.set(name, row);
}
return checks;
}
/**
* Required check names for the base branch, from rulesets and classic branch
* protection. Reading them up front means a required check that has not
* started yet counts as pending instead of green by omission. A 404 means no
* protection of that kind, which is normal; any other failure is logged and
* that source contributes nothing.
*/
export function requiredChecks(env: GhEnv, repo: string, base: string): Set<string> {
const sources: Array<[string, string]> = [
[`repos/${repo}/rules/branches/${base}`, '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context'],
[`repos/${repo}/branches/${base}/protection/required_status_checks`, '.contexts[]?, .checks[]?.context'],
];
const names = new Set<string>();
for (const [path, jq] of sources) {
try {
for (const name of ghLines(env, ['api', path, '--jq', jq])) names.add(name);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/HTTP 404/.test(message)) env.log(`could not read required checks from ${path}: ${message}`);
}
}
return names;
}
/** Files the PR changes, from `gh pr diff --name-only`. */
export function pullRequestFiles(env: GhEnv, repo: string, pr: number): Set<string> {
return new Set(ghLines(env, ['pr', 'diff', String(pr), '-R', repo, '--name-only']));
}
/** Files the commits between two base-branch heads changed. */
export function compareFiles(env: GhEnv, repo: string, from: string, to: string): string[] {
return ghLines(env, ['api', `repos/${repo}/compare/${from}...${to}`, '--jq', '.files[].filename']);
}
/** How many commits the head is behind the base head, or null when unreadable. */
export function behindBy(env: GhEnv, repo: string, baseOid: string, head: string): string | null {
try {
return runGh(env, ['api', `repos/${repo}/compare/${baseOid}...${head}`, '--jq', '.behind_by']).stdout.trim() || null;
} catch {
return null;
}
}
+546
View File
@@ -0,0 +1,546 @@
/**
* pr-wait — gstack-pr-wait's event logic: watch one pull request and print one
* line per real event on stdout, so a monitor wakes the agent once per event
* and never for a poll that found nothing. Progress goes to stderr. The GitHub
* reads are lib/pr-wait-github.ts.
*
* Design ported from tstack's `pr-wait` (t3dotgg/tstack,
* skills/babysit-pr/scripts/pr-wait at 7de017b, MIT, Copyright (c) 2026 Theo
* Browne; NOTICE.md and licenses/MIT-tstack.txt), rewritten in TypeScript:
* state lives under gstack's state root (lib/state-root.ts) instead of
* ~/.local/state, and the review-bot lists are `gstack-config` keys
* (`pr_wait_gating_bots`, `pr_wait_advisory_bots`) instead of constants.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import type { GhRunner } from './ci-gate';
import {
GhError, behindBy, bucketClass, compareFiles, confirmPullRequest, currentRepo, fetchChecks, fetchPullRequest,
pullRequestFiles, requiredChecks, type Check, type GhEnv, type PullRequest,
} from './pr-wait-github';
import { readConfigKey, resolveStateRoot, type StateRootEnv } from './state-root';
import { resultCodeSuffix, resultLineWithDocs } from './result-codes';
export type Until = 'green' | 'ready' | 'any-change';
export const PR_WAIT_EXIT = { met: 0, blocked: 1, refused: 2 } as const;
export const DEFAULT_GATING_BOTS = ['greptile', 'macroscope'];
export const DEFAULT_ADVISORY_BOTS = ['coderabbit'];
/** CI and deploy bots report through checks; their PR comments repeat that news. */
const STATUS_BOTS = ['github-actions', 'vercel', 'netlify', 'railway', 'cloudflare'];
/** A state file older than this describes an old session: start a fresh baseline instead of replaying it. */
export const STALE_STATE_MS = 6 * 3600 * 1000;
const MAX_FAILED_POLLS = 10;
const MIN_INTERVAL_SECONDS = 5;
/** Minutes before a watch that met nothing ends as PR_WAIT_BLOCKED, naming what is still pending; 0 waits without limit. */
export const DEFAULT_TIMEOUT_MINUTES = 60;
export interface PrWaitEnv {
gh: GhRunner;
vars: StateRootEnv;
pid: number;
now(): number;
sleep(ms: number): void;
out(text: string): void;
err(text: string): void;
pidAlive(pid: number): boolean;
}
export interface PrWaitOptions { target?: string; repo?: string; until: Until; interval: number; timeout: number }
export interface BotLists { gating: string[]; advisory: string[] }
interface State { reported: string[]; summaries: Record<string, string>; base_oid: string | null; merge_state: string | null }
export const PR_WAIT_USAGE = `gstack-pr-wait <pr> [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES]
Watches one pull request and prints one line per real event on stdout (prefixed
#<pr>), so a monitor wakes the agent once per event. Progress and recoverable
errors go to stderr. <pr> is a PR number or URL; without --repo the repository
comes from the URL or the current checkout.
--until
green every CI check on the current head passed, confirmed by two
identical polls; a check the base branch requires counts as
pending until it appears
ready green, a gating review bot reviewed the current head (none
configured: green alone), no review thread is unresolved and the
merge state is known (default)
any-change stop after the first poll that prints an event
--timeout minutes before a watch that met nothing ends as PR_WAIT_BLOCKED,
naming what is still pending (default 60; 0 waits without limit)
Events: a check failed (name and link); all checks finished on the
head (one summary line); a comment, review, thread or thread reply from someone
other than the PR author or the gh user; a review bot finished on the head, or
a gating bot's summary changed afterwards; the base branch moved and the new
commits touch files in this PR; merge state became BEHIND or DIRTY; the PR
merged or closed. Comment edits are never events, and top-level comments from
CI and deploy bots are skipped because checks carry that news.
Review bots: gstack-config set pr_wait_gating_bots greptile,macroscope
gstack-config set pr_wait_advisory_bots coderabbit
A bot owns the logins that start with its name (greptile-apps[bot]) and the
checks that mention it. An advisory bot's review is reported, never waited on;
its checks gate like any other. The value none empties a list.
Exit codes
0 the --until condition is met, or the PR merged
1 the condition needs action (PR_WAIT_BLOCKED): a check failed, the
branch conflicts with its base, the PR closed, ready is blocked by
unresolved threads or a BEHIND branch, GitHub stayed unreachable, or
--timeout passed
2 bad arguments, an unknown PR, another watcher holds this PR, or a local
error stopped the watch before a verdict (PR_WAIT_REFUSED)
Required checks come from rulesets and classic branch protection, so a
required check that has not started yet is pending, never green by omission.
State: <state root>/pr-wait/<owner>__<name>__<pr>.json and .lock; a baseline
older than 6 hours is discarded. Stopping the watcher (Ctrl-C, kill) leaves its
lock, which the next watcher for the PR takes over once the pid is gone.
Docs: docs/troubleshooting.md#gstack-pr-wait`;
export function parsePrWaitArgs(argv: string[]): PrWaitOptions | { help: true } | { error: string } {
const opts: PrWaitOptions = { until: 'ready', interval: 20, timeout: DEFAULT_TIMEOUT_MINUTES };
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
if (flag === '--help' || flag === '-h') return { help: true };
if (flag === '--repo' || flag === '-R') { opts.repo = argv[++i]; continue; }
if (flag === '--until') {
const value = argv[++i];
if (value !== 'green' && value !== 'ready' && value !== 'any-change') return { error: `--until needs green, ready or any-change, not '${value ?? ''}'` };
opts.until = value;
continue;
}
if (flag === '--interval') {
const value = argv[++i] ?? '';
if (!/^[0-9]+$/.test(value)) return { error: `--interval needs a whole number of seconds, not '${value}'` };
opts.interval = Math.max(Number(value), MIN_INTERVAL_SECONDS);
continue;
}
if (flag === '--timeout') {
const value = argv[++i] ?? '';
if (!/^[0-9]+$/.test(value)) return { error: `--timeout needs a whole number of minutes, not '${value}'` };
opts.timeout = Number(value);
continue;
}
if (flag.startsWith('-') && !/^-?\d+$/.test(flag)) return { error: `unknown argument ${flag}` };
if (opts.target !== undefined) return { error: `unexpected argument ${flag}` };
opts.target = flag;
}
return opts;
}
/** The PR number and, when the target is a URL, its repository. */
export function parsePrTarget(raw: string | undefined, repo?: string): { repo?: string; pr: number } | { error: string } {
const text = (raw ?? '').trim();
if (!text) return { error: 'missing PR number. Did a variable expand to nothing? Usage: gstack-pr-wait <pr> [--repo owner/name]' };
const url = /^https?:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)(?:[/?#]\S*)?$/.exec(text);
const number = /^#?(\d+)$/.exec(text);
const pr = url ? Number(url[2]) : number ? Number(number[1]) : 0;
if (!url && !number) return { error: `'${text}' is not a PR number or PR URL` };
if (pr <= 0) return { error: `'${text}' is not a valid PR number` };
const resolved = repo || (url ? url[1] : undefined);
if (resolved !== undefined && !/^[\w.-]+\/[\w.-]+$/.test(resolved)) return { error: `'${resolved}' is not owner/name` };
return { repo: resolved, pr };
}
function botList(value: string | null, fallback: string[]): string[] {
if (value === null) return fallback;
return value.split(',').map(s => s.trim().toLowerCase()).filter(s => s && s !== 'none');
}
/** The review-bot lists, from gstack-config with tstack's defaults; `none` empties a list. */
export function botLists(vars: StateRootEnv): BotLists {
return {
gating: botList(readConfigKey('pr_wait_gating_bots', vars), DEFAULT_GATING_BOTS),
advisory: botList(readConfigKey('pr_wait_advisory_bots', vars), DEFAULT_ADVISORY_BOTS),
};
}
export function stateDir(vars: StateRootEnv): string {
return path.join(resolveStateRoot(vars), 'pr-wait');
}
const plain = (body: string): string => body.replace(/<!--[\s\S]*?-->/g, ' ').replace(/<[^>]+>/g, ' ')
.replace(/&(amp|lt|gt|quot|#39|nbsp);/g, m => ({ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&nbsp;': ' ' })[m] ?? m);
export function snippet(body: string, limit = 100): string {
const text = plain(body).replace(/\s+/g, ' ').trim();
return text.length <= limit ? text : `${text.slice(0, limit - 3)}...`;
}
/** The verdict line of a review bot's summary, if it has one. */
export function verdictLine(body: string): string {
const line = plain(body).split(/\r?\n/).find(l => /verdict|confidence score/i.test(l));
return line ? snippet(line.replace(/\*/g, ''), 80) : '';
}
const digest = (body: string): string => createHash('sha1').update(plain(body).replace(/\s+/g, ' ')).digest('hex').slice(0, 12);
export class Watcher {
readonly stateFile: string;
readonly lockFile: string;
private state: State;
private reported: Set<string>;
private printed = new Set<string>();
private events = 0;
/** First run for this PR: record what exists and report nothing. */
baseline: boolean;
private required: Set<string> | null = null;
private prevSnapshot: string | null = null;
private prFiles = new Map<string, Set<string>>();
private pending: string[] = [];
private readonly gh: GhEnv;
constructor(readonly env: PrWaitEnv, readonly repo: string, readonly pr: number, readonly until: Until, readonly interval: number, readonly bots: BotLists, readonly timeoutMinutes = DEFAULT_TIMEOUT_MINUTES) {
const stem = path.join(stateDir(env.vars), `${repo.replace('/', '__')}__${pr}`);
this.stateFile = `${stem}.json`;
this.lockFile = `${stem}.lock`;
this.gh = { gh: env.gh, sleep: ms => env.sleep(ms), log: message => this.log(message) };
const loaded = this.load();
this.baseline = loaded === null;
this.state = loaded ?? { reported: [], summaries: {}, base_oid: null, merge_state: null };
this.reported = new Set(this.state.reported);
}
private log(message: string): void { this.env.err(`gstack-pr-wait: ${message}\n`); }
private load(): State | null {
try {
if (this.env.now() - fs.statSync(this.stateFile).mtimeMs > STALE_STATE_MS) return null;
const parsed = JSON.parse(fs.readFileSync(this.stateFile, 'utf8'));
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.reported)) return null;
return { reported: parsed.reported, summaries: parsed.summaries ?? {}, base_oid: parsed.base_oid ?? null, merge_state: parsed.merge_state ?? null };
} catch {
return null;
}
}
save(): void {
this.state.reported = [...this.reported].sort();
fs.mkdirSync(path.dirname(this.stateFile), { recursive: true });
const tmp = `${this.stateFile}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(this.state));
fs.renameSync(tmp, this.stateFile);
}
/** Hold the per-PR lock for the life of the process; a live holder means this watcher must not start. */
lock(): { held: string } | null {
fs.mkdirSync(path.dirname(this.lockFile), { recursive: true });
try {
fs.writeFileSync(this.lockFile, String(this.env.pid), { flag: 'wx' });
return null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
}
const holder = fs.readFileSync(this.lockFile, 'utf8').trim();
if (/^\d+$/.test(holder) && Number(holder) !== this.env.pid && this.env.pidAlive(Number(holder))) return { held: holder };
fs.writeFileSync(this.lockFile, String(this.env.pid));
return null;
}
unlock(): void {
try {
if (fs.readFileSync(this.lockFile, 'utf8').trim() === String(this.env.pid)) fs.unlinkSync(this.lockFile);
} catch { /* already gone */ }
}
/** One event is one line: control characters from GitHub text can never start a second one. */
private say(line: string): void {
if (this.printed.has(line)) return;
this.printed.add(line);
this.env.out(`#${this.pr} ${line.replace(/[\x00-\x1f\x7f]+/g, ' ')}\n`);
}
/** Print an event once per PR, across restarts. */
private emit(key: string, line: string): void {
if (this.reported.has(key)) return;
this.reported.add(key);
if (this.baseline) return;
this.events += 1;
this.say(line);
}
private finish(code: number, lines: string[] = []): number {
for (const line of lines) this.say(line);
if (code === PR_WAIT_EXIT.blocked) this.say(`stopped: action needed ${resultCodeSuffix('PR_WAIT_BLOCKED')}`);
// The verdict is printed; a state file that cannot be written must not turn it into a refusal.
try { this.save(); } catch (error) { this.log(`could not save state: ${error instanceof Error ? error.message : String(error)}`); }
return code;
}
/** The configured bot a check name mentions, or (for a login) that the login starts with: greptile-apps[bot] is greptile, notgreptile is nobody. */
private botOf(text: string, login = false): string | null {
const lowered = text.toLowerCase();
return [...this.bots.gating, ...this.bots.advisory].find(bot => login ? lowered.startsWith(bot) : lowered.includes(bot)) ?? null;
}
private isAdvisory(name: string): boolean {
const bot = this.botOf(name);
return bot !== null && this.bots.advisory.includes(bot);
}
private baseOverlap(head: string, from: string, to: string): string[] {
try {
if (!this.prFiles.has(head)) this.prFiles.set(head, pullRequestFiles(this.gh, this.repo, this.pr));
const mine = this.prFiles.get(head)!;
return compareFiles(this.gh, this.repo, from, to).filter(file => mine.has(file)).sort();
} catch (error) {
this.log(`could not compare base commits: ${error instanceof Error ? error.message : String(error)}`);
return [];
}
}
private behindSuffix(base: string, baseOid: string | null, head: string): string {
if (!baseOid) return '';
const count = behindBy(this.gh, this.repo, baseOid, head);
return count === null ? '' : `; ${count} commit${count === '1' ? '' : 's'} behind ${base}`;
}
private evaluateChecks(checks: Map<string, Check>, head: string) {
const required = this.required ?? new Set<string>();
// Every check on the head gates, as gstack-ci-gate does, so land-and-deploy's two gates never
// disagree; a required check gates even before it appears. An advisory bot's review is never
// waited for, but its check is a check like any other.
const watched = checks;
const gate = new Set([...required, ...watched.keys()]);
const bucketOf = (name: string) => checks.get(name)?.bucket ?? 'pending';
const failLines: string[] = [];
for (const name of [...gate].sort()) {
if (bucketClass(bucketOf(name)) !== 'fail') continue;
const line = `${required.has(name) ? 'required check' : 'check'} failed: ${name} ${checks.get(name)?.link ?? ''}`.trimEnd();
failLines.push(line);
this.emit(`fail:${head}:${name}:${checks.get(name)?.link ?? ''}`, line);
}
this.pending = [...gate].filter(name => bucketClass(bucketOf(name)) === 'pending').sort();
const allDone = gate.size > 0 && this.pending.length === 0;
// Checks appear a few seconds after a push: two identical polls in a row
// guard against calling a half-registered set of checks finished.
const snapshot = JSON.stringify([...watched].map(([name, check]) => [name, check.bucket]).sort());
const stable = snapshot === this.prevSnapshot;
this.prevSnapshot = snapshot;
return { watched, failLines, allDone, stable, green: allDone && stable && failLines.length === 0, requiredGreen: [...required].filter(name => bucketClass(bucketOf(name)) === 'pass').length };
}
/** People talking. Edits are never events. Returns the unresolved threads' first-comment URLs. */
private emitConversation(pr: PullRequest, mine: Set<string>): string[] {
for (const c of pr.comments) {
const { login, bot } = c.author;
if (mine.has(login) || this.botOf(login, true) || (bot && STATUS_BOTS.some(b => login.includes(b)))) continue;
this.emit(`c:${c.id}`, `comment from ${login}: ${snippet(c.body)} ${c.url}`);
}
for (const r of pr.reviews) {
if (mine.has(r.author.login) || r.author.bot) continue;
if (r.state === 'APPROVED' || r.state === 'CHANGES_REQUESTED' || r.body.trim()) {
this.emit(`c:${r.id}`, `review from ${r.author.login} (${r.state.toLowerCase().replace(/_/g, ' ')}): ${snippet(r.body)} ${r.url}`);
}
}
const unresolved: string[] = [];
for (const thread of pr.threads) {
if (!thread.isResolved && thread.comments.length > 0) unresolved.push(thread.comments[0].url);
const where = thread.line ? `${thread.path}:${thread.line}` : thread.path;
thread.comments.forEach((c, index) => {
if (mine.has(c.author.login)) return;
this.emit(`c:${c.id}`, `${index === 0 ? 'new thread' : 'thread reply'} from ${c.author.login} on ${where}: ${snippet(c.body)} ${c.url}`);
});
}
return unresolved;
}
/**
* Review bots on the current head. A gating bot with checks is done when they
* pass (an advisory one when they finish); otherwise once it reviewed the head
* commit or its summary names it.
*/
private emitBots(pr: PullRequest, checks: Map<string, Check>, head: string): { status: string[]; reviewed: string[] } {
const short = head.slice(0, 7);
const status: string[] = [];
const reviewed: string[] = [];
for (const bot of [...this.bots.gating, ...this.bots.advisory]) {
const advisory = this.bots.advisory.includes(bot);
const ownChecks = [...checks.values()].filter(c => this.botOf(c.name) === bot);
const summaries = pr.comments.filter(c => this.botOf(c.author.login, true) === bot);
const ownReviews = pr.reviews.filter(r => this.botOf(r.author.login, true) === bot);
const onHead = ownReviews.some(r => r.commitOid === head);
// A bot that reports through checks is done when they pass; a failed gating check is a failed check, never a review.
const done = ownChecks.length > 0
? ownChecks.every(c => bucketClass(c.bucket) === 'pass' || (advisory && bucketClass(c.bucket) !== 'pending'))
: onHead || summaries.some(s => s.body.includes(short));
const seen = ownChecks.length > 0 || summaries.length > 0 || ownReviews.length > 0;
if (advisory && ownChecks.length > 0) status.push(`${bot} ${ownChecks[0].description || ownChecks[0].bucket} (advisory)`);
else if (seen) status.push(`${bot} ${done ? 'done' : 'pending'}${advisory ? ' (advisory)' : ''}`);
const key = `bot:${bot}:${head}`;
const wasDone = this.reported.has(key);
if (done) {
if (!advisory) reviewed.push(bot);
const failed = ownChecks.filter(c => bucketClass(c.bucket) === 'fail').map(c => c.name);
const detail = [summaries.map(s => verdictLine(s.body)).find(Boolean) ?? '', failed.length ? `failed: ${failed.join(', ')}` : ''].filter(Boolean).join('; ');
this.emit(key, `${bot} finished on ${short}${detail ? `: ${detail}` : ''}${advisory ? ' (advisory, not waited on)' : ''}`);
}
if (advisory) continue;
for (const s of summaries) {
const fresh = digest(s.body);
const old = this.state.summaries[s.id];
this.state.summaries[s.id] = fresh;
if (wasDone && old && old !== fresh) this.emit(`summary:${s.id}:${fresh}`, `${bot} summary changed on ${short}: ${verdictLine(s.body) || snippet(s.body)} ${s.url}`);
}
}
return { status, reviewed };
}
/** The base branch moving under the PR, and BEHIND / DIRTY merge states. */
private emitBaseAndMerge(pr: PullRequest, head: string): void {
const base = pr.baseRefName;
const oldBase = this.state.base_oid;
if (pr.baseOid && oldBase && pr.baseOid !== oldBase && !this.baseline) {
const touched = this.baseOverlap(head, oldBase, pr.baseOid);
if (touched.length > 0) {
const more = touched.length > 5 ? ` and ${touched.length - 5} more` : '';
this.emit(`base:${pr.baseOid}`, `${base} moved to ${pr.baseOid.slice(0, 7)} and touches files in this PR: ${touched.slice(0, 5).join(', ')}${more}`);
}
}
if (pr.baseOid) this.state.base_oid = pr.baseOid;
const mergeState = pr.mergeStateStatus;
if (mergeState === 'UNKNOWN') return;
// Keyed on the head alone, so each state is announced once per head: not once per transition, and
// not again for every base commit while the branch stays behind.
if (mergeState === 'BEHIND' || mergeState === 'DIRTY') {
this.emit(`merge:${mergeState}:${head}`, mergeState === 'DIRTY' ? this.dirtyLine(base, head) : this.behindLine(base, head));
}
this.state.merge_state = mergeState;
}
private dirtyLine(base: string, head: string): string { return `merge state is DIRTY on ${head.slice(0, 7)}: the branch conflicts with ${base}, rebase it`; }
private behindLine(base: string, head: string): string { return `merge state is BEHIND on ${head.slice(0, 7)}: update the branch from ${base} before merging`; }
/** One poll. Returns an exit code when the watch ends, null to keep polling. Throws GhError. */
poll(): number | null {
const pr = fetchPullRequest(this.gh, this.repo, this.pr);
if (pr.state === 'MERGED') return this.finish(PR_WAIT_EXIT.met, [`merged: ${pr.url}`]);
if (pr.state === 'CLOSED') return this.finish(PR_WAIT_EXIT.blocked, [`closed without merging: ${pr.url}`]);
const base = pr.baseRefName;
if (this.required === null) {
this.required = requiredChecks(this.gh, this.repo, base);
const gate = [...this.required].sort().join(', ') || 'none set';
this.log(`watching ${this.repo}#${this.pr} until ${this.until}; required checks: ${gate}; every check on the head gates`);
}
const checks = fetchChecks(this.gh, this.repo, this.pr);
const head = pr.headRefOid;
const short = head.slice(0, 7);
this.events = 0;
const ci = this.evaluateChecks(checks, head);
const unresolved = this.emitConversation(pr, new Set([pr.author, pr.viewer]));
const bots = this.emitBots(pr, checks, head);
this.emitBaseAndMerge(pr, head);
const mergeState = pr.mergeStateStatus;
const counts = new Map<string, number>();
for (const check of ci.watched.values()) counts.set(check.bucket, (counts.get(check.bucket) ?? 0) + 1);
const failedNames = [...ci.watched.values()].filter(c => bucketClass(c.bucket) === 'fail').map(c => c.name).sort();
const summary = [
[...counts].sort().map(([bucket, n]) => `${n} ${bucket}`).join(', ') + (failedNames.length ? ` (${failedNames.join(', ')})` : ''),
this.required.size > 0 ? `required ${ci.requiredGreen}/${this.required.size} green` : '',
bots.status.join(', ') || `no ${this.bots.gating.join(' or ') || 'review bot'} yet`,
`${unresolved.length} unresolved threads`,
`merge state ${mergeState}`,
].filter(Boolean).join('; ');
const checksKey = `checks:${head}`;
// A green or ready line carries the same summary as the checks line, so it marks that event as reported.
if (this.until !== 'any-change') {
if (mergeState === 'DIRTY') return this.finish(PR_WAIT_EXIT.blocked, [this.dirtyLine(base, head)]);
if (ci.failLines.length > 0) return this.finish(PR_WAIT_EXIT.blocked, ci.failLines);
if (ci.green && this.until === 'green') {
this.reported.add(checksKey);
return this.finish(PR_WAIT_EXIT.met, [`green on ${short}: ${summary}${this.behindSuffix(base, pr.baseOid, head)}`]);
}
// ready waits for a gating bot, never for an advisory one; with no gating bot configured, green is the review.
if (ci.green && (bots.reviewed.length > 0 || this.bots.gating.length === 0)) {
if (unresolved.length > 0) return this.finish(PR_WAIT_EXIT.blocked, [`blocked by ${unresolved.length} unresolved threads on ${short}: ${unresolved.join(' ')}`]);
if (mergeState === 'BEHIND') return this.finish(PR_WAIT_EXIT.blocked, [this.behindLine(base, head)]);
// GitHub reports UNKNOWN while it recomputes mergeability after a push: not ready until it knows.
if (mergeState === 'UNKNOWN') return null;
this.reported.add(checksKey);
return this.finish(PR_WAIT_EXIT.met, [`ready on ${short}: ${summary}${this.behindSuffix(base, pr.baseOid, head)}`]);
}
}
if (ci.allDone && (ci.stable || this.baseline)) this.emit(checksKey, `checks finished on ${short}: ${summary}`);
if (this.until === 'any-change' && this.events > 0) return this.finish(PR_WAIT_EXIT.met);
return null;
}
/** Poll until the watch ends; returns the exit code. */
run(): number {
let failures = 0;
const deadline = this.timeoutMinutes > 0 ? this.env.now() + this.timeoutMinutes * 60_000 : Infinity;
for (;;) {
try {
const code = this.poll();
if (code !== null) return code;
failures = 0;
this.save();
if (this.baseline) this.log(`baseline recorded for ${this.repo}#${this.pr}; reporting only new events`);
this.baseline = false;
if (this.env.now() >= deadline) {
const still = this.pending.length > 0 ? `still pending: ${this.pending.join(', ')}` : 'nothing pending, the condition was not met';
return this.finish(PR_WAIT_EXIT.blocked, [`gstack-pr-wait stopped: no ${this.until} after ${this.timeoutMinutes} minutes; ${still}`]);
}
} catch (error) {
if (!(error instanceof GhError)) throw error;
if (!error.transient) return this.finish(PR_WAIT_EXIT.blocked, [`gstack-pr-wait stopped: ${error.message}`]);
failures += 1;
this.log(`poll failed (${failures}/${MAX_FAILED_POLLS}): ${error.message}`);
if (failures >= MAX_FAILED_POLLS) return this.finish(PR_WAIT_EXIT.blocked, [`gstack-pr-wait stopped: GitHub unreachable for ${failures} polls: ${error.message}`]);
}
this.env.sleep(this.interval * 1000);
}
}
}
function refuse(env: PrWaitEnv, message: string): number {
// Fatal lines go to stdout so a monitor shows the agent why the watcher stopped.
env.out(`${resultLineWithDocs('gstack-pr-wait', 'PR_WAIT_REFUSED', message)}\n`);
return PR_WAIT_EXIT.refused;
}
/** Resolve the target to a repository and PR number the current gh login can read. */
export function resolveTarget(env: PrWaitEnv, opts: PrWaitOptions): { repo: string; pr: number } | { error: string } {
const target = parsePrTarget(opts.target, opts.repo);
if ('error' in target) return target;
const gh: GhEnv = { gh: env.gh, sleep: ms => env.sleep(ms), log: m => env.err(`gstack-pr-wait: ${m}\n`) };
let repo = target.repo;
if (!repo) {
try { repo = currentRepo(gh); } catch (error) {
return { error: `no --repo given and the current directory has no GitHub repo (${error instanceof Error ? error.message : String(error)})` };
}
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) return { error: `'${repo}' is not owner/name` };
}
try { confirmPullRequest(gh, repo, target.pr); } catch (error) {
// A transient failure is retried by the poll loop.
if (!(error instanceof GhError) || !error.transient) return { error: `cannot read ${repo}#${target.pr}: ${error instanceof Error ? error.message : String(error)}` };
}
return { repo, pr: target.pr };
}
export function prWaitMain(argv: string[], env: PrWaitEnv): number {
const parsed = parsePrWaitArgs(argv);
if ('help' in parsed) {
env.out(`${PR_WAIT_USAGE}\n`);
return PR_WAIT_EXIT.met;
}
if ('error' in parsed) return refuse(env, parsed.error);
const target = resolveTarget(env, parsed);
if ('error' in target) return refuse(env, target.error);
const watcher = new Watcher(env, target.repo, target.pr, parsed.until, parsed.interval, botLists(env.vars), parsed.timeout);
// Exit 1 always carries a PR_WAIT_BLOCKED line, so a local fault (an unwritable state root, a full disk) is a refusal, never a verdict.
try {
const lock = watcher.lock();
if (lock) {
return refuse(env, `pid ${lock.held} already watches ${target.repo}#${target.pr}, so this watcher exits to avoid duplicate events. If no events reach you from it, run \`kill ${lock.held}\` and start again`);
}
return watcher.run();
} catch (error) {
return refuse(env, `a local error stopped the watch before a verdict: ${error instanceof Error ? error.message : String(error)}`);
} finally {
watcher.unlock();
}
}
+11
View File
@@ -249,4 +249,15 @@ export const RELEASE_RESULT_CODES = {
summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix or release metadata), so the reviewed verdict does not cover HEAD',
fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata or the base merge and the product patch-id is unchanged',
},
// gstack-pr-wait (lib/pr-wait.ts): the landing-path PR watcher's two terminal codes.
PR_WAIT_BLOCKED: {
anchor: 'pr-wait-blocked',
summary: 'the --until condition cannot be met without action: a check failed, the branch conflicts with its base, the PR closed, ready is blocked by unresolved threads or a BEHIND branch, GitHub stayed unreachable, or --timeout passed',
fix: 'act on the event lines above it (fix the check, update or rebase the branch, resolve the threads), push, and start gstack-pr-wait again; after a timeout, start again with a larger --timeout or --timeout 0',
},
PR_WAIT_REFUSED: {
anchor: 'pr-wait-refused',
summary: 'the watcher did not start, or a local error stopped it before a verdict',
fix: 'pass a PR number or URL the current gh login can read (--repo owner/name when the checkout is not that repository), stop the watcher the line names before starting another for the same PR, or fix the named local error',
},
} as const satisfies Record<string, ResultCode>;
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Theo Browne
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.265",
"version": "1.91.266",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+3 -1
View File
@@ -159,7 +159,9 @@
{ "file": "lib/ship-queue-train-cli.ts", "movedFrom": "(new: merge-train argument parsing and output, plan GSTA-49 T1)" },
{ "file": "lib/gate-cost.ts", "movedFrom": "(new: paid gate cost by model and token class, per-case costs, selection replays and the merge-train model, GSTA-49 T0)" },
{ "file": "scripts/gate-cost-replay.ts", "movedFrom": "(new: CLI behind lib/gate-cost.ts: cached report artifacts, merged-PR replay and the train model table, GSTA-49 T0)" },
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" }
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" },
{ "file": "lib/pr-wait.ts", "movedFrom": "(new: gstack-pr-wait event logic, GSTA-115 item 1; design ported from tstack pr-wait)" },
{ "file": "lib/pr-wait-github.ts", "movedFrom": "(new: gstack-pr-wait gh reads, GSTA-115 item 1; design ported from tstack pr-wait)" }
],
"residualFiles": [
{ "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" },
+7 -4
View File
@@ -155,14 +155,17 @@ describe('template wiring', () => {
const main = source('SKILL.md.tmpl');
const step = (title: string, next: string) => main.slice(main.indexOf(title), main.indexOf(next));
test('Step 2 and the Step 3 CI wait call the helper, not a --required-only query', () => {
test('Step 2 calls the gate helper and Step 3 is one gstack-pr-wait call, not a polling loop or a --required-only query', () => {
const step2 = step('## Step 2: Pre-merge checks', '## Step 3: Wait for CI');
const step3 = step('## Step 3: Wait for CI', '## Step 3.4');
expect(step2).toContain('bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD"');
expect(step3).toMatch(/bin\/gstack-ci-gate [^\n]*--expect-head "\$PR_HEAD" --wait \d+/);
expect(step3).toContain('bin/gstack-pr-wait "$PR_NUMBER" --repo "$REPO" --until green');
expect(step3.match(/```bash/g)).toHaveLength(1);
expect(`${step2}${step3}`).not.toMatch(/gh pr checks[^\n]*--required/);
expect(step3).not.toContain('--watch');
expect(step3).toContain('still-pending checks');
expect(step3).not.toMatch(/--watch|--wait \d+|4-minute|sleep/);
for (const code of ['Exit 0', 'PR_WAIT_BLOCKED', 'PR_WAIT_REFUSED']) expect(step3).toContain(code);
expect(step3).toMatch(/restart at\s+Step 1/);
expect(main).not.toContain('4-minute');
expect(step2).toContain('never on the exit code');
});
+665
View File
@@ -0,0 +1,665 @@
/**
* gstack-pr-wait (lib/pr-wait.ts, lib/pr-wait-github.ts): one stdout line per
* real event, state under the gstack state root, required checks from rulesets
* so an unstarted one is pending, two identical polls before green, the
* base-moved-and-touches-this-PR event, BEHIND and DIRTY, the per-PR lock, the
* 6-hour baseline staleness and the configurable bot lists. Every GitHub read
* goes through a fake gh runner, as test/gstack-ci-gate.test.ts does.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import type { GhResult } from '../lib/ci-gate';
import { DEFAULT_ADVISORY_BOTS, DEFAULT_GATING_BOTS, PR_WAIT_EXIT, Watcher, botLists, parsePrTarget, parsePrWaitArgs, prWaitMain, stateDir, type PrWaitEnv, type PrWaitOptions } from '../lib/pr-wait';
import { GhError, runGh } from '../lib/pr-wait-github';
import { RESULT_CODES } from '../lib/result-codes';
const ROOT = join(import.meta.dir, '..');
const HEAD = 'a'.repeat(40);
const BASE_A = 'b'.repeat(40);
const BASE_B = 'c'.repeat(40);
const REPO = 'owner/project';
const ok = (stdout: string): GhResult => ({ status: 0, stdout, stderr: '' });
const err = (stderr: string, status = 1): GhResult => ({ status, stdout: '', stderr: `${stderr}\n` });
const NOT_FOUND = err('gh: Not Found (HTTP 404)');
interface Author { login: string; bot?: boolean }
const actor = ({ login, bot }: Author) => ({ login, __typename: bot ? 'Bot' : 'User' });
const comment = (id: string, author: Author, body: string) => ({ id, url: `https://github.com/${REPO}/pull/42#issuecomment-${id}`, body, author: actor(author) });
const review = (id: string, author: Author, state: string, body = '', commit: string | null = HEAD) =>
({ id, url: `https://github.com/${REPO}/pull/42#pullrequestreview-${id}`, state, body, commit: commit ? { oid: commit } : null, author: actor(author) });
const thread = (path: string, line: number | null, resolved: boolean, ...comments: ReturnType<typeof comment>[]) => ({ isResolved: resolved, path, line, comments: { nodes: comments } });
interface PrShape {
state?: string; head?: string; base?: string; baseOid?: string | null; mergeState?: string;
comments?: ReturnType<typeof comment>[]; reviews?: ReturnType<typeof review>[]; threads?: ReturnType<typeof thread>[];
}
const prJson = (shape: PrShape = {}) => JSON.stringify({
data: {
viewer: { login: 'me' },
repository: {
pullRequest: {
url: `https://github.com/${REPO}/pull/42`, state: shape.state ?? 'OPEN', headRefOid: shape.head ?? HEAD,
baseRefName: shape.base ?? 'main', mergeStateStatus: shape.mergeState ?? 'CLEAN', author: { login: 'author' },
baseRef: shape.baseOid === null ? null : { target: { oid: shape.baseOid ?? BASE_A } },
comments: { nodes: shape.comments ?? [] }, reviews: { nodes: shape.reviews ?? [] }, reviewThreads: { nodes: shape.threads ?? [] },
},
},
},
});
const checksJson = (...rows: Array<[string, string] | [string, string, string]>) =>
JSON.stringify(rows.map(([name, bucket, description]) => ({ name, bucket, link: `https://ci.example/${name}`, description: description ?? '' })));
/** Scripted replies per gh command shape; a list is consumed one poll at a time, the last reply repeating. */
interface Script {
pr?: string | string[];
checks?: GhResult | GhResult[];
rulesets?: GhResult;
protection?: GhResult;
prFiles?: GhResult;
compare?: Record<string, GhResult>;
behind?: GhResult;
}
function fakeGh(script: Script) {
// Function declarations, not generic arrows: pregate's import scanner reads tests as TSX, where `<T>(x) =>` is a tag.
function queue<T>(value: T | T[] | undefined): T[] { return value === undefined ? [] : Array.isArray(value) ? [...value] : [value]; }
function next<T>(q: T[]): T | undefined { return q.length > 1 ? q.shift() : q[0]; }
const prs = queue(script.pr).map(ok);
const checks = queue(script.checks);
const calls: string[][] = [];
const gh = (args: string[]): GhResult => {
calls.push(args);
const [a, b] = args;
if (a === 'api' && b === 'graphql') return next(prs) ?? ok(prJson());
if (a === 'pr' && b === 'checks') return next(checks) ?? ok(checksJson());
if (a === 'pr' && b === 'view') return ok('{"number":42}');
if (a === 'pr' && b === 'diff') return script.prFiles ?? ok('');
if (a === 'repo' && b === 'view') return ok(`${REPO}\n`);
if (a === 'api' && b.includes('/rules/branches/')) return script.rulesets ?? NOT_FOUND;
if (a === 'api' && b.includes('/protection/')) return script.protection ?? NOT_FOUND;
if (a === 'api' && b.includes('/compare/')) {
if (args.includes('.behind_by')) return script.behind ?? ok('0\n');
return script.compare?.[b.split('/compare/')[1]] ?? ok('');
}
throw new Error(`fake gh: unexpected ${args.join(' ')}`);
};
return Object.assign(gh, { calls });
}
let dir: string;
let stdout: string[];
let stderr: string[];
let clock: number;
let alive: Set<number>;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'pr-wait-'));
stdout = [];
stderr = [];
clock = 1_700_000_000_000;
alive = new Set([4242]);
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
function env(gh: PrWaitEnv['gh'], pid = 4242, vars: Record<string, string> = {}): PrWaitEnv {
return {
gh, vars: { HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state'), ...vars }, pid,
now: () => clock, sleep: () => {}, out: t => stdout.push(t), err: t => stderr.push(t), pidAlive: p => alive.has(p),
};
}
const watcher = (script: Script, until: PrWaitOptions['until'] = 'ready', vars: Record<string, string> = {}) => {
const e = env(fakeGh(script), 4242, vars);
return new Watcher(e, REPO, 42, until, 20, botLists(e.vars));
};
/** Run polls until one returns a code or the budget is spent; the first poll is the baseline. */
function pollUntilDone(w: Watcher, budget = 6): number | null {
for (let i = 0; i < budget; i++) {
const code = w.poll();
if (code !== null) return code;
w.save();
w.baseline = false;
}
return null;
}
const lines = () => stdout.join('').split('\n').filter(Boolean);
const RULESETS = ok('free-tests\nwindows-free-tests\n');
describe('state lives under the gstack state root', () => {
test('state file and lock are <root>/pr-wait/<owner>__<name>__<pr>, never ~/.local/state', () => {
const w = watcher({});
expect(w.stateFile).toBe(join(dir, 'state', 'pr-wait', 'owner__project__42.json'));
expect(w.lockFile).toBe(join(dir, 'state', 'pr-wait', 'owner__project__42.lock'));
expect(stateDir({ HOME: dir })).toBe(join(dir, '.gstack', 'pr-wait'));
pollUntilDone(w, 1);
expect(existsSync(w.stateFile)).toBe(true);
expect(existsSync(join(dir, '.local'))).toBe(false);
});
test('a state file older than six hours is a fresh baseline: nothing from it is replayed and nothing new is reported on that poll', () => {
const file = join(dir, 'state', 'pr-wait', 'owner__project__42.json');
mkdirSync(join(dir, 'state', 'pr-wait'), { recursive: true });
writeFileSync(file, JSON.stringify({ reported: [], summaries: {}, base_oid: BASE_A, merge_state: 'CLEAN' }));
const old = new Date(clock - 7 * 3600 * 1000);
utimesSync(file, old, old);
const w = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'looks wrong')] }) });
expect(w.baseline).toBe(true);
expect(w.poll()).toBeNull();
expect(lines()).toEqual([]);
const fresh = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'looks wrong')] }) });
expect(fresh.baseline).toBe(true);
});
test('a state file within six hours carries the baseline across a restart, so only what arrived meanwhile prints', () => {
const first = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'first')] }) });
pollUntilDone(first, 1);
expect(lines()).toEqual([]);
clock += 60 * 60 * 1000;
const restarted = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'first'), comment('c2', { login: 'reviewer' }, 'second thought')] }) });
expect(restarted.baseline).toBe(false);
restarted.poll();
expect(lines()).toEqual([expect.stringMatching(/^#42 comment from reviewer: second thought https:/)]);
});
});
describe('events: one stdout line each, progress on stderr', () => {
test('the baseline poll records what exists and prints nothing; the next poll prints only new comments, reviews and thread replies, never the author, the gh user, or edits', () => {
const before = prJson({ comments: [comment('c1', { login: 'reviewer' }, 'old')] });
const after = prJson({
comments: [comment('c1', { login: 'reviewer' }, 'old, edited'), comment('c2', { login: 'author' }, 'mine'), comment('c3', { login: 'me' }, 'also mine'),
comment('c4', { login: 'github-actions', bot: true }, 'deploy preview ready'), comment('c5', { login: 'reviewer' }, '<b>Please</b> &amp; fix &lt;this&gt;')],
reviews: [review('r1', { login: 'reviewer' }, 'CHANGES_REQUESTED', 'nope'), review('r2', { login: 'lurker' }, 'COMMENTED', '')],
threads: [thread('lib/x.ts', 12, false, comment('t1', { login: 'reviewer' }, 'rename this'), comment('t2', { login: 'author' }, 'done'))],
});
const w = watcher({ pr: [before, after] }, 'any-change');
expect(w.run()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual([
'#42 comment from reviewer: Please & fix <this> https://github.com/owner/project/pull/42#issuecomment-c5',
'#42 review from reviewer (changes requested): nope https://github.com/owner/project/pull/42#pullrequestreview-r1',
'#42 new thread from reviewer on lib/x.ts:12: rename this https://github.com/owner/project/pull/42#issuecomment-t1',
]);
expect(stderr.join('')).toContain('watching owner/project#42 until any-change; required checks: none set; every check on the head gates');
expect(stderr.join('')).toContain('baseline recorded for owner/project#42');
});
test('merged ends with exit 0; closed without merging is PR_WAIT_BLOCKED', () => {
expect(pollUntilDone(watcher({ pr: prJson({ state: 'MERGED' }) }))).toBe(0);
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
stdout = [];
expect(pollUntilDone(watcher({ pr: prJson({ state: 'CLOSED' }) }))).toBe(1);
expect(lines()).toEqual(['#42 closed without merging: https://github.com/owner/project/pull/42', expect.stringContaining('stopped: action needed (PR_WAIT_BLOCKED; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#pr-wait-blocked)')]);
});
});
describe('required checks and the green condition', () => {
test('a required check named by the ruleset that has not started is pending, not green by omission', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'])) }, 'green');
expect(pollUntilDone(w, 4)).toBeNull();
expect(lines()).toEqual([]);
expect(stderr.join('')).toContain('required checks: free-tests, windows-free-tests');
});
test('a required check that failed ends the watch at once, naming it with its link, even on the baseline poll', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pending'])) }, 'green');
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 required check failed: free-tests https://ci.example/free-tests', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('an unknown bucket counts as failed, as gstack-ci-gate counts it', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'stale'], ['windows-free-tests', 'pass'])) }, 'green');
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests');
});
test('green needs two identical polls: a check set seen once is not stable', () => {
const w = watcher({ rulesets: RULESETS, checks: [ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'])), ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['late-check', 'pass']))], behind: ok('2\n') }, 'green');
expect(w.poll()).toBeNull();
w.baseline = false;
expect(w.poll()).toBeNull();
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 green on aaaaaaa: 3 pass; required 2/2 green; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN; 2 commits behind main']);
});
test('every check on the head gates, advisory bots\' own included, and a gating bot\'s review on the head makes it ready', () => {
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', 'Confidence score: 4/5')] });
const held = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'pass'], ['coderabbit', 'pending', 'Review in progress'])) });
expect(pollUntilDone(held, 4)).toBeNull();
expect(lines()).toEqual([]);
const w = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'pass'], ['coderabbit', 'pass', 'Reviewed'])) });
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 coderabbit finished on aaaaaaa (advisory, not waited on)', '#42 ready on aaaaaaa: 3 pass; greptile done, coderabbit Reviewed (advisory); 0 unresolved threads; merge state CLEAN; 0 commits behind main']);
});
test('ready is blocked by unresolved threads and by BEHIND, in that order', () => {
const base = { checks: ok(checksJson(['build', 'pass'])), rulesets: NOT_FOUND };
const reviewed = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
expect(pollUntilDone(watcher({ ...base, pr: prJson({ reviews: [reviewed], threads: [thread('a.ts', 1, false, comment('t1', { login: 'reviewer' }, 'hm'))] }) }))).toBe(1);
expect(lines()[0]).toBe('#42 blocked by 1 unresolved threads on aaaaaaa: https://github.com/owner/project/pull/42#issuecomment-t1');
stdout = [];
expect(pollUntilDone(watcher({ ...base, pr: prJson({ reviews: [reviewed], mergeState: 'BEHIND' }) }))).toBe(1);
expect(lines()).toContain('#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging');
});
});
describe('base branch and merge state', () => {
const compareKey = `${BASE_A}...${BASE_B}`;
test('the base moved and its new commits touch files in this PR → one event naming the overlap', () => {
const w = watcher({
pr: [prJson({ baseOid: BASE_A }), prJson({ baseOid: BASE_B })],
prFiles: ok('lib/pr-wait.ts\nREADME.md\n'),
compare: { [compareKey]: ok('lib/pr-wait.ts\nCHANGELOG.md\n') },
}, 'any-change');
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 main moved to ccccccc and touches files in this PR: lib/pr-wait.ts']);
});
test('the base moved without touching this PR → silence, and the new base oid is remembered', () => {
const w = watcher({
pr: [prJson({ baseOid: BASE_A }), prJson({ baseOid: BASE_B })],
prFiles: ok('lib/pr-wait.ts\n'),
compare: { [compareKey]: ok('CHANGELOG.md\n') },
}, 'any-change');
expect(pollUntilDone(w, 3)).toBeNull();
expect(lines()).toEqual([]);
expect(JSON.parse(readFileSync(w.stateFile, 'utf8')).base_oid).toBe(BASE_B);
});
test('a restarted watcher reports a base move that happened while no watcher ran', () => {
const first = watcher({ pr: prJson({ baseOid: BASE_A }) });
first.poll();
first.save();
const restarted = watcher({ pr: prJson({ baseOid: BASE_B }), prFiles: ok('x\n'), compare: { [compareKey]: ok('x\n') } }, 'any-change');
expect(restarted.baseline).toBe(false);
expect(restarted.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 main moved to ccccccc and touches files in this PR: x']);
});
test('BEHIND is an event once per head, again after a push that stays BEHIND; DIRTY ends the watch as blocked', () => {
const D = 'd'.repeat(40);
const behind = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'BEHIND' }), prJson({ mergeState: 'BEHIND' }), prJson({ mergeState: 'UNKNOWN', head: D }), prJson({ mergeState: 'BEHIND', head: D })], checks: ok(checksJson(['build', 'pending'])) }, 'green');
expect(pollUntilDone(behind, 5)).toBeNull();
expect(lines()).toEqual([
'#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging',
'#42 merge state is BEHIND on ddddddd: update the branch from main before merging',
]);
stdout = [];
const dirty = watcher({ pr: prJson({ mergeState: 'DIRTY' }) }, 'green');
expect(dirty.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it');
});
});
describe('review bots are configuration', () => {
const withConfig = (yaml: string) => {
mkdirSync(join(dir, 'state'), { recursive: true });
writeFileSync(join(dir, 'state', 'config.yaml'), yaml);
};
test('defaults are tstack\'s: greptile and macroscope gate, coderabbit is advisory', () => {
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope'], advisory: ['coderabbit'] });
});
test('pr_wait_gating_bots decides which bot\'s review makes the PR ready', () => {
withConfig('pr_wait_gating_bots: macroscope\n');
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '')] });
const w = watcher({ pr, checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'pass']))] });
expect(w.bots).toEqual({ gating: ['macroscope'], advisory: ['coderabbit'] });
expect(pollUntilDone(w, 4)).toBeNull();
expect(lines()).toEqual([expect.stringMatching(/^#42 checks finished on aaaaaaa: 1 pass; no macroscope yet; 0 unresolved threads; merge state CLEAN$/)]);
});
test('none empties a list', () => {
withConfig('pr_wait_advisory_bots: none\n');
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope'], advisory: [] });
});
test('pr_wait_advisory_bots: its review is reported but never waited on, while its check gates like any other', () => {
withConfig('pr_wait_gating_bots: greptile\npr_wait_advisory_bots: coderabbit, Sourcery\n');
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '')] });
const w = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['sourcery-ai', 'pass', 'Reviewed'])) });
expect(w.bots.advisory).toEqual(['coderabbit', 'sourcery']);
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()[0]).toContain('ready on aaaaaaa: 2 pass; greptile done, sourcery Reviewed (advisory)');
});
});
describe('the per-PR lock', () => {
test('a live holder refuses the second watcher with PR_WAIT_REFUSED; a dead holder is taken over and released on exit', () => {
const first = watcher({});
expect(first.lock()).toBeNull();
const second = new Watcher(env(fakeGh({}), 4343), REPO, 42, 'ready', 20, botLists({}));
expect(second.lock()).toEqual({ held: '4242' });
alive.clear();
expect(second.lock()).toBeNull();
expect(readFileSync(second.lockFile, 'utf8')).toBe('4343');
first.unlock();
expect(existsSync(second.lockFile)).toBe(true);
second.unlock();
expect(existsSync(second.lockFile)).toBe(false);
});
test('prWaitMain refuses a duplicate watcher before any poll', () => {
const w = watcher({});
expect(w.lock()).toBeNull();
const gh = fakeGh({});
const code = prWaitMain(['42', '--repo', REPO], env(gh, 9999));
expect(code).toBe(PR_WAIT_EXIT.refused);
expect(lines()).toEqual([expect.stringMatching(/^gstack-pr-wait: the watcher did not start, or a local error stopped it before a verdict — pid 4242 already watches owner\/project#42.*\(PR_WAIT_REFUSED; https:\/\/github\.com\/garrytan\/gstack\/blob\/main\/docs\/troubleshooting\.md#pr-wait-refused\)$/)]);
expect(gh.calls.some(c => c[1] === 'graphql')).toBe(false);
});
});
describe('arguments and the gh layer', () => {
test('targets: number, #number, URL (repo from the URL), --repo override; bad input is refused', () => {
expect(parsePrTarget('42')).toEqual({ repo: undefined, pr: 42 });
expect(parsePrTarget('#42', REPO)).toEqual({ repo: REPO, pr: 42 });
expect(parsePrTarget('https://github.com/garrytan/gstack/pull/3481/files')).toEqual({ repo: 'garrytan/gstack', pr: 3481 });
expect(parsePrTarget('')).toMatchObject({ error: expect.stringContaining('missing PR number') });
expect(parsePrTarget('abc')).toMatchObject({ error: "'abc' is not a PR number or PR URL" });
expect(parsePrTarget('0')).toMatchObject({ error: "'0' is not a valid PR number" });
expect(parsePrTarget('42', 'nope')).toMatchObject({ error: "'nope' is not owner/name" });
expect(parsePrWaitArgs(['42', '--until', 'green', '--interval', '2'])).toEqual({ target: '42', until: 'green', interval: 5, timeout: 60 });
expect(parsePrWaitArgs(['42', '--timeout', '0'])).toMatchObject({ timeout: 0 });
expect(parsePrWaitArgs(['42', '--timeout', 'soon'])).toMatchObject({ error: expect.stringContaining('--timeout needs') });
expect(parsePrWaitArgs(['--until', 'soon'])).toMatchObject({ error: expect.stringContaining('--until needs') });
expect(parsePrWaitArgs(['--bogus'])).toMatchObject({ error: 'unknown argument --bogus' });
expect(parsePrWaitArgs(['--help'])).toEqual({ help: true });
});
test('main: --help exits 0, bad input exits 2 with the code and anchor, an unreadable PR exits 2', () => {
expect(prWaitMain(['--help'], env(fakeGh({})))).toBe(0);
expect(stdout.join('')).toContain('--until');
stdout = [];
expect(prWaitMain([], env(fakeGh({})))).toBe(2);
expect(lines()[0]).toMatch(/missing PR number.*\(PR_WAIT_REFUSED; .*#pr-wait-refused\)$/);
stdout = [];
const gone = (args: string[]): GhResult => (args[1] === 'view' ? err('GraphQL: Could not resolve to a PullRequest with the number of 42. (HTTP 404)') : ok(''));
expect(prWaitMain(['42', '--repo', REPO], env(gone))).toBe(2);
expect(lines()[0]).toContain('cannot read owner/project#42: GraphQL: Could not resolve');
expect(RESULT_CODES.PR_WAIT_REFUSED.anchor).toBe('pr-wait-refused');
expect(RESULT_CODES.PR_WAIT_BLOCKED.anchor).toBe('pr-wait-blocked');
});
test('a transient gh failure is retried with backoff and never ends the watch; a 403 on classic protection is logged, not fatal', () => {
let graphqlCalls = 0;
const base = fakeGh({ checks: ok(checksJson(['build', 'pass'])), protection: err('gh: Resource not accessible by integration (HTTP 403)') });
const flaky = (args: string[]): GhResult => {
if (args[1] === 'graphql' && graphqlCalls++ === 0) return err('gh: Something went wrong (HTTP 502)');
return base(args);
};
const e = env(flaky);
const w = new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars));
expect(w.poll()).toBeNull();
expect(graphqlCalls).toBe(2);
expect(stderr.join('')).toContain('gh api graphql: gh: Something went wrong (HTTP 502); retry in 2s');
expect(stderr.join('')).toContain('could not read required checks from repos/owner/project/branches/main/protection/required_status_checks: gh: Resource not accessible by integration (HTTP 403)');
});
test('the bin runs on the isolated bun shebang and prints the usage', () => {
const result = spawnSync(join(ROOT, 'bin', 'gstack-pr-wait'), ['--help'], { encoding: 'utf8', cwd: ROOT, timeout: 30_000 });
expect(result.status).toBe(0);
expect(result.stdout).toContain('gstack-config set pr_wait_gating_bots greptile,macroscope');
expect(readFileSync(join(ROOT, 'bin', 'gstack-pr-wait'), 'utf8').startsWith('#!/usr/bin/env -S bun --no-env-file --config=/dev/null\n')).toBe(true);
});
});
describe('pass-1 review fixes: gates agree, bounded waits, bot identity, local faults', () => {
function withConfig(yaml: string) {
mkdirSync(join(dir, 'state'), { recursive: true });
writeFileSync(join(dir, 'state', 'config.yaml'), yaml);
}
const GREPTILE = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
test('green gates on every check on the head, as gstack-ci-gate does: a pending non-required check holds it and a red one blocks it', () => {
const pending = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'pending'])) }, 'green');
expect(pollUntilDone(pending, 4)).toBeNull();
expect(lines()).toEqual([]);
const red = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'fail'])) }, 'green');
expect(red.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 check failed: quality-gate https://ci.example/quality-gate', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('a failed bot check is a failed check, gating or advisory, as gstack-ci-gate counts it; never a completed review', () => {
const gating = watcher({ pr: prJson({ reviews: [] }), checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'fail'])) });
expect(pollUntilDone(gating)).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 check failed: greptile-review https://ci.example/greptile-review');
stdout = [];
const advisory = watcher({ pr: prJson({ reviews: [GREPTILE] }), checks: ok(checksJson(['build', 'pass'], ['coderabbit', 'fail'])) });
expect(pollUntilDone(advisory)).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual([
'#42 check failed: coderabbit https://ci.example/coderabbit',
'#42 greptile finished on aaaaaaa',
'#42 coderabbit finished on aaaaaaa: failed: coderabbit (advisory, not waited on)',
expect.stringContaining('(PR_WAIT_BLOCKED;'),
]);
});
test('BEHIND is keyed on the head alone: a base that keeps moving does not repeat the line, even across restarts', () => {
const first = watcher({ pr: [prJson({ mergeState: 'BEHIND', baseOid: BASE_A }), prJson({ mergeState: 'BEHIND', baseOid: BASE_B })], checks: ok(checksJson(['build', 'pending'])), prFiles: ok('x\n'), compare: { [`${BASE_A}...${BASE_B}`]: ok('') } }, 'any-change');
expect(pollUntilDone(first, 3)).toBeNull();
expect(lines()).toEqual([]);
const restarted = watcher({ pr: prJson({ mergeState: 'BEHIND', baseOid: 'e'.repeat(40) }), checks: ok(checksJson(['build', 'pending'])), prFiles: ok('x\n'), compare: {} }, 'any-change');
expect(restarted.baseline).toBe(false);
expect(pollUntilDone(restarted, 3)).toBeNull();
expect(lines()).toEqual([]);
});
test('a verdict keeps its exit code when the state file cannot be written; the save error goes to stderr', () => {
const w = watcher({ pr: prJson({ state: 'MERGED' }) }, 'green');
rmSync(join(dir, 'state'), { recursive: true, force: true });
writeFileSync(join(dir, 'state'), '');
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
expect(stderr.join('')).toContain('could not save state');
});
test('a bot owns logins that start with its name, so a person called notgreptile is a reviewer, not the gating bot', () => {
const human = review('h1', { login: 'notgreptile' }, 'COMMENTED', 'looks fine');
const w = watcher({ pr: [prJson({}), prJson({ reviews: [human] })], checks: ok(checksJson(['build', 'pass'])) });
expect(pollUntilDone(w, 4)).toBeNull();
expect(lines()).toEqual(['#42 review from notgreptile (commented): looks fine https://github.com/owner/project/pull/42#pullrequestreview-h1']);
});
test('ready with no gating bot configured is green plus the thread and merge-state checks', () => {
withConfig('pr_wait_gating_bots: none\n');
const w = watcher({ checks: ok(checksJson(['build', 'pass'])) });
expect(w.bots.gating).toEqual([]);
expect(pollUntilDone(w, 4)).toBe(PR_WAIT_EXIT.met);
expect(lines()[0]).toContain('ready on aaaaaaa: 1 pass; no review bot yet');
});
test('ready waits while the merge state is UNKNOWN and is met once GitHub knows it', () => {
const w = watcher({ pr: [prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'CLEAN', reviews: [GREPTILE] })], checks: ok(checksJson(['build', 'pass'])) });
expect(w.poll()).toBeNull();
w.baseline = false;
expect(w.poll()).toBeNull();
expect(w.poll()).toBeNull();
expect(lines()).toEqual([]);
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()[0]).toContain('merge state CLEAN');
});
test('--timeout ends a watch that met nothing as PR_WAIT_BLOCKED, naming what is still pending; 0 waits without limit', () => {
function timed(timeout: number) {
const e = env(fakeGh({ checks: ok(checksJson(['build', 'pending'], ['docs', 'pass'])) }));
e.sleep = ms => { clock += ms; };
return new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars), timeout);
}
expect(timed(1).run()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 gstack-pr-wait stopped: no green after 1 minutes; still pending: build', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
stdout = [];
const forever = timed(0);
let polls = 0;
const poll = forever.poll.bind(forever);
forever.poll = () => { polls += 1; return polls < 50 ? poll() : PR_WAIT_EXIT.met; };
expect(forever.run()).toBe(PR_WAIT_EXIT.met);
expect(polls).toBe(50);
});
test('run() ends as blocked after ten transient failures, and at once on a non-transient gh error', () => {
const down = (args: string[]): GhResult => (args[1] === 'graphql' ? err('gh: Something went wrong (HTTP 502)') : ok(''));
const e = env(down);
expect(new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars)).run()).toBe(PR_WAIT_EXIT.blocked);
expect(stderr.join('').match(/poll failed \(\d+\/10\)/g)).toHaveLength(10);
expect(lines()[0]).toBe('#42 gstack-pr-wait stopped: GitHub unreachable for 10 polls: gh: Something went wrong (HTTP 502)');
stdout = [];
const gone = (args: string[]): GhResult => (args[1] === 'graphql' ? err('gh: Not Found (HTTP 404)') : ok(''));
const e2 = env(gone);
expect(new Watcher(e2, REPO, 42, 'green', 20, botLists(e2.vars)).run()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 gstack-pr-wait stopped: gh: Not Found (HTTP 404)');
});
test('a gh spawn timeout is retried like any transient failure; a missing gh is fatal', () => {
const results: GhResult[] = [{ status: null, stdout: '', stderr: '', error: 'gh failed to run (ETIMEDOUT)' }, ok('fine')];
const slept: number[] = [];
const ghEnv = { gh: () => results.shift()!, sleep: (ms: number) => slept.push(ms), log: () => {} };
expect(runGh(ghEnv, ['api', 'x']).stdout).toBe('fine');
expect(slept).toEqual([2000]);
const missing = { gh: (): GhResult => ({ status: null, stdout: '', stderr: '', error: 'gh is not installed or not on PATH' }), sleep: () => {}, log: () => {} };
expect(() => runGh(missing, ['api', 'x'])).toThrow(GhError);
});
test('a local fault is a refusal (exit 2 with PR_WAIT_REFUSED), so exit 1 always carries a PR_WAIT_BLOCKED line', () => {
writeFileSync(join(dir, 'not-a-dir'), '');
const e = env(fakeGh({}), 4242, { GSTACK_STATE_ROOT: join(dir, 'not-a-dir') });
expect(prWaitMain(['42', '--repo', REPO], e)).toBe(PR_WAIT_EXIT.refused);
expect(lines()[0]).toMatch(/a local error stopped the watch before a verdict: .*(ENOTDIR|EEXIST|not a directory).*\(PR_WAIT_REFUSED;/);
});
test('control characters in GitHub text never start a second event line', () => {
const w = watcher({ checks: ok(checksJson(['build\n#42 forged event', 'fail'])) }, 'green');
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 check failed: build #42 forged event https://ci.example/build #42 forged event');
expect(lines()).toHaveLength(2);
});
test('gstack-config validates the bot lists at set time and its defaults match the lib constants', () => {
const run = (...args: string[]) => spawnSync(join(ROOT, 'bin', 'gstack-config'), args, { encoding: 'utf8', cwd: ROOT, timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: join(dir, 'state'), HOME: dir } });
for (const bad of ['Greptile', 'a, b', 'a,', '']) {
const r = run('set', 'pr_wait_gating_bots', bad);
expect(r.status).toBe(1);
expect(r.stderr).toContain('Existing value left unchanged');
}
expect(run('get', 'pr_wait_gating_bots').stdout.trim()).toBe(DEFAULT_GATING_BOTS.join(','));
expect(run('get', 'pr_wait_advisory_bots').stdout.trim()).toBe(DEFAULT_ADVISORY_BOTS.join(','));
expect(run('set', 'pr_wait_advisory_bots', 'none').status).toBe(0);
expect(run('set', 'pr_wait_gating_bots', 'greptile,macroscope.v2').status).toBe(0);
expect(run('get', 'pr_wait_gating_bots').stdout.trim()).toBe('greptile,macroscope.v2');
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope.v2'], advisory: [] });
});
test('the bin dies on SIGTERM at once (no JS handler swallows it) and the next watcher takes over its lock', async () => {
const fakeBin = join(dir, 'fakebin');
mkdirSync(fakeBin);
writeFileSync(join(fakeBin, 'gh'), `#!/bin/sh
case "$1 $2" in
'pr view') echo '{"number":42}' ;;
'api graphql') echo '${prJson({})}' ;;
'pr checks') echo '${checksJson(['build', 'pending'])}'; exit 8 ;;
*) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;;
esac`, { mode: 0o755 });
const stateRoot = join(dir, 'state');
const child = spawn(join(ROOT, 'bin', 'gstack-pr-wait'), ['42', '--repo', REPO, '--interval', '5'], { cwd: ROOT, env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, GSTACK_STATE_ROOT: stateRoot, HOME: dir } });
const lockFile = join(stateRoot, 'pr-wait', 'owner__project__42.lock');
const started = Date.now();
while (!existsSync(lockFile) && Date.now() - started < 15_000) await Bun.sleep(100);
expect(existsSync(lockFile)).toBe(true);
child.kill('SIGTERM');
const ended = await Promise.race([
new Promise<string>(resolve => child.on('exit', (code, signal) => resolve(`exit ${code} ${signal}`))),
Bun.sleep(5_000).then(() => 'alive'),
]);
expect(ended).toBe('exit null SIGTERM');
expect(readFileSync(lockFile, 'utf8')).toBe(String(child.pid));
const next = watcher({});
expect(next.lock()).toBeNull();
expect(readFileSync(lockFile, 'utf8')).toBe('4242');
}, 30_000);
});
describe('state matrix cells (gstack-state-matrix: --until × bucket, merge state × --until, lock, baseline × event)', () => {
const GREPTILE = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
test('ready × fail: exit 1 with the failed-check line and PR_WAIT_BLOCKED', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) });
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 required check failed: free-tests https://ci.example/free-tests', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('any-change × pass: silent while nothing is new; one "checks finished" line once the set turns stable, then exit 0', () => {
const w = watcher({ checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'pass']))] }, 'any-change');
expect(w.poll()).toBeNull();
w.baseline = false;
expect(w.poll()).toBeNull();
expect(lines()).toEqual([]);
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual([expect.stringMatching(/^#42 checks finished on aaaaaaa: 1 pass; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN$/)]);
});
test('any-change × pending: silent, keeps polling', () => {
expect(pollUntilDone(watcher({ checks: ok(checksJson(['build', 'pending'])) }, 'any-change'), 4)).toBeNull();
expect(lines()).toEqual([]);
});
test('any-change × fail: the failed check is an event and the watch ends with exit 0, not blocked', () => {
const w = watcher({ checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'fail']))] }, 'any-change');
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 check failed: build https://ci.example/build']);
});
test('BEHIND × any-change and DIRTY × any-change: one merge-state line each, exit 0, no PR_WAIT_BLOCKED', () => {
const behind = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'BEHIND' })], checks: ok(checksJson(['build', 'pending'])) }, 'any-change');
expect(pollUntilDone(behind)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging']);
stdout = [];
const dirty = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'DIRTY' })], checks: ok(checksJson(['build', 'pending'])) }, 'any-change');
expect(pollUntilDone(dirty)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it']);
});
test('DIRTY × ready: exit 1 with the DIRTY line and PR_WAIT_BLOCKED', () => {
const w = watcher({ pr: prJson({ mergeState: 'DIRTY', reviews: [GREPTILE] }), checks: ok(checksJson(['build', 'pass'])) });
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('UNKNOWN × green, UNKNOWN × any-change: no merge-state event; green is decided by the checks alone', () => {
const green = watcher({ pr: prJson({ mergeState: 'UNKNOWN' }), checks: ok(checksJson(['build', 'pass'])) }, 'green');
expect(pollUntilDone(green)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual([expect.stringMatching(/^#42 green on aaaaaaa: 1 pass; no greptile or macroscope yet; 0 unresolved threads; merge state UNKNOWN; 0 commits behind main$/)]);
expect(JSON.parse(readFileSync(green.stateFile, 'utf8')).merge_state).toBeNull();
stdout = [];
expect(pollUntilDone(watcher({ pr: prJson({ mergeState: 'UNKNOWN' }), checks: ok(checksJson(['build', 'pending'])) }, 'any-change'), 4)).toBeNull();
expect(lines()).toEqual([]);
});
test('lock: free × unlock is a no-op; dead-pid × unlock leaves the stale file for the next lock() to take over', () => {
const w = watcher({});
expect(() => w.unlock()).not.toThrow();
expect(existsSync(w.lockFile)).toBe(false);
mkdirSync(join(w.lockFile, '..'), { recursive: true });
writeFileSync(w.lockFile, '999999');
w.unlock();
expect(readFileSync(w.lockFile, 'utf8')).toBe('999999');
expect(w.lock()).toBeNull();
expect(readFileSync(w.lockFile, 'utf8')).toBe('4242');
});
test('restarted × required-check-failed and restarted × merged: the verdict line is printed again by the new process', () => {
const first = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'green');
expect(first.poll()).toBe(PR_WAIT_EXIT.blocked);
stdout = [];
const again = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'green');
expect(again.baseline).toBe(false);
expect(again.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests');
stdout = [];
const merged = watcher({ pr: prJson({ state: 'MERGED' }) }, 'green');
expect(merged.baseline).toBe(false);
expect(merged.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
});
});