mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* v1.91.70.0 feat(unattended): artifact contract, unattended session kind, findings-derived review log, gate list Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.73.0 feat(ship): repo ship policy, gstack-restamp stamp-at-merge, tree receipt, evidence bundle, ship receipt Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ci): result-codes is selection data, receipt counts come from files, C-stamp tests leave the Windows subset - scripts/pr-dependencies.ts: lib/result-codes.ts joins SELECTION_DATA_MODULES; its fix: prose names commands a person reruns, not files the module consumes (test/pr-dependencies.test.ts traced review cases through it to bin/gstack-next-version) - ship/sections/pr-body.md.tmpl: gstack-ship-receipt write reads --gate/--ci from agent-written .gstack/tmp/ship-*.json files and --spend-usd from the allowlisted <spend-usd> placeholder (generated-bash lint: no free text in commands) - scripts/lib/windows-curation.ts: the five C-stamp test files spawn bin/ shebang scripts through test/helpers/restamp-fixture.ts, which the source scan misses Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): the receipt step names where each input comes from The workflow judge (eval-slices 3, median 3/4) found pr-body's gstack-ship-receipt command demanding a tree receipt, CI counts and a spend figure no earlier step produces, with RECEIPT_INVALID halting publication. The command now takes only Step 16 stage 4's lane counts (file-written JSON); --receipt is added only when Step 16 wrote .gstack/tmp/tree-receipt.json; RECEIPT_INVALID says to fix the named input file and rerun. The unused <spend-usd> allowlist entry is removed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
156 lines
12 KiB
TypeScript
156 lines
12 KiB
TypeScript
/**
|
|
* C5: the handoff record. `write` renders the fenced block identifiers-first
|
|
* and places it at the top of a PR body; `read` parses it back from a body or
|
|
* a PR (RECEIPT_MISSING / RECEIPT_INVALID); `census` computes the P7 adoption
|
|
* number over merged PRs, deduplicated by run and thread, against a cohort.
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { EXIT } from '../lib/headless-artifacts';
|
|
import { computeCensus, extractReceipt, placeReceiptFirst, receiptSummaryLine, renderReceiptBlock } from '../lib/ship-receipt';
|
|
import { cleanup, ghShim, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture';
|
|
|
|
const repos: FixtureRepo[] = [];
|
|
const fixture = () => { const r = makeFixtureRepo({ prefix: 'ship-receipt-' }); repos.push(r); return r; };
|
|
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
|
|
|
const HEAD = 'a'.repeat(40);
|
|
const block = (receipt: Record<string, unknown>) => renderReceiptBlock(receipt as any);
|
|
|
|
describe('gstack-ship-receipt write', () => {
|
|
test('writes the block identifiers-first, validates it against the ship-receipt schema, and puts it at the top of the PR body', () => {
|
|
const repo = fixture();
|
|
makePr(repo, 'pr-a', { expects: '1.2.4.0' });
|
|
expect(restamp(repo, ['--version', '1.2.4.0']).status).toBe(EXIT.ok);
|
|
repo.git('commit', '-qam', 'stamp');
|
|
const head = repo.git('rev-parse', 'HEAD');
|
|
const bundle = path.join(repo.root, 'bundle.json');
|
|
recordAndBundle(repo, repo.work, [{ label: 'tests', command: 'true' }], bundle);
|
|
const treeReceipt = path.join(repo.root, 'tree-receipt.json');
|
|
const tr = runBin(repo, 'gstack-tree-receipt', ['--base', 'main', '--gated', head, '--bundle', bundle, '--predecessor', 'b'.repeat(40), '--json']);
|
|
fs.writeFileSync(treeReceipt, tr.out);
|
|
const body = path.join(repo.root, 'body.md');
|
|
fs.writeFileSync(body, '## Summary\n\nProse that a truncated message may lose.\n');
|
|
const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '42', '--base', 'main', '--receipt', treeReceipt,
|
|
'--gate', '{"tests":{"pass":1,"fail":0,"skip":0}}', '--ci', '@' + path.join(repo.root, 'ci.json'), '--spend-usd', '0.42',
|
|
'--session-kind', 'unattended', '--artifacts-consumed', '2/3', '--run', 'run-1', '--thread', 'jam_1', '--queue-mode', 'stamp-at-merge',
|
|
'--preregistration', 'none (no preregistration_shas)', '--history', 'squash-ok', '--body', body, '--out', path.join(repo.root, 'receipt.md')]);
|
|
expect(w.status).toBe(EXIT.usage);
|
|
fs.writeFileSync(path.join(repo.root, 'ci.json'), '{"checks":{"pass":3,"fail":0,"pending":0}}');
|
|
const ok = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '42', '--base', 'main', '--receipt', treeReceipt,
|
|
'--gate', '{"tests":{"pass":1,"fail":0,"skip":0}}', '--ci', '@' + path.join(repo.root, 'ci.json'), '--spend-usd', '0.42',
|
|
'--session-kind', 'unattended', '--artifacts-consumed', '2/3', '--run', 'run-1', '--thread', 'jam_1', '--queue-mode', 'stamp-at-merge',
|
|
'--preregistration', 'none (no preregistration_shas)', '--history', 'squash-ok', '--body', body, '--out', path.join(repo.root, 'receipt.md')]);
|
|
expect(ok.status).toBe(EXIT.ok);
|
|
expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=42 head=${head.slice(0, 12)} version=1.2.4.0 base=main gated_tree=${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} tree=same-modulo-stamps gate-reuse=eligible (reusable: tests; rerun: -) predecessor=${'b'.repeat(12)} session_kind=unattended artifacts_consumed=2/3`);
|
|
const parsed = extractReceipt(ok.out);
|
|
expect(parsed.found).toBe(true);
|
|
expect(parsed.errors).toEqual([]);
|
|
expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history']);
|
|
expect(parsed.receipt).toMatchObject({ schema_version: 1, pr: 42, head, version: '1.2.4.0', base: 'main', tree: 'same-modulo-stamps', spend_usd: 0.42, ci: { checks: { pass: 3 } } });
|
|
expect(parsed.receipt!.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/ship-policy\.json$/);
|
|
const written = fs.readFileSync(body, 'utf8');
|
|
expect(written.startsWith('```gstack-ship-receipt\n{\n "schema_version": 1,\n "pr": 42,\n "head": "' + head + '"')).toBe(true);
|
|
expect(written).toContain('\n```\n\n## Summary\n\nProse that a truncated message may lose.\n');
|
|
expect(fs.readFileSync(path.join(repo.root, 'receipt.md'), 'utf8')).toBe(ok.out);
|
|
const art = runBin(repo, 'gstack-artifact', ['validate', path.join(repo.root, 'receipt.json'), '--as', 'ship-receipt']);
|
|
expect(art.status).not.toBe(EXIT.ok);
|
|
fs.writeFileSync(path.join(repo.root, 'receipt.json'), JSON.stringify(parsed.receipt));
|
|
expect(runBin(repo, 'gstack-artifact', ['validate', path.join(repo.root, 'receipt.json'), '--as', 'ship-receipt']).status).toBe(EXIT.ok);
|
|
const again = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '43', '--base', 'main', '--body', body, '--session-kind', 'interactive', '--json']);
|
|
expect(again.status).toBe(EXIT.ok);
|
|
const j = JSON.parse(again.out);
|
|
expect(j).toMatchObject({ pr: 43, head, session_kind: 'interactive' });
|
|
expect('tree' in j).toBe(false);
|
|
const rewritten = fs.readFileSync(body, 'utf8');
|
|
expect(rewritten.match(/```gstack-ship-receipt/g)).toHaveLength(1);
|
|
expect(rewritten).toContain('"pr": 43');
|
|
expect(rewritten).toContain('## Summary');
|
|
});
|
|
|
|
test('refuses an invalid receipt before writing (RECEIPT_INVALID) and a non-integer --pr', () => {
|
|
const repo = fixture();
|
|
const body = path.join(repo.root, 'body.md');
|
|
fs.writeFileSync(body, 'prose\n');
|
|
const r = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', 'seven', '--base', 'main', '--session-kind', 'x', '--body', body]);
|
|
expect(r.status).toBe(EXIT.usage);
|
|
expect(r.err).toContain('--pr must be an integer');
|
|
const receiptFile = path.join(repo.root, 'tree-receipt.json');
|
|
fs.writeFileSync(receiptFile, JSON.stringify({ tree: 'sideways', gate_reuse: 'eligible', gate_reuse_reason: 'x' }));
|
|
const inv = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--session-kind', 'x', '--receipt', receiptFile, '--body', body]);
|
|
expect(inv.status).toBe(EXIT.fail);
|
|
expect(inv.err).toMatch(/\(RECEIPT_INVALID\)/);
|
|
expect(inv.err).toContain('nothing written');
|
|
expect(fs.readFileSync(body, 'utf8')).toBe('prose\n');
|
|
});
|
|
});
|
|
|
|
describe('gstack-ship-receipt read', () => {
|
|
test('read --body and read --pr (through gh) return the block; a missing block is RECEIPT_MISSING, a broken one RECEIPT_INVALID', () => {
|
|
const repo = fixture();
|
|
const receipt = { schema_version: 1, pr: 5, head: HEAD, version: '1.2.4.0', base: 'main', session_kind: 'unattended', artifacts_consumed: '1/1', run: 'run-5', thread: 'jam_5' };
|
|
const body = path.join(repo.root, 'body.md');
|
|
fs.writeFileSync(body, placeReceiptFirst('Prose.\n', receipt as any));
|
|
const b = runBin(repo, 'gstack-ship-receipt', ['read', '--body', body, '--json']);
|
|
expect(b.status).toBe(EXIT.ok);
|
|
expect(JSON.parse(b.out)).toEqual(receipt);
|
|
expect(b.err.trim()).toBe(receiptSummaryLine(receipt as any));
|
|
const env = ghShim(repo, {
|
|
5: { body: fs.readFileSync(body, 'utf8') },
|
|
6: { body: 'No receipt here.\n' },
|
|
7: { body: block({ schema_version: 2, head: HEAD, base: 'main' }) },
|
|
8: { body: '```gstack-ship-receipt\n{not json\n```\n' },
|
|
});
|
|
const p = runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '5', '--repo', 'o/r'], env);
|
|
expect(p.status).toBe(EXIT.ok);
|
|
expect(p.out).toBe(renderReceiptBlock(receipt as any));
|
|
const missing = runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '6', '--json'], env);
|
|
expect(missing.status).toBe(EXIT.fail);
|
|
expect(missing.err).toMatch(/PR #6.*\(RECEIPT_MISSING\)/);
|
|
expect(JSON.parse(missing.out)).toEqual({ found: false, errors: [] });
|
|
const invalid = runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '7', '--json'], env);
|
|
expect(invalid.status).toBe(EXIT.fail);
|
|
expect(invalid.err).toMatch(/\(RECEIPT_INVALID\)/);
|
|
expect(JSON.parse(invalid.out)).toMatchObject({ found: true, valid: false });
|
|
const broken = runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '8'], env);
|
|
expect(broken.status).toBe(EXIT.fail);
|
|
expect(broken.err).toMatch(/block is not JSON.*\(RECEIPT_INVALID\)/);
|
|
expect(runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '9'], env).status).toBe(EXIT.fail);
|
|
expect(runBin(repo, 'gstack-ship-receipt', ['read']).status).toBe(EXIT.usage);
|
|
});
|
|
});
|
|
|
|
describe('gstack-ship-receipt census (P7)', () => {
|
|
const prs = [
|
|
{ number: 1, mergedAt: '2026-10-02T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '2/3', run: 'run-a', thread: 'jam_1' } as any) },
|
|
{ number: 2, mergedAt: '2026-10-03T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '2/3', run: 'run-a', thread: 'jam_1' } as any) },
|
|
{ number: 3, mergedAt: '2026-10-04T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'unattended', artifacts_consumed: '0/2', run: 'run-b', thread: 'jam_2' } as any) },
|
|
{ number: 4, mergedAt: '2026-10-05T00:00:00Z', body: placeReceiptFirst('', { schema_version: 1, head: HEAD, base: 'main', session_kind: 'interactive', artifacts_consumed: '1/1', run: 'run-c', thread: 'jam_4' } as any) },
|
|
{ number: 5, mergedAt: '2026-10-06T00:00:00Z', body: 'A PR shipped without gstack.\n' },
|
|
{ number: 6, mergedAt: '2026-10-07T00:00:00Z', body: block({ schema_version: 1, base: 'main' }) },
|
|
];
|
|
|
|
test('computeCensus deduplicates by run, counts consumed threads against the cohort, and reports invalid receipts', () => {
|
|
const c = computeCensus('o/r', '2026-10-01', prs, ['jam_1', 'jam_2', 'jam_3']);
|
|
expect(c).toMatchObject({ merged_prs: 6, with_receipt: 5, invalid_receipts: 1, unattended_prs: 2, consumed_prs: 2, runs: ['run-a', 'run-b', 'run-c'], threads: ['jam_1', 'jam_2', 'jam_4'], consumed_threads: ['jam_1', 'jam_4'], cohort_consumed: 1, adoption: '1/3 (cohort)', durable: 'yes', by_session_kind: { unattended: 3, interactive: 1 } });
|
|
expect(c.prs.find(p => p.number === 6)).toMatchObject({ valid: false });
|
|
const noCohort = computeCensus('o/r', '2026-10-01', prs, null);
|
|
expect(noCohort.adoption).toBe('2/3 (threads seen; no cohort file)');
|
|
});
|
|
|
|
test('census --repo --since reads merged PRs through gh and prints the CENSUS line', () => {
|
|
const repo = fixture();
|
|
const env = ghShim(repo, {}, prs);
|
|
const cohort = path.join(repo.root, 'cohort.txt');
|
|
fs.writeFileSync(cohort, '# threads in the measured cohort\njam_1\njam_2\njam_3\n\n');
|
|
const r = runBin(repo, 'gstack-ship-receipt', ['census', '--repo', 'o/r', '--since', '2026-10-01', '--cohort', cohort], env);
|
|
expect(r.status).toBe(EXIT.ok);
|
|
expect(r.out).toMatch(/^CENSUS: repo=o\/r since=2026-10-01 merged_prs=6 with_receipt=5 invalid=1 unattended=2 consumed=2 runs=3 threads=3 adoption=1\/3 \(cohort\) durable=yes$/m);
|
|
const j = runBin(repo, 'gstack-ship-receipt', ['census', '--repo', 'o/r', '--since', '2026-10-01', '--json'], env);
|
|
expect(JSON.parse(j.out)).toMatchObject({ repo: 'o/r', adoption: '2/3 (threads seen; no cohort file)' });
|
|
expect(runBin(repo, 'gstack-ship-receipt', ['census', '--repo', 'o/r'], env).status).toBe(EXIT.usage);
|
|
expect(runBin(repo, 'gstack-ship-receipt', ['census', '--repo', 'o/r', '--since', 'v9.9.9'], env).status).toBe(EXIT.fail);
|
|
});
|
|
});
|