gstack/lib/merge.ts

307 lines
11 KiB
TypeScript

// 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 };
}