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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-09 23:29:20 +00:00
1 parent 97b796e5ae
commit 1d91652764
8 files changed
+140 -17

No files matched your search

+6 -4
View File
@@ -27,9 +27,10 @@ const HELP = `gstack-archive-check — refuse to archive a thread that still hol
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.
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, or gh failed`;
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[]) => {
@@ -67,8 +68,9 @@ function landed(args: string[]): number {
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;
try { policy = loadPlainTitlePolicy(process.cwd()); } catch {}
const r = landedIssues({ pr: Number(pr), repo, gh: ghBound(repo), policy });
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;
+2
View File
@@ -396,6 +396,8 @@ gstack_result_anchor() {
ARCHIVE_THREAD_MISMATCH) printf '%s' 'archive-thread-mismatch' ;;
ARCHIVE_PR_NOT_MERGED) printf '%s' 'archive-pr-not-merged' ;;
ARCHIVE_GH_READ_FAILED) printf '%s' 'archive-gh-read-failed' ;;
ARCHIVE_PR_STATE_UNKNOWN) printf '%s' 'archive-pr-state-unknown' ;;
ARCHIVE_POLICY_UNREADABLE) printf '%s' 'archive-policy-unreadable' ;;
BUDGET_CAP_REACHED) printf '%s' 'budget-cap-reached' ;;
BUDGET_READING_MISSING) printf '%s' 'budget-reading-missing' ;;
METER_SCOPE_PARENT) printf '%s' 'meter-scope-parent' ;;
@@ -0,0 +1 @@
- **The pre-archive check no longer passes a thread whose PR state it can't read.** `gstack-archive-check` treated any PR state other than `OPEN` as closed, so an empty or unknown state from `gh pr view` let a thread with a possibly open PR archive clean. It now accepts only `OPEN`, `CLOSED` and `MERGED` and exits 3 with `ARCHIVE_PR_STATE_UNKNOWN` on anything else, on both `thread` and `landed`. An issue row cut short (no assignee, title or age) is now unreadable instead of a complete row. A failed CI read shows as `ci=unknown (CI read failed: <reason>)` on the ORPHAN line, and the open PR still refuses. `landed` with a missing, invalid or empty `.gstack/plain-titles.json` now exits 3 with `ARCHIVE_POLICY_UNREADABLE` instead of treating every code-shaped ref as an issue to close (R91, GST-2241).
+5 -2
View File
@@ -251,7 +251,8 @@ went stale and conflicting, and started to look like junk.
only on exit 0 (`ARCHIVE_CHECK: clean`). The check re-reads every PR the thread holds
from GitHub and refuses while any is open, or while an issue in a working state (anything
open but Backlog or Needs you) is still on the thread. It fails closed: stale, truncated or
unreadable reads and failed gh reads refuse too ([troubleshooting](../../docs/troubleshooting.md#archive-reads-stale)).
unreadable reads, failed gh reads and a PR state other than `OPEN`, `CLOSED` or `MERGED`
refuse too ([troubleshooting](../../docs/troubleshooting.md#archive-reads-stale)).
Every run prints a handoff stub (each open PR's branch, head and CI at that head, and the
open issues) to paste into a successor brief. When an issue list has more than one page,
read every page into `issues`.
@@ -503,7 +504,9 @@ commits. Each PR merges at its own green head, and versions are set at release
2. Captain: `gstack-ship-queue land --pr <n> --pin <sha>` (add `--allow-repo-commands` when
the base policy lists `land.merged_checks`, so a stale base can run them). It merges with
`--match-head-commit`, checks main's tree and writes a land note. In the same program,
after `MERGED`, run `gstack-archive-check landed --pr <n>`. For each
after `MERGED`, run `gstack-archive-check landed --pr <n>` from the repository root (it
refuses with `ARCHIVE_POLICY_UNREADABLE` when `.gstack/plain-titles.json` is missing,
invalid or empty, and closes nothing). For each
`ISSUE_CLOSE: <code>` line, read the issue with `tools.issue.read`. Move it to Done with a
comment naming the merge commit only when it links this PR, links no other open PR and has
no open sub-issues; skip one already Done; otherwise leave it open with a comment saying
+24
View File
@@ -4675,6 +4675,30 @@ different PR than the one asked for. A failed read is never clean.
**Fix.** Check `gh auth status` and the repository, then rerun, at most twice. If it
still fails, leave the thread unarchived (or the issue open) and tell the Captain.
<a id="archive-pr-state-unknown"></a>
### `ARCHIVE_PR_STATE_UNKNOWN`
**Meaning.** `gh pr view` answered for the right PR, but its `state` was empty or something
other than `OPEN`, `CLOSED` or `MERGED`. The check can't tell whether the PR is still
open, so it refuses instead of treating the PR as closed. On the land step
(`gstack-archive-check landed`) it exits 3 instead of 1, because "not merged" would be a
guess too.
**Fix.** Rerun once. If gh still prints that state, check `gh --version` and look at the
PR on GitHub. Leave the thread unarchived (or the issue open) and tell the Captain.
<a id="archive-policy-unreadable"></a>
### `ARCHIVE_POLICY_UNREADABLE`
**Meaning.** `gstack-archive-check landed` found no usable `.gstack/plain-titles.json` in the
directory it ran from: the file is missing, isn't valid, or lists no `codes`. Without those
patterns any code-shaped ref in the title (another crew's `ZZZ-4`, say) would look like
an issue to close, so it closes nothing.
**Fix.** Run it from the repository root, where `.gstack/plain-titles.json` lives. If the
file is missing or invalid there, fix it on main
([plain titles](ship-policy.md#plain-titles)) and rerun. Close no issue until then.
## Eval plans, lane ownership, evidence verification, the owner brief
Codes printed by `bin/gstack-eval-plan`, `bin/gstack-lane-check`,
+25 -10
View File
@@ -18,7 +18,7 @@
import { splitTitle, type PlainTitlePolicy } from './plain-title';
import { ciAtHead, type Gh } from './ship-queue';
export type ArchiveCode = 'ARCHIVE_READS_STALE' | 'ARCHIVE_READS_UNPARSED' | 'ARCHIVE_THREAD_MISMATCH' | 'ARCHIVE_PR_NOT_MERGED' | 'ARCHIVE_GH_READ_FAILED';
export type ArchiveCode = 'ARCHIVE_READS_STALE' | 'ARCHIVE_READS_UNPARSED' | 'ARCHIVE_THREAD_MISMATCH' | 'ARCHIVE_PR_NOT_MERGED' | 'ARCHIVE_GH_READ_FAILED' | 'ARCHIVE_PR_STATE_UNKNOWN' | 'ARCHIVE_POLICY_UNREADABLE';
export interface ArchiveFailure { code: ArchiveCode; message: string }
export interface IssueRow { code: string; state: string; title: string }
export interface IssueList { open: string[]; closed: string[]; issues: IssueRow[] }
@@ -31,6 +31,10 @@ const MAX_CLOCK_SKEW_MS = 2 * 60_000;
const NOT_WORK = new Set(['Backlog', 'Needs you']);
const ISSUE_CODE = /^[A-Z][A-Z0-9]{1,9}-\d+$/;
const REPO = /^[\w.-]+\/[\w.-]+$/;
const PR_STATES = new Set(['OPEN', 'CLOSED', 'MERGED']);
const AGE = /^\d+[mhd]$/;
const SUB_ISSUES = /^\d+\/\d+ sub-issues closed$/;
const stateUnknown = (ref: string, state: unknown): ArchiveFailure => ({ code: 'ARCHIVE_PR_STATE_UNKNOWN', message: ref + ': gh pr view returned state ' + JSON.stringify(state) + ', not OPEN, CLOSED or MERGED' });
export interface CheckOptions {
thread: string;
@@ -49,7 +53,7 @@ export interface CheckResult {
const unparsed = (part: string, why: string): ArchiveFailure => ({ code: 'ARCHIVE_READS_UNPARSED', message: part + ': ' + why });
const isFailure = (v: unknown): v is ArchiveFailure => typeof v === 'object' && v !== null && 'code' in v && 'message' in v && !('issues' in v);
/** tools.issue.read list text: the States header is required; items are read by code and state only. */
/** tools.issue.read list text: the States header is required; each row is "- CODE · state · [markers ·] @assignee|unassigned · title · [n/m sub-issues closed ·] age". */
export function parseIssueList(text: unknown): IssueList | ArchiveFailure {
if (typeof text !== 'string' || !text.trim()) return unparsed('issues', 'empty or not the text of tools.issue.read');
const lines = text.split('\n');
@@ -69,8 +73,10 @@ export function parseIssueList(text: unknown): IssueList | ArchiveFailure {
const [code, state] = parts;
if (!code || !ISSUE_CODE.test(code) || !state) return unparsed('issues', 'unrecognized line ' + JSON.stringify(line.slice(0, 80)));
if (!open.includes(state) && !closed.includes(state)) return unparsed('issues', code + ' has state ' + JSON.stringify(state) + ', which the States header does not name');
const at = parts.map(p => p.startsWith('@')).lastIndexOf(true);
issues.push({ code, state, title: at >= 0 && at + 1 < parts.length - 1 ? parts[at + 1]! : '' });
const at = parts.findIndex((p, i) => i >= 2 && (p.startsWith('@') || p === 'unassigned'));
const end = parts.length - 1 - (SUB_ISSUES.test(parts.at(-2) ?? '') ? 1 : 0);
if (at < 0 || !AGE.test(parts.at(-1)!) || end <= at + 1) return unparsed('issues', code + ' row is cut short (no assignee, title or age): ' + JSON.stringify(line.slice(0, 80)));
issues.push({ code, state, title: parts.slice(at + 1, end).join(' · ') });
}
if (!issues.length && !none) return unparsed('issues', 'no issue rows and no "No issues match." line, so the list may be cut off');
return { open, closed, issues };
@@ -97,7 +103,7 @@ function readPr(gh: Gh, repo: string, n: number): Omit<LivePr, 'ci'> | ArchiveFa
try { j = JSON.parse(r.out); } catch { return ghFail('gh pr view printed no JSON'); }
const url = typeof j.url === 'string' ? j.url.toLowerCase() : '';
if (j.number !== n || !url.endsWith(('github.com/' + repo + '/pull/' + n).toLowerCase())) return ghFail('gh answered for ' + (url || 'another PR') + ', not this PR');
if (typeof j.state !== 'string') return ghFail('gh pr view returned no state');
if (typeof j.state !== 'string' || !PR_STATES.has(j.state)) return stateUnknown(repo + '#' + n, j.state);
return { repo, number: n, state: j.state, head: typeof j.headRefOid === 'string' ? j.headRefOid : '', branch: typeof j.headRefName === 'string' ? j.headRefName : '', title: typeof j.title === 'string' ? j.title : '' };
}
@@ -135,8 +141,15 @@ export function checkThread(raw: unknown, o: CheckOptions): CheckResult {
const pr = readPr(o.ghFor(p.repo), p.repo, p.number);
if (isFailure(pr)) return fail(pr);
if (pr.state !== 'OPEN') continue;
let ci = 'unknown';
try { ci = (o.ci ?? ((repo, sha) => ciAtHead({ repoRoot: process.cwd(), base: 'main', gh: o.ghFor(repo) }, sha).line))(p.repo, pr.head); } catch {}
let ci: string;
try {
ci = (o.ci ?? ((repo, sha) => {
const line = ciAtHead({ repoRoot: process.cwd(), base: 'main', gh: o.ghFor(repo) }, sha).line;
const failed = /^unknown at \S+ \((.*)\)$/.exec(line);
if (failed) throw new Error(failed[1]);
return line;
}))(p.repo, pr.head);
} catch (e: any) { ci = 'unknown (CI read failed: ' + String(e?.message ?? e).replace(/\s+/g, ' ').slice(0, 160) + ')'; }
live.push({ ...pr, ci });
}
const openIssues = list.issues.filter(i => list.open.includes(i.state));
@@ -158,20 +171,22 @@ export function checkThread(raw: unknown, o: CheckOptions): CheckResult {
export interface LandedResult { exit: 0 | 1 | 3; lines: string[]; codes: string[]; failure?: ArchiveFailure; json: Record<string, unknown> }
/** The tracker codes a merged PR names in its title's trailing group: what the land step closes. */
export function landedIssues(o: { pr: number; repo?: string; gh: Gh; policy: PlainTitlePolicy | null }): LandedResult {
export function landedIssues(o: { pr: number; repo?: string; gh: Gh; policy: PlainTitlePolicy | null; policyError?: string }): LandedResult {
const ref = (o.repo ?? '') + '#' + o.pr;
const fail = (exit: 1 | 3, failure: ArchiveFailure): LandedResult => ({ exit, lines: ['ISSUE_CLOSE: refused pr=' + ref + ' (' + failure.code + ')'], codes: [], failure, json: { pr: o.pr, repo: o.repo ?? null, codes: [], failure } });
if (!o.policy?.codes.length) return fail(3, { code: 'ARCHIVE_POLICY_UNREADABLE', message: o.policyError ?? (o.policy ? '.gstack/plain-titles.json lists no tracker codes' : '.gstack/plain-titles.json is missing') + ', so no ref can be proven a tracker code' });
const r = o.gh(['pr', 'view', String(o.pr), ...(o.repo ? ['--repo', o.repo] : []), '--json', 'number,url,state,title,mergeCommit']);
if (!r.ok) return fail(3, { code: 'ARCHIVE_GH_READ_FAILED', message: ref + ': ' + ((r.err ?? r.out).trim().slice(0, 160) || 'gh pr view failed') });
let j: Record<string, unknown>;
try { j = JSON.parse(r.out); } catch { return fail(3, { code: 'ARCHIVE_GH_READ_FAILED', message: ref + ': gh pr view printed no JSON' }); }
const repo = /github\.com\/([\w.-]+\/[\w.-]+)\/pull\//.exec(typeof j.url === 'string' ? j.url : '')?.[1] ?? o.repo ?? '';
if (j.number !== o.pr || (o.repo && repo.toLowerCase() !== o.repo.toLowerCase())) return fail(3, { code: 'ARCHIVE_GH_READ_FAILED', message: ref + ': gh answered for another PR' });
if (typeof j.state !== 'string' || !PR_STATES.has(j.state)) return fail(3, stateUnknown(repo + '#' + o.pr, j.state));
if (j.state !== 'MERGED') return fail(1, { code: 'ARCHIVE_PR_NOT_MERGED', message: repo + '#' + o.pr + ' is ' + String(j.state) });
const merge = String((j.mergeCommit as { oid?: unknown } | null)?.oid ?? '').slice(0, 12);
const refs = splitTitle(typeof j.title === 'string' ? j.title : '').refs?.split(/[\s,]+/) ?? [];
const patterns = (o.policy?.codes ?? []).map(c => new RegExp(c, 'i'));
const codes = [...new Set(refs.map(x => x.toUpperCase()).filter(x => ISSUE_CODE.test(x) && (!patterns.length || patterns.some(p => p.test(x)))))];
const patterns = o.policy.codes.map(c => new RegExp(c, 'i'));
const codes = [...new Set(refs.map(x => x.toUpperCase()).filter(x => ISSUE_CODE.test(x) && patterns.some(p => p.test(x))))];
const lines = codes.length ? codes.map(c => 'ISSUE_CLOSE: ' + c + ' pr=' + repo + '#' + o.pr + ' merge=' + merge) : ['ISSUE_CLOSE: none (the title names no tracker code)'];
return { exit: 0, lines, codes, json: { pr: o.pr, repo, merge, codes } };
}
+10
View File
@@ -145,4 +145,14 @@ export const CAPY_RESULT_CODES = {
summary: 'gh could not read a PR gstack-archive-check needs, or answered for another PR, so the check fails closed',
fix: 'check `gh auth status` and the repository, then rerun (at most 2 times); if it still fails, leave the thread unarchived (or the issue open) and tell the Captain',
},
ARCHIVE_PR_STATE_UNKNOWN: {
anchor: 'archive-pr-state-unknown',
summary: 'gh pr view returned a PR state other than OPEN, CLOSED or MERGED (empty or unknown), so the PR may still be open and the check fails closed',
fix: 'rerun once; if gh still prints that state, check `gh --version` and the PR on GitHub, leave the thread unarchived (or the issue open) and tell the Captain',
},
ARCHIVE_POLICY_UNREADABLE: {
anchor: 'archive-policy-unreadable',
summary: 'gstack-archive-check landed found no usable .gstack/plain-titles.json (missing, invalid or with no codes), so it cannot tell a tracker code from any other ref and closes nothing',
fix: 'run it from the repository root, where .gstack/plain-titles.json lives; if the file is missing or invalid there, fix it on main (docs/ship-policy.md#plain-titles) and rerun; close nothing until then',
},
} satisfies Record<string, ResultCode>;
+67 -1
View File
@@ -85,6 +85,29 @@ describe('archive check (reads and verdict)', () => {
expect(ci.lines.some(l => l.includes('ci=unknown'))).toBe(true);
});
// Value: protects=only OPEN, CLOSED and MERGED are PR states, anything else fails closed; fails_when=an empty or unknown gh state is read as closed and the thread archives clean
test('an unknown or empty live PR state is unreadable, never closed', () => {
const t = thread('GSTA-1', 'idle', [pr(7)]);
for (const state of ['', 'DRAFT', 'open', 'UNKNOWN']) {
const r = checkThread(reads({ thread: t }), opts({ 'garrytan/gstack#7': { state } }));
expect([state, r.verdict, r.failure?.code]).toEqual([state, 'unreadable', 'ARCHIVE_PR_STATE_UNKNOWN']);
expect(r.failure?.message).toContain('garrytan/gstack#7');
}
for (const state of ['CLOSED', 'MERGED']) expect(checkThread(reads({ thread: t }), opts({ 'garrytan/gstack#7': { state } })).verdict).toBe('clean');
});
// Value: protects=a failed CI read says so on the ORPHAN line while the open PR still refuses; fails_when=the failure prints as a bare ci=unknown or clears the PR
test('a failed CI read is named on the ORPHAN line and the PR still blocks', () => {
const t = thread('GSTA-1', 'idle', [pr(7)]);
const thrown = checkThread(reads({ thread: t }), opts({ 'garrytan/gstack#7': { state: 'OPEN' } }, { ci: () => { throw new Error('boom'); } }));
expect(thrown.verdict).toBe('refused');
expect(thrown.lines[0]).toContain('ci=unknown (CI read failed: boom)');
const api = checkThread(reads({ thread: t }), opts({ 'garrytan/gstack#7': { state: 'OPEN' } }, { ci: undefined }));
expect(api.verdict).toBe('refused');
expect(api.lines[0]).toContain('ci=unknown (CI read failed: check_runs page 1 unreadable');
expect(api.lines[0]).toContain('HTTP 502');
});
// Value: protects=empty, missing or truncated reads never parse as zero work (gate B); fails_when=any of them prints clean
test('empty, missing or truncated reads are unparsed', () => {
const bad: Array<[string, Record<string, unknown>]> = [
@@ -135,6 +158,15 @@ describe('archive check (reads and verdict)', () => {
}
});
// Value: protects=an issue row cut short (no assignee, title or age) never passes as a whole row; fails_when="- GST-1 · Backlog" parses as a complete issue
test('issue rows missing the assignee, title or age are unparsed', () => {
for (const row of ['- GST-1 · Backlog', '- GST-1 · In progress · @GSTA-1', '- GST-1 · In progress · @GSTA-1 · Build it', '- GST-1 · In progress · Build it · 2m', '- GST-1 · In progress · @GSTA-1 · Build it · 2']) {
expect([row, parseIssueList(issues(row))]).toMatchObject([row, { code: 'ARCHIVE_READS_UNPARSED' }]);
}
const whole = parseIssueList(issues('- GST-707 · Waiting · high · blocked · @GGST-1 · Plan it · 8d', '- GST-640 · Backlog · unassigned · Redo it · 9d', '- GST-9 · Todo · @GSTA-1 · A · B title · 1/2 sub-issues closed · 5h'));
expect(whole).toMatchObject({ issues: [{ code: 'GST-707', title: 'Plan it' }, { code: 'GST-640', title: 'Redo it' }, { code: 'GST-9', title: 'A · B title' }] });
});
test('issue lines: unknown states and unknown lines are unparsed', () => {
expect(parseIssueList(issues('- GST-1 · Doing · @GSTA-1 · x · 1m'))).toMatchObject({ code: 'ARCHIVE_READS_UNPARSED' });
expect(parseIssueList(issues('Next page: cursor abc'))).toMatchObject({ code: 'ARCHIVE_READS_UNPARSED' });
@@ -182,13 +214,33 @@ describe('landed (issue close on land)', () => {
const failed = landedIssues({ pr: 2, gh: gh('fail'), policy });
expect([failed.exit, failed.failure?.code]).toEqual([3, 'ARCHIVE_GH_READ_FAILED']);
});
// Value: protects=an unknown state on the land step is unreadable, not "not merged"; fails_when=an empty gh state exits 1 as if GitHub said the PR is open
test('an unknown PR state exits 3', () => {
for (const state of ['', 'merged', 'DRAFT']) {
const r = landedIssues({ pr: 2, gh: gh({ state, title: 'x (GST-1)' }), policy });
expect([state, r.exit, r.failure?.code]).toEqual([state, 3, 'ARCHIVE_PR_STATE_UNKNOWN']);
}
});
// Value: protects=a missing, invalid or empty plain-titles policy refuses with a reason instead of closing every code-shaped ref; fails_when=no policy lets ZZZ-4 through as a tracker code
test('a missing or empty policy refuses with ARCHIVE_POLICY_UNREADABLE', () => {
const title = { title: 'Fix x (GST-1, ZZZ-4)' };
const missing = landedIssues({ pr: 3, gh: gh(title), policy: null });
expect([missing.exit, missing.codes, missing.failure?.code]).toEqual([3, [], 'ARCHIVE_POLICY_UNREADABLE']);
expect(missing.failure?.message).toContain('.gstack/plain-titles.json');
const invalid = landedIssues({ pr: 3, gh: gh(title), policy: null, policyError: '.gstack/plain-titles.json is not valid JSON: x' });
expect(invalid.failure?.message).toContain('not valid JSON');
const empty = landedIssues({ pr: 3, gh: gh(title), policy: { ...policy, codes: [] } });
expect([empty.exit, empty.failure?.code]).toEqual([3, 'ARCHIVE_POLICY_UNREADABLE']);
});
});
describe('result codes', () => {
// Value: protects=every code the check prints has a summary, fix and troubleshooting anchor; fails_when=a code prints with no recovery
test('archive codes are registered with anchors', () => {
const doc = fs.readFileSync(path.join(import.meta.dir, '..', 'docs', 'troubleshooting.md'), 'utf8');
for (const code of ['ARCHIVE_READS_STALE', 'ARCHIVE_READS_UNPARSED', 'ARCHIVE_THREAD_MISMATCH', 'ARCHIVE_PR_NOT_MERGED', 'ARCHIVE_GH_READ_FAILED'] as const) {
for (const code of ['ARCHIVE_READS_STALE', 'ARCHIVE_READS_UNPARSED', 'ARCHIVE_THREAD_MISMATCH', 'ARCHIVE_PR_NOT_MERGED', 'ARCHIVE_GH_READ_FAILED', 'ARCHIVE_PR_STATE_UNKNOWN', 'ARCHIVE_POLICY_UNREADABLE'] as const) {
const row = (RESULT_CODES as Record<string, { anchor: string }>)[code];
expect(row?.anchor).toBeTruthy();
expect(doc).toContain('<a id="' + row!.anchor + '"></a>');
@@ -242,6 +294,11 @@ describe('gstack-archive-check (bin)', () => {
const clean = runBin(repo, 'gstack-archive-check', ['thread', '--thread', 'GSTA-1', '--reads', file], env);
expect([clean.status, clean.out.trim().split('\n').at(-1)]).toEqual([0, 'ARCHIVE_CHECK: clean thread=GSTA-1 prs=0 issues=0']);
expect(runBin(repo, 'gstack-archive-check', ['thread', '--reads', file], env).status).toBe(2);
const unknownEnv = shim(repo, { 'garrytan/gbrain#5': { state: '' } });
fs.writeFileSync(file, JSON.stringify({ ...reads({ thread: thread('GSTA-1', 'idle', [pr(5, 'garrytan/gbrain')]) }), read_at: new Date().toISOString() }));
const unknown = runBin(repo, 'gstack-archive-check', ['thread', '--thread', 'GSTA-1', '--reads', file], unknownEnv);
expect(unknown.status).toBe(3);
expect(unknown.out).toContain('ARCHIVE_PR_STATE_UNKNOWN');
});
// Value: protects=the land step's CLI: usage guards, the merged-PR close lines and the not-merged refusal; fails_when=a bad --pr or --repo reaches gh, or an open PR prints ISSUE_CLOSE
@@ -249,6 +306,15 @@ describe('gstack-archive-check (bin)', () => {
if (process.platform === 'win32') return;
const repo = makeFixtureRepo(); repos.push(repo);
const env = shim(repo, { 'garrytan/gstack#7': { state: 'MERGED', title: 'Fix x (GST-12)', mergeCommit: { oid: 'd'.repeat(40) } }, 'garrytan/gstack#8': { state: 'OPEN', title: 'Fix y (GST-13)' } });
const policyFile = path.join(repo.work, '.gstack', 'plain-titles.json');
fs.mkdirSync(path.dirname(policyFile), { recursive: true });
fs.rmSync(policyFile, { force: true });
const missing = runBin(repo, 'gstack-archive-check', ['landed', '--pr', '7', '--repo', 'garrytan/gstack'], env);
expect([missing.status, missing.out]).toEqual([3, expect.stringContaining('ARCHIVE_POLICY_UNREADABLE')]);
fs.writeFileSync(policyFile, '{ not json');
const invalid = runBin(repo, 'gstack-archive-check', ['landed', '--pr', '7', '--repo', 'garrytan/gstack'], env);
expect([invalid.status, invalid.out]).toEqual([3, expect.stringContaining('not valid JSON')]);
fs.writeFileSync(policyFile, JSON.stringify({ codes: ['\\bGST-\\d+\\b'], terms: ['lane'], since: '2026-01-01' }));
expect(runBin(repo, 'gstack-archive-check', ['landed', '--pr', 'x'], env).status).toBe(2);
expect(runBin(repo, 'gstack-archive-check', ['landed', '--pr', '7', '--repo', 'bad'], env).status).toBe(2);
const merged = runBin(repo, 'gstack-archive-check', ['landed', '--pr', '7', '--repo', 'garrytan/gstack'], env);