mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 11:57:19 +01:00
* Let an owner recorded approval close a gate finding in unattended ship (GST-2491) An unattended /ship logs every gate finding open and nothing could close it, so a PR with an owner-approved gate change could never finish. gstack-approvals gate records the owner disposition outside the run; it closes only that fingerprint on a review row whose range holds the head, and finish, the receipt, the PR body and READY all carry it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep the pinned review brief unchanged; document the resume steps for a dispositioned gate finding (GST-2491) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Bind a gate disposition to its gate change and show it as kept to the rest of /ship (GST-2491) A disposition now names the finding's gate_id as well as its fingerprint, so a later loosening of the same line stays open. gstack-review-read shows a closed gate finding as kept with the disposition's approval id, so the existing --blocking-open, --gate-open, draft and Gate edits rules carry it without prose changes, and an unreadable approval record applies no disposition instead of failing the reader. Review-Finding: p1/red-team#1 reader=red-team Review-Finding: s11/outside-voice#1 reader=outside-voice Review-Finding: s11/outside-voice#2 reader=outside-voice Review-Finding: s11/adversarial-native#1 reader=adversarial-native Review-Finding: s11/adversarial-native#2 reader=adversarial-native Review-Finding: s11/adversarial-native#4 reader=adversarial-native Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * List the Gate disposition line in the receipt and prose troubleshooting entries (GST-2491) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Require the lander to accept each owner gate disposition before merging (GST-2491) Land and a new read-only verify --pin re-derive the dispositions the approval store applies at the pin and merge only with --accept-gate-disposition for each one. The gate writer refuses while any worktree holds an open /ship run on the branch, under a lock; a row counts only in the writer's full shape with its digest; receipt write needs --body when a disposition applies; ready and land cross-check the receipt and body against the store. Supersedes #4299. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Regenerate the result-code anchors for the gate-disposition refusal (GST-2491) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Always record the receipt's gate dispositions and refuse a store that disagrees at merge (GST-2491) The receipt always carries gate_dispositions (an empty list when none apply) and write refuses on an unreadable store or a git error. Land, verify and ready refuse a receipt id the merge-time store lacks, naming the likely wrong state root or mirror, and a field-less receipt beside an applied row. A git ancestry error while deriving dispositions refuses instead of reading as not applied. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Count a disposition whose head a rewrite removed as not applied, and expect the receipt to end with gate_dispositions (GST-2491) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Trim and check the gate disposition recorder name, and let a parked run's ended lease stop blocking it (GST-2491) Review-Finding: s11/outside-voice#3 reader=outside-voice Review-Finding: s11/outside-voice#2 reader=outside-voice 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>
280 lines
23 KiB
TypeScript
Executable File
280 lines
23 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/**
|
|
* gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md).
|
|
*
|
|
* gstack-approvals add --ask <text> --words <exact words> --by <who> --scope <what it covers>
|
|
* [--pending <text>] [--outcome approved|rejected|pending|standing]
|
|
* [--date YYYY-MM-DD] [--source <ref>] [--branch <name>] [--json]
|
|
* gstack-approvals import --autoplan <run dir> [--by <whose words>] [--branch <name> | --stack <b1,b2,...>] [--json]
|
|
* gstack-approvals render [--no-heading] [--full | --redacted] [--branch <name>] [--plan <the brief's Plan line>]
|
|
* gstack-approvals plan-check --plan <the brief's Plan line>
|
|
* gstack-approvals rule --finding <fingerprint> --head <stop head> --reason <why> --words <exact words> --by <who ruled> [--json]
|
|
* gstack-approvals rulings [--branch <name>]
|
|
* gstack-approvals gate --finding <fingerprint> --gate-id <gate_id> --head <stop head> --reason <the gate change> --words <exact words> --by <who recorded> [--json]
|
|
* gstack-approvals dispositions [--branch <name>]
|
|
* gstack-approvals lane-exception --head <pushed head> --files <f1,f2> --reason <why> --words <exact words> --by <who granted> [--json]
|
|
* gstack-approvals list [--json] [--branch <name>]
|
|
* gstack-approvals sync [--branch <name>]
|
|
* gstack-approvals path [--branch <name>]
|
|
*
|
|
* Rows are kept per branch under the state root and, on Capy, mirrored to
|
|
* GSTACK_APPROVALS_DIR, else the `approvals/` directory a Captain created in
|
|
* its drive (readable by the whole crew), else the private project-user Drive
|
|
* folder. Every field is secret-scanned before it is written. A row with
|
|
* `render` prints the `## Approvals` section /ship puts in the PR body. Logic:
|
|
* lib/approval-record.ts.
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as path from 'node:path';
|
|
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
|
import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderMode, renderRecord, repoVisibility, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record';
|
|
import { GSTACK_ACTOR, openShipRun, RULED_NOTE_SCOPE, ruledNoteRow, ruledNotes, ruledNoteText } from '../lib/ruled-notes';
|
|
import { resultLine } from '../lib/result-codes';
|
|
import { parsePlanLine, publicPlanLine, resolvePlan } from '../lib/plan-record';
|
|
import { TEST_LANE_EXCEPTION_SCOPE, testLaneExceptionRow } from '../lib/test-lane-exception';
|
|
import { GATE_DISPOSITION_SCOPE, GATE_SOURCE, gateDispositionRow, gateDispositions, gateDispositionText, ONE_LINE, recordGateDisposition, validGateId } from '../lib/gate-dispositions';
|
|
|
|
const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md)
|
|
|
|
add --ask <text> --words <exact words> --by <who> --scope <what it covers>
|
|
[--pending <text>] [--outcome approved|rejected|pending|standing] [--date YYYY-MM-DD] [--source <ref>]
|
|
import --autoplan <run dir> [--by <whose words, e.g. Garry>] [--stack <b1,b2,...>]
|
|
--stack writes the same rows to every branch of a stack; each branch is checked first, and one unknown branch writes none
|
|
rule --finding <fingerprint> --head <the stop head the owner ruled at> --reason <why it ships as a note, one line>
|
|
--words <the owner's exact words> --by <who ruled>
|
|
the owner's ruling that a review finding ships as a disclosed note (docs/ship-policy.md#ruled-notes); finish
|
|
counts that finding closed on a fix-check whose range_base..commit holds the head, and the receipt and PR body
|
|
list it; refused inside this checkout's open /ship run
|
|
rulings the "Ruled note: …" lines the Pre-Landing Review and the PR body carry
|
|
gate --finding <the gate finding's fingerprint> --gate-id <its gate_id> --head <the stop head the owner answered at>
|
|
--reason <the gate change, one line> --words <the owner's exact words, one line> --by <who recorded them>
|
|
the owner's disposition of a gate finding an unattended /ship logged open (docs/ship-policy.md#gate-dispositions);
|
|
it closes that fingerprint and gate_id on a review row whose range_base..commit holds the head (a first pass: its
|
|
commit), and gstack-review-read shows it kept;
|
|
finish prints it, and the receipt, PR body and READY carry it; refused while any worktree holds an open /ship run on
|
|
the branch; the lander accepts each one with gstack-ship-queue land|verify --accept-gate-disposition <id>
|
|
dispositions the "Gate disposition: …" lines the Pre-Landing Review and the PR body carry
|
|
lane-exception --head <the pushed head the owner granted at> --files <the uncovered inventory files, comma-separated>
|
|
--reason <why, one line> --words <the owner's exact words> --by <who granted>
|
|
the owner's grant that a CI-carried lane counts although exactly those files are in no run's tree.json
|
|
(docs/ship-policy.md#test-lane-exceptions); gstack-evidence record-ci binds it only at that head, for that exact
|
|
set, after a local preflight of those files passes; refused inside this checkout's open /ship run
|
|
render [--no-heading] [--full | --redacted] the ## Approvals section for a PR body or report; a public repository, or one
|
|
whose visibility gh cannot read, gets only "Approved: yes|no (record <id>)", because a PR description keeps
|
|
its edit history; --full prints the rows when gh cannot read the visibility and you know the repo is private;
|
|
--redacted prints the public form on any repository (a private repo that may go public); the two conflict;
|
|
the rows show each local path in Asked, Covers and Still pending as …/<file name> (list keeps the path);
|
|
--plan "<the brief's Plan line>" adds that line and its check, which gstack-ship-queue ready reads (lib/plan-record.ts);
|
|
redacted, an approval prints as "Plan: approved (record r-<id>)" with no run or row id, and one that does not
|
|
resolve refuses with no output
|
|
plan-check --plan "Plan: approved: <run id or row id> | approved (record r-<id>) | exempt: <reason> | required"
|
|
resolve a Plan line against every branch's approval record; PLAN: … and exit 0, or the refusal code and exit 3
|
|
list [--json]
|
|
sync re-mirror the record (GSTACK_APPROVALS_DIR, the Captain's drive approvals/, or the project-user Drive)
|
|
path print the record and mirror paths, and which rule chose the mirror
|
|
Every command takes [--branch <name>] (default: the checked-out branch).
|
|
|
|
Example:
|
|
gstack-approvals add --ask "Approve the plan for the Capy approvals PR" --words "rec" \\
|
|
--by "Garry (via GStack Minder)" --scope "the reviewed plan, every recommendation"
|
|
|
|
${exitTableHelp()}`;
|
|
|
|
const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack', '--finding', '--reason', '--head', '--files', '--plan', '--gate-id']);
|
|
|
|
function parse(args: string[]): { flags: Record<string, string>; bools: Set<string>; error?: string } {
|
|
const flags: Record<string, string> = {};
|
|
const bools = new Set<string>();
|
|
for (let i = 0; i < args.length; i++) {
|
|
const a = args[i]!;
|
|
if (VALUE_FLAGS.has(a)) {
|
|
const v = args[i + 1];
|
|
if (v === undefined) return { flags, bools, error: `${a} needs a value` };
|
|
flags[a] = v; i++;
|
|
} else if (a === '--json' || a === '--no-heading' || a === '--full' || a === '--redacted') bools.add(a);
|
|
else return { flags, bools, error: `unknown argument ${a}` };
|
|
}
|
|
return { flags, bools };
|
|
}
|
|
|
|
function fail(cmd: string, e: RecordError): number {
|
|
process.stderr.write(resultLine(`gstack-approvals ${cmd}`, e.code, e.message) + '\n');
|
|
if (e.code === 'APPROVAL_RECORD_INVALID') return EXIT.usage;
|
|
if (e.code === 'APPROVAL_RECORD_WRITE_FAILED' || e.code === 'APPROVAL_RECORD_UNAVAILABLE' || e.code === 'ARTIFACT_MALFORMED_JSONL') return EXIT.fail;
|
|
return EXIT.refused;
|
|
}
|
|
|
|
/** `import --stack`: every branch checked first, then the same rows (ids are branch-independent) written to each branch's record. */
|
|
function importStack(cmd: string, flags: Record<string, string>, json: boolean): number {
|
|
if (cmd !== 'import' || !flags['--autoplan'] || flags['--branch'] !== undefined) { process.stderr.write(`gstack-approvals: --stack goes with import --autoplan <run dir>, without --branch\n`); return EXIT.usage; }
|
|
const branches = [...new Set(flags['--stack']!.split(',').map(b => b.trim()).filter(Boolean))];
|
|
if (!branches.length) { process.stderr.write(`gstack-approvals: --stack needs <b1,b2,...>\n`); return EXIT.usage; }
|
|
const cwd = process.cwd();
|
|
const missing = branches.filter(b => !branchExists(cwd, b));
|
|
if (missing.length) return fail(cmd, { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no local or origin branch ${missing.join(', ')}; nothing was written` });
|
|
const runDir = path.resolve(flags['--autoplan']);
|
|
const results: Array<{ branch: string; added: string[]; skipped: number; file: string; mirror: string | null; error?: string }> = [];
|
|
let firstError: RecordError | undefined;
|
|
const plans: Array<{ branch: string; loc: StoreLocation; rows: ReturnType<typeof importAutoplan> & unknown[] }> = [];
|
|
for (const branch of branches) {
|
|
const loc = locate({ cwd, env: process.env, branch });
|
|
if ('code' in loc) return fail(cmd, loc);
|
|
const rows = importAutoplan(runDir, branch, flags['--by']);
|
|
if ('code' in rows) return fail(cmd, rows);
|
|
plans.push({ branch, loc, rows });
|
|
}
|
|
for (const { branch, loc, rows } of plans) {
|
|
const res = appendRows(loc, rows);
|
|
if ('code' in res) { firstError ??= res; results.push({ branch, added: [], skipped: 0, file: loc.file, mirror: loc.mirror ?? null, error: res.message }); continue; }
|
|
if (res.mirrorError) firstError ??= res.mirrorError;
|
|
results.push({ branch, added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null, ...(res.mirrorError ? { error: res.mirrorError.message } : {}) });
|
|
}
|
|
if (json) process.stdout.write(JSON.stringify({ branches: results }) + '\n');
|
|
else {
|
|
for (const r of results) process.stdout.write(`APPROVAL_IMPORTED: branch=${r.branch} n=${r.added.length} skipped=${r.skipped} ${r.file}${r.mirror ? ` mirror=${r.mirror}` : ''}${r.error ? ` error=${r.error}` : ''}\n`);
|
|
process.stdout.write(`APPROVAL_IMPORTED_STACK: branches=${results.length} added=${results.reduce((n, r) => n + r.added.length, 0)}\n`);
|
|
}
|
|
return firstError ? fail(cmd, firstError) : EXIT.ok;
|
|
}
|
|
|
|
const planText = (line: string) => (/^\s*Plan:/i.test(line) ? line.trim().replace(/^plan:/i, 'Plan:') : `Plan: ${line.trim()}`);
|
|
|
|
function main(argv: string[]): number {
|
|
const [cmd, ...rest] = argv;
|
|
if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
|
|
const { flags, bools, error } = parse(rest);
|
|
if (error) { process.stderr.write(`gstack-approvals: ${error}\n${HELP}\n`); return EXIT.usage; }
|
|
if (flags['--stack'] !== undefined) return importStack(cmd, flags, bools.has('--json'));
|
|
if (cmd === 'plan-check') {
|
|
if (!flags['--plan']) { process.stderr.write(`gstack-approvals: plan-check needs --plan "<line>"\n`); return EXIT.usage; }
|
|
const res = resolvePlan(parsePlanLine(planText(flags['--plan'])), { cwd: process.cwd(), env: process.env });
|
|
if (!res.ok) return fail(cmd, res.failure);
|
|
process.stdout.write(`PLAN: ${res.summary}\n`);
|
|
return EXIT.ok;
|
|
}
|
|
const loc = locate({ cwd: process.cwd(), env: process.env, branch: flags['--branch'] });
|
|
if ('code' in loc) return fail(cmd, loc);
|
|
const json = bools.has('--json');
|
|
const recorded = (where: StoreLocation) => `${where.file}${where.mirror ? ` mirror=${where.mirror}` : ''}`;
|
|
|
|
if (cmd === 'add' || cmd === 'import' || cmd === 'rule' || cmd === 'lane-exception' || cmd === 'gate') {
|
|
let rows;
|
|
if (cmd === 'lane-exception') {
|
|
const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message });
|
|
const files = [...new Set((flags['--files'] ?? '').split(',').map(f => f.trim().replace(/^(\.\/)+/, '')).filter(Boolean))].sort();
|
|
const reason = (flags['--reason'] ?? '').trim();
|
|
if (!files.length || files.some(f => /\s/.test(f)) || !reason || /[\x00-\x1f\x7f]/.test(reason)) return invalid('lane-exception needs --files <the uncovered inventory files, comma-separated, no spaces> and --reason <why, one line>');
|
|
const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${flags['--head'] ?? ''}^{commit}`], { encoding: 'utf8', timeout: 10_000 });
|
|
if (!flags['--head'] || head.status !== 0) return invalid('lane-exception needs --head <the pushed head the owner granted at, a commit in this repository>');
|
|
if (GSTACK_ACTOR.test((flags['--by'] ?? '').trim())) return invalid('--by names gstack; a test-lane exception is the owner\'s, so --by names who granted it');
|
|
const open = openShipRun(process.cwd());
|
|
if (open) return invalid(`/ship run ${open} is open on this checkout: a test-lane exception is the owner's, never the lane's own runner's; record it from the owner's session`);
|
|
const built = buildRow({ ...testLaneExceptionRow(head.stdout.trim(), files, reason), words: flags['--words'] ?? '', by: flags['--by'] ?? '', date: flags['--date'] }, loc.branch);
|
|
if ('code' in built) return fail(cmd, built);
|
|
rows = [built];
|
|
} else if (cmd === 'rule') {
|
|
const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message });
|
|
const finding = (flags['--finding'] ?? '').trim(), reason = (flags['--reason'] ?? '').trim();
|
|
if (!finding || /\s/.test(finding) || !reason) return invalid('rule needs --finding <the finding\'s fingerprint, no spaces> and --reason <why it ships as a note>');
|
|
if (/[\x00-\x1f\x7f]/.test(reason)) return invalid('--reason is one line: no newline or control character');
|
|
const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${flags['--head'] ?? ''}^{commit}`], { encoding: 'utf8', timeout: 10_000 });
|
|
if (!flags['--head'] || head.status !== 0) return invalid('rule needs --head <the stop head the owner ruled at, a commit in this repository>');
|
|
if (GSTACK_ACTOR.test((flags['--by'] ?? '').trim())) return invalid('--by names gstack; a ruling is the owner\'s, so --by names who ruled');
|
|
const open = openShipRun(process.cwd());
|
|
if (open) return invalid(`/ship run ${open} is open on this checkout: a ruling is the owner's, never the lane's own runner's; record it from the owner's session`);
|
|
const built = buildRow({ ...ruledNoteRow(finding, head.stdout.trim(), reason), words: flags['--words'] ?? '', by: flags['--by'] ?? '', date: flags['--date'] }, loc.branch);
|
|
if ('code' in built) return fail(cmd, built);
|
|
rows = [built];
|
|
} else if (cmd === 'gate') {
|
|
const invalid = (message: string) => fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message });
|
|
const finding = (flags['--finding'] ?? '').trim(), gateId = (flags['--gate-id'] ?? '').trim(), reason = (flags['--reason'] ?? '').trim(), words = (flags['--words'] ?? '').trim(), by = (flags['--by'] ?? '').trim();
|
|
if (!finding || /\s/.test(finding) || !validGateId(gateId) || !reason) return invalid('gate needs --finding <the gate finding\'s fingerprint>, --gate-id <its gate_id from the review row>, both without spaces (a finding with no gate_id, or a literal undefined, cannot be dispositioned), and --reason <the gate change, one line>');
|
|
if (!words) return invalid('gate needs --words <the owner\'s exact words>: a gate disposition is the owner\'s, in the owner\'s words');
|
|
if (!ONE_LINE.test(reason) || !ONE_LINE.test(words) || !ONE_LINE.test(by)) return invalid('--reason, --words and --by are one line each: no newline or control character');
|
|
const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${flags['--head'] ?? ''}^{commit}`], { encoding: 'utf8', timeout: 10_000 });
|
|
if (!flags['--head'] || head.status !== 0) return invalid('gate needs --head <the stop head the owner answered at, a commit in this repository>');
|
|
if (GSTACK_ACTOR.test(by)) return invalid('--by names gstack; a gate disposition is the owner\'s, so --by names who recorded it');
|
|
const built = buildRow({ ...gateDispositionRow(finding, gateId, head.stdout.trim(), reason), words, by, date: flags['--date'], source: GATE_SOURCE }, loc.branch);
|
|
if ('code' in built) return fail(cmd, built);
|
|
const res = recordGateDisposition(process.cwd(), loc, built);
|
|
if ('code' in res) return fail(cmd, res);
|
|
if (json) process.stdout.write(JSON.stringify({ added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null }) + '\n');
|
|
else process.stdout.write(`APPROVAL_RECORDED: ${built.id}${res.skipped ? ' (already present)' : ''} ${recorded(loc)}\n`);
|
|
return res.mirrorError ? fail(cmd, res.mirrorError) : EXIT.ok;
|
|
} else if (cmd === 'add') {
|
|
if (RULED_NOTE_SCOPE.test(flags['--scope'] ?? '')) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a ruled_note scope is recorded with gstack-approvals rule' });
|
|
if (GATE_DISPOSITION_SCOPE.test(flags['--scope'] ?? '') || (flags['--source'] ?? '').trim() === GATE_SOURCE) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a gate_disposition scope is recorded with gstack-approvals gate' });
|
|
if (TEST_LANE_EXCEPTION_SCOPE.test(flags['--scope'] ?? '')) return fail(cmd, { code: 'APPROVAL_RECORD_INVALID', message: 'a test_lane_exception scope is recorded with gstack-approvals lane-exception' });
|
|
const built = buildRow({ ask: flags['--ask'] ?? '', words: flags['--words'] ?? '', by: flags['--by'] ?? '', scope: flags['--scope'] ?? '', pending: flags['--pending'], outcome: flags['--outcome'], date: flags['--date'], source: flags['--source'] }, loc.branch);
|
|
if ('code' in built) return fail(cmd, built);
|
|
rows = [built];
|
|
} else {
|
|
if (!flags['--autoplan']) { process.stderr.write(`gstack-approvals: import needs --autoplan <run dir>\n`); return EXIT.usage; }
|
|
const imported = importAutoplan(path.resolve(flags['--autoplan']), loc.branch, flags['--by']);
|
|
if ('code' in imported) return fail(cmd, imported);
|
|
rows = imported;
|
|
}
|
|
const res = appendRows(loc, rows);
|
|
if ('code' in res) return fail(cmd, res);
|
|
if (json) process.stdout.write(JSON.stringify({ added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null }) + '\n');
|
|
else if (cmd !== 'import') process.stdout.write(`APPROVAL_RECORDED: ${rows[0]!.id}${res.skipped ? ' (already present)' : ''} ${recorded(loc)}\n`);
|
|
else process.stdout.write(`APPROVAL_IMPORTED: n=${res.added.length} skipped=${res.skipped} ${recorded(loc)}\n`);
|
|
if (res.mirrorError) return fail(cmd, res.mirrorError);
|
|
return EXIT.ok;
|
|
}
|
|
if (cmd === 'render' && bools.has('--redacted') && bools.has('--full')) {
|
|
process.stderr.write('gstack-approvals: --redacted and --full conflict: --redacted forces the public form and --full asks for the rows; pass one\n');
|
|
return EXIT.usage;
|
|
}
|
|
if (cmd === 'render' || cmd === 'list') {
|
|
const read = readRecord(loc);
|
|
if (read.restored) process.stderr.write(`gstack-approvals: read the Drive mirror ${loc.mirror} (no local copy on this machine; gstack-approvals sync restores it)\n`);
|
|
if (cmd === 'list' && json) process.stdout.write(JSON.stringify({ branch: loc.branch, state: read.state, rows: read.rows }) + '\n');
|
|
else if (cmd === 'list') for (const r of read.rows) process.stdout.write(`${r.date} ${r.id} ${r.outcome} ${JSON.stringify(r.ask)} words=${JSON.stringify(r.words)} by=${JSON.stringify(r.by)}\n`);
|
|
else {
|
|
const decl = flags['--plan'] ? parsePlanLine(planText(flags['--plan'])) : null;
|
|
if (flags['--plan'] && !decl) { process.stderr.write(`gstack-approvals: render --plan needs "Plan: approved: <id> | exempt: <reason> | required"; ${JSON.stringify(flags['--plan'])} is none of them\n`); return EXIT.usage; }
|
|
const visibility = repoVisibility(process.cwd());
|
|
const mode = renderMode(visibility, bools.has('--full'), bools.has('--redacted'));
|
|
const res = decl ? resolvePlan(decl, { cwd: process.cwd(), env: process.env }) : null;
|
|
if (decl && res && !res.ok && mode === 'redacted' && decl.kind === 'approved') {
|
|
return fail(cmd, { code: res.failure.code, message: `${res.failure.message}; on a public or unknown-visibility repository render prints no Plan line until it resolves; fix: ${res.failure.fix}` });
|
|
}
|
|
if (mode === 'redacted') process.stderr.write(`APPROVALS_RENDER: redacted (${bools.has('--redacted') ? '--redacted; ' : ''}repository visibility ${visibility}${bools.has('--full') ? '; --full applies only to a private repository' : ''}); gstack-approvals list shows the full record\n`);
|
|
process.stdout.write(renderRecord(read, loc, { heading: !bools.has('--no-heading'), mode }));
|
|
if (decl && res) {
|
|
const line = mode === 'redacted' ? publicPlanLine(decl, res) : decl.line;
|
|
const short = !res.ok ? `${res.failure.code} (gstack-approvals plan-check says why)`
|
|
: mode === 'redacted' && res.record.state === 'approved' ? line.replace(/^Plan: /, '')
|
|
: `${res.record.state} ${res.record.ref ?? res.record.reason}${res.record.row_id && res.record.row_id !== res.record.ref ? ` row=${res.record.row_id}` : ''}`;
|
|
process.stdout.write(`${line}\nPlan check: ${short}\n\n`);
|
|
}
|
|
}
|
|
if (read.state === 'unavailable') return fail(cmd, { code: 'APPROVAL_RECORD_UNAVAILABLE', message: `no record at ${loc.file} and no mirror` });
|
|
return EXIT.ok;
|
|
}
|
|
if (cmd === 'rulings') {
|
|
const notes = ruledNotes(readRecord(loc).rows);
|
|
process.stdout.write(notes.length ? notes.map(n => `Ruled note: ${ruledNoteText(n)}\n`).join('') : 'RULED_NOTES: none\n');
|
|
return EXIT.ok;
|
|
}
|
|
if (cmd === 'dispositions') {
|
|
const ds = gateDispositions(readRecord(loc).rows, loc.branch);
|
|
process.stdout.write(ds.length ? ds.map(d => `Gate disposition: ${gateDispositionText(d)}\n`).join('') : 'GATE_DISPOSITIONS: none\n');
|
|
return EXIT.ok;
|
|
}
|
|
if (cmd === 'sync') {
|
|
const s = syncRecord(loc);
|
|
process.stdout.write(`APPROVAL_SYNCED: rows=${s.rows} ${recorded(loc)}\n`);
|
|
return s.error ? fail(cmd, s.error) : EXIT.ok;
|
|
}
|
|
if (cmd === 'path') {
|
|
process.stdout.write(`RECORD: ${loc.file}\nMIRROR: ${loc.mirror ? `${loc.mirror} (${loc.mirror_source})` : 'none'}\n${(loc.read_also ?? []).map(f => `READ_ALSO: ${f}\n`).join('')}BRANCH: ${loc.branch}\n`);
|
|
return EXIT.ok;
|
|
}
|
|
process.stderr.write(`gstack-approvals: unknown command ${cmd}\n${HELP}\n`);
|
|
return EXIT.usage;
|
|
}
|
|
|
|
process.exitCode = main(process.argv.slice(2));
|