mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
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>
This commit is contained in:
1 parent
576839cb8c
commit
57c5b91444
18 files changed
+1330
-18
No files matched your search
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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]` prints one stdout line per real event, prefixed `#<pr>`, and progress on stderr: a required 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. Required checks are read from the base branch's rulesets and classic branch protection, so a required check that has not started is pending rather than green by omission; two identical polls confirm the check set before `green`. 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 or a second watcher for the same PR (`PR_WAIT_REFUSED`; a lock whose pid is dead is taken over). 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) retry with backoff; ten failed polls in a row end the watch as blocked.
|
||||
- **`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-pr-wait.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 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. `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.256.0] - 2026-10-26
|
||||
|
||||
**The /ship review loop converges, and a receipt survives a base merge.** Five Captains' /ship runs hit the review cap on the same PR shape: every pass re-reviewed the whole diff, reviewers answered an empty range with an invented advisory, and each advisory earned another full pass; four more re-ran /ship on product code they had not changed because a conflict-only main merge or a test fix moved the head the receipt named. The loop now fixes blocking findings in the diff on pass 1, reads only the range since the previous pass afterwards, caps at three passes, and counts `passes=<n> blocking_found=<k> deferred=<m>` in the ship receipt; the receipt carries to the new head when the product patch-id is unchanged. The blocking floor is the owner's call (Garry, 2026-10-26: "Ok set it at that"), and the policy lives in gstack rather than in any one team's rules (Garry, 2026-10-26: "Gstack should be the actual holder of how to do things not one off Capy files"): `docs/review-policy.md` is the canonical statement, the skills carry it as their written policy, and a crew rule can be one line pointing at it.
|
||||
|
||||
@@ -86,6 +86,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,
|
||||
|
||||
@@ -200,7 +200,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)**
|
||||
|
||||
|
||||
@@ -270,6 +270,13 @@ 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
|
||||
# # substrings of a bot login or check name).
|
||||
# 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 +360,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 +866,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
|
||||
|
||||
@@ -307,6 +307,8 @@ gstack_result_anchor() {
|
||||
PR_SHOTS_IDENTICAL) printf '%s' 'pr-shots-identical' ;;
|
||||
PR_SHOTS_RENDER_FAILED) printf '%s' 'pr-shots-render-failed' ;;
|
||||
LAUNCH_FILE_UNWRITABLE) printf '%s' 'capy-install-launch-file-unwritable' ;;
|
||||
PR_WAIT_BLOCKED) printf '%s' 'pr-wait-blocked' ;;
|
||||
PR_WAIT_REFUSED) printf '%s' 'pr-wait-refused' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/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]
|
||||
*
|
||||
* Exit 0 when the --until condition is met or the PR merged, 1 when it needs
|
||||
* action (PR_WAIT_BLOCKED), 2 on bad input or a duplicate watcher
|
||||
* (PR_WAIT_REFUSED). 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'; }
|
||||
},
|
||||
}, release => {
|
||||
process.on('exit', release);
|
||||
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
|
||||
process.on(signal, () => { release(); process.exit(signal === 'SIGINT' ? 130 : 143); });
|
||||
}
|
||||
});
|
||||
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]` polls one pull request through `gh` and prints one stdout line per real event (a required 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. Required checks come from rulesets and classic branch protection, so an unstarted required check is pending rather than green by omission; two identical polls confirm a check set is complete. Exit 0 when the condition is met, 1 when it needs action (`PR_WAIT_BLOCKED`), 2 on bad input or a second watcher for the same PR (`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. |
|
||||
|
||||
@@ -4359,6 +4359,53 @@ 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]` 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. Required checks come from the base branch's rulesets and
|
||||
classic branch protection; 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 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 required check failed (name and link; with no
|
||||
required checks configured, every non-bot check gates); 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; or
|
||||
GitHub stayed unreachable for ten polls, or answered with a non-transient
|
||||
error (`gstack-pr-wait stopped: <gh message>`).
|
||||
|
||||
**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. 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 — ... (PR_WAIT_REFUSED)`
|
||||
|
||||
**Meaning.** Exit 2, before any poll. 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, or
|
||||
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.
|
||||
|
||||
**Fix.** Pass the number or URL and `--repo owner/name`, check `gh auth
|
||||
status`, 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);
|
||||
if (result.error) throw new GhError(result.error, false);
|
||||
// A null status is gh killed by a signal (the watcher itself being stopped): retry, never a verdict.
|
||||
const status = result.status ?? -1;
|
||||
const transient = status !== 0 && (result.status === null || TRANSIENT.test(result.stderr));
|
||||
if (!transient && allow.includes(status)) return result;
|
||||
const message = 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;
|
||||
}
|
||||
}
|
||||
+507
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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 }
|
||||
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]
|
||||
|
||||
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 required checks passed on the current head (every CI check when
|
||||
the base branch requires none), confirmed by two identical polls
|
||||
ready green, a gating review bot reviewed the current head, and no
|
||||
review thread is unresolved (default)
|
||||
any-change stop after the first poll that prints an event
|
||||
|
||||
Events: a required 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
|
||||
Advisory bots are reported but never waited on; 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 required check failed,
|
||||
the branch conflicts with its base, the PR closed, ready is blocked by
|
||||
unresolved threads or a BEHIND branch, or GitHub stayed unreachable
|
||||
2 bad arguments, an unknown PR, or another watcher holds this PR
|
||||
(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. Docs: docs/troubleshooting.md#gstack-pr-wait`;
|
||||
|
||||
export function parsePrWaitArgs(argv: string[]): PrWaitOptions | { help: true } | { error: string } {
|
||||
const opts: PrWaitOptions = { until: 'ready', interval: 20 };
|
||||
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.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 readonly gh: GhEnv;
|
||||
|
||||
constructor(readonly env: PrWaitEnv, readonly repo: string, readonly pr: number, readonly until: Until, readonly interval: number, readonly bots: BotLists) {
|
||||
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 */ }
|
||||
}
|
||||
|
||||
private say(line: string): void {
|
||||
if (this.printed.has(line)) return;
|
||||
this.printed.add(line);
|
||||
this.env.out(`#${this.pr} ${line}\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')}`);
|
||||
this.save();
|
||||
return code;
|
||||
}
|
||||
|
||||
private botOf(text: string): string | null {
|
||||
const lowered = text.toLowerCase();
|
||||
return [...this.bots.gating, ...this.bots.advisory].find(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>();
|
||||
const gate = required.size > 0 ? required : new Set([...checks.keys()].filter(name => !this.botOf(name)));
|
||||
const label = required.size > 0 ? 'required check' : 'check';
|
||||
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 = `${label} failed: ${name} ${checks.get(name)?.link ?? ''}`.trimEnd();
|
||||
failLines.push(line);
|
||||
this.emit(`fail:${head}:${name}:${checks.get(name)?.link ?? ''}`, line);
|
||||
}
|
||||
const watched = new Map([...checks].filter(([name]) => !this.isAdvisory(name)));
|
||||
const gateDone = gate.size > 0 && [...gate].every(name => bucketClass(bucketOf(name)) !== 'pending');
|
||||
const allDone = gateDone && [...watched.values()].every(check => bucketClass(check.bucket) !== 'pending');
|
||||
// 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: gateDone && 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) || (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 bot with checks is done 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) === bot);
|
||||
const ownReviews = pr.reviews.filter(r => this.botOf(r.author.login) === bot);
|
||||
const onHead = ownReviews.some(r => r.commitOid === head);
|
||||
const done = ownChecks.length > 0 ? ownChecks.every(c => 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;
|
||||
if ((mergeState === 'BEHIND' || mergeState === 'DIRTY') && this.state.merge_state !== mergeState) {
|
||||
this.emit(`merge:${mergeState}:${head}:${pr.baseOid ?? ''}`, mergeState === 'DIRTY' ? this.dirtyLine(base) : this.behindLine(base));
|
||||
}
|
||||
this.state.merge_state = mergeState;
|
||||
}
|
||||
|
||||
private dirtyLine(base: string): string { return `merge state is DIRTY: the branch conflicts with ${base}, rebase it`; }
|
||||
private behindLine(base: string): string { return `merge state is BEHIND: 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, so every CI check gates';
|
||||
this.log(`watching ${this.repo}#${this.pr} until ${this.until}; required checks: ${gate}`);
|
||||
}
|
||||
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)]);
|
||||
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.
|
||||
if (ci.green && bots.reviewed.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)]);
|
||||
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;
|
||||
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;
|
||||
} 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, onLocked?: (release: () => void) => void): 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));
|
||||
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`);
|
||||
}
|
||||
onLocked?.(() => watcher.unlock());
|
||||
try {
|
||||
return watcher.run();
|
||||
} finally {
|
||||
watcher.unlock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Result codes for gstack-pr-wait (lib/pr-wait.ts). Kept beside
|
||||
* lib/result-codes.ts, which spreads this table into RESULT_CODES, so the owner
|
||||
* module stays within the module-size ratchet; the same rules apply: one stable
|
||||
* docs/troubleshooting.md anchor, a summary and a fix clause each.
|
||||
*/
|
||||
import type { ResultCode } from './result-codes';
|
||||
|
||||
export const PR_WAIT_RESULT_CODES = {
|
||||
PR_WAIT_BLOCKED: {
|
||||
anchor: 'pr-wait-blocked',
|
||||
summary: 'the --until condition cannot be met without action: a required check failed, the branch conflicts with its base, the PR closed, ready is blocked by unresolved threads or a BEHIND branch, or GitHub stayed unreachable',
|
||||
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',
|
||||
},
|
||||
PR_WAIT_REFUSED: {
|
||||
anchor: 'pr-wait-refused',
|
||||
summary: 'the watcher did not start',
|
||||
fix: 'pass a PR number or URL the current gh login can read (--repo owner/name when the checkout is not that repository), or stop the watcher the line names before starting another for the same PR',
|
||||
},
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
import { RELEASE_RESULT_CODES } from './result-codes-release';
|
||||
import { CAPY_RESULT_CODES } from './result-codes-capy';
|
||||
import { PR_WAIT_RESULT_CODES } from './result-codes-pr-wait';
|
||||
|
||||
export interface ResultCode {
|
||||
/** Stable `<a id>` in docs/troubleshooting.md. */
|
||||
@@ -770,6 +771,7 @@ export const RESULT_CODES = {
|
||||
},
|
||||
...RELEASE_RESULT_CODES,
|
||||
...CAPY_RESULT_CODES,
|
||||
...PR_WAIT_RESULT_CODES,
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
|
||||
export type ResultCodeName = keyof typeof RESULT_CODES;
|
||||
|
||||
@@ -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.
|
||||
+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,395 @@
|
||||
/**
|
||||
* 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 { spawnSync } from 'node:child_process';
|
||||
import type { GhResult } from '../lib/ci-gate';
|
||||
import { PR_WAIT_EXIT, Watcher, botLists, parsePrTarget, parsePrWaitArgs, prWaitMain, stateDir, type PrWaitEnv, type PrWaitOptions } from '../lib/pr-wait';
|
||||
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) {
|
||||
const queue = <T>(value: T | T[] | undefined): T[] => (value === undefined ? [] : Array.isArray(value) ? [...value] : [value]);
|
||||
const prs = queue(script.pr).map(ok);
|
||||
const checks = queue(script.checks);
|
||||
const next = <T>(q: T[]): T | undefined => (q.length > 1 ? q.shift() : q[0]);
|
||||
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, so every CI check 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('with no required checks every CI check gates except the review bots\' own, 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 w = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'pass'], ['coderabbit', 'pending', 'Review in progress'])) });
|
||||
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 ready on aaaaaaa: 2 pass; greptile done, coderabbit Review in progress (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: 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; DIRTY ends the watch as blocked', () => {
|
||||
const behind = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'BEHIND' })], checks: ok(checksJson(['build', 'pending'])) }, 'green');
|
||||
expect(pollUntilDone(behind, 4)).toBeNull();
|
||||
expect(lines()).toEqual(['#42 merge state is BEHIND: 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: 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 is reported but never waited on', () => {
|
||||
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', 'pending', 'Reviewing'])) });
|
||||
expect(w.bots.advisory).toEqual(['coderabbit', 'sourcery']);
|
||||
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()[0]).toContain('ready on aaaaaaa: 1 pass; greptile done, sourcery Reviewing (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 — 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 });
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user