mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 10:40:32 +01:00
* 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>
82 lines
5.1 KiB
TypeScript
Executable File
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'));
|