mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
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>
This commit is contained in:
1 parent
6954a8c42b
commit
b8d6eec915
5 files changed
+145
-28
No files matched your search
+11
-4
@@ -574,13 +574,20 @@ gstack-ship-queue wave settle --wave <nonce> [--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
|
||||
|
||||
+38
-22
@@ -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<string, string> }
|
||||
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<string, PlanPr[]>();
|
||||
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<string, string> } | 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<string, string> = {};
|
||||
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 <pins>`, '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 <pins>`, '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 <n>] [--min <n>] [--max <n>]
|
||||
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 <n>@<sha>,... [--area <name>] [--min <n>] [--max <n>] [--dry-run]
|
||||
the integrator branch wave/<nonce>: main plus each pin merged unchanged (train merge
|
||||
rules), the wave record in refs/notes/gstack-trains, the branch pushed, a frozen notice
|
||||
|
||||
+2
-2
@@ -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)`;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string>, 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, string | null>): 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', () => {
|
||||
|
||||
Reference in new issue
Block a user