mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 02:30:39 +01:00
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:
1 parent
e58be617f1
commit
fb663a2bdd
13 files changed
+247
-33
No files matched your search
+121
-1
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user