fix: close review-cycle gaps (a stamp must change a stamp file, result requires a full --head and fails closed without headRefOid, digits-only --pr) with tests for every cycle-3 branch

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 02:56:14 +00:00
1 parent 4afaa98f54
commit 95cf833708
7 files changed
+54 -12

No files matched your search

+5
View File
@@ -330,6 +330,11 @@ describe('ready', () => {
const bare = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, bareMerge)], shim(bareMerge, shippedBody('1', pin)));
expect(bare.status).toBe(EXIT.refused);
expect(bare.err).toMatch(staleRe(bareMerge));
repo.git('commit', '-q', '--allow-empty', '-m', 'empty commit is not a stamp');
const emptyOnMerge = repo.git('rev-parse', 'HEAD');
const empty = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, emptyOnMerge)], shim(emptyOnMerge, shippedBody('1', pin)));
expect(empty.status).toBe(EXIT.refused);
expect(empty.err).toMatch(staleRe(emptyOnMerge));
repo.git('reset', '-q', '--hard', pin);
fs.appendFileSync(path.join(repo.work, 'pr-s.txt'), 'real change before the merge\n');
repo.git('commit', '-qam', 'edit on the pin side');
+25 -7
View File
@@ -154,9 +154,11 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
const head = repo.git('rev-parse', 'HEAD');
const state = path.join(repo.root, 'state');
const env = { ...ghShim(repo, {
5: { body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 5, head, base: 'main', queue_mode: 'stamp-at-merge' } as any) },
6: { body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 6, head: 'b'.repeat(40), base: 'main' } as any) },
7: { body: 'No receipt here.\n' },
5: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 5, head, base: 'main', queue_mode: 'stamp-at-merge' } as any) },
6: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 6, head: 'b'.repeat(40), base: 'main' } as any) },
7: { headRefOid: head, body: 'No receipt here.\n' },
9: { headRefOid: 'd'.repeat(40), body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 9, head, base: 'main' } as any) },
10: { body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 10, head, base: 'main' } as any) },
}), GSTACK_STATE_ROOT: state, GSTACK_SESSION_KIND: 'unattended' };
const ok = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '5'], env);
expect(ok.status).toBe(EXIT.ok);
@@ -179,6 +181,13 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
const missing = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '7', '--json'], env);
expect(missing.status).toBe(EXIT.refused);
expect(JSON.parse(missing.out)).toMatchObject({ skill: 'ship', status: 'refused', run: '-', code: 'QUEUE_RECEIPT_MISSING' });
const unpushed = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '9'], env);
expect([unpushed.status, unpushed.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']);
expect(unpushed.err).toContain(`HEAD ${head.slice(0, 12)} is not PR #9's pushed head dddddddddddd; push it, then rerun gstack-ship-receipt result`);
const noOid = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '10'], env);
expect([noOid.status, noOid.out.trim()]).toEqual([EXIT.fail, 'GSTACK_RESULT: skill=ship status=incomplete run=-']);
expect(noOid.err).toContain('gh pr view returned no headRefOid for PR #10');
expect(runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '--repo'], env).status).toBe(EXIT.usage);
const bodyFile = path.join(repo.work, '.gstack/tmp/ship-receipt.md');
fs.mkdirSync(path.dirname(bodyFile), { recursive: true });
@@ -189,6 +198,15 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'run-out', 'ship-run.json'), 'utf8'))).toMatchObject({ head, pr: null, receipt_source: bodyFile, session_kind: 'unattended' });
expect(runBin(repo, 'gstack-ship-receipt', ['result', '--body', path.join(repo.root, 'absent.md')], env).status).toBe(EXIT.refused);
expect(runBin(repo, 'gstack-ship-receipt', ['result'], env).status).toBe(EXIT.usage);
const relative = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', 'rel-out'], env);
expect(relative.status).toBe(EXIT.ok);
expect(relative.out.trim()).toBe(`GSTACK_RESULT: skill=ship status=complete run=${path.join(repo.work, 'rel-out')}`);
expect(fs.existsSync(path.join(repo.work, 'rel-out', 'ship-run.json'))).toBe(true);
const blocker = path.join(repo.root, 'not-a-dir');
fs.writeFileSync(blocker, 'file\n');
const unwritable = runBin(repo, 'gstack-ship-receipt', ['result', '--body', bodyFile, '--out', path.join(blocker, 'sub')], env);
expect([unwritable.status, unwritable.out.trim()]).toEqual([EXIT.fail, 'GSTACK_RESULT: skill=ship status=incomplete run=-']);
expect(unwritable.err).toContain(`cannot write the run record under ${path.join(blocker, 'sub')}`);
expect(fs.readdirSync(path.join(state, 'ship', 'runs')).length).toBe(1);
});
@@ -218,7 +236,7 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
const invalid = runBin(repo, 'gstack-ship-receipt', ['result', '--body', invalidFile], env);
expect([invalid.status, invalid.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']);
expect(invalid.err).toMatch(/the receipt's ship receipt is invalid \(.*\).*\(QUEUE_RECEIPT_MISSING/);
const otherPrEnv = { ...env, ...ghShim(repo, { 8: { body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 99, head, base: 'main' } as any) } }) };
const otherPrEnv = { ...env, ...ghShim(repo, { 8: { headRefOid: head, body: placeReceiptFirst('Prose.\n', { schema_version: 1, pr: 99, head, base: 'main' } as any) } }) };
const otherPr = runBin(repo, 'gstack-ship-receipt', ['result', '--pr', '8'], otherPrEnv);
expect([otherPr.status, otherPr.out.trim()]).toEqual([EXIT.refused, 'GSTACK_RESULT: skill=ship status=refused run=-']);
expect(otherPr.err).toContain("PR #8's ship receipt names pr=99, not this PR");
@@ -229,10 +247,10 @@ describe('gstack-ship-receipt result (/ship terminal line, GBRA-108)', () => {
expect(JSON.parse(fs.readFileSync(path.join(repo.root, 'pinned-out', 'ship-run.json'), 'utf8')).head).toBe('c'.repeat(40));
const badHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', '../../etc'], env);
expect([badHead.status, badHead.out]).toEqual([EXIT.usage, '']);
expect(badHead.err).toContain('--head must be a hex commit id of at least 12 characters');
const shortHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', 'abcdef1'], env);
expect(badHead.err).toContain('--head must be a full hex commit id');
const shortHead = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned, '--head', head.slice(0, 12)], env);
expect([shortHead.status, shortHead.out]).toEqual([EXIT.usage, '']);
expect(shortHead.err).toContain('--head must be a hex commit id of at least 12 characters');
expect(shortHead.err).toContain('--head must be a full hex commit id');
const noGit = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-receipt-nogit-'));
const outside = runBin(repo, 'gstack-ship-receipt', ['result', '--body', pinned], env, noGit);
expect([outside.status, outside.err.trim()]).toEqual([EXIT.usage, 'gstack-ship-receipt result: --head <sha> is required outside a git repository']);
+16
View File
@@ -12,6 +12,7 @@
* prune, ready's screen choice, and the policy gate.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
@@ -215,6 +216,16 @@ describe('ready under a train policy: the screen choice', () => {
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
expect(missing.status).toBe(EXIT.refused);
expect(missing.err).toMatch(/has no refs\/pull\/1\/merge.*\(QUEUE_SCREEN_STALE_REF\)/);
const tree = repo.git('rev-parse', `${moved}^{tree}`);
const main = repo.git('rev-parse', 'origin/main');
const badMerge = spawnSync('git', ['-C', repo.origin, 'hash-object', '-t', 'commit', '-w', '--stdin', '--literally'], { input: `tree ${tree}\nparent ${main}\nparent ${moved}\nauthor A <a@example.com> 1700000000 +0000\ncommitter B 1700000000 +0000\n\nmerge without a committer email\n`, encoding: 'utf8' }).stdout.trim();
spawnSync('git', ['-C', repo.origin, 'update-ref', 'refs/pull/1/merge', badMerge]);
repo.git('config', 'fetch.fsckObjects', 'true');
const unfetchable = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
expect(unfetchable.status).toBe(EXIT.refused);
expect(unfetchable.err).toMatch(/could not fetch refs\/pull\/1\/merge from origin: .*\(QUEUE_SCREEN_STALE_REF\)/);
expect(line(unfetchable.out, 'NEXT:')).toBe('NEXT: check the remote, then gstack-ship-queue ready --pr 1 --screen');
repo.git('config', '--unset', 'fetch.fsckObjects');
expect(dispatches(fx)).toEqual([]);
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
repo.git('push', '-q', '-f', 'origin', `${mergeRefCommit(fx, moved)}:refs/pull/1/merge`);
@@ -247,6 +258,11 @@ describe('ready under a train policy: the screen choice', () => {
expect(followed.status).toBe(EXIT.ok);
expect(line(followed.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 102 (same merge SHA, base and policy; no duplicate)');
expect(dispatches(fx, 'evals.yml').length).toBe(3);
const liveBuild = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
expect([liveBuild.status, line(liveBuild.out, 'ORDER:'), line(liveBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', '']);
fx.write(d => { d.rules['evals.yml'] = { status: 'in_progress', conclusion: null }; });
const runningBuild = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
expect([runningBuild.status, line(runningBuild.out, 'ORDER:'), line(runningBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', '']);
}, 120_000);
});