mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
* fix(ship): base merges, adjacent base edits, test-only and audit-requested fixes no longer force a second review loop (GST-407, GST-384, GST-391) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship driver: trim two phrases for headroom with #3662 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship driver: keep the Next steps heading phrase the handoff check reads; trim elsewhere Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: regenerate ship goldens Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes comparePath returns same only when every hunk sits where the base change puts it (patch-ids ignore positions) and treats a file-mode change as changed; the scoped-review packet says when a fence is truncated; --docs-result must be the posted audit's result; the --context-reviewed record goes beside the packet, outside the worktree. Negative controls for each. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pass-2 review fixes A test edit routes as tests= only when it adds lines with no gate tag; a removed or changed line or an unreadable test diff goes through the full loop. Diffs skip textconv. A hunkless patch (binary, empty, mode-only) is same when its patch-id matches. Without --reviewed-base test files route as before. finish's packet path and attempt's state directory have fallbacks. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes; a version-only stamp_files edit after review is release metadata (GBRA-251) gateEditedPaths reads removed lines only inside hunks; carry refuses when a path listing fails; docs freshness stops on a failed listing; attempt counts an unreadable state file. releasePaths includes stamp_files paths whose edit is a version swap under stamp_patterns. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: no test edit skips review; guard packet writes; exclusive attempt state A test-only edit with no gate tag gets the scoped fix-check of its test hunks instead of a lanes-only rerun. Packet write failures print a STOP or RECEIPT_CARRY_REFUSED. attempt's allowance uses an exclusive create. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: attempt prints gstack's install command; carry checks the product diff outside context paths The attempt STOP no longer echoes the audit child's fix text. A scoped-review carry now requires the product patch-id without the context paths to match at both heads. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: oversized packets take the full loop; exact --docs-requested match; name-only test globs only in test dirs A scoped-review packet section over 200 lines sends that path to the full loop instead of a truncated packet. The docs-result path match is exact. test_*.py, *Test.java and similar count as test code only under a test directory. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(unattended): carry's scoped-review refusal and what it keeps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry's scoped-review route in ship-policy, review-policy and troubleshooting (docs-a-1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): curate ship-rereview-triggers out of the Windows lane like ship-release (run 41193336209) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review-log): record a notes-only /ship pass clean; name the cause and repair when a pass's tree moved (GST-430) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(receipt): carry and write handle PRs with thousands of files; tree receipt fails closed on a failed diff (GST-448) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): preflight routes RECEIPT_CARRY_SCOPED_REVIEW to the scoped review; carry --dry-run writes no packet (GST-442) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship): Step 1.4 and Step 16 route the scoped-review refusal and the --docs-requested fix; trims keep the cap (GST-442, GST-444) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: /ship review pass 1: notes-only floor, scoped carry order, named temp and diff refusals - A notes-only pass is recorded clean only when every finding is fixed, or noted or deferred below the blocking floor (never CRITICAL or blocking), by its logger-stamped action, and the findings account for the claimed issue and critical counts (GST-430). - The preflight scoped carry route commits and pushes the base-merge fix before writing the packet (GST-442). - patchId refuses by name on an unwritable TMPDIR; the tree receipt names why its diff failed (GST-448). - Step 16 stage 3 spells out --docs-requested with its --docs-result (GST-444). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: pin the Step 16 --docs-requested route with its --docs-result Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a claimed clean review row with a logger-stamped open gate finding reads UNVERIFIED (red-team, pass 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 findings: gate rows from --findings are stamped, one open rule, stale patch-id dirs swept - deriveFromFindings stamps gate rows with the session kind before deriving status, so a findings file cannot dispose of a gate finding the unattended logger forces open (outside-voice#1). - reviewFreshness reads a claimed row's findings with isOpenByAction, the rule the notes-only rewrite uses; noted and deferred clear a finding only when it is marked blocking: false and is not CRITICAL. - patchId sweeps gstack-patch-id-* directories over an hour old that a killed carry left in TMPDIR. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 round 2: stamped-open gates win over status and disposition; resolved gate, skipped and below-floor actions stay CURRENT - isOpenFinding treats action 'open' (the logger's forced-open stamp) as open before a row's status or disposition. - reviewFreshness flags a claimed clean row only for an 'open' action or a CRITICAL or blocking deferral or note, so interactive kept and restored gates, skipped advisories and INFORMATIONAL deferrals read CURRENT as before. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: carry dry run offers the scoped route last; patch-id diff under the git dir; changelog matches the review-log floor Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a claimed clean review row with any unresolved non-gate CRITICAL finding reads UNVERIFIED (pass 2 red-team) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry dry-run scoped route, patch-id directory, tree-receipt refusal (docs-d-1 audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): Step 3 names merge conflicts in its STOP sentence again (ship-workflow-clarity pin) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(tree-receipt): keep the gated-to-current diff at the file helper timeout (60 s); pregate literals check Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(carry): large-diff fixture sets core.longpaths so Git for Windows can add its long receipt paths (windows-free-shard 1, run 41210080055) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): a stale local base branch yields to its origin twin in gate-diff and diff-scope (GST-462) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): Step 11 saves the targeted record and the receipt writes carry it; Step 9 clears it (GST-464) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GST-464 and GST-462 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): pass 1: ahead and diverged labels, diff-scope needs shared history; preflight refreshes origin/<base> and stops on a diff far beyond the PR (GST-462, HARN-15) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pass-2 review fixes for base twin, prSized stop and targeted record Resolve the tracking twin by refs/remotes/<remote>/<ref> and only for a bare branch name, keep a local base that is ahead or diverged, quote the prSized stop's base and name the fork case, and have every Step 9 entry create, exclude and clear .gstack/tmp/targeted.json. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: origin's tracking base wins whenever related; prSized lower bound The fix-check found that keeping a local base ahead of origin left unpushed base commits out of the gate scan while review-plan listed them. The tracking branch now wins whenever it shares history with HEAD, an explicit origin/main reads refs/remotes/origin/main, the preflight also stops when the diff lists fewer files than the PR, and the Step 9 exclude line survives a missing .git/info and an exclude file without a final newline. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: the tracking-ref claim holds while the tracking branch exists Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: repin the two ship section briefs under no-loss replay GST-464 GST-464 brief-identity check: every reviewer brief lib/review-brief.ts cuts from ship/sections/review-army.md and adversarial.md stays byte-identical to the bodies the L and GST-257 replays admitted; only lead instructions outside the cut spans changed. The check is a test CI runs. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: keep Step 9's PASS_HEAD sentence whole; repin review-army under GST-464 The targeted-record line follows the PASS_HEAD/RANGE_BASE sentence instead of splitting it (test/review-fix-range.test.ts pins that sentence). The brief-body check still holds: every reviewer brief is byte-identical. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: restore bin/gstack-ship-receipt after the main merge The merge commitb10e2dbwrote a truncated copy of the file. This is the three-way merge of both sides with the refused() union: main's codes plus RECEIPT_CARRY_SCOPED_REVIEW. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: narrow the consumer scan and give it one 300 s deadline (GST-544) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: keep the consumer scan deadline under the 180 s plan timeout (review fix) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: plain paths under --literal-pathspecs; whole-millisecond timeout override (review fix) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: whole-tree grep for non-UTF-8 names, no argument spread, name spawn errors (review fix) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: cap the consumer grep output at 256 MB across calls; per-scan deadline wording (review fix, cycle 4) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-collect: read a missing spawn status as null (Bun 1.4.2 returns undefined on ENOENT) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: review-collect whole-tree helper reads a null status as -1 (typecheck:test ratchet) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-bots: /review and /ship triage every configured review bot, not only Greptile (#3122) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (gh before 2.48 pages without --slurp; driver routes Step 10; tests for guards, review-bot fence, REMOTE_SLUG owner) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: drop the review-bot fence test that could not fail (the tool-fence WARN degrade is unreachable: placeholder spans are skipped first) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes (record by comment id so a filename never reaches a shell; lookup failures are errors; tojson pages; longest bot prefix wins) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pass-2 review fixes (record refuses * paths, colliding ids and a missing fetch dir; parent requires id and dir; botOwning doc) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: classify the PR type and always check repo conventions (#356) The checklist now opens with a PR-type line (application, CI/infra, scripts/tooling, docs-only or mixed). Application and mixed diffs run every category; other types always run five categories, including the new Repo Conventions & Platform Fit, and report the rest as not applicable in one line instead of skipping silently. No-loss replay: GSTA-224 H1 preregistered no-loss run (run id gsta224-h1-20261031, Opus 5.5, 81 calls, $9.53) against main's checklist with a no-gstack arm; every preregistered bar passed. The review/checklist.md pin in test/review-packets-removed.test.ts moves to a32963ef with replay 'GSTA-224 H1'. Refs GSTA-224 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review-bots: allowlist the record placeholders and match Step 10's id rule in tests The record fence uses <comment-id>, <fix|fp|already-fixed> and <category>, each with an enum or numeric grammar; <greptile-dir> becomes <bot-dir>. review/greptile-triage.md also lists every REVIEW_BOTS skip reason. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: review-bot triage covers every configured bot (#3122) README, CONTRIBUTING, the contributor reference and standalone-binaries name gstack-review-bots and the review-bot fence; TODOS drops the #3122 item; the changelog fragment covers record-by-id and lookup errors. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the PR-type test also pins the compose, Terraform and README/CHANGELOG phrases Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: follow the review-bot rename in the remote free tier's three reds The cross-skill consistency judge describes review-bot-history.md and its record helper; the tracker-guard exemption for the removed raw Greptile fetch in review/greptile-triage.md is dropped (the fetch now lives in gstack-review-bots, whose bodies still reach context only through gstack-issue-guard); the Step 10 PR-check test expects 'Review bots: not run'. HARNESS_VERSION stays 6: skill-fixture.ts and llm-judge.ts are recorded non-behavioral for every other case. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep the cross-skill judge's rubric to one renamed bullet Name review-bot-history.md beside the older greptile-history.md so the calibration corpus still matches, and keep the REMOTE_SLUG bullet. One live 3-sample panel at this tree: consistent 3/3, scores 4/4/4. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: H1 fragment passes prose lint Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: name the H1 fragment after its branch; TODOS drops #356 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: /review's PR type step in docs/skills.md (#356) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-policy: review_exclude_paths key and loadPolicyAt (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: apply the base policy's review_exclude_paths and the reviewer diff ceiling (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * receipt: bind review_exclude in write, carry and the queue (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs, tests and changelog for review_exclude_paths (GST-508) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (binary by bytes, links kept, untracked files out of the selection) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep binary files at code paths under review_exclude_paths Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: name the reasons that keep a binary file under review_exclude_paths Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep every review_exclude_paths candidate when the gate scan is unknown; docs example names its version Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep the NUL fixture clear of the sync-spawn tripwire Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: split the ceiling sentence Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep a live binary file whose bytes cannot be read; tests render outside voices from a clean checkout and read the ship fixture text Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review-exclude): explicit +x for the census script; skip the chmod-000 read case on Windows and root Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * refactor: move loadPolicyAt to lib/ship-policy-at.ts so lib/ship-policy.ts stays under the 800-line module cap Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: refuse a base that contains HEAD; skip a self-pointing origin/HEAD (GST-774) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: regenerate gstack-doctor-components.sh for BASE_CONTAINS_HEAD Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: land resumes a merged merge-only PR instead of refusing BASE_CONTAINS_HEAD (GST-774 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog fragment for GST-774 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: troubleshooting names diff-scope base_contains_head and the self-pointing no_base case (GST-774 docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: repin the reviewer briefs to GST-508's no-loss-tested bytes ship/sections/review-army.md, ship/sections/adversarial.md and lib/review-depth.ts now pin the bytes the GST-508 preregistered no-loss replay ran atef2760f(handoffs/GSTA-202/noloss-results.md in the GStack Minder drive): Arm V 33/41 vs R 33/41, delta 4, exact sign flip, p_NI 0.0279 (pass). The merge of main83ae96eleaves these three files byte-identical toef2760f, and an Arm P unit (3593) renders byte-identical briefs atef2760fand at the merge. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: GST-774 review round 1 — merged resume depth, aliased and detached PR checkouts, develop/trunk defaults Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship: Step 20 says a metrics row, keeping DRIVER.md at 12 KB after the main merge Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: ship-parts resend case leaves room for the first phase-3 part after the Step 9 targeted-record sentence Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: /ship delivers review/checklist.md in two parts so checklist v1 fits the 24576-byte part cap (#3670) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: drop the GST-407 and GST-430 fragments, already released on main (stacked history) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wave review-53cfb5bfe011: lib/ship-policy.ts back to 800 lines (module-size ratchet after merging #3744 and #3795) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * wave review-53cfb5bfe011: /ship pass 1 fixes - lib/ship-receipt-depth.ts: a merged resume (land after a true merge) no longer recomputes the review_exclude digest over the emptied range, so it lands instead of RECEIPT_INVALID (failing-first land test with review_exclude_paths active). - bin/gstack-review-dispatch render: a standalone targeted render under an active review_exclude_paths refuses and points at the pass manifest voices stage, instead of writing a brief with no selection that leaves verify dirty (failing-first test). - test/default-base.test.ts: #3795 expectations follow main R51 depth (light for an 80-line code diff; runtime_lines=80 still proves the real base). - test/review-dispatch.test.ts: the GST-897 fingerprint test renders from the clean checkout, like its neighbor, because CI rebuilds the tracked lib/diagram-render/dist/diagram-render.html and the reviewer diff ceiling refused the ROOT render. * wave review-53cfb5bfe011: /ship pass 2 fixes - lib/review-exclude.ts: binaryContent reads each blob's first 8,000 bytes (git cat-file blob, 8 KB buffer) instead of one --batch read capped at 256 MB, so head mode (receipt, carry, queue) and the plan agree past 256 MB of blobs; an unreadable blob is kept, as in live mode. Failing-first test with two 270 MB blobs. - lib/review-bots.ts: a file-level bot review comment (subject_type file, no line position) is triaged instead of read as outdated. Failing-first test. - test/ship-queue-land.test.ts: the merged-resume exclusion test runs its gate scan under the fixture state root, not the real home (CI free-suite 6 home containment). * fix: merge follow-ups for main3d99c22: origin/* stays qualified in diff-scope's twin step, retro skeleton back under its cap, combined no-loss pin GST-508+R51+GST-911 * fix(review): receipt write gate-scans whenever the base declares review_exclude_paths; collectLive never re-qualifies origin/<base> (#3852 /ship red-team#1, outside-voice#1) * test: the docs-only receipt digest test keeps its gate scan out of the real home (free-suite shard 5 FREE_SUBSHARD_REJECTED) * fix(mergefc292e22f): tentative REVIEW_EXCLUDE_KEY_VERSION, classify targeted.json, render main's new outside-voice test from a clean checkout - lib/ship-policy.ts: REVIEW_EXCLUDE_KEY_VERSION 1.93.0.0 -> 1.94.0.0, the next-minor tentative form stamp_paths moves (docs/ship-policy.md, #3512); 1.93.0.0 is a released heading that never names the key. - test/gstack-clean-producers.test.ts (new from main): KEPT gains targeted.json, the wave's --review-targeted input. - test/review-dispatch.test.ts: main's wrong-tree test rendered the outside voice from ROOT; a CI build that dirties ROOT pushes that diff past the wave's REVIEW_DIFF_MAX_BYTES ceiling, so render refused and verify saw no brief (exit 3). It now runs in cleanCheckout like the wave's other outside-voice tests. * test(merge68410828f): GST-967's brief-body pin carries the wave's GST-508 placeholders The merged sectionTexts differ from main's by exactly the wave's GST-508 exclude-pathspec lines (merged minus main equals wave minusfc292e22f); the 1,440-case plan grid still hashes to main's MAIN_PLANS. Checks: GStack Minder drive handoffs/GSTA-202/merge-6841-checks.md. * fix(mergee85005482): targeted classes since the read accept an empty range; the wave's receipt tests pass main's targeted record - lib/ship-receipt-depth.ts targetedClassesSince (main, GST-986) reads the range from the commit the targeted reader read to HEAD through headInputs, which since #3795 runs gstack-diff-scope's BASE_CONTAINS_HEAD check. When the reader read HEAD itself the range is empty, diff-scope reported base_contains_head, and every write with --review-targeted was refused (the product patch matched). It now passes merged=true (--merged), which skips only that check; an ancestor check already guards the range. - test/review-exclude.test.ts and test/review-targeted.test.ts: since #3887 write refuses a money head with no targeted record and requires the record's head, so the wave's two writes that must succeed pass one, as main's own tests do (targetedAtHead in test/ship-receipt-depth.test.ts). * test(mergee85005482): drop the wave's duplicate targeted.json KEPT entry Main (#3887) classified .gstack/tmp/targeted.json in KEPT too; the auto-merge kept both keys, which typecheck:test rejects (TS1117). Main's entry stays. * test(mergee85005482): ship size ledger entry for the merged tree (-232 B) Main's ship size ledger plus the wave's entries summed to 345,334 B; the merged tree measures 345,102 B because DRIVER.md takes main's shorter Step 19 line. Recorded with scripts/size-ledger.ts record ship. --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
596 lines
31 KiB
TypeScript
596 lines
31 KiB
TypeScript
/**
|
|
* pr-wait — gstack-pr-wait's event logic: watch one pull request and print one
|
|
* line per real event on stdout, so a monitor wakes the agent once per event
|
|
* and never for a poll that found nothing. Progress goes to stderr. The GitHub
|
|
* reads are lib/pr-wait-github.ts.
|
|
*
|
|
* Design ported from tstack's `pr-wait` (t3dotgg/tstack,
|
|
* skills/babysit-pr/scripts/pr-wait at 7de017b, MIT, Copyright (c) 2026 Theo
|
|
* Browne; NOTICE.md and licenses/MIT-tstack.txt), rewritten in TypeScript:
|
|
* state lives under gstack's state root (lib/state-root.ts) instead of
|
|
* ~/.local/state, and the review-bot lists are `gstack-config` keys
|
|
* (`pr_wait_gating_bots`, `pr_wait_advisory_bots`) instead of constants.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import type { GhRunner } from './ci-gate';
|
|
import {
|
|
GhError, behindBy, bucketClass, compareFiles, confirmPullRequest, currentRepo, fetchChecks, fetchPullRequest,
|
|
pullRequestFiles, requiredChecks, type Check, type GhEnv, type PullRequest,
|
|
} from './pr-wait-github';
|
|
import { readConfigKey, resolveStateRoot, type StateRootEnv } from './state-root';
|
|
import { resultCodeSuffix, resultLineWithDocs } from './result-codes';
|
|
|
|
export type Until = 'green' | 'ready' | 'any-change';
|
|
export const PR_WAIT_EXIT = { met: 0, blocked: 1, refused: 2 } as const;
|
|
export const DEFAULT_GATING_BOTS = ['greptile', 'macroscope'];
|
|
export const DEFAULT_ADVISORY_BOTS = ['coderabbit'];
|
|
/** CI and deploy bots report through checks; their PR comments repeat that news. */
|
|
const STATUS_BOTS = ['github-actions', 'vercel', 'netlify', 'railway', 'cloudflare'];
|
|
/** A state file older than this describes an old session: start a fresh baseline instead of replaying it. */
|
|
export const STALE_STATE_MS = 6 * 3600 * 1000;
|
|
const MAX_FAILED_POLLS = 10;
|
|
const MIN_INTERVAL_SECONDS = 5;
|
|
/** Minutes before a watch that met nothing ends as PR_WAIT_BLOCKED, naming what is still pending; 0 waits without limit. */
|
|
export const DEFAULT_TIMEOUT_MINUTES = 60;
|
|
|
|
export interface PrWaitEnv {
|
|
gh: GhRunner;
|
|
vars: StateRootEnv;
|
|
pid: number;
|
|
now(): number;
|
|
sleep(ms: number): void;
|
|
out(text: string): void;
|
|
err(text: string): void;
|
|
pidAlive(pid: number): boolean;
|
|
}
|
|
|
|
export interface PrWaitOptions { target?: string; repo?: string; until: Until; interval: number; timeout: number; excludes: string[] }
|
|
export interface BotLists { gating: string[]; advisory: string[] }
|
|
|
|
interface State { reported: string[]; summaries: Record<string, string>; base_oid: string | null; merge_state: string | null }
|
|
|
|
/**
|
|
* The base-overlap event, one shape for the watcher and for `gstack-ship-queue
|
|
* ready` / `train build`, which read the same fact from git (handoff
|
|
* GSTA-119): the base moved and its new commits touch files this PR changes.
|
|
*/
|
|
export function baseOverlapLine(base: string, baseOid: string, touched: readonly string[]): string {
|
|
const more = touched.length > 5 ? ` and ${touched.length - 5} more` : '';
|
|
return `${base} moved to ${baseOid.slice(0, 7)} and touches files in this PR: ${touched.slice(0, 5).join(', ')}${more}`;
|
|
}
|
|
|
|
export const PR_WAIT_USAGE = `gstack-pr-wait <pr> [--repo owner/name] [--until green|ready|any-change] [--interval SECONDS] [--timeout MINUTES] [--exclude CHECK,...]
|
|
|
|
Watches one pull request and prints one line per real event on stdout (prefixed
|
|
#<pr>), so a monitor wakes the agent once per event. Progress and recoverable
|
|
errors go to stderr. <pr> is a PR number or URL; without --repo the repository
|
|
comes from the URL or the current checkout.
|
|
|
|
--until
|
|
green every CI check on the current head passed, confirmed by two
|
|
identical polls; a check the base branch requires counts as
|
|
pending until it appears
|
|
ready green, a gating review bot reviewed the current head (none
|
|
configured: green alone), no review thread is unresolved and the
|
|
merge state is known (default)
|
|
any-change stop after the first poll that prints an event
|
|
|
|
--timeout minutes before a watch that met nothing ends as PR_WAIT_BLOCKED,
|
|
naming what is still pending (default 60; 0 waits without limit)
|
|
--exclude check names (comma-separated, repeatable) left out of the verdict,
|
|
as gstack-ci-gate --exclude does: a named check neither holds green
|
|
while pending nor blocks when it fails. A check the base branch
|
|
requires cannot be excluded; the name is logged as refused and gates
|
|
|
|
Events: a check failed (name and link); all checks finished on the
|
|
head (one summary line); a comment, review, thread or thread reply from someone
|
|
other than the PR author or the gh user; a review bot finished on the head, or
|
|
a gating bot's summary changed afterwards; the base branch moved and the new
|
|
commits touch files in this PR; merge state became BEHIND or DIRTY; the PR
|
|
merged or closed. Comment edits are never events, and top-level comments from
|
|
CI and deploy bots are skipped because checks carry that news.
|
|
|
|
Review bots: gstack-config set pr_wait_gating_bots greptile,macroscope
|
|
gstack-config set pr_wait_advisory_bots coderabbit
|
|
A bot owns the logins that start with its name (greptile-apps[bot]) and the
|
|
checks that mention it. An advisory bot's review is reported, never waited on;
|
|
its checks gate like any other. The value none empties a list.
|
|
|
|
Exit codes
|
|
0 the --until condition is met, or the PR merged
|
|
1 the condition needs action (PR_WAIT_BLOCKED): a check failed, the
|
|
branch conflicts with its base, the PR closed, ready is blocked by
|
|
unresolved threads or a BEHIND branch, GitHub stayed unreachable, or
|
|
--timeout passed
|
|
2 bad arguments, an unknown PR, another watcher holds this PR, or a local
|
|
error stopped the watch before a verdict (PR_WAIT_REFUSED)
|
|
|
|
Required checks come from rulesets and classic branch protection, so a
|
|
required check that has not started yet is pending, never green by omission.
|
|
State: <state root>/pr-wait/<owner>__<name>__<pr>.json and .lock; a baseline
|
|
older than 6 hours is discarded. Stopping the watcher (Ctrl-C, kill) leaves its
|
|
lock, which the next watcher for the PR takes over once the pid is gone.
|
|
Docs: docs/troubleshooting.md#gstack-pr-wait`;
|
|
|
|
export function parsePrWaitArgs(argv: string[]): PrWaitOptions | { help: true } | { error: string } {
|
|
const opts: PrWaitOptions = { until: 'ready', interval: 20, timeout: DEFAULT_TIMEOUT_MINUTES, excludes: [] };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const flag = argv[i];
|
|
if (flag === '--help' || flag === '-h') return { help: true };
|
|
if (flag === '--repo' || flag === '-R') { opts.repo = argv[++i]; continue; }
|
|
if (flag === '--until') {
|
|
const value = argv[++i];
|
|
if (value !== 'green' && value !== 'ready' && value !== 'any-change') return { error: `--until needs green, ready or any-change, not '${value ?? ''}'` };
|
|
opts.until = value;
|
|
continue;
|
|
}
|
|
if (flag === '--interval') {
|
|
const value = argv[++i] ?? '';
|
|
if (!/^[0-9]+$/.test(value)) return { error: `--interval needs a whole number of seconds, not '${value}'` };
|
|
opts.interval = Math.max(Number(value), MIN_INTERVAL_SECONDS);
|
|
continue;
|
|
}
|
|
if (flag === '--timeout') {
|
|
const value = argv[++i] ?? '';
|
|
if (!/^[0-9]+$/.test(value)) return { error: `--timeout needs a whole number of minutes, not '${value}'` };
|
|
opts.timeout = Number(value);
|
|
continue;
|
|
}
|
|
if (flag === '--exclude') {
|
|
const names = (argv[++i] ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
|
if (!names.length) return { error: '--exclude needs one or more check names, comma-separated' };
|
|
for (const name of names) if (!opts.excludes.includes(name)) opts.excludes.push(name);
|
|
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 };
|
|
}
|
|
|
|
/** The configured bot that owns a login: the longest name the lowercased login starts with (greptile-apps[bot] is greptile, notgreptile is nobody). Check names keep botOf's first-match rule. */
|
|
export function botOwning(login: string | null | undefined, bots: readonly string[]): string | null {
|
|
const lowered = (login ?? '').toLowerCase();
|
|
if (!lowered) return null;
|
|
return bots.filter(bot => bot !== '' && lowered.startsWith(bot)).sort((a, b) => b.length - a.length)[0] ?? null;
|
|
}
|
|
|
|
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;
|
|
/** False when a required-check source could not be read: the set may be short, so no --exclude name is excluded. */
|
|
private requiredKnown = true;
|
|
private prevSnapshot: string | null = null;
|
|
private prFiles = new Map<string, Set<string>>();
|
|
private pending: string[] = [];
|
|
private readonly gh: GhEnv;
|
|
|
|
constructor(readonly env: PrWaitEnv, readonly repo: string, readonly pr: number, readonly until: Until, readonly interval: number, readonly bots: BotLists, readonly timeoutMinutes = DEFAULT_TIMEOUT_MINUTES, readonly excludes: readonly string[] = []) {
|
|
const stem = path.join(stateDir(env.vars), `${repo.replace('/', '__')}__${pr}`);
|
|
this.stateFile = `${stem}.json`;
|
|
this.lockFile = `${stem}.lock`;
|
|
this.gh = { gh: env.gh, sleep: ms => env.sleep(ms), log: message => this.log(message) };
|
|
const loaded = this.load();
|
|
this.baseline = loaded === null;
|
|
this.state = loaded ?? { reported: [], summaries: {}, base_oid: null, merge_state: null };
|
|
this.reported = new Set(this.state.reported);
|
|
}
|
|
|
|
private log(message: string): void { this.env.err(`gstack-pr-wait: ${message}\n`); }
|
|
|
|
private load(): State | null {
|
|
try {
|
|
if (this.env.now() - fs.statSync(this.stateFile).mtimeMs > STALE_STATE_MS) return null;
|
|
const parsed = JSON.parse(fs.readFileSync(this.stateFile, 'utf8'));
|
|
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.reported)) return null;
|
|
return { reported: parsed.reported, summaries: parsed.summaries ?? {}, base_oid: parsed.base_oid ?? null, merge_state: parsed.merge_state ?? null };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
save(): void {
|
|
this.state.reported = [...this.reported].sort();
|
|
fs.mkdirSync(path.dirname(this.stateFile), { recursive: true });
|
|
const tmp = `${this.stateFile}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify(this.state));
|
|
fs.renameSync(tmp, this.stateFile);
|
|
}
|
|
|
|
/** Hold the per-PR lock for the life of the process; a live holder means this watcher must not start. */
|
|
lock(): { held: string } | null {
|
|
fs.mkdirSync(path.dirname(this.lockFile), { recursive: true });
|
|
try {
|
|
fs.writeFileSync(this.lockFile, String(this.env.pid), { flag: 'wx' });
|
|
return null;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
}
|
|
const holder = fs.readFileSync(this.lockFile, 'utf8').trim();
|
|
if (/^\d+$/.test(holder) && Number(holder) !== this.env.pid && this.env.pidAlive(Number(holder))) return { held: holder };
|
|
fs.writeFileSync(this.lockFile, String(this.env.pid));
|
|
return null;
|
|
}
|
|
|
|
unlock(): void {
|
|
try {
|
|
if (fs.readFileSync(this.lockFile, 'utf8').trim() === String(this.env.pid)) fs.unlinkSync(this.lockFile);
|
|
} catch { /* already gone */ }
|
|
}
|
|
|
|
/** One event is one line: control characters from GitHub text can never start a second one. */
|
|
private say(line: string): void {
|
|
if (this.printed.has(line)) return;
|
|
this.printed.add(line);
|
|
this.env.out(`#${this.pr} ${line.replace(/[\x00-\x1f\x7f]+/g, ' ')}\n`);
|
|
}
|
|
|
|
/** Print an event once per PR, across restarts. */
|
|
private emit(key: string, line: string): void {
|
|
if (this.reported.has(key)) return;
|
|
this.reported.add(key);
|
|
if (this.baseline) return;
|
|
this.events += 1;
|
|
this.say(line);
|
|
}
|
|
|
|
private finish(code: number, lines: string[] = []): number {
|
|
for (const line of lines) this.say(line);
|
|
if (code === PR_WAIT_EXIT.blocked) this.say(`stopped: action needed ${resultCodeSuffix('PR_WAIT_BLOCKED')}`);
|
|
// The verdict is printed; a state file that cannot be written must not turn it into a refusal.
|
|
try { this.save(); } catch (error) { this.log(`could not save state: ${error instanceof Error ? error.message : String(error)}`); }
|
|
return code;
|
|
}
|
|
|
|
/** The configured bot a check name mentions, or (for a login) that the login starts with: greptile-apps[bot] is greptile, notgreptile is nobody. */
|
|
private botOf(text: string, login = false): string | null {
|
|
const bots = [...this.bots.gating, ...this.bots.advisory];
|
|
if (login) return botOwning(text, bots);
|
|
const lowered = text.toLowerCase();
|
|
return bots.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}`;
|
|
}
|
|
|
|
/**
|
|
* `--exclude` names that are not required checks: the ones the verdict leaves out, as gstack-ci-gate does. While the
|
|
* required set is unknown (a source could not be read) nothing is excluded, as gstack-ci-gate refuses its overrides.
|
|
*/
|
|
private excluded(): Set<string> {
|
|
return new Set(this.requiredKnown ? this.excludes.filter(name => !this.required?.has(name)) : []);
|
|
}
|
|
|
|
private evaluateChecks(all: Map<string, Check>, head: string) {
|
|
const required = this.required ?? new Set<string>();
|
|
// Every check on the head gates, as gstack-ci-gate does, so land-and-deploy's two gates never
|
|
// disagree; a required check gates even before it appears. An advisory bot's review is never
|
|
// waited for, but its check is a check like any other. A --exclude name that is not required
|
|
// is left out of the verdict; a required one cannot be excluded and gates.
|
|
const excluded = this.excluded();
|
|
const checks = new Map([...all].filter(([name]) => !excluded.has(name)));
|
|
const watched = checks;
|
|
const gate = new Set([...required, ...watched.keys()]);
|
|
const bucketOf = (name: string) => checks.get(name)?.bucket ?? 'pending';
|
|
const failLines: string[] = [];
|
|
for (const name of [...gate].sort()) {
|
|
if (bucketClass(bucketOf(name)) !== 'fail') continue;
|
|
const line = `${required.has(name) ? 'required check' : 'check'} failed: ${name} ${checks.get(name)?.link ?? ''}`.trimEnd();
|
|
failLines.push(line);
|
|
this.emit(`fail:${head}:${name}:${checks.get(name)?.link ?? ''}`, line);
|
|
}
|
|
this.pending = [...gate].filter(name => bucketClass(bucketOf(name)) === 'pending').sort();
|
|
const allDone = gate.size > 0 && this.pending.length === 0;
|
|
// Checks appear a few seconds after a push: two identical polls in a row
|
|
// guard against calling a half-registered set of checks finished.
|
|
const snapshot = JSON.stringify([...watched].map(([name, check]) => [name, check.bucket]).sort());
|
|
const stable = snapshot === this.prevSnapshot;
|
|
this.prevSnapshot = snapshot;
|
|
return { watched, failLines, allDone, stable, green: allDone && stable && failLines.length === 0, requiredGreen: [...required].filter(name => bucketClass(bucketOf(name)) === 'pass').length };
|
|
}
|
|
|
|
/** People talking. Edits are never events. Returns the unresolved threads' first-comment URLs. */
|
|
private emitConversation(pr: PullRequest, mine: Set<string>): string[] {
|
|
for (const c of pr.comments) {
|
|
const { login, bot } = c.author;
|
|
if (mine.has(login) || this.botOf(login, true) || (bot && STATUS_BOTS.some(b => login.includes(b)))) continue;
|
|
this.emit(`c:${c.id}`, `comment from ${login}: ${snippet(c.body)} ${c.url}`);
|
|
}
|
|
for (const r of pr.reviews) {
|
|
if (mine.has(r.author.login) || r.author.bot) continue;
|
|
if (r.state === 'APPROVED' || r.state === 'CHANGES_REQUESTED' || r.body.trim()) {
|
|
this.emit(`c:${r.id}`, `review from ${r.author.login} (${r.state.toLowerCase().replace(/_/g, ' ')}): ${snippet(r.body)} ${r.url}`);
|
|
}
|
|
}
|
|
const unresolved: string[] = [];
|
|
for (const thread of pr.threads) {
|
|
if (!thread.isResolved && thread.comments.length > 0) unresolved.push(thread.comments[0].url);
|
|
const where = thread.line ? `${thread.path}:${thread.line}` : thread.path;
|
|
thread.comments.forEach((c, index) => {
|
|
if (mine.has(c.author.login)) return;
|
|
this.emit(`c:${c.id}`, `${index === 0 ? 'new thread' : 'thread reply'} from ${c.author.login} on ${where}: ${snippet(c.body)} ${c.url}`);
|
|
});
|
|
}
|
|
return unresolved;
|
|
}
|
|
|
|
/**
|
|
* Review bots on the current head. A gating bot with checks is done when they
|
|
* pass (an advisory one when they finish); otherwise once it reviewed the head
|
|
* commit or its summary names it.
|
|
*/
|
|
private emitBots(pr: PullRequest, checks: Map<string, Check>, head: string): { status: string[]; reviewed: string[] } {
|
|
const short = head.slice(0, 7);
|
|
const status: string[] = [];
|
|
const reviewed: string[] = [];
|
|
for (const bot of [...this.bots.gating, ...this.bots.advisory]) {
|
|
const advisory = this.bots.advisory.includes(bot);
|
|
const ownChecks = [...checks.values()].filter(c => this.botOf(c.name) === bot);
|
|
const summaries = pr.comments.filter(c => this.botOf(c.author.login, true) === bot);
|
|
const ownReviews = pr.reviews.filter(r => this.botOf(r.author.login, true) === bot);
|
|
const onHead = ownReviews.some(r => r.commitOid === head);
|
|
// A bot that reports through checks is done when they pass; a failed gating check is a failed check, never a review.
|
|
const done = ownChecks.length > 0
|
|
? ownChecks.every(c => bucketClass(c.bucket) === 'pass' || (advisory && bucketClass(c.bucket) !== 'pending'))
|
|
: onHead || summaries.some(s => s.body.includes(short));
|
|
const seen = ownChecks.length > 0 || summaries.length > 0 || ownReviews.length > 0;
|
|
if (advisory && ownChecks.length > 0) status.push(`${bot} ${ownChecks[0].description || ownChecks[0].bucket} (advisory)`);
|
|
else if (seen) status.push(`${bot} ${done ? 'done' : 'pending'}${advisory ? ' (advisory)' : ''}`);
|
|
const key = `bot:${bot}:${head}`;
|
|
const wasDone = this.reported.has(key);
|
|
if (done) {
|
|
if (!advisory) reviewed.push(bot);
|
|
const failed = ownChecks.filter(c => bucketClass(c.bucket) === 'fail').map(c => c.name);
|
|
const detail = [summaries.map(s => verdictLine(s.body)).find(Boolean) ?? '', failed.length ? `failed: ${failed.join(', ')}` : ''].filter(Boolean).join('; ');
|
|
this.emit(key, `${bot} finished on ${short}${detail ? `: ${detail}` : ''}${advisory ? ' (advisory, not waited on)' : ''}`);
|
|
}
|
|
if (advisory) continue;
|
|
for (const s of summaries) {
|
|
const fresh = digest(s.body);
|
|
const old = this.state.summaries[s.id];
|
|
this.state.summaries[s.id] = fresh;
|
|
if (wasDone && old && old !== fresh) this.emit(`summary:${s.id}:${fresh}`, `${bot} summary changed on ${short}: ${verdictLine(s.body) || snippet(s.body)} ${s.url}`);
|
|
}
|
|
}
|
|
return { status, reviewed };
|
|
}
|
|
|
|
/** The base branch moving under the PR, and BEHIND / DIRTY merge states. */
|
|
private emitBaseAndMerge(pr: PullRequest, head: string): void {
|
|
const base = pr.baseRefName;
|
|
const oldBase = this.state.base_oid;
|
|
if (pr.baseOid && oldBase && pr.baseOid !== oldBase && !this.baseline) {
|
|
const touched = this.baseOverlap(head, oldBase, pr.baseOid);
|
|
if (touched.length > 0) this.emit(`base:${pr.baseOid}`, baseOverlapLine(base, pr.baseOid, touched));
|
|
}
|
|
if (pr.baseOid) this.state.base_oid = pr.baseOid;
|
|
const mergeState = pr.mergeStateStatus;
|
|
if (mergeState === 'UNKNOWN') return;
|
|
// Keyed on the head alone, so each state is announced once per head: not once per transition, and
|
|
// not again for every base commit while the branch stays behind.
|
|
if (mergeState === 'BEHIND' || mergeState === 'DIRTY') {
|
|
this.emit(`merge:${mergeState}:${head}`, mergeState === 'DIRTY' ? this.dirtyLine(base, head) : this.behindLine(base, head));
|
|
}
|
|
this.state.merge_state = mergeState;
|
|
}
|
|
|
|
private dirtyLine(base: string, head: string): string { return `merge state is DIRTY on ${head.slice(0, 7)}: the branch conflicts with ${base}, rebase it`; }
|
|
private behindLine(base: string, head: string): string { return `merge state is BEHIND on ${head.slice(0, 7)}: update the branch from ${base} before merging`; }
|
|
|
|
/** One poll. Returns an exit code when the watch ends, null to keep polling. Throws GhError. */
|
|
poll(): number | null {
|
|
const pr = fetchPullRequest(this.gh, this.repo, this.pr);
|
|
if (pr.state === 'MERGED') return this.finish(PR_WAIT_EXIT.met, [`merged: ${pr.url}`]);
|
|
if (pr.state === 'CLOSED') return this.finish(PR_WAIT_EXIT.blocked, [`closed without merging: ${pr.url}`]);
|
|
const base = pr.baseRefName;
|
|
if (this.required === null) {
|
|
const read = requiredChecks(this.gh, this.repo, base);
|
|
this.required = read.names;
|
|
this.requiredKnown = read.known;
|
|
const gate = [...this.required].sort().join(', ') || 'none set';
|
|
this.log(`watching ${this.repo}#${this.pr} until ${this.until}; required checks: ${gate}; every check on the head gates`);
|
|
if (this.excludes.length && !this.requiredKnown) {
|
|
this.log(`exclude refused for ${this.excludes.join(', ')}: the required checks could not be read, so every check gates`);
|
|
} else if (this.excludes.length) {
|
|
const refused = this.excludes.filter(name => this.required!.has(name));
|
|
const dropped = this.excludes.filter(name => !this.required!.has(name));
|
|
if (dropped.length) this.log(`excluded from the verdict: ${dropped.join(', ')}`);
|
|
if (refused.length) this.log(`exclude refused for required check${refused.length === 1 ? '' : 's'} ${refused.join(', ')}: a required check gates`);
|
|
}
|
|
}
|
|
const checks = fetchChecks(this.gh, this.repo, this.pr);
|
|
const head = pr.headRefOid;
|
|
const short = head.slice(0, 7);
|
|
this.events = 0;
|
|
|
|
const ci = this.evaluateChecks(checks, head);
|
|
const unresolved = this.emitConversation(pr, new Set([pr.author, pr.viewer]));
|
|
const bots = this.emitBots(pr, checks, head);
|
|
this.emitBaseAndMerge(pr, head);
|
|
const mergeState = pr.mergeStateStatus;
|
|
|
|
const counts = new Map<string, number>();
|
|
for (const check of ci.watched.values()) counts.set(check.bucket, (counts.get(check.bucket) ?? 0) + 1);
|
|
const failedNames = [...ci.watched.values()].filter(c => bucketClass(c.bucket) === 'fail').map(c => c.name).sort();
|
|
const summary = [
|
|
[...counts].sort().map(([bucket, n]) => `${n} ${bucket}`).join(', ') + (failedNames.length ? ` (${failedNames.join(', ')})` : ''),
|
|
this.required.size > 0 ? `required ${ci.requiredGreen}/${this.required.size} green` : '',
|
|
bots.status.join(', ') || `no ${this.bots.gating.join(' or ') || 'review bot'} yet`,
|
|
`${unresolved.length} unresolved threads`,
|
|
`merge state ${mergeState}`,
|
|
].filter(Boolean).join('; ');
|
|
const checksKey = `checks:${head}`;
|
|
|
|
// A green or ready line carries the same summary as the checks line, so it marks that event as reported.
|
|
if (this.until !== 'any-change') {
|
|
if (mergeState === 'DIRTY') return this.finish(PR_WAIT_EXIT.blocked, [this.dirtyLine(base, head)]);
|
|
if (ci.failLines.length > 0) return this.finish(PR_WAIT_EXIT.blocked, ci.failLines);
|
|
if (ci.green && this.until === 'green') {
|
|
this.reported.add(checksKey);
|
|
return this.finish(PR_WAIT_EXIT.met, [`green on ${short}: ${summary}${this.behindSuffix(base, pr.baseOid, head)}`]);
|
|
}
|
|
// ready waits for a gating bot, never for an advisory one; with no gating bot configured, green is the review.
|
|
if (ci.green && (bots.reviewed.length > 0 || this.bots.gating.length === 0)) {
|
|
if (unresolved.length > 0) return this.finish(PR_WAIT_EXIT.blocked, [`blocked by ${unresolved.length} unresolved threads on ${short}: ${unresolved.join(' ')}`]);
|
|
if (mergeState === 'BEHIND') return this.finish(PR_WAIT_EXIT.blocked, [this.behindLine(base, head)]);
|
|
// GitHub reports UNKNOWN while it recomputes mergeability after a push: not ready until it knows.
|
|
if (mergeState === 'UNKNOWN') return null;
|
|
this.reported.add(checksKey);
|
|
return this.finish(PR_WAIT_EXIT.met, [`ready on ${short}: ${summary}${this.behindSuffix(base, pr.baseOid, head)}`]);
|
|
}
|
|
}
|
|
if (ci.allDone && (ci.stable || this.baseline)) this.emit(checksKey, `checks finished on ${short}: ${summary}`);
|
|
if (this.until === 'any-change' && this.events > 0) return this.finish(PR_WAIT_EXIT.met);
|
|
return null;
|
|
}
|
|
|
|
/** Poll until the watch ends; returns the exit code. */
|
|
run(): number {
|
|
let failures = 0;
|
|
const deadline = this.timeoutMinutes > 0 ? this.env.now() + this.timeoutMinutes * 60_000 : Infinity;
|
|
for (;;) {
|
|
try {
|
|
const code = this.poll();
|
|
if (code !== null) return code;
|
|
failures = 0;
|
|
this.save();
|
|
if (this.baseline) this.log(`baseline recorded for ${this.repo}#${this.pr}; reporting only new events`);
|
|
this.baseline = false;
|
|
if (this.env.now() >= deadline) {
|
|
const still = this.pending.length > 0 ? `still pending: ${this.pending.join(', ')}` : 'nothing pending, the condition was not met';
|
|
return this.finish(PR_WAIT_EXIT.blocked, [`gstack-pr-wait stopped: no ${this.until} after ${this.timeoutMinutes} minutes; ${still}`]);
|
|
}
|
|
} catch (error) {
|
|
if (!(error instanceof GhError)) throw error;
|
|
if (!error.transient) return this.finish(PR_WAIT_EXIT.blocked, [`gstack-pr-wait stopped: ${error.message}`]);
|
|
failures += 1;
|
|
this.log(`poll failed (${failures}/${MAX_FAILED_POLLS}): ${error.message}`);
|
|
if (failures >= MAX_FAILED_POLLS) return this.finish(PR_WAIT_EXIT.blocked, [`gstack-pr-wait stopped: GitHub unreachable for ${failures} polls: ${error.message}`]);
|
|
}
|
|
this.env.sleep(this.interval * 1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
function refuse(env: PrWaitEnv, message: string): number {
|
|
// Fatal lines go to stdout so a monitor shows the agent why the watcher stopped.
|
|
env.out(`${resultLineWithDocs('gstack-pr-wait', 'PR_WAIT_REFUSED', message)}\n`);
|
|
return PR_WAIT_EXIT.refused;
|
|
}
|
|
|
|
/** Resolve the target to a repository and PR number the current gh login can read. */
|
|
export function resolveTarget(env: PrWaitEnv, opts: PrWaitOptions): { repo: string; pr: number } | { error: string } {
|
|
const target = parsePrTarget(opts.target, opts.repo);
|
|
if ('error' in target) return target;
|
|
const gh: GhEnv = { gh: env.gh, sleep: ms => env.sleep(ms), log: m => env.err(`gstack-pr-wait: ${m}\n`) };
|
|
let repo = target.repo;
|
|
if (!repo) {
|
|
try { repo = currentRepo(gh); } catch (error) {
|
|
return { error: `no --repo given and the current directory has no GitHub repo (${error instanceof Error ? error.message : String(error)})` };
|
|
}
|
|
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) return { error: `'${repo}' is not owner/name` };
|
|
}
|
|
try { confirmPullRequest(gh, repo, target.pr); } catch (error) {
|
|
// A transient failure is retried by the poll loop.
|
|
if (!(error instanceof GhError) || !error.transient) return { error: `cannot read ${repo}#${target.pr}: ${error instanceof Error ? error.message : String(error)}` };
|
|
}
|
|
return { repo, pr: target.pr };
|
|
}
|
|
|
|
export function prWaitMain(argv: string[], env: PrWaitEnv): number {
|
|
const parsed = parsePrWaitArgs(argv);
|
|
if ('help' in parsed) {
|
|
env.out(`${PR_WAIT_USAGE}\n`);
|
|
return PR_WAIT_EXIT.met;
|
|
}
|
|
if ('error' in parsed) return refuse(env, parsed.error);
|
|
const target = resolveTarget(env, parsed);
|
|
if ('error' in target) return refuse(env, target.error);
|
|
const watcher = new Watcher(env, target.repo, target.pr, parsed.until, parsed.interval, botLists(env.vars), parsed.timeout, parsed.excludes);
|
|
// Exit 1 always carries a PR_WAIT_BLOCKED line, so a local fault (an unwritable state root, a full disk) is a refusal, never a verdict.
|
|
try {
|
|
const lock = watcher.lock();
|
|
if (lock) {
|
|
return refuse(env, `pid ${lock.held} already watches ${target.repo}#${target.pr}, so this watcher exits to avoid duplicate events. If no events reach you from it, run \`kill ${lock.held}\` and start again`);
|
|
}
|
|
return watcher.run();
|
|
} catch (error) {
|
|
return refuse(env, `a local error stopped the watch before a verdict: ${error instanceof Error ? error.message : String(error)}`);
|
|
} finally {
|
|
watcher.unlock();
|
|
}
|
|
}
|