Files
gstack/test/pr-wait.test.ts
Garry Tanandcapy-ai[bot] 6bbd0a536f v1.91.266.0 feat(pr-wait): gstack-pr-wait event watcher (tstack pr-wait design port) and /land-and-deploy Step 3 wiring (#3495)
* feat(pr-wait): gstack-pr-wait event watcher (tstack pr-wait design port) and /land-and-deploy Step 3 wiring

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-27 04:45:33 +00:00

666 lines
40 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* gstack-pr-wait (lib/pr-wait.ts, lib/pr-wait-github.ts): one stdout line per
* real event, state under the gstack state root, required checks from rulesets
* so an unstarted one is pending, two identical polls before green, the
* base-moved-and-touches-this-PR event, BEHIND and DIRTY, the per-PR lock, the
* 6-hour baseline staleness and the configurable bot lists. Every GitHub read
* goes through a fake gh runner, as test/gstack-ci-gate.test.ts does.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import type { GhResult } from '../lib/ci-gate';
import { DEFAULT_ADVISORY_BOTS, DEFAULT_GATING_BOTS, PR_WAIT_EXIT, Watcher, botLists, parsePrTarget, parsePrWaitArgs, prWaitMain, stateDir, type PrWaitEnv, type PrWaitOptions } from '../lib/pr-wait';
import { GhError, runGh } from '../lib/pr-wait-github';
import { RESULT_CODES } from '../lib/result-codes';
const ROOT = join(import.meta.dir, '..');
const HEAD = 'a'.repeat(40);
const BASE_A = 'b'.repeat(40);
const BASE_B = 'c'.repeat(40);
const REPO = 'owner/project';
const ok = (stdout: string): GhResult => ({ status: 0, stdout, stderr: '' });
const err = (stderr: string, status = 1): GhResult => ({ status, stdout: '', stderr: `${stderr}\n` });
const NOT_FOUND = err('gh: Not Found (HTTP 404)');
interface Author { login: string; bot?: boolean }
const actor = ({ login, bot }: Author) => ({ login, __typename: bot ? 'Bot' : 'User' });
const comment = (id: string, author: Author, body: string) => ({ id, url: `https://github.com/${REPO}/pull/42#issuecomment-${id}`, body, author: actor(author) });
const review = (id: string, author: Author, state: string, body = '', commit: string | null = HEAD) =>
({ id, url: `https://github.com/${REPO}/pull/42#pullrequestreview-${id}`, state, body, commit: commit ? { oid: commit } : null, author: actor(author) });
const thread = (path: string, line: number | null, resolved: boolean, ...comments: ReturnType<typeof comment>[]) => ({ isResolved: resolved, path, line, comments: { nodes: comments } });
interface PrShape {
state?: string; head?: string; base?: string; baseOid?: string | null; mergeState?: string;
comments?: ReturnType<typeof comment>[]; reviews?: ReturnType<typeof review>[]; threads?: ReturnType<typeof thread>[];
}
const prJson = (shape: PrShape = {}) => JSON.stringify({
data: {
viewer: { login: 'me' },
repository: {
pullRequest: {
url: `https://github.com/${REPO}/pull/42`, state: shape.state ?? 'OPEN', headRefOid: shape.head ?? HEAD,
baseRefName: shape.base ?? 'main', mergeStateStatus: shape.mergeState ?? 'CLEAN', author: { login: 'author' },
baseRef: shape.baseOid === null ? null : { target: { oid: shape.baseOid ?? BASE_A } },
comments: { nodes: shape.comments ?? [] }, reviews: { nodes: shape.reviews ?? [] }, reviewThreads: { nodes: shape.threads ?? [] },
},
},
},
});
const checksJson = (...rows: Array<[string, string] | [string, string, string]>) =>
JSON.stringify(rows.map(([name, bucket, description]) => ({ name, bucket, link: `https://ci.example/${name}`, description: description ?? '' })));
/** Scripted replies per gh command shape; a list is consumed one poll at a time, the last reply repeating. */
interface Script {
pr?: string | string[];
checks?: GhResult | GhResult[];
rulesets?: GhResult;
protection?: GhResult;
prFiles?: GhResult;
compare?: Record<string, GhResult>;
behind?: GhResult;
}
function fakeGh(script: Script) {
// Function declarations, not generic arrows: pregate's import scanner reads tests as TSX, where `<T>(x) =>` is a tag.
function queue<T>(value: T | T[] | undefined): T[] { return value === undefined ? [] : Array.isArray(value) ? [...value] : [value]; }
function next<T>(q: T[]): T | undefined { return q.length > 1 ? q.shift() : q[0]; }
const prs = queue(script.pr).map(ok);
const checks = queue(script.checks);
const calls: string[][] = [];
const gh = (args: string[]): GhResult => {
calls.push(args);
const [a, b] = args;
if (a === 'api' && b === 'graphql') return next(prs) ?? ok(prJson());
if (a === 'pr' && b === 'checks') return next(checks) ?? ok(checksJson());
if (a === 'pr' && b === 'view') return ok('{"number":42}');
if (a === 'pr' && b === 'diff') return script.prFiles ?? ok('');
if (a === 'repo' && b === 'view') return ok(`${REPO}\n`);
if (a === 'api' && b.includes('/rules/branches/')) return script.rulesets ?? NOT_FOUND;
if (a === 'api' && b.includes('/protection/')) return script.protection ?? NOT_FOUND;
if (a === 'api' && b.includes('/compare/')) {
if (args.includes('.behind_by')) return script.behind ?? ok('0\n');
return script.compare?.[b.split('/compare/')[1]] ?? ok('');
}
throw new Error(`fake gh: unexpected ${args.join(' ')}`);
};
return Object.assign(gh, { calls });
}
let dir: string;
let stdout: string[];
let stderr: string[];
let clock: number;
let alive: Set<number>;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'pr-wait-'));
stdout = [];
stderr = [];
clock = 1_700_000_000_000;
alive = new Set([4242]);
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
function env(gh: PrWaitEnv['gh'], pid = 4242, vars: Record<string, string> = {}): PrWaitEnv {
return {
gh, vars: { HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state'), ...vars }, pid,
now: () => clock, sleep: () => {}, out: t => stdout.push(t), err: t => stderr.push(t), pidAlive: p => alive.has(p),
};
}
const watcher = (script: Script, until: PrWaitOptions['until'] = 'ready', vars: Record<string, string> = {}) => {
const e = env(fakeGh(script), 4242, vars);
return new Watcher(e, REPO, 42, until, 20, botLists(e.vars));
};
/** Run polls until one returns a code or the budget is spent; the first poll is the baseline. */
function pollUntilDone(w: Watcher, budget = 6): number | null {
for (let i = 0; i < budget; i++) {
const code = w.poll();
if (code !== null) return code;
w.save();
w.baseline = false;
}
return null;
}
const lines = () => stdout.join('').split('\n').filter(Boolean);
const RULESETS = ok('free-tests\nwindows-free-tests\n');
describe('state lives under the gstack state root', () => {
test('state file and lock are <root>/pr-wait/<owner>__<name>__<pr>, never ~/.local/state', () => {
const w = watcher({});
expect(w.stateFile).toBe(join(dir, 'state', 'pr-wait', 'owner__project__42.json'));
expect(w.lockFile).toBe(join(dir, 'state', 'pr-wait', 'owner__project__42.lock'));
expect(stateDir({ HOME: dir })).toBe(join(dir, '.gstack', 'pr-wait'));
pollUntilDone(w, 1);
expect(existsSync(w.stateFile)).toBe(true);
expect(existsSync(join(dir, '.local'))).toBe(false);
});
test('a state file older than six hours is a fresh baseline: nothing from it is replayed and nothing new is reported on that poll', () => {
const file = join(dir, 'state', 'pr-wait', 'owner__project__42.json');
mkdirSync(join(dir, 'state', 'pr-wait'), { recursive: true });
writeFileSync(file, JSON.stringify({ reported: [], summaries: {}, base_oid: BASE_A, merge_state: 'CLEAN' }));
const old = new Date(clock - 7 * 3600 * 1000);
utimesSync(file, old, old);
const w = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'looks wrong')] }) });
expect(w.baseline).toBe(true);
expect(w.poll()).toBeNull();
expect(lines()).toEqual([]);
const fresh = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'looks wrong')] }) });
expect(fresh.baseline).toBe(true);
});
test('a state file within six hours carries the baseline across a restart, so only what arrived meanwhile prints', () => {
const first = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'first')] }) });
pollUntilDone(first, 1);
expect(lines()).toEqual([]);
clock += 60 * 60 * 1000;
const restarted = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'first'), comment('c2', { login: 'reviewer' }, 'second thought')] }) });
expect(restarted.baseline).toBe(false);
restarted.poll();
expect(lines()).toEqual([expect.stringMatching(/^#42 comment from reviewer: second thought https:/)]);
});
});
describe('events: one stdout line each, progress on stderr', () => {
test('the baseline poll records what exists and prints nothing; the next poll prints only new comments, reviews and thread replies, never the author, the gh user, or edits', () => {
const before = prJson({ comments: [comment('c1', { login: 'reviewer' }, 'old')] });
const after = prJson({
comments: [comment('c1', { login: 'reviewer' }, 'old, edited'), comment('c2', { login: 'author' }, 'mine'), comment('c3', { login: 'me' }, 'also mine'),
comment('c4', { login: 'github-actions', bot: true }, 'deploy preview ready'), comment('c5', { login: 'reviewer' }, '<b>Please</b> &amp; fix &lt;this&gt;')],
reviews: [review('r1', { login: 'reviewer' }, 'CHANGES_REQUESTED', 'nope'), review('r2', { login: 'lurker' }, 'COMMENTED', '')],
threads: [thread('lib/x.ts', 12, false, comment('t1', { login: 'reviewer' }, 'rename this'), comment('t2', { login: 'author' }, 'done'))],
});
const w = watcher({ pr: [before, after] }, 'any-change');
expect(w.run()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual([
'#42 comment from reviewer: Please & fix <this> https://github.com/owner/project/pull/42#issuecomment-c5',
'#42 review from reviewer (changes requested): nope https://github.com/owner/project/pull/42#pullrequestreview-r1',
'#42 new thread from reviewer on lib/x.ts:12: rename this https://github.com/owner/project/pull/42#issuecomment-t1',
]);
expect(stderr.join('')).toContain('watching owner/project#42 until any-change; required checks: none set; every check on the head gates');
expect(stderr.join('')).toContain('baseline recorded for owner/project#42');
});
test('merged ends with exit 0; closed without merging is PR_WAIT_BLOCKED', () => {
expect(pollUntilDone(watcher({ pr: prJson({ state: 'MERGED' }) }))).toBe(0);
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
stdout = [];
expect(pollUntilDone(watcher({ pr: prJson({ state: 'CLOSED' }) }))).toBe(1);
expect(lines()).toEqual(['#42 closed without merging: https://github.com/owner/project/pull/42', expect.stringContaining('stopped: action needed (PR_WAIT_BLOCKED; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#pr-wait-blocked)')]);
});
});
describe('required checks and the green condition', () => {
test('a required check named by the ruleset that has not started is pending, not green by omission', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'])) }, 'green');
expect(pollUntilDone(w, 4)).toBeNull();
expect(lines()).toEqual([]);
expect(stderr.join('')).toContain('required checks: free-tests, windows-free-tests');
});
test('a required check that failed ends the watch at once, naming it with its link, even on the baseline poll', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pending'])) }, 'green');
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 required check failed: free-tests https://ci.example/free-tests', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('an unknown bucket counts as failed, as gstack-ci-gate counts it', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'stale'], ['windows-free-tests', 'pass'])) }, 'green');
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests');
});
test('green needs two identical polls: a check set seen once is not stable', () => {
const w = watcher({ rulesets: RULESETS, checks: [ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'])), ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['late-check', 'pass']))], behind: ok('2\n') }, 'green');
expect(w.poll()).toBeNull();
w.baseline = false;
expect(w.poll()).toBeNull();
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 green on aaaaaaa: 3 pass; required 2/2 green; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN; 2 commits behind main']);
});
test('every check on the head gates, advisory bots\' own included, and a gating bot\'s review on the head makes it ready', () => {
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', 'Confidence score: 4/5')] });
const held = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'pass'], ['coderabbit', 'pending', 'Review in progress'])) });
expect(pollUntilDone(held, 4)).toBeNull();
expect(lines()).toEqual([]);
const w = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'pass'], ['coderabbit', 'pass', 'Reviewed'])) });
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 coderabbit finished on aaaaaaa (advisory, not waited on)', '#42 ready on aaaaaaa: 3 pass; greptile done, coderabbit Reviewed (advisory); 0 unresolved threads; merge state CLEAN; 0 commits behind main']);
});
test('ready is blocked by unresolved threads and by BEHIND, in that order', () => {
const base = { checks: ok(checksJson(['build', 'pass'])), rulesets: NOT_FOUND };
const reviewed = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
expect(pollUntilDone(watcher({ ...base, pr: prJson({ reviews: [reviewed], threads: [thread('a.ts', 1, false, comment('t1', { login: 'reviewer' }, 'hm'))] }) }))).toBe(1);
expect(lines()[0]).toBe('#42 blocked by 1 unresolved threads on aaaaaaa: https://github.com/owner/project/pull/42#issuecomment-t1');
stdout = [];
expect(pollUntilDone(watcher({ ...base, pr: prJson({ reviews: [reviewed], mergeState: 'BEHIND' }) }))).toBe(1);
expect(lines()).toContain('#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging');
});
});
describe('base branch and merge state', () => {
const compareKey = `${BASE_A}...${BASE_B}`;
test('the base moved and its new commits touch files in this PR → one event naming the overlap', () => {
const w = watcher({
pr: [prJson({ baseOid: BASE_A }), prJson({ baseOid: BASE_B })],
prFiles: ok('lib/pr-wait.ts\nREADME.md\n'),
compare: { [compareKey]: ok('lib/pr-wait.ts\nCHANGELOG.md\n') },
}, 'any-change');
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 main moved to ccccccc and touches files in this PR: lib/pr-wait.ts']);
});
test('the base moved without touching this PR → silence, and the new base oid is remembered', () => {
const w = watcher({
pr: [prJson({ baseOid: BASE_A }), prJson({ baseOid: BASE_B })],
prFiles: ok('lib/pr-wait.ts\n'),
compare: { [compareKey]: ok('CHANGELOG.md\n') },
}, 'any-change');
expect(pollUntilDone(w, 3)).toBeNull();
expect(lines()).toEqual([]);
expect(JSON.parse(readFileSync(w.stateFile, 'utf8')).base_oid).toBe(BASE_B);
});
test('a restarted watcher reports a base move that happened while no watcher ran', () => {
const first = watcher({ pr: prJson({ baseOid: BASE_A }) });
first.poll();
first.save();
const restarted = watcher({ pr: prJson({ baseOid: BASE_B }), prFiles: ok('x\n'), compare: { [compareKey]: ok('x\n') } }, 'any-change');
expect(restarted.baseline).toBe(false);
expect(restarted.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 main moved to ccccccc and touches files in this PR: x']);
});
test('BEHIND is an event once per head, again after a push that stays BEHIND; DIRTY ends the watch as blocked', () => {
const D = 'd'.repeat(40);
const behind = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'BEHIND' }), prJson({ mergeState: 'BEHIND' }), prJson({ mergeState: 'UNKNOWN', head: D }), prJson({ mergeState: 'BEHIND', head: D })], checks: ok(checksJson(['build', 'pending'])) }, 'green');
expect(pollUntilDone(behind, 5)).toBeNull();
expect(lines()).toEqual([
'#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging',
'#42 merge state is BEHIND on ddddddd: update the branch from main before merging',
]);
stdout = [];
const dirty = watcher({ pr: prJson({ mergeState: 'DIRTY' }) }, 'green');
expect(dirty.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it');
});
});
describe('review bots are configuration', () => {
const withConfig = (yaml: string) => {
mkdirSync(join(dir, 'state'), { recursive: true });
writeFileSync(join(dir, 'state', 'config.yaml'), yaml);
};
test('defaults are tstack\'s: greptile and macroscope gate, coderabbit is advisory', () => {
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope'], advisory: ['coderabbit'] });
});
test('pr_wait_gating_bots decides which bot\'s review makes the PR ready', () => {
withConfig('pr_wait_gating_bots: macroscope\n');
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '')] });
const w = watcher({ pr, checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'pass']))] });
expect(w.bots).toEqual({ gating: ['macroscope'], advisory: ['coderabbit'] });
expect(pollUntilDone(w, 4)).toBeNull();
expect(lines()).toEqual([expect.stringMatching(/^#42 checks finished on aaaaaaa: 1 pass; no macroscope yet; 0 unresolved threads; merge state CLEAN$/)]);
});
test('none empties a list', () => {
withConfig('pr_wait_advisory_bots: none\n');
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope'], advisory: [] });
});
test('pr_wait_advisory_bots: its review is reported but never waited on, while its check gates like any other', () => {
withConfig('pr_wait_gating_bots: greptile\npr_wait_advisory_bots: coderabbit, Sourcery\n');
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '')] });
const w = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['sourcery-ai', 'pass', 'Reviewed'])) });
expect(w.bots.advisory).toEqual(['coderabbit', 'sourcery']);
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()[0]).toContain('ready on aaaaaaa: 2 pass; greptile done, sourcery Reviewed (advisory)');
});
});
describe('the per-PR lock', () => {
test('a live holder refuses the second watcher with PR_WAIT_REFUSED; a dead holder is taken over and released on exit', () => {
const first = watcher({});
expect(first.lock()).toBeNull();
const second = new Watcher(env(fakeGh({}), 4343), REPO, 42, 'ready', 20, botLists({}));
expect(second.lock()).toEqual({ held: '4242' });
alive.clear();
expect(second.lock()).toBeNull();
expect(readFileSync(second.lockFile, 'utf8')).toBe('4343');
first.unlock();
expect(existsSync(second.lockFile)).toBe(true);
second.unlock();
expect(existsSync(second.lockFile)).toBe(false);
});
test('prWaitMain refuses a duplicate watcher before any poll', () => {
const w = watcher({});
expect(w.lock()).toBeNull();
const gh = fakeGh({});
const code = prWaitMain(['42', '--repo', REPO], env(gh, 9999));
expect(code).toBe(PR_WAIT_EXIT.refused);
expect(lines()).toEqual([expect.stringMatching(/^gstack-pr-wait: the watcher did not start, or a local error stopped it before a verdict — pid 4242 already watches owner\/project#42.*\(PR_WAIT_REFUSED; https:\/\/github\.com\/garrytan\/gstack\/blob\/main\/docs\/troubleshooting\.md#pr-wait-refused\)$/)]);
expect(gh.calls.some(c => c[1] === 'graphql')).toBe(false);
});
});
describe('arguments and the gh layer', () => {
test('targets: number, #number, URL (repo from the URL), --repo override; bad input is refused', () => {
expect(parsePrTarget('42')).toEqual({ repo: undefined, pr: 42 });
expect(parsePrTarget('#42', REPO)).toEqual({ repo: REPO, pr: 42 });
expect(parsePrTarget('https://github.com/garrytan/gstack/pull/3481/files')).toEqual({ repo: 'garrytan/gstack', pr: 3481 });
expect(parsePrTarget('')).toMatchObject({ error: expect.stringContaining('missing PR number') });
expect(parsePrTarget('abc')).toMatchObject({ error: "'abc' is not a PR number or PR URL" });
expect(parsePrTarget('0')).toMatchObject({ error: "'0' is not a valid PR number" });
expect(parsePrTarget('42', 'nope')).toMatchObject({ error: "'nope' is not owner/name" });
expect(parsePrWaitArgs(['42', '--until', 'green', '--interval', '2'])).toEqual({ target: '42', until: 'green', interval: 5, timeout: 60 });
expect(parsePrWaitArgs(['42', '--timeout', '0'])).toMatchObject({ timeout: 0 });
expect(parsePrWaitArgs(['42', '--timeout', 'soon'])).toMatchObject({ error: expect.stringContaining('--timeout needs') });
expect(parsePrWaitArgs(['--until', 'soon'])).toMatchObject({ error: expect.stringContaining('--until needs') });
expect(parsePrWaitArgs(['--bogus'])).toMatchObject({ error: 'unknown argument --bogus' });
expect(parsePrWaitArgs(['--help'])).toEqual({ help: true });
});
test('main: --help exits 0, bad input exits 2 with the code and anchor, an unreadable PR exits 2', () => {
expect(prWaitMain(['--help'], env(fakeGh({})))).toBe(0);
expect(stdout.join('')).toContain('--until');
stdout = [];
expect(prWaitMain([], env(fakeGh({})))).toBe(2);
expect(lines()[0]).toMatch(/missing PR number.*\(PR_WAIT_REFUSED; .*#pr-wait-refused\)$/);
stdout = [];
const gone = (args: string[]): GhResult => (args[1] === 'view' ? err('GraphQL: Could not resolve to a PullRequest with the number of 42. (HTTP 404)') : ok(''));
expect(prWaitMain(['42', '--repo', REPO], env(gone))).toBe(2);
expect(lines()[0]).toContain('cannot read owner/project#42: GraphQL: Could not resolve');
expect(RESULT_CODES.PR_WAIT_REFUSED.anchor).toBe('pr-wait-refused');
expect(RESULT_CODES.PR_WAIT_BLOCKED.anchor).toBe('pr-wait-blocked');
});
test('a transient gh failure is retried with backoff and never ends the watch; a 403 on classic protection is logged, not fatal', () => {
let graphqlCalls = 0;
const base = fakeGh({ checks: ok(checksJson(['build', 'pass'])), protection: err('gh: Resource not accessible by integration (HTTP 403)') });
const flaky = (args: string[]): GhResult => {
if (args[1] === 'graphql' && graphqlCalls++ === 0) return err('gh: Something went wrong (HTTP 502)');
return base(args);
};
const e = env(flaky);
const w = new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars));
expect(w.poll()).toBeNull();
expect(graphqlCalls).toBe(2);
expect(stderr.join('')).toContain('gh api graphql: gh: Something went wrong (HTTP 502); retry in 2s');
expect(stderr.join('')).toContain('could not read required checks from repos/owner/project/branches/main/protection/required_status_checks: gh: Resource not accessible by integration (HTTP 403)');
});
test('the bin runs on the isolated bun shebang and prints the usage', () => {
const result = spawnSync(join(ROOT, 'bin', 'gstack-pr-wait'), ['--help'], { encoding: 'utf8', cwd: ROOT, timeout: 30_000 });
expect(result.status).toBe(0);
expect(result.stdout).toContain('gstack-config set pr_wait_gating_bots greptile,macroscope');
expect(readFileSync(join(ROOT, 'bin', 'gstack-pr-wait'), 'utf8').startsWith('#!/usr/bin/env -S bun --no-env-file --config=/dev/null\n')).toBe(true);
});
});
describe('pass-1 review fixes: gates agree, bounded waits, bot identity, local faults', () => {
function withConfig(yaml: string) {
mkdirSync(join(dir, 'state'), { recursive: true });
writeFileSync(join(dir, 'state', 'config.yaml'), yaml);
}
const GREPTILE = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
test('green gates on every check on the head, as gstack-ci-gate does: a pending non-required check holds it and a red one blocks it', () => {
const pending = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'pending'])) }, 'green');
expect(pollUntilDone(pending, 4)).toBeNull();
expect(lines()).toEqual([]);
const red = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'fail'])) }, 'green');
expect(red.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 check failed: quality-gate https://ci.example/quality-gate', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('a failed bot check is a failed check, gating or advisory, as gstack-ci-gate counts it; never a completed review', () => {
const gating = watcher({ pr: prJson({ reviews: [] }), checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'fail'])) });
expect(pollUntilDone(gating)).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 check failed: greptile-review https://ci.example/greptile-review');
stdout = [];
const advisory = watcher({ pr: prJson({ reviews: [GREPTILE] }), checks: ok(checksJson(['build', 'pass'], ['coderabbit', 'fail'])) });
expect(pollUntilDone(advisory)).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual([
'#42 check failed: coderabbit https://ci.example/coderabbit',
'#42 greptile finished on aaaaaaa',
'#42 coderabbit finished on aaaaaaa: failed: coderabbit (advisory, not waited on)',
expect.stringContaining('(PR_WAIT_BLOCKED;'),
]);
});
test('BEHIND is keyed on the head alone: a base that keeps moving does not repeat the line, even across restarts', () => {
const first = watcher({ pr: [prJson({ mergeState: 'BEHIND', baseOid: BASE_A }), prJson({ mergeState: 'BEHIND', baseOid: BASE_B })], checks: ok(checksJson(['build', 'pending'])), prFiles: ok('x\n'), compare: { [`${BASE_A}...${BASE_B}`]: ok('') } }, 'any-change');
expect(pollUntilDone(first, 3)).toBeNull();
expect(lines()).toEqual([]);
const restarted = watcher({ pr: prJson({ mergeState: 'BEHIND', baseOid: 'e'.repeat(40) }), checks: ok(checksJson(['build', 'pending'])), prFiles: ok('x\n'), compare: {} }, 'any-change');
expect(restarted.baseline).toBe(false);
expect(pollUntilDone(restarted, 3)).toBeNull();
expect(lines()).toEqual([]);
});
test('a verdict keeps its exit code when the state file cannot be written; the save error goes to stderr', () => {
const w = watcher({ pr: prJson({ state: 'MERGED' }) }, 'green');
rmSync(join(dir, 'state'), { recursive: true, force: true });
writeFileSync(join(dir, 'state'), '');
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
expect(stderr.join('')).toContain('could not save state');
});
test('a bot owns logins that start with its name, so a person called notgreptile is a reviewer, not the gating bot', () => {
const human = review('h1', { login: 'notgreptile' }, 'COMMENTED', 'looks fine');
const w = watcher({ pr: [prJson({}), prJson({ reviews: [human] })], checks: ok(checksJson(['build', 'pass'])) });
expect(pollUntilDone(w, 4)).toBeNull();
expect(lines()).toEqual(['#42 review from notgreptile (commented): looks fine https://github.com/owner/project/pull/42#pullrequestreview-h1']);
});
test('ready with no gating bot configured is green plus the thread and merge-state checks', () => {
withConfig('pr_wait_gating_bots: none\n');
const w = watcher({ checks: ok(checksJson(['build', 'pass'])) });
expect(w.bots.gating).toEqual([]);
expect(pollUntilDone(w, 4)).toBe(PR_WAIT_EXIT.met);
expect(lines()[0]).toContain('ready on aaaaaaa: 1 pass; no review bot yet');
});
test('ready waits while the merge state is UNKNOWN and is met once GitHub knows it', () => {
const w = watcher({ pr: [prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'CLEAN', reviews: [GREPTILE] })], checks: ok(checksJson(['build', 'pass'])) });
expect(w.poll()).toBeNull();
w.baseline = false;
expect(w.poll()).toBeNull();
expect(w.poll()).toBeNull();
expect(lines()).toEqual([]);
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()[0]).toContain('merge state CLEAN');
});
test('--timeout ends a watch that met nothing as PR_WAIT_BLOCKED, naming what is still pending; 0 waits without limit', () => {
function timed(timeout: number) {
const e = env(fakeGh({ checks: ok(checksJson(['build', 'pending'], ['docs', 'pass'])) }));
e.sleep = ms => { clock += ms; };
return new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars), timeout);
}
expect(timed(1).run()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 gstack-pr-wait stopped: no green after 1 minutes; still pending: build', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
stdout = [];
const forever = timed(0);
let polls = 0;
const poll = forever.poll.bind(forever);
forever.poll = () => { polls += 1; return polls < 50 ? poll() : PR_WAIT_EXIT.met; };
expect(forever.run()).toBe(PR_WAIT_EXIT.met);
expect(polls).toBe(50);
});
test('run() ends as blocked after ten transient failures, and at once on a non-transient gh error', () => {
const down = (args: string[]): GhResult => (args[1] === 'graphql' ? err('gh: Something went wrong (HTTP 502)') : ok(''));
const e = env(down);
expect(new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars)).run()).toBe(PR_WAIT_EXIT.blocked);
expect(stderr.join('').match(/poll failed \(\d+\/10\)/g)).toHaveLength(10);
expect(lines()[0]).toBe('#42 gstack-pr-wait stopped: GitHub unreachable for 10 polls: gh: Something went wrong (HTTP 502)');
stdout = [];
const gone = (args: string[]): GhResult => (args[1] === 'graphql' ? err('gh: Not Found (HTTP 404)') : ok(''));
const e2 = env(gone);
expect(new Watcher(e2, REPO, 42, 'green', 20, botLists(e2.vars)).run()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 gstack-pr-wait stopped: gh: Not Found (HTTP 404)');
});
test('a gh spawn timeout is retried like any transient failure; a missing gh is fatal', () => {
const results: GhResult[] = [{ status: null, stdout: '', stderr: '', error: 'gh failed to run (ETIMEDOUT)' }, ok('fine')];
const slept: number[] = [];
const ghEnv = { gh: () => results.shift()!, sleep: (ms: number) => slept.push(ms), log: () => {} };
expect(runGh(ghEnv, ['api', 'x']).stdout).toBe('fine');
expect(slept).toEqual([2000]);
const missing = { gh: (): GhResult => ({ status: null, stdout: '', stderr: '', error: 'gh is not installed or not on PATH' }), sleep: () => {}, log: () => {} };
expect(() => runGh(missing, ['api', 'x'])).toThrow(GhError);
});
test('a local fault is a refusal (exit 2 with PR_WAIT_REFUSED), so exit 1 always carries a PR_WAIT_BLOCKED line', () => {
writeFileSync(join(dir, 'not-a-dir'), '');
const e = env(fakeGh({}), 4242, { GSTACK_STATE_ROOT: join(dir, 'not-a-dir') });
expect(prWaitMain(['42', '--repo', REPO], e)).toBe(PR_WAIT_EXIT.refused);
expect(lines()[0]).toMatch(/a local error stopped the watch before a verdict: .*(ENOTDIR|EEXIST|not a directory).*\(PR_WAIT_REFUSED;/);
});
test('control characters in GitHub text never start a second event line', () => {
const w = watcher({ checks: ok(checksJson(['build\n#42 forged event', 'fail'])) }, 'green');
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 check failed: build #42 forged event https://ci.example/build #42 forged event');
expect(lines()).toHaveLength(2);
});
test('gstack-config validates the bot lists at set time and its defaults match the lib constants', () => {
const run = (...args: string[]) => spawnSync(join(ROOT, 'bin', 'gstack-config'), args, { encoding: 'utf8', cwd: ROOT, timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: join(dir, 'state'), HOME: dir } });
for (const bad of ['Greptile', 'a, b', 'a,', '']) {
const r = run('set', 'pr_wait_gating_bots', bad);
expect(r.status).toBe(1);
expect(r.stderr).toContain('Existing value left unchanged');
}
expect(run('get', 'pr_wait_gating_bots').stdout.trim()).toBe(DEFAULT_GATING_BOTS.join(','));
expect(run('get', 'pr_wait_advisory_bots').stdout.trim()).toBe(DEFAULT_ADVISORY_BOTS.join(','));
expect(run('set', 'pr_wait_advisory_bots', 'none').status).toBe(0);
expect(run('set', 'pr_wait_gating_bots', 'greptile,macroscope.v2').status).toBe(0);
expect(run('get', 'pr_wait_gating_bots').stdout.trim()).toBe('greptile,macroscope.v2');
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope.v2'], advisory: [] });
});
test('the bin dies on SIGTERM at once (no JS handler swallows it) and the next watcher takes over its lock', async () => {
const fakeBin = join(dir, 'fakebin');
mkdirSync(fakeBin);
writeFileSync(join(fakeBin, 'gh'), `#!/bin/sh
case "$1 $2" in
'pr view') echo '{"number":42}' ;;
'api graphql') echo '${prJson({})}' ;;
'pr checks') echo '${checksJson(['build', 'pending'])}'; exit 8 ;;
*) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;;
esac`, { mode: 0o755 });
const stateRoot = join(dir, 'state');
const child = spawn(join(ROOT, 'bin', 'gstack-pr-wait'), ['42', '--repo', REPO, '--interval', '5'], { cwd: ROOT, env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, GSTACK_STATE_ROOT: stateRoot, HOME: dir } });
const lockFile = join(stateRoot, 'pr-wait', 'owner__project__42.lock');
const started = Date.now();
while (!existsSync(lockFile) && Date.now() - started < 15_000) await Bun.sleep(100);
expect(existsSync(lockFile)).toBe(true);
child.kill('SIGTERM');
const ended = await Promise.race([
new Promise<string>(resolve => child.on('exit', (code, signal) => resolve(`exit ${code} ${signal}`))),
Bun.sleep(5_000).then(() => 'alive'),
]);
expect(ended).toBe('exit null SIGTERM');
expect(readFileSync(lockFile, 'utf8')).toBe(String(child.pid));
const next = watcher({});
expect(next.lock()).toBeNull();
expect(readFileSync(lockFile, 'utf8')).toBe('4242');
}, 30_000);
});
describe('state matrix cells (gstack-state-matrix: --until × bucket, merge state × --until, lock, baseline × event)', () => {
const GREPTILE = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
test('ready × fail: exit 1 with the failed-check line and PR_WAIT_BLOCKED', () => {
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) });
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 required check failed: free-tests https://ci.example/free-tests', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('any-change × pass: silent while nothing is new; one "checks finished" line once the set turns stable, then exit 0', () => {
const w = watcher({ checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'pass']))] }, 'any-change');
expect(w.poll()).toBeNull();
w.baseline = false;
expect(w.poll()).toBeNull();
expect(lines()).toEqual([]);
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual([expect.stringMatching(/^#42 checks finished on aaaaaaa: 1 pass; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN$/)]);
});
test('any-change × pending: silent, keeps polling', () => {
expect(pollUntilDone(watcher({ checks: ok(checksJson(['build', 'pending'])) }, 'any-change'), 4)).toBeNull();
expect(lines()).toEqual([]);
});
test('any-change × fail: the failed check is an event and the watch ends with exit 0, not blocked', () => {
const w = watcher({ checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'fail']))] }, 'any-change');
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 check failed: build https://ci.example/build']);
});
test('BEHIND × any-change and DIRTY × any-change: one merge-state line each, exit 0, no PR_WAIT_BLOCKED', () => {
const behind = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'BEHIND' })], checks: ok(checksJson(['build', 'pending'])) }, 'any-change');
expect(pollUntilDone(behind)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging']);
stdout = [];
const dirty = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'DIRTY' })], checks: ok(checksJson(['build', 'pending'])) }, 'any-change');
expect(pollUntilDone(dirty)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it']);
});
test('DIRTY × ready: exit 1 with the DIRTY line and PR_WAIT_BLOCKED', () => {
const w = watcher({ pr: prJson({ mergeState: 'DIRTY', reviews: [GREPTILE] }), checks: ok(checksJson(['build', 'pass'])) });
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()).toEqual(['#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
});
test('UNKNOWN × green, UNKNOWN × any-change: no merge-state event; green is decided by the checks alone', () => {
const green = watcher({ pr: prJson({ mergeState: 'UNKNOWN' }), checks: ok(checksJson(['build', 'pass'])) }, 'green');
expect(pollUntilDone(green)).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual([expect.stringMatching(/^#42 green on aaaaaaa: 1 pass; no greptile or macroscope yet; 0 unresolved threads; merge state UNKNOWN; 0 commits behind main$/)]);
expect(JSON.parse(readFileSync(green.stateFile, 'utf8')).merge_state).toBeNull();
stdout = [];
expect(pollUntilDone(watcher({ pr: prJson({ mergeState: 'UNKNOWN' }), checks: ok(checksJson(['build', 'pending'])) }, 'any-change'), 4)).toBeNull();
expect(lines()).toEqual([]);
});
test('lock: free × unlock is a no-op; dead-pid × unlock leaves the stale file for the next lock() to take over', () => {
const w = watcher({});
expect(() => w.unlock()).not.toThrow();
expect(existsSync(w.lockFile)).toBe(false);
mkdirSync(join(w.lockFile, '..'), { recursive: true });
writeFileSync(w.lockFile, '999999');
w.unlock();
expect(readFileSync(w.lockFile, 'utf8')).toBe('999999');
expect(w.lock()).toBeNull();
expect(readFileSync(w.lockFile, 'utf8')).toBe('4242');
});
test('restarted × required-check-failed and restarted × merged: the verdict line is printed again by the new process', () => {
const first = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'green');
expect(first.poll()).toBe(PR_WAIT_EXIT.blocked);
stdout = [];
const again = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'green');
expect(again.baseline).toBe(false);
expect(again.poll()).toBe(PR_WAIT_EXIT.blocked);
expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests');
stdout = [];
const merged = watcher({ pr: prJson({ state: 'MERGED' }) }, 'green');
expect(merged.baseline).toBe(false);
expect(merged.poll()).toBe(PR_WAIT_EXIT.met);
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
});
});