#!/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 --reads [--stub-out ] [--json] * gstack-archive-check landed --pr [--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 --reads [--stub-out ] [--json] The reads file is what the Captain's execute program wrote: {"read_at": , "thread": , "issues": }. Every PR the thread holds is re-read with gh pr view --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 [--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 (a thread code such as GSTA-462)'); if (!readsFile) return usage('thread needs --reads '); 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 '); 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'));