Files
gstack/test/land-and-deploy-ci-gate.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

222 lines
12 KiB
TypeScript

/**
* /land-and-deploy merge gate journeys (#2995): v1.90.2.0 gated only on
* `gh pr checks --required`, so repos without required checks merged over red
* or pending CI. These tests run the template's own merge blocks with the real
* bin/gstack-ci-gate against a fake `gh` that records every `gh pr merge`.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { getQuestion } from '../scripts/question-registry';
import { expectMentions } from './helpers/prompt-structure';
const ROOT = join(import.meta.dir, '..');
const GATE_BIN = join(ROOT, 'bin', 'gstack-ci-gate');
const source = (name: string) => readFileSync(join(ROOT, 'land-and-deploy', name), 'utf8');
const HEAD = 'a'.repeat(40);
const OTHER = 'b'.repeat(40);
const FAKE_GH = `#!/usr/bin/env bash
D="$FAKE_GH_DIR"
if [ "$1" = api ]; then kind=api
elif [ "$1 $2" = "pr merge" ]; then printf '%s\\n' "$*" >> "$D/merges.log"; exit 0
elif [ "$1 $2" = "pr view" ]; then kind=head
elif [ "$1 $2" = "pr checks" ]; then case " $* " in *" --required "*) kind=required ;; *) kind=all ;; esac
else echo "fake gh: unexpected $*" >&2; exit 97
fi
n=$(( $(cat "$D/count.$kind" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$D/count.$kind"
f="$D/$kind.$n"; [ -e "$f.code" ] || f="$D/$kind"
[ -e "$f.out" ] && cat "$f.out"
[ -e "$f.err" ] && cat "$f.err" >&2
exit "$(cat "$f.code" 2>/dev/null || echo 1)"
`;
type Reply = { out?: string; err?: string; code?: number };
const checks = (...rows: Array<[string, string]>): Reply => ({ out: JSON.stringify(rows.map(([name, bucket]) => ({ name, bucket, state: 'X', link: `https://ci.example/${name}` }))), code: 0 });
const NONE: Reply = { err: "no checks reported on the 'feature' branch", code: 1 };
const NONE_REQUIRED: Reply = { err: "no required checks reported on the 'feature' branch", code: 1 };
const head = (sha: string): Reply => ({ out: `${sha}\n`, code: 0 });
/** The two fenced blocks in Step 4 that run `gh pr merge`, keyed by mode. */
function mergeBlocks(): Record<'auto' | 'direct', string> {
const blocks = [...source('sections/merge-and-deploy.md.tmpl').matchAll(/```bash\n([\s\S]*?)\n```/g)]
.map(match => match[1])
.filter(block => /\bgh pr merge /.test(block));
expect(blocks).toHaveLength(2);
const auto = blocks.find(block => block.includes('--auto'));
const direct = blocks.find(block => !block.includes('--auto'));
expect(auto && direct).toBeTruthy();
return { auto: auto!, direct: direct! };
}
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'ci-gate-journey-'));
mkdirSync(join(dir, 'bin'));
writeFileSync(join(dir, 'bin', 'gh'), FAKE_GH);
chmodSync(join(dir, 'bin', 'gh'), 0o755);
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
interface Journey {
heads?: Reply[]; required: Reply | Reply[]; all: Reply | Reply[]; api?: Reply;
mode: 'auto' | 'direct'; noCiApprovedHead?: string; override?: string;
}
function runJourney(j: Journey) {
const replies: Record<string, Reply | Reply[] | undefined> = { head: j.heads ?? [head(HEAD)], required: j.required, all: j.all, api: j.api ?? { err: 'HTTP 404', code: 1 } };
for (const [kind, reply] of Object.entries(replies)) {
const list = Array.isArray(reply) ? reply : [reply!];
list.forEach((r, i) => {
const stem = join(dir, i === list.length - 1 ? kind : `${kind}.${i + 1}`);
writeFileSync(`${stem}.out`, r.out ?? '');
writeFileSync(`${stem}.err`, r.err ?? '');
writeFileSync(`${stem}.code`, String(r.code ?? 0));
});
}
const block = mergeBlocks()[j.mode].replaceAll('~/.claude/skills/gstack/bin/gstack-ci-gate', GATE_BIN);
const script = `${j.override ? `CI_OVERRIDE=(${j.override})\n` : ''}${block}`;
const result = spawnSync('bash', ['-c', script], {
encoding: 'utf8', timeout: 30_000,
env: {
PATH: `${join(dir, 'bin')}:${process.env.PATH}`, HOME: dir, FAKE_GH_DIR: dir,
REPO: 'owner/project', PR_NUMBER: '42', PR_HEAD: HEAD, MERGE_FLAG: '--squash',
NO_CI_APPROVED_HEAD: j.noCiApprovedHead ?? '',
},
});
const log = join(dir, 'merges.log');
const merges = existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [];
for (const name of readdirSync(dir).filter(name => name.startsWith('count.'))) rmSync(join(dir, name));
rmSync(log, { force: true });
return { ...result, merges };
}
describe('merge blocks never merge without a PASS for the pinned head', () => {
const blocked: Array<[string, Omit<Journey, 'mode'>]> = [
['pending check, no required checks configured', { required: NONE_REQUIRED, all: checks(['build', 'pass'], ['test', 'pending']) }],
['failing check, no required checks configured', { required: NONE_REQUIRED, all: checks(['build', 'fail']) }],
['failing required check', { required: checks(['ci', 'fail']), all: checks(['ci', 'fail']) }],
['cancelled check', { required: NONE_REQUIRED, all: checks(['build', 'cancel']) }],
['unknown bucket', { required: NONE_REQUIRED, all: checks(['build', 'stale']) }],
['no checks and no explicit no-CI approval', { required: NONE, all: NONE }],
['no-CI approval recorded for another head', { required: NONE, all: NONE, noCiApprovedHead: OTHER }],
['malformed checks JSON', { required: NONE_REQUIRED, all: { out: '{"oops"', code: 0 } }],
['check without a bucket field (old gh)', { required: NONE_REQUIRED, all: { out: '[{"name":"build","state":"SUCCESS"}]', code: 0 } }],
['gh auth failure', { required: { err: 'HTTP 401: Bad credentials', code: 1 }, all: { err: 'HTTP 401: Bad credentials', code: 1 } }],
['checks registered after the no-CI approval', { required: NONE_REQUIRED, all: checks(['build', 'pending']), noCiApprovedHead: HEAD }],
['head moved mid-gate', { heads: [head(HEAD), head(OTHER)], required: NONE_REQUIRED, all: checks(['build', 'pass']) }],
['head already moved', { heads: [head(OTHER)], required: NONE_REQUIRED, all: checks(['build', 'pass']) }],
['required green, non-required pending', { required: checks(['ci', 'pass']), all: checks(['ci', 'pass'], ['preview', 'pending']) }],
['required green, non-required red', { required: checks(['ci', 'pass']), all: checks(['ci', 'pass'], ['lint', 'fail']) }],
['override approved for another head', { required: checks(['ci', 'pass']), all: checks(['ci', 'pass'], ['lint', 'fail']), override: `--override-head ${OTHER} --exclude lint` }],
['override naming a required check', { required: checks(['ci', 'fail']), all: checks(['ci', 'fail']), override: `--override-head ${HEAD} --exclude ci` }],
];
for (const mode of ['auto', 'direct'] as const) {
for (const [name, journey] of blocked) {
test(`${mode}: ${name} → zero merges`, () => {
const result = runJourney({ ...journey, mode });
expect(result.merges).toEqual([]);
expect(result.status).not.toBe(0);
expect(result.stdout).toMatch(/^VERDICT (FAIL|PENDING|NO_CHECKS|ERROR) /);
});
}
}
});
describe('positive controls: the same blocks do merge when the gate passes', () => {
for (const mode of ['auto', 'direct'] as const) {
test(`${mode}: all green → exactly one merge pinned to the head`, () => {
const result = runJourney({ mode, required: checks(['ci', 'pass']), all: checks(['ci', 'pass'], ['docs', 'skipping']) });
expect(result.status).toBe(0);
expect(result.merges).toHaveLength(1);
expect(result.merges[0]).toContain(`--match-head-commit ${HEAD}`);
expect(result.merges[0].includes('--auto')).toBe(mode === 'auto');
});
test(`${mode}: explicit per-head override of named non-required checks → one merge`, () => {
const result = runJourney({
mode, required: checks(['ci', 'pass']), all: checks(['ci', 'pass'], ['lint check', 'fail']),
override: `--override-head "$PR_HEAD" --exclude "lint check"`,
});
expect(result.stdout).toContain('\texcluded');
expect(result.merges).toHaveLength(1);
});
test(`${mode}: no CI on the head, explicitly confirmed for this head → one merge`, () => {
const result = runJourney({ mode, required: NONE, all: NONE, noCiApprovedHead: HEAD });
expect(result.stdout).toStartWith(`VERDICT NO_CHECKS ${HEAD}`);
expect(result.merges).toHaveLength(1);
});
}
});
describe('template wiring', () => {
const main = source('SKILL.md.tmpl');
const step = (title: string, next: string) => main.slice(main.indexOf(title), main.indexOf(next));
test('Step 2 calls the gate helper and Step 3 is one gstack-pr-wait call, not a polling loop or a --required-only query', () => {
const step2 = step('## Step 2: Pre-merge checks', '## Step 3: Wait for CI');
const step3 = step('## Step 3: Wait for CI', '## Step 3.4');
expect(step2).toContain('bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD"');
expect(step3).toContain('bin/gstack-pr-wait "$PR_NUMBER" --repo "$REPO" --until green');
expect(step3.match(/```bash/g)).toHaveLength(1);
expect(`${step2}${step3}`).not.toMatch(/gh pr checks[^\n]*--required/);
expect(step3).not.toMatch(/--watch|--wait \d+|4-minute|sleep/);
for (const code of ['Exit 0', 'PR_WAIT_BLOCKED', 'PR_WAIT_REFUSED']) expect(step3).toContain(code);
expect(step3).toMatch(/restart at\s+Step 1/);
expect(main).not.toContain('4-minute');
expect(step2).toContain('never on the exit code');
});
test('both merge commands, including the direct fallback, run only after the gate', () => {
for (const block of Object.values(mergeBlocks())) {
const gate = block.indexOf('bin/gstack-ci-gate');
expect(gate).toBeGreaterThanOrEqual(0);
expect(gate).toBeLessThan(block.indexOf('gh pr merge'));
expect(block).toContain('"VERDICT PASS $PR_HEAD"|"VERDICT NO_CHECKS $NO_CI_APPROVED_HEAD") ;;');
expect(block).toContain('*) exit 1 ;;');
}
expectMentions(source('sections/merge-and-deploy.md.tmpl'), [['only', 'check-to-merge', 'protection']], 'section');
});
test('readiness report carries a CI row and one-way override questions', () => {
const readiness = source('sections/readiness-gate.md.tmpl');
expect(readiness).toContain('CI (head <sha7>)');
expect(readiness).toContain('land-and-deploy-ci-override');
expect(readiness).toContain('land-and-deploy-no-ci-confirm');
expect(readiness).toContain('never stored or reused');
});
test('generated skill files carry the helper calls', () => {
expect(source('SKILL.md')).toContain('bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD"');
expect(source('sections/merge-and-deploy.md').match(/bin\/gstack-ci-gate /g)).toHaveLength(2);
expect(source('sections/readiness-gate.md')).toContain('CI (head <sha7>)');
});
});
describe('CI override questions are one-way doors', () => {
const ids = ['land-and-deploy-ci-override', 'land-and-deploy-no-ci-confirm'];
const PREF = join(ROOT, 'bin', 'gstack-question-preference');
const pref = (...args: string[]) => spawnSync(PREF, args, { encoding: 'utf8', cwd: ROOT, timeout: 30_000, env: { ...process.env, GSTACK_HOME: dir } });
test('registered as one-way', () => {
for (const id of ids) expect(getQuestion(id)).toMatchObject({ skill: 'land-and-deploy', door_type: 'one-way' });
});
test('a never-ask preference cannot be written or auto-decide the merge over red', () => {
for (const id of ids) {
const write = pref('--write', JSON.stringify({ question_id: id, preference: 'never-ask', source: 'plan-tune' }));
expect(write.status).toBe(1);
expect(write.stderr).toContain('(door_type: one-way)');
pref('--read');
const project = readdirSync(join(dir, 'projects'))[0];
const file = join(dir, 'projects', project, 'question-preferences.json');
writeFileSync(file, JSON.stringify({ ...JSON.parse(readFileSync(file, 'utf8')), [id]: 'never-ask' }));
const check = pref('--check', id);
expect(check.stdout).toContain('ASK_NORMALLY');
expect(check.stdout).not.toContain('AUTO_DECIDE');
}
});
});