Files
gstack/bin/gstack-archive-check
T
Garry Tanandcapy-ai[bot] 1d91652764 Make the archive check refuse unknown PR states, cut-short issue rows and a missing title policy (GST-2241) (#4251)
* Make the archive check refuse unknown PR states, cut-short issue rows and a missing title policy (GST-2241)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Regenerate the doctor result-code table for the two new archive codes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Document that the archive check refuses unknown PR states and needs the title policy on land

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>
2026-11-09 23:29:20 +00:00

82 lines
5.1 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-archive-check — a Captain's pre-archive orphan check and the land
* step's issue close (GST-2188; R86, R91). Logic: lib/archive-check.ts; how a
* Captain runs it: crew/docs/captain.md#no-orphaned-work and #direct-land-merge-direct.
*
* gstack-archive-check thread --thread <code> --reads <file.json> [--stub-out <file>] [--json]
* gstack-archive-check landed --pr <n> [--repo <owner/repo>] [--json]
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import { checkThread, landedIssues, MAX_READ_AGE_MS } from '../lib/archive-check';
import { flagValue } from '../lib/bin-context';
import { EXIT } from '../lib/headless-artifacts';
import { loadPlainTitlePolicy } from '../lib/plain-title';
import { resultLine } from '../lib/result-codes';
import type { Gh } from '../lib/ship-queue';
const HELP = `gstack-archive-check — refuse to archive a thread that still holds open work (crew/docs/captain.md#no-orphaned-work)
thread --thread <code> --reads <file.json> [--stub-out <file>] [--json]
The reads file is what the Captain's execute program wrote: {"read_at": <ISO from now()>,
"thread": <tools.thread.get result>, "issues": <tools.issue.read({ assignee, status: "open" }).text>}.
Every PR the thread holds is re-read with gh pr view --repo <its repo>. Clean only when none is open
(R91: no successor makes an open PR OK) and no issue in a working state is still on the thread.
Prints ORPHAN and NOTE lines, a handoff stub, and last ARCHIVE_CHECK: clean|refused|unreadable.
Reads older than ${MAX_READ_AGE_MS / 60_000} minutes, truncated or unparsable are refused.
landed --pr <n> [--repo <owner/repo>] [--json]
After gstack-ship-queue land: one ISSUE_CLOSE line per tracker code in the merged PR's trailing
title group (.gstack/plain-titles.json codes), or ISSUE_CLOSE: none. Run it from the repository root:
a missing, invalid or empty .gstack/plain-titles.json refuses (ARCHIVE_POLICY_UNREADABLE).
Exit codes: 0 clean (thread) or read (landed) · 1 refused, or landed on a PR that is not merged · 2 usage · 3 a read was stale, unparsable, for another thread, gh failed or returned a PR state other than OPEN, CLOSED or MERGED, or (landed) the title policy is missing or invalid`;
function ghBound(repo?: string): Gh {
return (args: string[]) => {
const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024, env: repo ? { ...process.env, GH_REPO: repo } : process.env });
return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr || (r.error ? String(r.error.message) : '') };
};
}
function usage(message: string): number {
process.stderr.write('gstack-archive-check: ' + message + '\n' + HELP + '\n');
return EXIT.usage;
}
function thread(args: string[]): number {
const code = flagValue(args, '--thread');
const readsFile = flagValue(args, '--reads');
const stubOut = flagValue(args, '--stub-out');
if (!code || !/^[A-Z][A-Z0-9]*-\d+$/.test(code)) return usage('thread needs --thread <code> (a thread code such as GSTA-462)');
if (!readsFile) return usage('thread needs --reads <file.json>');
let raw: unknown;
try { raw = JSON.parse(fs.readFileSync(readsFile, 'utf8')); } catch (e: any) { raw = { parseError: String(e?.message ?? e) }; }
const r = checkThread(raw, { thread: code, now: Date.now(), ghFor: ghBound });
if (stubOut) { fs.writeFileSync(stubOut, r.stub, { mode: 0o600 }); fs.chmodSync(stubOut, 0o600); }
if (args.includes('--json')) process.stdout.write(JSON.stringify(r.json) + '\n');
else {
const failure = r.failure ? [resultLine('gstack-archive-check thread', r.failure.code, r.failure.message)] : [];
process.stdout.write([...failure, ...r.lines.slice(0, -1), ...(r.stub ? [r.stub.trimEnd()] : []), r.lines.at(-1)!].join('\n') + '\n');
}
return r.verdict === 'clean' ? EXIT.ok : r.verdict === 'refused' ? EXIT.fail : EXIT.refused;
}
function landed(args: string[]): number {
const pr = flagValue(args, '--pr');
const repo = flagValue(args, '--repo');
if (!pr || !/^\d+$/.test(pr)) return usage('landed needs --pr <number>');
if (repo !== undefined && !/^[\w.-]+\/[\w.-]+$/.test(repo)) return usage('--repo takes owner/repo');
let policy = null;
let policyError: string | undefined;
try { policy = loadPlainTitlePolicy(process.cwd()); } catch (e: any) { policyError = String(e?.message ?? e); }
const r = landedIssues({ pr: Number(pr), repo, gh: ghBound(repo), policy, policyError });
if (args.includes('--json')) process.stdout.write(JSON.stringify(r.json) + '\n');
else process.stdout.write([...(r.failure ? [resultLine('gstack-archive-check landed', r.failure.code, r.failure.message)] : []), ...r.lines].join('\n') + '\n');
return r.exit;
}
const [cmd, ...rest] = process.argv.slice(2);
if (cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); process.exit(EXIT.ok); }
process.exit(cmd === 'thread' ? thread(rest) : cmd === 'landed' ? landed(rest) : usage(cmd ? 'unknown command ' + JSON.stringify(cmd) : 'name a command'));