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