From 6bbd0a536f99430f6760931e912ce4428e8ecd5e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 26 Oct 2026 21:45:33 -0700 Subject: [PATCH] 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) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- CHANGELOG.md | 16 + NOTICE.md | 9 + README.md | 2 +- VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-config | 18 + bin/gstack-doctor-components.sh | 2 + bin/gstack-pr-wait | 32 ++ docs/standalone-binaries.md | 1 + docs/troubleshooting.md | 61 +++ land-and-deploy/SKILL.md | 13 +- land-and-deploy/SKILL.md.tmpl | 13 +- lib/pr-wait-github.ts | 233 +++++++++ lib/pr-wait.ts | 546 ++++++++++++++++++++ lib/result-codes-release.ts | 11 + licenses/MIT-tstack.txt | 21 + package.json | 2 +- test/fixtures/module-size-ratchet.json | 4 +- test/land-and-deploy-ci-gate.test.ts | 11 +- test/pr-wait.test.ts | 665 +++++++++++++++++++++++++ 20 files changed, 1643 insertions(+), 21 deletions(-) create mode 100755 bin/gstack-pr-wait create mode 100644 lib/pr-wait-github.ts create mode 100644 lib/pr-wait.ts create mode 100644 licenses/MIT-tstack.txt create mode 100644 test/pr-wait.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 87263afda..dad90567f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES]` prints one stdout line per real event, prefixed `#`, 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 `/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. diff --git a/NOTICE.md b/NOTICE.md index 274809e6f..8bbaa0804 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -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, diff --git a/README.md b/README.md index 18ac7541b..2fcc6b6ae 100644 --- a/README.md +++ b/README.md @@ -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)** diff --git a/VERSION b/VERSION index faf3b5765..200924bbb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.265.0 +1.91.266.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index e80a8f112..251b28e41 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, diff --git a/bin/gstack-config b/bin/gstack-config index 3e64e6a39..0e239428f 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -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 2>/dev/null || echo "") @@ -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 diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 25662fcbf..ed16ab94f 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -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' ;; diff --git a/bin/gstack-pr-wait b/bin/gstack-pr-wait new file mode 100755 index 000000000..a26655a41 --- /dev/null +++ b/bin/gstack-pr-wait @@ -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 [--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); diff --git a/docs/standalone-binaries.md b/docs/standalone-binaries.md index 245488bdc..7a17a5f7e 100644 --- a/docs/standalone-binaries.md +++ b/docs/standalone-binaries.md @@ -16,6 +16,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that | `gstack-review-log` | **Review-pass receipts** — `--start ` captures the working-tree fingerprint before a diff review; `'' --finish ` 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 ` 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 -- ` 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 [--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 `/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 `, `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. | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index acbbc22dc..94d7e29b7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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)). + +## gstack-pr-wait + +`gstack-pr-wait [--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 `#`; 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. + + +### `# 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: `); or `--timeout` passed (default 60 +minutes) with the condition unmet (`gstack-pr-wait stopped: no green after 60 +minutes; still pending: `). + +**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. + + +### `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: `, 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 ` 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 diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 56f3307e5..69f235213 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -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. diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl index 6243389b1..2590c1fe5 100644 --- a/land-and-deploy/SKILL.md.tmpl +++ b/land-and-deploy/SKILL.md.tmpl @@ -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. diff --git a/lib/pr-wait-github.ts b/lib/pr-wait-github.ts new file mode 100644 index 000000000..fa8eda600 --- /dev/null +++ b/lib/pr-wait-github.ts @@ -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 = { 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; +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 { + const result = runGh(env, ['pr', 'checks', String(pr), '-R', repo, '--json', 'name,bucket,link,description'], [0, 1, 8]); + const checks = new Map(); + 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 { + 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(); + 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 { + 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; + } +} diff --git a/lib/pr-wait.ts b/lib/pr-wait.ts new file mode 100644 index 000000000..557b90906 --- /dev/null +++ b/lib/pr-wait.ts @@ -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; base_oid: string | null; merge_state: string | null } + +export const PR_WAIT_USAGE = `gstack-pr-wait [--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 +#), so a monitor wakes the agent once per event. Progress and recoverable +errors go to stderr. 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: /pr-wait/____.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 [--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(//g, ' ').replace(/<[^>]+>/g, ' ') + .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ' })[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; + private printed = new Set(); + private events = 0; + /** First run for this PR: record what exists and report nothing. */ + baseline: boolean; + private required: Set | null = null; + private prevSnapshot: string | null = null; + private prFiles = new Map>(); + 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, head: string) { + const required = this.required ?? new Set(); + // 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[] { + 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, 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(); + 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(); + } +} diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 5c25f026e..757c11646 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -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; diff --git a/licenses/MIT-tstack.txt b/licenses/MIT-tstack.txt new file mode 100644 index 000000000..eccbd9f1c --- /dev/null +++ b/licenses/MIT-tstack.txt @@ -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. diff --git a/package.json b/package.json index 2c7bfc2f1..94fcb922f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index cb917071b..493bb7de3 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -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" }, diff --git a/test/land-and-deploy-ci-gate.test.ts b/test/land-and-deploy-ci-gate.test.ts index 2f84e1acc..cca23de59 100644 --- a/test/land-and-deploy-ci-gate.test.ts +++ b/test/land-and-deploy-ci-gate.test.ts @@ -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'); }); diff --git a/test/pr-wait.test.ts b/test/pr-wait.test.ts new file mode 100644 index 000000000..54689d8a0 --- /dev/null +++ b/test/pr-wait.test.ts @@ -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[]) => ({ isResolved: resolved, path, line, comments: { nodes: comments } }); + +interface PrShape { + state?: string; head?: string; base?: string; baseOid?: string | null; mergeState?: string; + comments?: ReturnType[]; reviews?: ReturnType[]; threads?: ReturnType[]; +} +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; + behind?: GhResult; +} +function fakeGh(script: Script) { + // Function declarations, not generic arrows: pregate's import scanner reads tests as TSX, where `(x) =>` is a tag. + function queue(value: T | T[] | undefined): T[] { return value === undefined ? [] : Array.isArray(value) ? [...value] : [value]; } + function next(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; +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 = {}): 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 = {}) => { + 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 /pr-wait/____, 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' }, 'Please & fix <this>')], + 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 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(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']); + }); +}); +