feat: the plan check works on public repos: a machine-local plan_record and a record-keyed Plan line (GST-1774) (#4133)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-07 18:02:40 +00:00
1 parent e58be617f1
commit fb663a2bdd
13 files changed
+247 -33

No files matched your search

+121 -1
View File
@@ -4,11 +4,12 @@
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { buildRow, locate, type ApprovalRow } from '../lib/approval-record';
import { exemptReason, finishPlan, landPlan, parsePlanLine, planMode, readyPlan, resolvePlan } from '../lib/plan-record';
import { exemptReason, finishPlan, landPlan, parsePlanLine, planMode, planRecordId, publicPlanLine, readyPlan, resolvePlan } from '../lib/plan-record';
import { planBinding } from '../lib/ship-audit';
import { loadReady, persist, repoIdentity } from '../lib/ship-queue-train-record';
@@ -287,3 +288,122 @@ describe('a broken approval store never throws, and changes nothing with the key
expect(req.failure?.code).toBe('PLAN_STORE_UNAVAILABLE');
});
});
describe('public repos: the record-keyed Plan line and the machine-local key (GST-1774)', () => {
const ghStub = (visibility: string) => {
const bin = tmp('plan-gh-');
fs.writeFileSync(path.join(bin, 'gh'), `#!/bin/sh\necho ${visibility}\n`, { mode: 0o755 });
return bin;
};
const approvals = (f: { cwd: string; env: NodeJS.ProcessEnv }, visibility: string) => (args: string[]) => {
const r = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-approvals'), ...args], { cwd: f.cwd, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...f.env, PATH: `${ghStub(visibility)}:${process.env.PATH}` } });
return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
};
const setLocal = (env: NodeJS.ProcessEnv, value: string) => fs.writeFileSync(path.join(env.GSTACK_STATE_ROOT!, 'config.yaml'), `plan_record: ${value}\n`);
test('parsePlanLine reads the record form; planRecordId is stable and opaque', () => {
expect(parsePlanLine('Plan: approved (record r-0123456789ab)')).toEqual({ kind: 'approved', line: 'Plan: approved (record r-0123456789ab)', token: 'r-0123456789ab' });
expect(parsePlanLine('**Plan:** approved (record r-0123456789AB).')).toMatchObject({ token: 'r-0123456789ab' });
expect(parsePlanLine('```\nPlan: approved (record r-0123456789ab)\n```')).toBeNull();
expect(planRecordId('a-000000000000')).toMatch(/^r-[0-9a-f]{12}$/);
expect(planRecordId('a-000000000000')).toBe(planRecordId('a-000000000000'));
expect(planRecordId('a-000000000000')).not.toContain('000000000000');
});
test('planMode: a tracked warn or require wins, then the env opt-in, then the machine key, then the Capy default', () => {
const state = tmp('plan-local-');
const env = { GSTACK_STATE_ROOT: state } as NodeJS.ProcessEnv;
expect(planMode('off', env)).toEqual({ mode: 'off', source: 'default' });
setLocal(env, 'require');
expect(planMode('warn', env)).toEqual({ mode: 'warn', source: 'base policy plan_record' });
expect(planMode('off', env)).toEqual({ mode: 'require', source: `state-root config ${state}/config.yaml` });
expect(planMode(undefined, { ...env, GSTACK_PLAN_RECORD: 'warn' }).source).toBe('GSTACK_PLAN_RECORD');
setLocal(env, 'off');
expect(planMode('off', { ...env, GSTACK_CAPY: '1' }).mode).toBe('off');
setLocal(env, 'requre');
const typo = planMode('off', { ...env, GSTACK_CAPY: '1' });
expect(typo.mode).toBe('warn');
expect(typo.source).toContain('plan_record "requre" ignored');
expect(planMode(undefined, { HOME: '' }).mode).toBe('off');
});
test('a record id resolves to its row under the row-id rules, and never names the run, the row or the ask', () => {
const f = fixture({ main: [
{ ask: 'Approve the plan A (autoplan run ap-1)', source: 'autoplan:ap-1@gate_rev=1' },
{ ask: 'review_loop_grant abc for #1', scope: 'one fix-check' },
{ ask: 'Approve the plan C rev1', source: 'autoplan:ap-3@gate_rev=1' },
{ ask: 'Approve the plan C rev2', source: 'autoplan:ap-3@gate_rev=2', outcome: 'rejected', at: '2026-11-07T02:00:00Z' },
] });
const id = planRecordId(f.ids['Approve the plan A (autoplan run ap-1)']);
const ok = resolvePlan(parsePlanLine(`Plan: approved (record ${id})`), f);
expect(ok.ok && ok.record).toEqual({ line: `Plan: approved (record ${id})`, state: 'approved', ref: id, row_sha256: expect.any(String), resolver: 1 });
expect(ok.ok && ok.summary).toBe(`approved ${id} (gate_rev 1, 2026-11-07)`);
const grant = resolvePlan(parsePlanLine(`Plan: approved (record ${planRecordId(f.ids['review_loop_grant abc for #1'])})`), f);
expect(!grant.ok && grant.failure.code).toBe('PLAN_UNRESOLVED');
const stale = resolvePlan(parsePlanLine(`Plan: approved (record ${planRecordId(f.ids['Approve the plan C rev1'])})`), f);
expect(!stale.ok && stale.failure.code).toBe('PLAN_NOT_APPROVED');
expect(!stale.ok && stale.failure.message).not.toContain('ap-3');
const unknown = resolvePlan(parsePlanLine('Plan: approved (record r-000000000000)'), f);
expect(!unknown.ok && unknown.failure.code).toBe('PLAN_UNRESOLVED');
const block = 'r-' + createHash('sha256').update(Object.values(f.ids).sort().join('\n')).digest('hex').slice(0, 12);
const pasted = resolvePlan(parsePlanLine(`Plan: approved (record ${block})`), f);
expect(!pasted.ok && pasted.failure.message).toContain("the Approvals block's id");
expect(publicPlanLine(parsePlanLine('Plan: approved: ap-1')!, resolvePlan(parsePlanLine('Plan: approved: ap-1'), f))).toBe(`Plan: approved (record ${id})`);
});
test('render on a public repo prints the record form, re-renders it unchanged, and refuses a line that does not resolve', () => {
const f = fixture({ 'capy-feature': [{ ask: 'Approve the plan A (autoplan run ap-1)', source: 'autoplan:ap-1@gate_rev=1' }] });
const run = approvals(f, 'PUBLIC');
const id = planRecordId(f.ids['Approve the plan A (autoplan run ap-1)']);
const body = run(['render', '--plan', 'Plan: approved: ap-1']).out;
expect(body).toContain(`Plan: approved (record ${id})\nPlan check: approved (record ${id})\n`);
for (const secret of ['ap-1', f.ids['Approve the plan A (autoplan run ap-1)']!, 'Approve the plan A']) expect(body).not.toContain(secret);
expect(parsePlanLine(body)).toMatchObject({ token: id });
expect(run(['render', '--plan', `Plan: approved (record ${id})`]).out).toBe(body);
const check = run(['plan-check', '--plan', `approved (record ${id})`]);
expect(check.status).toBe(0);
expect(check.out).toBe(`PLAN: approved ${id} (gate_rev 1, 2026-11-07)\n`);
expect(run(['render', '--plan', 'Plan: exempt: bug fix (R65)']).out).toContain('Plan: exempt: bug fix (R65)\nPlan check: exempt bug fix\n');
const missing = run(['render', '--plan', 'Plan: approved: ap-9']);
expect(missing.status).not.toBe(0);
expect(missing.out).toBe('');
expect(missing.err).toContain('PLAN_UNRESOLVED');
expect(missing.err).toContain('fix:');
expect(approvals(f, 'PRIVATE')(['render', '--plan', 'Plan: approved: ap-1']).out).toContain('Plan: approved: ap-1\nPlan check: approved ap-1 row=');
});
test('ready, finish and land carry a record-form line with no run id, row id or ask', () => {
const f = fixture({ main: [{ ask: 'Approve the plan A (autoplan run ap-1)', source: 'autoplan:ap-1@gate_rev=1' }] });
const rowId = f.ids['Approve the plan A (autoplan run ap-1)']!;
const line = `Plan: approved (record ${planRecordId(rowId)})`;
const ready = readyPlan({ cwd: f.cwd, env: f.env, body: line, policyValue: 'require', changed: ['lib/x.ts'] });
const finish = finishPlan(f.cwd, 'main', line, { ...f.env, GSTACK_PLAN_RECORD: 'require' });
for (const out of [ready.line!, finish.line!, JSON.stringify(ready.record)]) for (const secret of ['ap-1', rowId, 'Approve the plan']) expect(out).not.toContain(secret);
expect(ready.record?.row_id).toBeUndefined();
expect(landPlan({ env: {}, policyValue: 'require', record: ready.record, body: line })).toEqual({ line: `PLAN: approved ${planRecordId(rowId)} (from the READY note)` });
});
test('with no tracked policy the machine key drives ready and finish, and a deciding off still prints a line', () => {
const f = fixture({ main: [{ ask: 'Approve the plan A', source: 'autoplan:ap-1@gate_rev=1' }] });
setLocal(f.env, 'require');
const ready = readyPlan({ cwd: f.cwd, env: f.env, body: '', changed: ['lib/x.ts'] });
expect(ready.failure?.code).toBe('PLAN_RECORD_MISSING');
expect(ready.line).toContain(`[mode require from state-root config ${f.env.GSTACK_STATE_ROOT}/config.yaml]`);
expect(finishPlan(f.cwd, 'main', undefined, f.env).failure?.code).toBe('PLAN_RECORD_MISSING');
setLocal(f.env, 'off');
const off = readyPlan({ cwd: f.cwd, env: { ...f.env, GSTACK_CAPY: '1' }, body: '', changed: ['lib/x.ts'] });
expect(off).toEqual({ line: `PLAN: off [mode off from state-root config ${f.env.GSTACK_STATE_ROOT}/config.yaml]` });
expect(landPlan({ env: { ...f.env, GSTACK_CAPY: '1' }, policyValue: 'off', body: '' }).line).toContain('PLAN: off');
});
test('gstack-config takes off, warn or require for plan_record and rejects a typo', () => {
const state = tmp('plan-config-');
const config = (...args: string[]) => spawnSync(path.join(ROOT, 'bin', 'gstack-config'), args, { encoding: 'utf8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: state, GSTACK_HOME: '' } });
expect(config('get', 'plan_record')).toMatchObject({ status: 0, stdout: '' });
expect(config('set', 'plan_record', 'warn').status).toBe(0);
const typo = config('set', 'plan_record', 'requre');
expect(typo.status).toBe(1);
expect(typo.stderr).toContain('Existing value left unchanged');
expect(config('get', 'plan_record').stdout.trim()).toBe('warn');
});
});
@@ -0,0 +1,4 @@
{
"bytes": 139,
"why": "GST-1774: capy-pr item 7 names the public Plan line form and render's PLAN_* refusal"
}