Files
gstack/lib/ship-queue-stack-tests.ts
T
garrytanandcapy-ai[bot] c75ec3ad5a feat(ship-queue): screens read from the run, train watch/forget/prune --stale, verify --post-merge, ready --dispatch-ci, --stack-tests
Operating merge trains on 2026-10-26 showed the gaps this closes. A dispatched
screen's verdict is read from the run's gate-binding and report-verdict artifacts
(lib/ship-queue-screen.ts) and recorded in the READY receipt: a RED screen refuses
ready under either choice and is never re-dispatched at the same merge SHA
(QUEUE_SCREEN_RED); a run that concluded success with no slice is NOT-RUN
(QUEUE_SCREEN_NOT_RUN), never followed, and counted as unscreened by train build.
When refs/pull/<n>/merge lags or is missing the screen is planned on a scratch merge
pushed as screen/<pr>-<head12> (#3465); QUEUE_SCREEN_STALE_REF means a real
conflict only. train watch --train is the resumable follow command; train prune
--stale settles a merging/green train whose PRs all merged or closed elsewhere;
train forget removes one record with an exact lease on refs/notes/gstack-trains;
verify --post-merge <sha> watches main's push workflows until they settle;
ready --dispatch-ci <wf,…> dispatches the free workflows on a conflicting PR,
waits, and clears the matching requires-remote pregate lanes with the run URLs;
train build --dry-run --stack-tests runs each PR's new test files on its integrated
prefix before anything is dispatched (QUEUE_TRAIN_STACK_RED).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 15:30:32 +00:00

63 lines
3.9 KiB
TypeScript

/**
* `train build --dry-run --stack-tests`: before a train spends a gate, run
* each PR's new test files on the integrated prefix that includes it (T<k>,
* the merge of main and every slot up to and including the PR), not on the
* PR head alone. A test that passes on its branch and fails once an earlier
* slot changed what it reads is a stack red, caught here for the price of a
* few test files instead of a gate. Each prefix is checked out in a scratch
* worktree (the repo's node_modules linked in when it has one), the files run
* with the test command (`bun --no-env-file --config=/dev/null test` by
* default; `--stack-tests-command "<cmd>"` for another runner, the files
* appended), and the worktree is removed. A PR that adds no test file is
* reported as such and never fails.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { bunIsolationArgs } from './bun-isolation';
import { git } from './ship-queue-gate';
import type { TrainPrefix, TrainSlot } from './ship-queue-train-record';
export const DEFAULT_STACK_TEST_COMMAND = ['bun', ...bunIsolationArgs(), 'test'];
const TEST_FILE = /(?:^|\/)(?:tests?|__tests__|spec)\/.*\.[cm]?[jt]sx?$|\.(?:test|spec)\.[cm]?[jt]sx?$/;
export interface StackTestResult { pr: string; prefix: number; files: string[]; ok: boolean; detail: string }
/** Test files the PR adds relative to the base (added, not modified). */
export function newTestFiles(repoRoot: string, baseSha: string, head: string): string[] {
const r = git(repoRoot, ['diff', '--name-only', '--diff-filter=A', `${baseSha}...${head}`]);
return r.ok ? r.out.split('\n').map(s => s.trim()).filter(f => f && TEST_FILE.test(f)) : [];
}
export function stackTests(repoRoot: string, baseSha: string, slots: TrainSlot[], prefixes: TrainPrefix[], command: string[] = DEFAULT_STACK_TEST_COMMAND, timeoutMs = 600_000): StackTestResult[] {
const results: StackTestResult[] = [];
for (const [i, slot] of slots.entries()) {
const prefix = prefixes[i + 1];
if (!prefix) break;
const files = newTestFiles(repoRoot, baseSha, slot.head);
if (!files.length) { results.push({ pr: slot.pr, prefix: prefix.index, files, ok: true, detail: 'no new test files' }); continue; }
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-stack-tests-'));
try {
const add = git(repoRoot, ['worktree', 'add', '--detach', '-q', dir, prefix.commit]);
if (!add.ok) { results.push({ pr: slot.pr, prefix: prefix.index, files, ok: false, detail: `cannot check out T${prefix.index}: ${add.err.slice(0, 160)}` }); continue; }
const modules = path.join(repoRoot, 'node_modules');
if (fs.existsSync(modules) && !fs.existsSync(path.join(dir, 'node_modules'))) fs.symlinkSync(modules, path.join(dir, 'node_modules'), 'dir');
const [bin, ...args] = command;
const run = spawnSync(bin!, [...args, ...files], { cwd: dir, encoding: 'utf8', timeout: timeoutMs, env: { ...process.env, CI: process.env.CI ?? '1' }, maxBuffer: 64 * 1024 * 1024 });
const tail = `${run.stdout ?? ''}\n${run.stderr ?? ''}`.split('\n').filter(l => /^\(fail\)|^\s*\d+ (pass|fail)$|^error:/.test(l)).slice(-6).join('; ');
results.push({ pr: slot.pr, prefix: prefix.index, files, ok: run.status === 0, detail: run.status === 0 ? 'pass' : run.error ? `${run.error.message}` : tail || `exit ${run.status}` });
} finally {
git(repoRoot, ['worktree', 'remove', '--force', dir]);
fs.rmSync(dir, { recursive: true, force: true });
}
}
return results;
}
/** One `STACK_TESTS:` line per slot. */
export function stackTestLine(r: StackTestResult): string {
if (!r.files.length) return `STACK_TESTS: #${r.pr} T${r.prefix} no new test files`;
return `STACK_TESTS: #${r.pr} T${r.prefix} ${r.files.length} new test file(s) ${r.ok ? 'pass' : 'FAIL'} (${r.files.join(', ')})${r.ok || r.detail === 'pass' ? '' : `: ${r.detail}`}`;
}