diff --git a/bin/gstack-archive-check b/bin/gstack-archive-check index 50b15f14e..6c340346a 100755 --- a/bin/gstack-archive-check +++ b/bin/gstack-archive-check @@ -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 [--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 '); 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; diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index b7e347486..af036cfe7 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -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' ;; diff --git a/changelog.d/archive-check-fails-closed-on-unknown-pr-states.md b/changelog.d/archive-check-fails-closed-on-unknown-pr-states.md new file mode 100644 index 000000000..420af318a --- /dev/null +++ b/changelog.d/archive-check-fails-closed-on-unknown-pr-states.md @@ -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: )` 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). diff --git a/crew/docs/captain.md b/crew/docs/captain.md index 2115c28d6..1fb01ef1f 100644 --- a/crew/docs/captain.md +++ b/crew/docs/captain.md @@ -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 --pin ` (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 `. For each + after `MERGED`, run `gstack-archive-check landed --pr ` 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: ` 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 68e590c65..797191f50 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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. + +### `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. + + +### `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`, diff --git a/lib/archive-check.ts b/lib/archive-check.ts index 8e012d922..5f2dca116 100644 --- a/lib/archive-check.ts +++ b/lib/archive-check.ts @@ -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 | 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 } /** 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; 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 } }; } diff --git a/lib/result-codes-capy.ts b/lib/result-codes-capy.ts index 76e2365dd..c3300cb35 100644 --- a/lib/result-codes-capy.ts +++ b/lib/result-codes-capy.ts @@ -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; diff --git a/test/archive-check.test.ts b/test/archive-check.test.ts index 28c59ef4d..efdc71daa 100644 --- a/test/archive-check.test.ts +++ b/test/archive-check.test.ts @@ -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]> = [ @@ -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)[code]; expect(row?.anchor).toBeTruthy(); expect(doc).toContain(''); @@ -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);