mirror of https://github.com/garrytan/gstack.git
feat(merge): gstack-merge regime-aware merge helper + tests
Pure regime logic in lib/merge.ts (detect/submit-plan/classify-land/handoff schema+validation) behind a thin bin/gstack-merge CLI (detect/submit/wait/ write-state/read-state). Supports none, GitHub native merge queue, and the trunk.io merge queue with a comment-first submit chain. 41 deterministic tests (30 unit + 11 CLI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9562ad4e70
commit
e7741d9841
|
|
@ -0,0 +1,433 @@
|
|||
#!/usr/bin/env bun
|
||||
// gstack-merge — regime-aware merge driver for /land and /land-and-deploy.
|
||||
//
|
||||
// Owns the four risky merge operations as a thin CLI over the pure logic in
|
||||
// lib/merge.ts (which is unit-tested). Supports three regimes: none, GitHub
|
||||
// native merge queue, and trunk.io merge queue.
|
||||
//
|
||||
// Subcommands:
|
||||
// detect [--base B] [--pr N] [--json]
|
||||
// submit --regime R --pr N [--base B] [--priority P]
|
||||
// wait --pr N --base B [--regime R] [--timeout S] [--interval S]
|
||||
// [--enqueue-timeout S] [--once]
|
||||
// write-state --pr N --base B --slug SLUG [--regime R] [--sha-timeout S]
|
||||
// read-state --slug SLUG --pr N --repo OWNER/NAME [--max-age-ms N] [--json]
|
||||
//
|
||||
// Contract: detect/wait/read-state never mutate git/PR state. submit and
|
||||
// write-state are the only state-touching subcommands.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
detectRegime,
|
||||
planSubmit,
|
||||
classifyLand,
|
||||
buildLandState,
|
||||
validateConsume,
|
||||
isTrunkQueueCheck,
|
||||
trunkQueueCheckName,
|
||||
type Regime,
|
||||
type PrCheck,
|
||||
} from '../lib/merge';
|
||||
|
||||
// --- arg parsing --------------------------------------------------------
|
||||
|
||||
function parseArgs(argv: string[]): { cmd: string; flags: Record<string, string>; bools: Set<string> } {
|
||||
const cmd = argv[0] || '';
|
||||
const flags: Record<string, string> = {};
|
||||
const bools = new Set<string>();
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith('--')) {
|
||||
const key = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next !== undefined && !next.startsWith('--')) {
|
||||
flags[key] = next;
|
||||
i++;
|
||||
} else {
|
||||
bools.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { cmd, flags, bools };
|
||||
}
|
||||
|
||||
// --- shell helpers ------------------------------------------------------
|
||||
|
||||
function run(cmd: string, args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf-8', timeout: 60000 });
|
||||
return { code: r.status ?? 1, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() };
|
||||
}
|
||||
|
||||
function ghJson<T>(args: string[]): T | null {
|
||||
const r = run('gh', args);
|
||||
if (!r.stdout) return null;
|
||||
try {
|
||||
return JSON.parse(r.stdout) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function which(bin: string): boolean {
|
||||
return run('command', ['-v', bin]).code === 0 || run('sh', ['-c', `command -v ${bin}`]).code === 0;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// --- data gatherers -----------------------------------------------------
|
||||
|
||||
interface PrView {
|
||||
number?: number;
|
||||
state?: string;
|
||||
mergeCommit?: { oid?: string } | null;
|
||||
headRefOid?: string;
|
||||
headRefName?: string;
|
||||
baseRefName?: string;
|
||||
autoMergeRequest?: unknown;
|
||||
}
|
||||
|
||||
function prView(pr: string | undefined): PrView | null {
|
||||
const args = ['pr', 'view'];
|
||||
if (pr) args.push(pr);
|
||||
args.push('--json', 'number,state,mergeCommit,headRefOid,headRefName,baseRefName,autoMergeRequest');
|
||||
return ghJson<PrView>(args);
|
||||
}
|
||||
|
||||
function prChecks(pr: string | undefined): PrCheck[] {
|
||||
const args = ['pr', 'checks'];
|
||||
if (pr) args.push(pr);
|
||||
// gh pr checks exits non-zero when checks are failing/pending; we still want the JSON.
|
||||
args.push('--json', 'name,state,bucket');
|
||||
const r = run('gh', args);
|
||||
if (!r.stdout) return [];
|
||||
try {
|
||||
return JSON.parse(r.stdout) as PrCheck[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function repoSlug(): string {
|
||||
const r = ghJson<{ nameWithOwner?: string }>(['repo', 'view', '--json', 'nameWithOwner']);
|
||||
return r?.nameWithOwner || '';
|
||||
}
|
||||
|
||||
function readTrunkYaml(): string | null {
|
||||
const p = join(process.cwd(), '.trunk', 'trunk.yaml');
|
||||
if (existsSync(p)) {
|
||||
try { return readFileSync(p, 'utf-8'); } catch { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse "Merge queue: X" from the ## Merge Configuration section of CLAUDE.md. */
|
||||
function readConfigRegime(): string | null {
|
||||
const p = join(process.cwd(), 'CLAUDE.md');
|
||||
if (!existsSync(p)) return null;
|
||||
let body: string;
|
||||
try { body = readFileSync(p, 'utf-8'); } catch { return null; }
|
||||
const m = body.match(/##\s*Merge Configuration[\s\S]*?(?=\n##\s|\n#\s|$)/);
|
||||
if (!m) return null;
|
||||
const line = m[0].match(/Merge queue:\s*([a-zA-Z]+)/);
|
||||
return line ? line[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
/** Best-effort GitHub-native merge-queue detection via the GraphQL API. */
|
||||
function githubMergeQueueEnabled(slug: string, base: string): boolean {
|
||||
if (!slug.includes('/')) return false;
|
||||
const [owner, name] = slug.split('/');
|
||||
const q = `query($o:String!,$n:String!,$b:String!){repository(owner:$o,name:$n){mergeQueue(branch:$b){id}}}`;
|
||||
const r = run('gh', ['api', 'graphql', '-f', `query=${q}`, '-f', `o=${owner}`, '-f', `n=${name}`, '-f', `b=${base}`]);
|
||||
if (r.code !== 0 || !r.stdout) return false;
|
||||
try {
|
||||
const j = JSON.parse(r.stdout);
|
||||
return !!j?.data?.repository?.mergeQueue?.id;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Does origin/<base> contain the head commit? (fetches first). */
|
||||
function baseContainsHead(base: string, headRefOid: string): boolean {
|
||||
if (!headRefOid) return false;
|
||||
run('git', ['fetch', 'origin', base]);
|
||||
return run('git', ['merge-base', '--is-ancestor', headRefOid, `origin/${base}`]).code === 0;
|
||||
}
|
||||
|
||||
function findQueueCheck(checks: PrCheck[], base: string): PrCheck | null {
|
||||
return (
|
||||
checks.find((c) => c.name === trunkQueueCheckName(base)) ||
|
||||
checks.find((c) => isTrunkQueueCheck(c.name)) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// --- subcommands --------------------------------------------------------
|
||||
|
||||
async function cmdDetect(flags: Record<string, string>, bools: Set<string>): Promise<number> {
|
||||
const pr = flags.pr;
|
||||
const slug = repoSlug();
|
||||
const base = flags.base || prView(pr)?.baseRefName || 'main';
|
||||
const checks = prChecks(pr);
|
||||
const result = detectRegime({
|
||||
base,
|
||||
checks,
|
||||
trunkYaml: readTrunkYaml(),
|
||||
configRegime: readConfigRegime(),
|
||||
githubMergeQueue: githubMergeQueueEnabled(slug, base),
|
||||
});
|
||||
if (bools.has('json')) {
|
||||
process.stdout.write(JSON.stringify({ ...result, base }) + '\n');
|
||||
} else {
|
||||
process.stdout.write(`MERGE_REGIME=${result.regime}\nMERGE_REGIME_SOURCE=${result.source}\nBASE=${base}\n`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cmdSubmit(flags: Record<string, string>): number {
|
||||
const pr = flags.pr;
|
||||
const regime = (flags.regime || 'none') as Regime;
|
||||
if (!pr) { process.stderr.write('submit: --pr is required\n'); return 2; }
|
||||
|
||||
const plan = planSubmit(regime, Number(pr), {
|
||||
trunkCliAvailable: which('trunk'),
|
||||
trunkToken: !!process.env.TRUNK_API_TOKEN,
|
||||
priority: flags.priority,
|
||||
});
|
||||
|
||||
for (const cand of plan.candidates) {
|
||||
process.stdout.write(`→ ${cand.desc}: ${cand.cmd} ${cand.args.join(' ')}\n`);
|
||||
let r: { code: number; stdout: string; stderr: string };
|
||||
if (cand.cmd === 'trunk-rest') {
|
||||
r = submitViaRest(Number(pr), flags.base || '', flags.priority);
|
||||
} else {
|
||||
r = run(cand.cmd, cand.args);
|
||||
}
|
||||
if (r.code === 0) {
|
||||
process.stdout.write(`SUBMIT_OK=${regime}\nSUBMIT_VIA=${cand.desc}\n`);
|
||||
if (r.stdout) process.stdout.write(r.stdout + '\n');
|
||||
return 0;
|
||||
}
|
||||
process.stderr.write(` (failed: ${r.stderr || r.stdout || 'exit ' + r.code})\n`);
|
||||
}
|
||||
process.stderr.write(`SUBMIT_FAILED=${regime} — no candidate succeeded\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
function submitViaRest(pr: number, base: string, priority?: string): { code: number; stdout: string; stderr: string } {
|
||||
const token = process.env.TRUNK_API_TOKEN;
|
||||
if (!token) return { code: 1, stdout: '', stderr: 'TRUNK_API_TOKEN not set' };
|
||||
const slug = repoSlug();
|
||||
const [owner, name] = slug.split('/');
|
||||
const payload = JSON.stringify({
|
||||
repo: { host: 'github.com', owner, name },
|
||||
pr: { number: pr },
|
||||
targetBranch: base || undefined,
|
||||
...(priority ? { priority } : {}),
|
||||
});
|
||||
return run('curl', [
|
||||
'-sS', '-X', 'POST', 'https://api.trunk.io/v1/submitPullRequest',
|
||||
'-H', `x-api-token: ${token}`,
|
||||
'-H', 'Content-Type: application/json',
|
||||
'-d', payload,
|
||||
]);
|
||||
}
|
||||
|
||||
async function cmdWait(flags: Record<string, string>): Promise<number> {
|
||||
const pr = flags.pr;
|
||||
const base = flags.base;
|
||||
if (!pr || !base) { process.stderr.write('wait: --pr and --base are required\n'); return 2; }
|
||||
const regime = (flags.regime || 'none') as Regime;
|
||||
const intervalMs = (Number(flags.interval) || 30) * 1000;
|
||||
const timeoutMs = (Number(flags.timeout) || 1800) * 1000; // 30 min
|
||||
const enqueueMs = (Number(flags['enqueue-timeout']) || 120) * 1000;
|
||||
const once = flags.once !== undefined || process.argv.includes('--once');
|
||||
|
||||
const classifyNow = () => {
|
||||
const v = prView(pr) || {};
|
||||
const checks = prChecks(pr);
|
||||
const queueCheck = findQueueCheck(checks, base);
|
||||
const oid = v.mergeCommit?.oid || null;
|
||||
return classifyLand({
|
||||
state: v.state || 'OPEN',
|
||||
mergeCommitOid: oid,
|
||||
baseContainsHead: oid ? true : baseContainsHead(base, v.headRefOid || ''),
|
||||
queueCheck,
|
||||
autoMergeEnabled: v.autoMergeRequest != null,
|
||||
});
|
||||
};
|
||||
|
||||
if (once) {
|
||||
const c = classifyNow();
|
||||
process.stdout.write(`LAND_STATUS=${c.status}\nLAND_REASON=${c.reason}\n`);
|
||||
return c.status === 'landed' ? 0 : c.status === 'pending' ? 0 : 1;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
// Trunk phase 1: confirm the comment actually enqueued (the queue check
|
||||
// appears) — a posted "/trunk merge" comment is silently inert if the
|
||||
// GitHub App isn't installed or "GitHub commands" is off.
|
||||
if (regime === 'trunk') {
|
||||
let enqueued = false;
|
||||
while (Date.now() - start < enqueueMs) {
|
||||
const v = prView(pr) || {};
|
||||
if ((v.state || '').toUpperCase() === 'MERGED') { enqueued = true; break; }
|
||||
if (findQueueCheck(prChecks(pr), base)) { enqueued = true; break; }
|
||||
await sleep(Math.min(intervalMs, 15000));
|
||||
}
|
||||
if (!enqueued) {
|
||||
process.stderr.write(
|
||||
'TRUNK_ENQUEUE_TIMEOUT — posted "/trunk merge" but no "' + trunkQueueCheckName(base) +
|
||||
'" check appeared. Is the Trunk GitHub App installed and "GitHub commands" enabled?\n',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write('Trunk picked up the PR — waiting for the queue to finish.\n');
|
||||
}
|
||||
|
||||
// Phase 2: resolution.
|
||||
let lastNarrate = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const c = classifyNow();
|
||||
if (c.status === 'landed') {
|
||||
process.stdout.write(`LAND_STATUS=landed\nLAND_REASON=${c.reason}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (c.status === 'ejected') {
|
||||
process.stderr.write(`LAND_STATUS=ejected\nLAND_REASON=${c.reason}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (c.status === 'closed') {
|
||||
process.stderr.write(`LAND_STATUS=closed\nLAND_REASON=${c.reason}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (Date.now() - lastNarrate > 120000) {
|
||||
const mins = Math.round((Date.now() - start) / 60000);
|
||||
process.stdout.write(`Still waiting to land... (${mins}m so far)\n`);
|
||||
lastNarrate = Date.now();
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
process.stderr.write(`LAND_STATUS=timeout\nLAND_REASON=did not land within ${Math.round(timeoutMs / 60000)}m\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function cmdWriteState(flags: Record<string, string>): Promise<number> {
|
||||
const pr = flags.pr;
|
||||
const base = flags.base;
|
||||
const slug = flags.slug;
|
||||
if (!pr || !base || !slug) { process.stderr.write('write-state: --pr, --base, --slug are required\n'); return 2; }
|
||||
const regime = (flags.regime || 'none') as Regime;
|
||||
const shaTimeoutMs = (Number(flags['sha-timeout']) || 90) * 1000;
|
||||
|
||||
// Poll until MERGED with a non-null merge SHA (the SHA can lag the state
|
||||
// flip on squash/queue merges). Fall back to the base tip only in the
|
||||
// genuine rebase-null case (MERGED + base contains head but oid stays null).
|
||||
const start = Date.now();
|
||||
let v: PrView | null = null;
|
||||
let oid: string | null = null;
|
||||
while (Date.now() - start < shaTimeoutMs) {
|
||||
v = prView(pr);
|
||||
const state = (v?.state || '').toUpperCase();
|
||||
oid = v?.mergeCommit?.oid || null;
|
||||
if (state === 'MERGED' && oid) break;
|
||||
if (state === 'CLOSED') { process.stderr.write('write-state: PR is CLOSED, not merged\n'); return 1; }
|
||||
await sleep(5000);
|
||||
}
|
||||
v = v || prView(pr) || {};
|
||||
const state = (v.state || '').toUpperCase();
|
||||
const headRefOid = v.headRefOid || '';
|
||||
const contains = baseContainsHead(base, headRefOid);
|
||||
|
||||
if (state !== 'MERGED' || !(oid || contains)) {
|
||||
process.stderr.write('write-state: landing not confirmed (PR not MERGED on base) — refusing to write handoff\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
let sha = oid || '';
|
||||
let shaNote = '';
|
||||
if (!sha) {
|
||||
// rebase-null edge: use the base tip as the best available revert target.
|
||||
const tip = run('git', ['rev-parse', `origin/${base}`]).stdout;
|
||||
sha = tip;
|
||||
shaNote = ' (no mergeCommit.oid — using base tip; rebase-merge repo)';
|
||||
}
|
||||
|
||||
const state2 = buildLandState({
|
||||
pr: Number(pr),
|
||||
sha,
|
||||
headRefOid,
|
||||
base,
|
||||
head_branch: v.headRefName || '',
|
||||
repo: repoSlug(),
|
||||
regime,
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const dir = join(homedir(), '.gstack', 'projects', slug);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const dest = join(dir, 'last-land.json');
|
||||
const tmp = `${dest}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(state2, null, 2) + '\n', { mode: 0o600 });
|
||||
renameSync(tmp, dest);
|
||||
|
||||
process.stdout.write(`LANDED: pr=#${pr} sha=${sha} regime=${regime} base=${base}${shaNote}\n`);
|
||||
process.stdout.write(`HANDOFF_FILE=${dest}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cmdReadState(flags: Record<string, string>, bools: Set<string>): number {
|
||||
const slug = flags.slug;
|
||||
const pr = flags.pr;
|
||||
const repo = flags.repo;
|
||||
if (!slug || !pr || !repo) { process.stderr.write('read-state: --slug, --pr, --repo are required\n'); return 2; }
|
||||
const dest = join(homedir(), '.gstack', 'projects', slug, 'last-land.json');
|
||||
let state: any = null;
|
||||
if (existsSync(dest)) {
|
||||
try { state = JSON.parse(readFileSync(dest, 'utf-8')); } catch { state = null; }
|
||||
}
|
||||
const v = validateConsume(state, {
|
||||
pr: Number(pr),
|
||||
repo,
|
||||
maxAgeMs: flags['max-age-ms'] ? Number(flags['max-age-ms']) : undefined,
|
||||
}, Date.now());
|
||||
if (!v.ok) {
|
||||
process.stderr.write(`READ_STATE_INVALID=${v.reason}\n`);
|
||||
return 1;
|
||||
}
|
||||
if (bools.has('json')) {
|
||||
process.stdout.write(JSON.stringify(state) + '\n');
|
||||
} else {
|
||||
process.stdout.write(`LAND_SHA=${state.sha}\nLAND_BASE=${state.base}\nLAND_REGIME=${state.regime}\nLAND_HEAD=${state.head_branch}\n`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- main ---------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const { cmd, flags, bools } = parseArgs(process.argv.slice(2));
|
||||
switch (cmd) {
|
||||
case 'detect': return cmdDetect(flags, bools);
|
||||
case 'submit': return cmdSubmit(flags);
|
||||
case 'wait': return cmdWait(flags);
|
||||
case 'write-state': return cmdWriteState(flags);
|
||||
case 'read-state': return cmdReadState(flags, bools);
|
||||
default:
|
||||
process.stderr.write('usage: gstack-merge <detect|submit|wait|write-state|read-state> [flags]\n');
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().then((code) => process.exit(code)).catch((err) => {
|
||||
process.stderr.write(`gstack-merge: ${err?.message || err}\n`);
|
||||
process.exit(3);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
// lib/merge.ts — pure merge-regime logic for /land and /land-and-deploy.
|
||||
//
|
||||
// This module is the single source of truth for the four risky merge
|
||||
// operations, kept pure (no I/O) so they can be unit-tested with fixtures:
|
||||
//
|
||||
// detectRegime — none | github | trunk, from gh/check/config signals
|
||||
// planSubmit — ordered list of submit commands per regime (trunk = comment-first)
|
||||
// classifyLand — landed | ejected | pending | closed, from PR state + checks
|
||||
// buildLandState / validateConsume — the last-land.json handoff contract
|
||||
//
|
||||
// The CLI wrapper (bin/gstack-merge) gathers the live data via gh/git/fs and
|
||||
// calls these functions. Keeping logic here means an E2E that inspects a
|
||||
// command tests prompt text, but the unit tests here test behavior.
|
||||
|
||||
export type Regime = 'none' | 'github' | 'trunk';
|
||||
|
||||
export const LAND_STATE_SCHEMA_VERSION = 1;
|
||||
|
||||
// --- Regime detection ---------------------------------------------------
|
||||
|
||||
export interface PrCheck {
|
||||
/** Check name, e.g. "Trunk Merge Queue (main)". */
|
||||
name?: string;
|
||||
/** gh exposes `state` (e.g. SUCCESS) and/or `bucket` (pass/fail/pending). */
|
||||
state?: string;
|
||||
bucket?: string;
|
||||
}
|
||||
|
||||
export interface DetectInput {
|
||||
/** Base branch the PR targets, e.g. "main". */
|
||||
base: string;
|
||||
/** Output of `gh pr checks --json name,state,bucket`. */
|
||||
checks: PrCheck[];
|
||||
/** Contents of .trunk/trunk.yaml if present, else null. */
|
||||
trunkYaml: string | null;
|
||||
/** Explicit "Merge queue: X" from CLAUDE.md ## Merge Configuration, else null. */
|
||||
configRegime?: string | null;
|
||||
/** True when branch protection on `base` has a GitHub-native merge queue. */
|
||||
githubMergeQueue?: boolean;
|
||||
}
|
||||
|
||||
export interface DetectResult {
|
||||
regime: Regime;
|
||||
/** How the regime was decided — for honest narration. */
|
||||
source: 'config' | 'trunk-status-check' | 'trunk-yaml' | 'github-branch-protection' | 'default';
|
||||
}
|
||||
|
||||
const VALID_REGIMES: Regime[] = ['none', 'github', 'trunk'];
|
||||
|
||||
/** Name of the GitHub status check Trunk posts on PRs, e.g. "Trunk Merge Queue (main)". */
|
||||
export function trunkQueueCheckName(base: string): string {
|
||||
return `Trunk Merge Queue (${base})`;
|
||||
}
|
||||
|
||||
/** Match any "Trunk Merge Queue (<branch>)" check regardless of branch. */
|
||||
export function isTrunkQueueCheck(name: string | undefined): boolean {
|
||||
return !!name && /^Trunk Merge Queue \(.+\)$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the merge regime. Precedence:
|
||||
* 1. explicit config key (the project owns its config)
|
||||
* 2. live Trunk status check on the PR (the authoritative live signal)
|
||||
* 3. .trunk/trunk.yaml `merge:` section (secondary — NOT `.trunk/` presence
|
||||
* alone, which `trunk check` also creates)
|
||||
* 4. GitHub-native merge queue from branch protection
|
||||
* 5. none
|
||||
*/
|
||||
export function detectRegime(input: DetectInput): DetectResult {
|
||||
const cfg = (input.configRegime || '').trim().toLowerCase();
|
||||
if (VALID_REGIMES.includes(cfg as Regime)) {
|
||||
return { regime: cfg as Regime, source: 'config' };
|
||||
}
|
||||
|
||||
if (input.checks.some((c) => isTrunkQueueCheck(c.name))) {
|
||||
return { regime: 'trunk', source: 'trunk-status-check' };
|
||||
}
|
||||
|
||||
// Secondary: a `merge:` section in .trunk/trunk.yaml means merge-queue is
|
||||
// configured for this repo (distinct from a bare .trunk/ that only carries
|
||||
// `trunk check` linter config).
|
||||
if (input.trunkYaml && /^\s*merge\s*:/m.test(input.trunkYaml)) {
|
||||
return { regime: 'trunk', source: 'trunk-yaml' };
|
||||
}
|
||||
|
||||
if (input.githubMergeQueue) {
|
||||
return { regime: 'github', source: 'github-branch-protection' };
|
||||
}
|
||||
|
||||
return { regime: 'none', source: 'default' };
|
||||
}
|
||||
|
||||
// --- Submit planning ----------------------------------------------------
|
||||
|
||||
export interface SubmitCandidate {
|
||||
/** Program to run. */
|
||||
cmd: string;
|
||||
/** Args (pr number substituted). */
|
||||
args: string[];
|
||||
/** Human description for narration. */
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface SubmitPlan {
|
||||
regime: Regime;
|
||||
/** Candidates to try in order; first that runs cleanly wins. */
|
||||
candidates: SubmitCandidate[];
|
||||
/** Whether `--delete-branch` is owned by us (false for trunk — Trunk owns it). */
|
||||
deleteBranch: boolean;
|
||||
}
|
||||
|
||||
export interface SubmitOpts {
|
||||
/** `trunk` CLI is installed + on PATH. */
|
||||
trunkCliAvailable?: boolean;
|
||||
/** $TRUNK_API_TOKEN is set (enables the REST fallback). */
|
||||
trunkToken?: boolean;
|
||||
/** Optional priority word for trunk (urgent|high|medium|low|lowest). */
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ordered submit plan for a regime.
|
||||
*
|
||||
* trunk is **comment-first**: `gh pr comment "/trunk merge"` needs zero new
|
||||
* auth (gh is already required and Trunk's "GitHub commands" toggle is
|
||||
* default-ON), so it works the moment Trunk's GitHub App is installed. The
|
||||
* trunk CLI and REST are opportunistic upgrades.
|
||||
*/
|
||||
export function planSubmit(regime: Regime, pr: number, opts: SubmitOpts = {}): SubmitPlan {
|
||||
const prRef = String(pr);
|
||||
if (regime === 'none') {
|
||||
return {
|
||||
regime,
|
||||
deleteBranch: true,
|
||||
candidates: [
|
||||
{ cmd: 'gh', args: ['pr', 'merge', prRef, '--squash', '--delete-branch'], desc: 'direct squash merge' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (regime === 'github') {
|
||||
return {
|
||||
regime,
|
||||
deleteBranch: true,
|
||||
candidates: [
|
||||
{ cmd: 'gh', args: ['pr', 'merge', prRef, '--auto', '--delete-branch'], desc: 'GitHub auto-merge / merge queue' },
|
||||
// If --auto is not enabled on the repo, fall back to a direct squash.
|
||||
{ cmd: 'gh', args: ['pr', 'merge', prRef, '--squash', '--delete-branch'], desc: 'direct squash merge (auto unavailable)' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// trunk — comment-first, then CLI, then REST. NEVER `gh pr merge`, NEVER
|
||||
// --delete-branch (Trunk owns the merge and branch cleanup).
|
||||
const commentBody = opts.priority ? `/trunk merge --priority=${opts.priority}` : '/trunk merge';
|
||||
const candidates: SubmitCandidate[] = [
|
||||
{ cmd: 'gh', args: ['pr', 'comment', prRef, '--body', commentBody], desc: 'enqueue via GitHub comment (/trunk merge)' },
|
||||
];
|
||||
if (opts.trunkCliAvailable) {
|
||||
const cliArgs = ['merge', prRef];
|
||||
if (opts.priority) cliArgs.push('--priority', opts.priority);
|
||||
candidates.push({ cmd: 'trunk', args: cliArgs, desc: 'enqueue via trunk CLI' });
|
||||
}
|
||||
if (opts.trunkToken) {
|
||||
// REST fallback is executed specially by the CLI wrapper (curl with token);
|
||||
// represented here so callers/tests see the full ordered chain.
|
||||
candidates.push({ cmd: 'trunk-rest', args: [prRef], desc: 'enqueue via Trunk REST API' });
|
||||
}
|
||||
return { regime, deleteBranch: false, candidates };
|
||||
}
|
||||
|
||||
// --- Landing classification ---------------------------------------------
|
||||
|
||||
export type LandStatus = 'landed' | 'ejected' | 'pending' | 'closed';
|
||||
|
||||
export interface ClassifyInput {
|
||||
/** PR state: OPEN | MERGED | CLOSED. */
|
||||
state: string;
|
||||
/** `gh pr view --json mergeCommit -q .mergeCommit.oid`, null if absent. */
|
||||
mergeCommitOid: string | null;
|
||||
/** True if `git branch -r --contains <headRefOid>` shows the base branch. */
|
||||
baseContainsHead: boolean;
|
||||
/** The Trunk/GitHub merge-queue check on the PR, if present. */
|
||||
queueCheck?: { name?: string; state?: string; bucket?: string } | null;
|
||||
/** Whether GitHub auto-merge is enabled (autoMergeRequest non-null). */
|
||||
autoMergeEnabled?: boolean;
|
||||
}
|
||||
|
||||
const EJECTED_STATES = new Set(['FAILURE', 'ERROR', 'CANCELLED', 'CANCELED', 'FAIL']);
|
||||
|
||||
/**
|
||||
* Decide whether a PR has landed. Uniform across all three regimes:
|
||||
*
|
||||
* landed = MERGED AND (mergeCommit.oid non-null OR base contains the head)
|
||||
* pending = MERGED but SHA not yet visible (squash/rebase lag), or still in queue
|
||||
* ejected = queue check failed/cancelled while PR is still OPEN
|
||||
* closed = CLOSED without merging
|
||||
*
|
||||
* The (oid OR baseContainsHead) guard handles rebase-merge repos where
|
||||
* mergeCommit.oid stays null — H3.
|
||||
*/
|
||||
export function classifyLand(input: ClassifyInput): { status: LandStatus; reason: string } {
|
||||
const state = (input.state || '').toUpperCase();
|
||||
|
||||
if (state === 'MERGED') {
|
||||
if (input.mergeCommitOid || input.baseContainsHead) {
|
||||
return { status: 'landed', reason: 'PR is MERGED and the commit is on the base branch' };
|
||||
}
|
||||
return { status: 'pending', reason: 'PR is MERGED but the merge SHA is not visible yet (squash/rebase lag)' };
|
||||
}
|
||||
|
||||
if (state === 'CLOSED') {
|
||||
return { status: 'closed', reason: 'PR was closed without merging' };
|
||||
}
|
||||
|
||||
// state OPEN
|
||||
const cs = (input.queueCheck?.state || input.queueCheck?.bucket || '').toUpperCase();
|
||||
if (input.queueCheck && EJECTED_STATES.has(cs)) {
|
||||
return { status: 'ejected', reason: `merge-queue check reported ${cs}` };
|
||||
}
|
||||
|
||||
return { status: 'pending', reason: 'PR is still open (in queue or merge pending)' };
|
||||
}
|
||||
|
||||
// --- Handoff state (last-land.json) -------------------------------------
|
||||
|
||||
export interface LandState {
|
||||
schema_version: number;
|
||||
pr: number;
|
||||
sha: string;
|
||||
headRefOid: string;
|
||||
base: string;
|
||||
head_branch: string;
|
||||
repo: string; // owner/name
|
||||
regime: Regime;
|
||||
ts: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface BuildLandStateInput {
|
||||
pr: number;
|
||||
sha: string;
|
||||
headRefOid: string;
|
||||
base: string;
|
||||
head_branch: string;
|
||||
repo: string;
|
||||
regime: Regime;
|
||||
/** ISO timestamp — injected so this stays pure/deterministic in tests. */
|
||||
ts: string;
|
||||
}
|
||||
|
||||
/** Assemble a validated LandState. Throws if the landing SHA is missing. */
|
||||
export function buildLandState(input: BuildLandStateInput): LandState {
|
||||
if (!input.sha) {
|
||||
throw new Error('refusing to write last-land.json with an empty merge SHA — landing not confirmed');
|
||||
}
|
||||
return {
|
||||
schema_version: LAND_STATE_SCHEMA_VERSION,
|
||||
pr: input.pr,
|
||||
sha: input.sha,
|
||||
headRefOid: input.headRefOid,
|
||||
base: input.base,
|
||||
head_branch: input.head_branch,
|
||||
repo: input.repo,
|
||||
regime: input.regime,
|
||||
ts: input.ts,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConsumeExpectation {
|
||||
pr: number;
|
||||
repo: string;
|
||||
/** Max age in ms before the state is considered stale. Default 6h. */
|
||||
maxAgeMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Validate a last-land.json the parent is about to consume. Guards against
|
||||
* stale-state-drives-wrong-deploy (H5): the file must be for THIS pr+repo,
|
||||
* recent, schema-compatible, and carry a non-null SHA.
|
||||
*/
|
||||
export function validateConsume(
|
||||
state: Partial<LandState> | null,
|
||||
expected: ConsumeExpectation,
|
||||
nowMs: number,
|
||||
): { ok: boolean; reason?: string } {
|
||||
if (!state) return { ok: false, reason: 'no last-land.json found' };
|
||||
if (state.schema_version !== LAND_STATE_SCHEMA_VERSION) {
|
||||
return { ok: false, reason: `schema_version mismatch (got ${state.schema_version}, want ${LAND_STATE_SCHEMA_VERSION})` };
|
||||
}
|
||||
if (!state.sha) return { ok: false, reason: 'last-land.json has no merge SHA' };
|
||||
if (state.pr !== expected.pr) {
|
||||
return { ok: false, reason: `last-land.json is for PR #${state.pr}, not #${expected.pr}` };
|
||||
}
|
||||
if (state.repo !== expected.repo) {
|
||||
return { ok: false, reason: `last-land.json is for repo ${state.repo}, not ${expected.repo}` };
|
||||
}
|
||||
const ts = state.ts ? Date.parse(state.ts) : NaN;
|
||||
if (Number.isNaN(ts)) return { ok: false, reason: 'last-land.json has an invalid timestamp' };
|
||||
const maxAge = expected.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
||||
if (nowMs - ts > maxAge) {
|
||||
return { ok: false, reason: `last-land.json is stale (${Math.round((nowMs - ts) / 60000)} min old)` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
// Deterministic end-to-end coverage of the bin/gstack-merge CLI (arg parsing,
|
||||
// file IO, and the lib/merge.ts wiring through the real binary). Free + fast —
|
||||
// no claude -p, no network. The submit/wait/classify *logic* is unit-tested in
|
||||
// gstack-merge.test.ts; this proves the executable plumbs it correctly and that
|
||||
// the handoff contract (write → consume, with null/stale/foreign rejection)
|
||||
// round-trips through the actual CLI surface /land and /land-and-deploy call.
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const BIN = join(import.meta.dir, '..', 'bin', 'gstack-merge');
|
||||
|
||||
function runCli(args: string[], opts: { cwd?: string; home?: string } = {}) {
|
||||
const r = spawnSync('bun', [BIN, ...args], {
|
||||
encoding: 'utf-8',
|
||||
cwd: opts.cwd || process.cwd(),
|
||||
env: { ...process.env, ...(opts.home ? { HOME: opts.home } : {}) },
|
||||
timeout: 30000,
|
||||
});
|
||||
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
let tmp: string;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'gstack-merge-cli-')); });
|
||||
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
describe('gstack-merge CLI: detect', () => {
|
||||
test('an explicit "Merge queue: trunk" config wins (no git/gh needed)', () => {
|
||||
// A non-git temp dir: gh/git calls return nothing, so the config key is the
|
||||
// only signal — exactly the precedence /land relies on.
|
||||
writeFileSync(join(tmp, 'CLAUDE.md'), '# proj\n\n## Merge Configuration\n- Merge queue: trunk\n');
|
||||
const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp });
|
||||
expect(r.code).toBe(0);
|
||||
const out = JSON.parse(r.stdout.trim());
|
||||
expect(out.regime).toBe('trunk');
|
||||
expect(out.source).toBe('config');
|
||||
});
|
||||
|
||||
test('config "Merge queue: github" is honored', () => {
|
||||
writeFileSync(join(tmp, 'CLAUDE.md'), '## Merge Configuration\n- Merge queue: github\n');
|
||||
const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp });
|
||||
expect(JSON.parse(r.stdout.trim()).regime).toBe('github');
|
||||
});
|
||||
|
||||
test('no config + no signals → none', () => {
|
||||
const r = runCli(['detect', '--base', 'main', '--json'], { cwd: tmp });
|
||||
expect(r.code).toBe(0);
|
||||
expect(JSON.parse(r.stdout.trim()).regime).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-merge CLI: handoff round-trip (write → consume)', () => {
|
||||
const SLUG = 'acme-widget';
|
||||
const REPO = 'acme/widget';
|
||||
|
||||
function seedHandoff(home: string, overrides: Record<string, unknown> = {}) {
|
||||
const dir = join(home, '.gstack', 'projects', SLUG);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const state = {
|
||||
schema_version: 1,
|
||||
pr: 42,
|
||||
sha: 'deadbeefcafe',
|
||||
headRefOid: 'cafef00d',
|
||||
base: 'main',
|
||||
head_branch: 'feat/x',
|
||||
repo: REPO,
|
||||
regime: 'trunk',
|
||||
ts: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
writeFileSync(join(dir, 'last-land.json'), JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
test('read-state accepts a matching recent handoff and emits the SHA', () => {
|
||||
seedHandoff(tmp);
|
||||
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp });
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.stdout).toContain('LAND_SHA=deadbeefcafe');
|
||||
expect(r.stdout).toContain('LAND_REGIME=trunk');
|
||||
});
|
||||
|
||||
test('read-state rejects a handoff for a different PR (no cross-PR deploy)', () => {
|
||||
seedHandoff(tmp);
|
||||
const r = runCli(['read-state', '--slug', SLUG, '--pr', '99', '--repo', REPO], { home: tmp });
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('READ_STATE_INVALID');
|
||||
});
|
||||
|
||||
test('read-state rejects a handoff for a different repo', () => {
|
||||
seedHandoff(tmp);
|
||||
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', 'other/repo'], { home: tmp });
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('READ_STATE_INVALID');
|
||||
});
|
||||
|
||||
test('read-state rejects a stale handoff', () => {
|
||||
seedHandoff(tmp, { ts: '2020-01-01T00:00:00.000Z' });
|
||||
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp });
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('READ_STATE_INVALID');
|
||||
});
|
||||
|
||||
test('read-state rejects a handoff with an empty SHA (the null-SHA STOP)', () => {
|
||||
seedHandoff(tmp, { sha: '' });
|
||||
const r = runCli(['read-state', '--slug', SLUG, '--pr', '42', '--repo', REPO], { home: tmp });
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('READ_STATE_INVALID');
|
||||
});
|
||||
|
||||
test('read-state reports missing when there is no handoff at all', () => {
|
||||
const r = runCli(['read-state', '--slug', 'nope', '--pr', '1', '--repo', REPO], { home: tmp });
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.stderr).toContain('READ_STATE_INVALID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-merge CLI: argument handling', () => {
|
||||
test('unknown subcommand exits 2 with usage', () => {
|
||||
const r = runCli(['frobnicate']);
|
||||
expect(r.code).toBe(2);
|
||||
expect(r.stderr).toContain('usage: gstack-merge');
|
||||
});
|
||||
|
||||
test('submit without --pr exits 2', () => {
|
||||
const r = runCli(['submit', '--regime', 'none']);
|
||||
expect(r.code).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
detectRegime,
|
||||
planSubmit,
|
||||
classifyLand,
|
||||
buildLandState,
|
||||
validateConsume,
|
||||
isTrunkQueueCheck,
|
||||
trunkQueueCheckName,
|
||||
LAND_STATE_SCHEMA_VERSION,
|
||||
type LandState,
|
||||
} from '../lib/merge';
|
||||
|
||||
describe('detectRegime', () => {
|
||||
test('explicit config key wins over everything', () => {
|
||||
const r = detectRegime({
|
||||
base: 'main',
|
||||
configRegime: 'github',
|
||||
checks: [{ name: trunkQueueCheckName('main'), state: 'PENDING' }], // would say trunk
|
||||
trunkYaml: 'merge:\n required_statuses: [ci]',
|
||||
githubMergeQueue: false,
|
||||
});
|
||||
expect(r.regime).toBe('github');
|
||||
expect(r.source).toBe('config');
|
||||
});
|
||||
|
||||
test('invalid config key is ignored and falls through to live signals', () => {
|
||||
const r = detectRegime({
|
||||
base: 'main',
|
||||
configRegime: 'banana',
|
||||
checks: [{ name: 'Trunk Merge Queue (main)' }],
|
||||
trunkYaml: null,
|
||||
});
|
||||
expect(r.regime).toBe('trunk');
|
||||
expect(r.source).toBe('trunk-status-check');
|
||||
});
|
||||
|
||||
test('trunk status check on the PR → trunk (even on a non-main base)', () => {
|
||||
const r = detectRegime({
|
||||
base: 'develop',
|
||||
checks: [{ name: 'Trunk Merge Queue (develop)', state: 'TESTING' }],
|
||||
trunkYaml: null,
|
||||
});
|
||||
expect(r.regime).toBe('trunk');
|
||||
expect(r.source).toBe('trunk-status-check');
|
||||
});
|
||||
|
||||
test('.trunk/trunk.yaml with a merge: section → trunk (secondary signal)', () => {
|
||||
const r = detectRegime({
|
||||
base: 'main',
|
||||
checks: [{ name: 'build' }, { name: 'test' }],
|
||||
trunkYaml: 'version: 0.1\nmerge:\n required_statuses:\n - build\n',
|
||||
});
|
||||
expect(r.regime).toBe('trunk');
|
||||
expect(r.source).toBe('trunk-yaml');
|
||||
});
|
||||
|
||||
test('bare .trunk/trunk.yaml WITHOUT a merge: section is NOT trunk (check-only false positive guard)', () => {
|
||||
const r = detectRegime({
|
||||
base: 'main',
|
||||
checks: [{ name: 'build' }],
|
||||
trunkYaml: 'version: 0.1\ncli:\n version: 1.22.0\nlint:\n enabled:\n - eslint\n',
|
||||
});
|
||||
expect(r.regime).toBe('none');
|
||||
expect(r.source).toBe('default');
|
||||
});
|
||||
|
||||
test('github branch-protection merge queue → github', () => {
|
||||
const r = detectRegime({
|
||||
base: 'main',
|
||||
checks: [{ name: 'build' }],
|
||||
trunkYaml: null,
|
||||
githubMergeQueue: true,
|
||||
});
|
||||
expect(r.regime).toBe('github');
|
||||
expect(r.source).toBe('github-branch-protection');
|
||||
});
|
||||
|
||||
test('no signals → none', () => {
|
||||
const r = detectRegime({ base: 'main', checks: [], trunkYaml: null });
|
||||
expect(r.regime).toBe('none');
|
||||
expect(r.source).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTrunkQueueCheck', () => {
|
||||
test('matches the queue check name for any branch', () => {
|
||||
expect(isTrunkQueueCheck('Trunk Merge Queue (main)')).toBe(true);
|
||||
expect(isTrunkQueueCheck('Trunk Merge Queue (release/v2)')).toBe(true);
|
||||
});
|
||||
test('does not match unrelated checks', () => {
|
||||
expect(isTrunkQueueCheck('Trunk Check')).toBe(false);
|
||||
expect(isTrunkQueueCheck('build')).toBe(false);
|
||||
expect(isTrunkQueueCheck(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planSubmit', () => {
|
||||
test('none → single direct squash with branch delete', () => {
|
||||
const p = planSubmit('none', 42);
|
||||
expect(p.deleteBranch).toBe(true);
|
||||
expect(p.candidates).toHaveLength(1);
|
||||
expect(p.candidates[0].args).toEqual(['pr', 'merge', '42', '--squash', '--delete-branch']);
|
||||
});
|
||||
|
||||
test('github → auto-merge first, squash fallback', () => {
|
||||
const p = planSubmit('github', 42);
|
||||
expect(p.deleteBranch).toBe(true);
|
||||
expect(p.candidates[0].args).toContain('--auto');
|
||||
expect(p.candidates[1].args).toContain('--squash');
|
||||
});
|
||||
|
||||
test('trunk is comment-first and never deletes the branch', () => {
|
||||
const p = planSubmit('trunk', 7, { trunkCliAvailable: false, trunkToken: false });
|
||||
expect(p.deleteBranch).toBe(false);
|
||||
expect(p.candidates).toHaveLength(1);
|
||||
expect(p.candidates[0].cmd).toBe('gh');
|
||||
expect(p.candidates[0].args).toEqual(['pr', 'comment', '7', '--body', '/trunk merge']);
|
||||
// No gh pr merge anywhere in the trunk plan.
|
||||
expect(p.candidates.some((c) => c.args.includes('merge') && c.cmd === 'gh' && c.args.includes('pr') && c.args[1] === 'merge')).toBe(false);
|
||||
});
|
||||
|
||||
test('trunk adds CLI then REST as opportunistic fallbacks, in order', () => {
|
||||
const p = planSubmit('trunk', 7, { trunkCliAvailable: true, trunkToken: true });
|
||||
expect(p.candidates.map((c) => c.cmd)).toEqual(['gh', 'trunk', 'trunk-rest']);
|
||||
});
|
||||
|
||||
test('trunk priority threads into the comment body and CLI', () => {
|
||||
const p = planSubmit('trunk', 7, { trunkCliAvailable: true, priority: 'high' });
|
||||
expect(p.candidates[0].args).toContain('/trunk merge --priority=high');
|
||||
expect(p.candidates[1].args).toEqual(['merge', '7', '--priority', 'high']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyLand', () => {
|
||||
test('MERGED with a merge SHA → landed', () => {
|
||||
const c = classifyLand({ state: 'MERGED', mergeCommitOid: 'abc123', baseContainsHead: false });
|
||||
expect(c.status).toBe('landed');
|
||||
});
|
||||
|
||||
test('MERGED with null SHA but base contains head → landed (rebase-merge case, H3)', () => {
|
||||
const c = classifyLand({ state: 'MERGED', mergeCommitOid: null, baseContainsHead: true });
|
||||
expect(c.status).toBe('landed');
|
||||
});
|
||||
|
||||
test('MERGED but SHA not visible and base does not yet contain head → pending (squash lag)', () => {
|
||||
const c = classifyLand({ state: 'MERGED', mergeCommitOid: null, baseContainsHead: false });
|
||||
expect(c.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('OPEN with a failed queue check → ejected', () => {
|
||||
const c = classifyLand({
|
||||
state: 'OPEN',
|
||||
mergeCommitOid: null,
|
||||
baseContainsHead: false,
|
||||
queueCheck: { name: 'Trunk Merge Queue (main)', state: 'FAILURE' },
|
||||
});
|
||||
expect(c.status).toBe('ejected');
|
||||
});
|
||||
|
||||
test('OPEN with a cancelled queue check → ejected', () => {
|
||||
const c = classifyLand({
|
||||
state: 'OPEN',
|
||||
mergeCommitOid: null,
|
||||
baseContainsHead: false,
|
||||
queueCheck: { name: 'Trunk Merge Queue (main)', bucket: 'CANCELLED' },
|
||||
});
|
||||
expect(c.status).toBe('ejected');
|
||||
});
|
||||
|
||||
test('OPEN with a still-testing queue check → pending', () => {
|
||||
const c = classifyLand({
|
||||
state: 'OPEN',
|
||||
mergeCommitOid: null,
|
||||
baseContainsHead: false,
|
||||
queueCheck: { name: 'Trunk Merge Queue (main)', state: 'IN_PROGRESS' },
|
||||
});
|
||||
expect(c.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('CLOSED → closed', () => {
|
||||
const c = classifyLand({ state: 'CLOSED', mergeCommitOid: null, baseContainsHead: false });
|
||||
expect(c.status).toBe('closed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLandState', () => {
|
||||
const base = {
|
||||
pr: 12,
|
||||
sha: 'deadbeef',
|
||||
headRefOid: 'cafe',
|
||||
base: 'main',
|
||||
head_branch: 'feat/x',
|
||||
repo: 'owner/name',
|
||||
regime: 'trunk' as const,
|
||||
ts: '2026-05-31T00:00:00.000Z',
|
||||
};
|
||||
|
||||
test('assembles a schema-versioned state', () => {
|
||||
const s = buildLandState(base);
|
||||
expect(s.schema_version).toBe(LAND_STATE_SCHEMA_VERSION);
|
||||
expect(s.sha).toBe('deadbeef');
|
||||
expect(s.repo).toBe('owner/name');
|
||||
// scope is intentionally absent (T2 — parent recomputes diff-scope).
|
||||
expect('scope' in s).toBe(false);
|
||||
});
|
||||
|
||||
test('refuses to build with an empty SHA (handoff would silently kill revert)', () => {
|
||||
expect(() => buildLandState({ ...base, sha: '' })).toThrow(/empty merge SHA/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConsume', () => {
|
||||
const now = Date.parse('2026-05-31T01:00:00.000Z');
|
||||
const good: LandState = {
|
||||
schema_version: LAND_STATE_SCHEMA_VERSION,
|
||||
pr: 12,
|
||||
sha: 'deadbeef',
|
||||
headRefOid: 'cafe',
|
||||
base: 'main',
|
||||
head_branch: 'feat/x',
|
||||
repo: 'owner/name',
|
||||
regime: 'trunk',
|
||||
ts: '2026-05-31T00:30:00.000Z',
|
||||
};
|
||||
|
||||
test('accepts a matching recent state', () => {
|
||||
expect(validateConsume(good, { pr: 12, repo: 'owner/name' }, now).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects when no state file', () => {
|
||||
const v = validateConsume(null, { pr: 12, repo: 'owner/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a state for a different PR (stale-state-drives-wrong-deploy, H5)', () => {
|
||||
const v = validateConsume(good, { pr: 99, repo: 'owner/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reason).toMatch(/PR #12/);
|
||||
});
|
||||
|
||||
test('rejects a state for a different repo', () => {
|
||||
const v = validateConsume(good, { pr: 12, repo: 'other/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a stale state past max age', () => {
|
||||
const stale = { ...good, ts: '2026-05-30T00:00:00.000Z' };
|
||||
const v = validateConsume(stale, { pr: 12, repo: 'owner/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.reason).toMatch(/stale/);
|
||||
});
|
||||
|
||||
test('rejects an empty SHA', () => {
|
||||
const v = validateConsume({ ...good, sha: '' }, { pr: 12, repo: 'owner/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects a schema_version mismatch', () => {
|
||||
const v = validateConsume({ ...good, schema_version: 99 }, { pr: 12, repo: 'owner/name' }, now);
|
||||
expect(v.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue