From b8d6eec9158c1282b29e10b2fa214b54d2e13d44 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 16:29:22 +0000 Subject: [PATCH] fix(ship-queue): wave plan leaves out heads that conflict with main, links rename-vs-edit conflicts, never splits a conflict cluster; ready reports unstamped against the fork point (GST-770 follow-up, #3558) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/ship-policy.md | 15 +++++-- lib/ship-queue-wave.ts | 60 +++++++++++++++++---------- lib/ship-queue.ts | 4 +- test/gstack-ship-queue.test.ts | 20 +++++++++ test/ship-queue-wave.test.ts | 74 ++++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 28 deletions(-) diff --git a/docs/ship-policy.md b/docs/ship-policy.md index e7d658833..04223f82d 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -574,13 +574,20 @@ gstack-ship-queue wave settle --wave [--dry-run] # after the wave - **When.** `wave plan` counts the live READY PRs (open, head equal to their READY receipt, CI green at that head; a READY head whose CI is red, still - running or absent prints a `NOT_GREEN` line and stays out of the plan). A - READY PR whose CI, head or diff cannot be read fails the whole plan with - `QUEUE_PROOF_MISSING`. Over the + running or absent prints a `NOT_GREEN` line and stays out of the plan; a + READY head that does not merge onto main with the train's merge rules prints + `NOT_MERGEABLE` with the conflicting files and stays out too, while a + conflict only in the CHANGELOG, which the train merges, does not count). A + READY PR whose CI, head, diff or merge onto main cannot be read fails the + whole plan with `QUEUE_PROOF_MISSING`. Over the trigger (6) it prints `WAVE_ADVISED` and suggested groups of 3 to 6: PRs whose changes to a common code file conflict (`git merge-tree` cannot merge the two heads, or the check itself fails) always share a group, so that - conflict is resolved once. Hub + conflict is resolved once. A PR that renames a file and one that edits the + old path are linked when the edit conflicts at the new path. A group of + linked PRs is never split across waves: one larger than the wave maximum + prints `WAVE_OVERSIZE` with no build command (land part of it as single + slots, then plan again). Hub files never link PRs: anything under `docs/`, Markdown (generated skill renders included; their `.tmpl` sources still link) and every `.json` or `.jsonl` file (ledgers and registries). Most PRs append to them, and diff --git a/lib/ship-queue-wave.ts b/lib/ship-queue-wave.ts index c0f505f41..7e0f0767e 100644 --- a/lib/ship-queue-wave.ts +++ b/lib/ship-queue-wave.ts @@ -27,7 +27,7 @@ import { versionAtRef } from './restamp'; import { globToRegExp, type ShipPolicy } from './ship-policy'; import { ciAtHead, type QueueContext } from './ship-queue'; import { git, sha256, type QueueFailure } from './ship-queue-gate'; -import { fetchBase, mergeCommit, outFailed, outOk, outRefused, parsePins, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train'; +import { fetchBase, mergeCommit, mergeStep, outFailed, outOk, outRefused, parsePins, trainEnv, type TrainEnv, type TrainOut } from './ship-queue-train'; import { prInfo, type PrInfo } from './ship-queue-train-github'; import { listWaves, loadWave, nowIso, persist, queueRoot, rebuildFromNotes, redactCause, wavePath, type ReadyReceipt, type WaveRecord } from './ship-queue-train-record'; @@ -67,7 +67,8 @@ export function areaKey(file: string, isSkillDir: (dir: string) => boolean): str return parts.length > 1 ? top : 'root'; } -export interface PlanPr { pr: string; head: string; files: string[]; lines: number } +/** renamed maps a path the PR renamed to its new path; files lists both. */ +export interface PlanPr { pr: string; head: string; files: string[]; lines: number; renamed?: Record } export interface WaveGroup { area: string; prs: PlanPr[]; integrator: string } /** The most common area across files, ties broken by name; 'misc' with no files. */ @@ -91,11 +92,13 @@ export const isHubFile = (file: string) => file.startsWith('docs/') || /\.(md|js * Group PRs into waves: PRs whose changes to a common code file conflict always travel together * (one integrator resolves the conflict once); hub files never link. conflicts(a, b) names the * files a merge of the two heads cannot resolve, or null when unknown, which counts every shared - * file as conflicting. Each cluster takes its largest PR's area, clusters of one area are packed - * in PR order into waves of at most max, and a wave below min rides solo. The integrator is the - * PR with the most changed lines. + * file as conflicting; a conflict at the path either PR renamed the shared file to counts as one + * on that file. Each cluster takes its largest PR's area and is packed whole, first fit in PR + * order, into the waves of its area of at most max; a cluster larger than max is oversize (never + * split, so its conflicts still meet one integrator), and a wave below min rides solo. The + * integrator is the PR with the most changed lines. */ -export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX, conflicts: (a: PlanPr, b: PlanPr) => string[] | null = () => null): { groups: WaveGroup[]; solo: string[] } { +export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = WAVE_MIN, max = WAVE_MAX, conflicts: (a: PlanPr, b: PlanPr) => string[] | null = () => null): { groups: WaveGroup[]; solo: string[]; oversize: Array<{ area: string; prs: PlanPr[] }> } { const sorted = [...prs].sort((a, b) => Number(a.pr) - Number(b.pr)); const root = new Map(sorted.map(p => [p.pr, p.pr])); const find = (x: string): string => { const r = root.get(x)!; return r === x ? x : find(r); }; @@ -106,7 +109,8 @@ export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = W const key = `${a.pr} ${b.pr}`; if (!clash.has(key)) clash.set(key, conflicts(a, b)); const hit = clash.get(key); - if ((!hit || hit.includes(f)) && find(a.pr) !== find(b.pr)) root.set(find(b.pr), find(a.pr)); + const at = [f, a.renamed?.[f], b.renamed?.[f]]; + if ((!hit || at.some(p => p && hit.includes(p))) && find(a.pr) !== find(b.pr)) root.set(find(b.pr), find(a.pr)); })); const clusters = new Map(); for (const p of sorted) clusters.set(find(p.pr), [...(clusters.get(find(p.pr)) ?? []), p]); @@ -117,17 +121,20 @@ export function groupWaves(prs: PlanPr[], areaOf: (f: string) => string, min = W } const groups: WaveGroup[] = []; const solo: string[] = []; + const oversize: Array<{ area: string; prs: PlanPr[] }> = []; for (const [area, list] of [...byArea].sort((a, b) => a[0].localeCompare(b[0]))) { - let cur: PlanPr[] = []; - const flush = () => { - if (cur.length >= min) groups.push({ area, prs: cur, integrator: [...cur].sort(largestFirst)[0]!.pr }); - else solo.push(...cur.map(p => p.pr)); - cur = []; - }; - for (const c of list) for (const p of c) { if (cur.length >= max) flush(); cur.push(p); } - flush(); + const waves: PlanPr[][] = []; + for (const c of list) { + if (c.length > max) { oversize.push({ area, prs: c }); continue; } + const fit = waves.find(w => w.length + c.length <= max); + if (fit) fit.push(...c); else waves.push([...c]); + } + for (const w of waves) { + if (w.length >= min) groups.push({ area, prs: w, integrator: [...w].sort(largestFirst)[0]!.pr }); + else solo.push(...w.map(p => p.pr)); + } } - return { groups, solo: solo.sort((a, b) => Number(a) - Number(b)) }; + return { groups, solo: solo.sort((a, b) => Number(a) - Number(b)), oversize }; } export interface WaveChain { integrator: string[]; merges: string[]; stamps: string[] } @@ -225,10 +232,14 @@ function readyReceipts(env: TrainEnv): ReadyReceipt[] { return [...newest.values()]; } -function changed(env: TrainEnv, head: string): { files: string[]; lines: number } | null { +function changed(env: TrainEnv, head: string): { files: string[]; lines: number; renamed: Record } | null { const fork = git(env.ctx.repoRoot, ['merge-base', `${env.remote}/${env.ctx.base}`, head]); const stat = fork.ok ? git(env.ctx.repoRoot, ['diff', '--numstat', '--no-renames', fork.out, head]) : fork; - if (!stat.ok) return null; + const moves = fork.ok ? git(env.ctx.repoRoot, ['diff', '--name-status', '-z', '-M', '--diff-filter=R', fork.out, head]) : fork; + if (!stat.ok || !moves.ok) return null; + const renamed: Record = {}; + const fields = moves.out.split('\0'); + for (let i = 0; i + 2 < fields.length; i += 3) if (!isReleasePath(fields[i + 1]!, env.policy.policy)) renamed[fields[i + 1]!] = fields[i + 2]!; let lines = 0; const files: string[] = []; for (const row of stat.out.split('\n').filter(Boolean)) { @@ -237,7 +248,7 @@ function changed(env: TrainEnv, head: string): { files: string[]; lines: number files.push(file); lines += (Number(add) || 0) + (Number(del) || 0); } - return { files, lines }; + return { files, lines, renamed }; } const areaOf = (env: TrainEnv) => (f: string) => areaKey(f, dir => fs.existsSync(path.join(env.ctx.repoRoot, dir, 'SKILL.md.tmpl'))); @@ -269,12 +280,15 @@ export function wavePlan(ctx: QueueContext, o: { trigger: number; min: number; m if (ci.line.startsWith('unknown')) { unreadable.push(`#${r.pr} (CI ${ci.line})`); continue; } if (!ci.green) { lines.push(`NOT_GREEN: #${r.pr} (${ci.line}; left out until CI is green at the READY head)`); continue; } if (!ensureCommit(env, r.head, r.pr)) { unreadable.push(`#${r.pr} (head ${short(r.head)} not fetchable)`); continue; } + const onBase = mergeStep(env.ctx.repoRoot, `${env.remote}/${env.ctx.base}`, r.head, env.policy.policy); + if ('conflicts' in onBase && onBase.conflicts.some(f => f.startsWith('('))) { unreadable.push(`#${r.pr} (merge onto ${env.ctx.base} unreadable: ${onBase.conflicts.join(', ')})`); continue; } + if ('conflicts' in onBase) { lines.push(`NOT_MERGEABLE: #${r.pr} (conflicts with ${env.remote}/${env.ctx.base} at ${short(r.head)} in ${onBase.conflicts.join(', ')}; left out until the head merges cleanly onto ${env.ctx.base})`); continue; } const c = changed(env, r.head); if (!c) { unreadable.push(`#${r.pr} (diff unreadable)`); continue; } live.push({ pr: r.pr, head: r.head, ...c }); } if (unreadable.length) return outFailed(fail('QUEUE_PROOF_MISSING', `cannot read ${unreadable.length} READY PR(s): ${unreadable.join('; ')}; a plan from a partial queue would mislead`), lines, `${Q} wave plan (after gh and git can read every PR)`, 'none'); - lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt, CI green there); wave trigger ${o.trigger}`); + lines.push(`QUEUE: ${live.length} live READY PR(s) (open, head equal to its READY receipt, CI green there, merging cleanly onto ${env.ctx.base}); wave trigger ${o.trigger}`); if (live.length <= o.trigger) return outOk([...lines, `WAVE: not needed (${live.length} <= ${o.trigger}); build trains of single PRs`], `${Q} train build --prs `, 'none'); const plan = groupWaves(live, areaOf(env), o.min, o.max, (a, b) => { const m = git(env.ctx.repoRoot, ['merge-tree', '--write-tree', '--name-only', '--no-messages', a.head, b.head]); @@ -285,6 +299,7 @@ export function wavePlan(ctx: QueueContext, o: { trigger: number; min: number; m lines.push(`WAVE_GROUP: area=${g.area} prs=${g.prs.map(p => '#' + p.pr).join(',')} integrator=#${g.integrator} (most changed lines)`); lines.push(` build: ${buildCmd(g.area, g.prs)}`); } + for (const c of plan.oversize) lines.push(`WAVE_OVERSIZE: area=${c.area} prs=${c.prs.map(p => '#' + p.pr).join(',')} (${c.prs.length} PRs whose changes conflict, over the wave maximum ${o.max}; kept whole with no build command: land part of it as single slots, then run wave plan again)`); if (plan.solo.length) lines.push(`SOLO: ${plan.solo.map(p => '#' + p).join(',')} (no area group of ${o.min}+; they ride as single slots)`); const first = plan.groups[0]; return outOk(lines, first ? `#${first.integrator}'s owner integrates: ${buildCmd(first.area, first.prs)}` : `${Q} train build --prs `, 'none'); @@ -577,8 +592,9 @@ export function waveSettle(ctx: QueueContext, o: { wave: string; dryRun: boolean export const WAVE_HELP = `gstack-ship-queue wave: wave PRs for merge trains (docs/ship-policy.md#wave-prs) wave plan [--trigger ] [--min ] [--max ] - read-only: live READY PRs; over the trigger (default ${WAVE_TRIGGER}) WAVE_ADVISED and - WAVE_GROUP lines (area, PRs, integrator) with each group's build command + read-only: live READY PRs (NOT_GREEN and NOT_MERGEABLE heads left out); over the + trigger (default ${WAVE_TRIGGER}) WAVE_ADVISED and WAVE_GROUP lines (area, PRs, integrator) + with each group's build command; WAVE_OVERSIZE for linked PRs over --max (no command) wave build --prs @,... [--area ] [--min ] [--max ] [--dry-run] the integrator branch wave/: main plus each pin merged unchanged (train merge rules), the wave record in refs/notes/gstack-trains, the branch pushed, a frozen notice diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index ae3925ece..9c0dbdd9b 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -226,8 +226,8 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate if (info.headRefOid !== head) return fail({ code: 'QUEUE_NOT_READY', message: `HEAD ${head.slice(0, 12)} is not PR #${o.pr}'s pushed head ${info.headRefOid.slice(0, 12)}` }, [], run); const atHead = versionAtRef(ctx.repoRoot, head); const baseSha = git(ctx.repoRoot, ['rev-parse', `origin/${ctx.base}`]).out; - const atBase = versionAtRef(ctx.repoRoot, baseSha); - const version = 'version' in atHead && 'version' in atBase && atHead.version !== atBase.version ? atHead.version : 'unstamped'; + const atFork = versionAtRef(ctx.repoRoot, git(ctx.repoRoot, ['merge-base', baseSha, head]).out || baseSha); + const version = 'version' in atHead && 'version' in atFork && atHead.version !== atFork.version ? atHead.version : 'unstamped'; const ci = ciAtHead(ctx, head); const pregateFile = path.resolve(ctx.repoRoot, o.pregate ?? PREGATE_OUT_REL); let pregateLine = `PREGATE: missing (${path.relative(ctx.repoRoot, pregateFile)}; run gstack-pregate run --allow-repo-commands)`; diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts index 9b9248631..88bcbc511 100644 --- a/test/gstack-ship-queue.test.ts +++ b/test/gstack-ship-queue.test.ts @@ -361,6 +361,26 @@ describe('ready', () => { expect(queue(repo, ['ready', '--pr', '1'], shim(moved, green)).err).toMatch(/uncommitted tracked changes: pr-a\.txt.*\(QUEUE_NOT_READY\)/); }, 180_000); + test('STAMP_VERSION is unstamped for a PR whose own diff leaves VERSION alone after main moved to a newer version, and the stamp for one that changes it (GST-770 follow-up)', () => { + const repo = fixture(); + const head = makePr(repo, 'pr-a'); + repo.git('checkout', '-q', 'origin/main', '-B', 'main-moves'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n'); + repo.git('commit', '-qam', 'v1.2.4.0 another PR landed'); + repo.git('push', '-q', 'origin', 'HEAD:main'); + repo.git('fetch', '-q', 'origin'); + repo.git('checkout', '-q', 'pr-a'); + const shim = (prHead: string) => ghQueueShim(repo, { prs: { '1': { headRefOid: prHead, url: 'https://github.com/o/r/pull/1', body: shippedBody('1', head) } }, checks: { [prHead]: green } }); + const forked = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, head)], shim(head)); + expect([forked.status, forked.err]).toEqual([EXIT.ok, '']); + expect(forked.out.split('\n').slice(0, 4)).toEqual([`READY #1 ${head} STAMP_VERSION=unstamped`, 'pr: https://github.com/o/r/pull/1', `head: ${head}`, 'version: unstamped']); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n'); + repo.git('commit', '-qam', 'v1.2.5.0 stamp'); + const stamped = repo.git('rev-parse', 'HEAD'); + const ok = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, stamped)], shim(stamped)); + expect(ok.out.split('\n')[0]).toBe(`READY #1 ${stamped} STAMP_VERSION=1.2.5.0`); + }, 180_000); + // Value: protects=the re-READY after a slot's stamp push accepts the receipt /ship wrote for the pinned head, and only then; // fails_when=ready binds the receipt to HEAD alone (every stamped READY turns QUEUE_RECEIPT_STALE) or accepts any ancestor's receipt; // why_new=the READY test above binds receipts to the exact head only; seam=none; no_claim=real gh and CI (shims; unit only) diff --git a/test/ship-queue-wave.test.ts b/test/ship-queue-wave.test.ts index d2f0a1ce2..8ea60dab6 100644 --- a/test/ship-queue-wave.test.ts +++ b/test/ship-queue-wave.test.ts @@ -24,6 +24,36 @@ const nonceOf = (out: string) => /^WAVE: (\S+)/m.exec(out)?.[1] ?? ''; const waveJson = (fx: TrainFixture, nonce: string) => JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'waves', `${nonce}.json`), 'utf8')); const gitIn = (fx: TrainFixture, cwd: string, ...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }); +/** Commit seed files onto main and push them. */ +function seedMain(fx: TrainFixture, files: Record, message = 'seed'): void { + fx.repo.git('checkout', '-q', 'origin/main', '-B', 'seed'); + for (const [f, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(fx.repo.work, f)), { recursive: true }); fs.writeFileSync(path.join(fx.repo.work, f), t); } + fx.repo.git('add', '-A'); + fx.repo.git('commit', '-q', '-m', message); + fx.repo.git('push', '-q', 'origin', 'HEAD:main'); + fx.repo.git('fetch', '-q', 'origin'); +} + +/** A READY PR off main writing (or, for null, deleting) files, with green CI and a READY receipt at its head. */ +function readyPr(fx: TrainFixture, n: string, files: Record): string { + fx.repo.git('checkout', '-q', 'origin/main', '-B', `pr-${n}`); + for (const [f, t] of Object.entries(files)) { + if (t === null) { fs.rmSync(path.join(fx.repo.work, f)); continue; } + fs.mkdirSync(path.dirname(path.join(fx.repo.work, f)), { recursive: true }); + fs.writeFileSync(path.join(fx.repo.work, f), t); + } + fx.repo.git('add', '-A'); + fx.repo.git('commit', '-q', '-m', `feat #${n}`); + const head = fx.repo.git('rev-parse', 'HEAD'); + fx.repo.git('push', '-q', '-f', 'origin', `HEAD:refs/pull/${n}/head`); + fx.write(d => { + d.prs[n] = { headRefOid: head, isCrossRepository: false, state: 'OPEN', headRefName: `pr-${n}`, url: `https://github.com/fixture/repo/pull/${n}`, body: shippedBody(n, head) }; + d.ci[head] = [{ name: 'free-tests', status: 'completed', conclusion: 'success' }]; + }); + readyReceipt(fx, n, head); + return head; +} + function threePrs(fx: TrainFixture) { return ['1', '2', '3'].map(pr => openPr(fx, pr, { file: `lib/ship-${pr}.ts`, changelog: pr === '1' })); } @@ -79,6 +109,15 @@ describe('areas and grouping', () => { for (const hub of ['docs/troubleshooting.md', 'ship/SKILL.md', 'scripts/harness-version.json']) expect(linked([hub], () => [hub])).toEqual([]); expect([isHubFile('docs/x.ts'), isHubFile('TODOS.md'), isHubFile('test/fixtures/x.json'), isHubFile('ship/SKILL.md.tmpl'), isHubFile('lib/x.ts')]).toEqual([true, true, true, false, false]); }); + + test('groupWaves never splits a conflict cluster across waves, and a cluster over the wave maximum is oversize and stays whole (GST-770 follow-up)', () => { + const pr = (n: number, file: string): PlanPr => ({ pr: String(n), head: String(n).repeat(40).slice(0, 40), files: [file], lines: 10 }); + const cluster = (from: number, to: number, file: string) => Array.from({ length: to - from + 1 }, (_, i) => pr(from + i, file)); + const plan = groupWaves([...cluster(1, 4, 'lib/ship-x.ts'), ...cluster(5, 8, 'lib/ship-y.ts'), ...cluster(9, 15, 'lib/ship-z.ts')], f => areaKey(f, () => false), 3, 6); + expect(plan.groups.map(g => g.prs.map(p => p.pr))).toEqual([['1', '2', '3', '4'], ['5', '6', '7', '8']]); + expect(plan.oversize.map(g => [g.area, g.prs.map(p => p.pr)])).toEqual([['ship', ['9', '10', '11', '12', '13', '14', '15']]]); + expect(plan.solo).toEqual([]); + }); }); describe('wave build', () => { @@ -309,6 +348,41 @@ describe('ready and train build read the wave', () => { expect([unread.status, unread.err]).toEqual([EXIT.fail, expect.stringContaining('(QUEUE_PROOF_MISSING')]); expect(unread.err).toContain('#4 (CI unknown at'); }, 180_000); + + test('wave plan leaves out a READY head that conflicts with main as NOT_MERGEABLE, and keeps one whose only conflict is the CHANGELOG the train merges (GST-770 follow-up)', () => { + const fx = make(); + seedMain(fx, { 'lib/ship-a.ts': 'export const a = 0;\n' }); + const changelog = (entry: string) => fx.repo.git('show', 'origin/main:CHANGELOG.md').replace('# Changelog\n', `# Changelog\n\n## [Unreleased]\n\n- ${entry}\n`) + '\n'; + const conflicting = readyPr(fx, '1', { 'lib/ship-a.ts': "export const a = 'one';\n" }); + readyPr(fx, '2', { 'lib/ship-b.ts': 'export const b = 2;\n', 'CHANGELOG.md': changelog('#2 change') }); + seedMain(fx, { 'lib/ship-a.ts': "export const a = 'main';\n", 'CHANGELOG.md': changelog('a change that landed first') }, 'main moves'); + const plan = queue(fx, ['wave', 'plan']); + expect([plan.status, plan.err]).toEqual([EXIT.ok, '']); + expect(plan.out.split('\n').filter(l => l.startsWith('NOT_MERGEABLE:'))).toEqual([ + `NOT_MERGEABLE: #1 (conflicts with origin/main at ${conflicting.slice(0, 12)} in lib/ship-a.ts; left out until the head merges cleanly onto main)`, + ]); + expect(line(plan.out, 'QUEUE:')).toContain('1 live READY PR(s)'); + }, 180_000); + + test('wave plan links a rename with an edit of the old path that conflicts with it, not with an edit that merges cleanly, and prints a cluster over --max as WAVE_OVERSIZE with no build command (GST-770 follow-up)', () => { + const fx = make(); + const body = (first = '', last = '') => Array.from({ length: 10 }, (_, i) => i === 0 && first ? `export const o0 = '${first}';\n` : i === 9 && last ? `export const o9 = '${last}';\n` : `export const o${i} = ${i};\n`).join(''); + seedMain(fx, { 'lib/ship-old.ts': body() }); + readyPr(fx, '1', { 'lib/ship-old.ts': null, 'lib/ship-new.ts': body('one') }); + readyPr(fx, '2', { 'lib/ship-old.ts': body('two'), 'lib/review-2.ts': 'export const r = 2;\n', 'test/review-2.test.ts': '// #2\n' }); + readyPr(fx, '3', { 'lib/ship-old.ts': body('', 'three'), 'lib/pregate-3.ts': 'export const p = 3;\n', 'test/pregate-3.test.ts': '// #3\n' }); + const plan = queue(fx, ['wave', 'plan', '--trigger', '1', '--min', '2']); + expect([plan.status, plan.err]).toEqual([EXIT.ok, '']); + expect(plan.out.split('\n').filter(l => l.startsWith('WAVE_GROUP:'))).toEqual(['WAVE_GROUP: area=ship prs=#1,#2 integrator=#1 (most changed lines)']); + expect(line(plan.out, 'SOLO:')).toBe('SOLO: #3 (no area group of 2+; they ride as single slots)'); + const small = queue(fx, ['wave', 'plan', '--trigger', '1', '--min', '1', '--max', '1']); + expect([small.status, small.err]).toEqual([EXIT.ok, '']); + const rows = small.out.split('\n'); + const at = rows.findIndex(l => l.startsWith('WAVE_OVERSIZE:')); + expect(rows[at]).toBe('WAVE_OVERSIZE: area=ship prs=#1,#2 (2 PRs whose changes conflict, over the wave maximum 1; kept whole with no build command: land part of it as single slots, then run wave plan again)'); + expect(rows[at + 1]).not.toContain('build:'); + expect(rows.filter(l => l.startsWith('WAVE_GROUP:'))).toEqual(['WAVE_GROUP: area=pregate prs=#3 integrator=#3 (most changed lines)']); + }, 180_000); }); describe('wave settle', () => {