#!/usr/bin/env bun /** * gstack-issue-links — issue links by intent for /ship Step 18 (plan C6). * * gstack-issue-links [--base ] [--issue ]... [--ci-run ]... [--repo ] [--out ] [--json] * * Reads `git log origin/..HEAD` and classifies every issue mention: * `Fixes #N` only on explicit closure intent (a Fixes/Closes/Resolves trailer * or --issue), `Refs #N` for everything else (bare mentions, partial fixes, * deferred asks, cross-repo ids). Each closure issue is read through * gstack-issue-guard; unreadable ones are downgraded to Refs * (ISSUE_UNREADABLE). Each --ci-run id is searched among open issues whose * body names it (bot flake issues) and rendered `Flake: #N (run )`. * Prints the `## Issues` lines; --json / --out emit the receipt's `issues` * object for `gstack-ship-receipt write --issues @file`. Logic: lib/issue-links.ts. */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { mentionsIn, receiptIssues, renderIssueLines, resolveLinks, type IssueMention } from '../lib/issue-links'; import { resultLine } from '../lib/result-codes'; import { resolveBaseBranch } from '../lib/ship-policy'; const HELP = `gstack-issue-links — Fixes only on explicit closure intent; everything else is Refs (docs/pregate.md#issue-links) --base base branch (default: origin/HEAD, else main); commits origin/..HEAD are read --issue explicit closure intent (repeatable); still read through gstack-issue-guard --ci-run a failing CI run id (repeatable); open issues naming it become Flake: #N (run ) --repo repository for gh (default: the current remote) --out write the issues object (fixes, refs, flakes) for gstack-ship-receipt write --issues @file --json JSON only on stdout Exit 0 with links or none; 1 when a closure issue was unreadable (its link is Refs); 2 usage. ${exitTableHelp()}`; function flags(args: string[], name: string): string[] { const out: string[] = []; for (let i = 0; i < args.length; i++) if (args[i] === name && args[i + 1] !== undefined) out.push(args[++i]!); return out; } function git(args: string[], cwd: string): string | undefined { const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 }); return r.status === 0 ? r.stdout : undefined; } function main(argv: string[]): number { if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; } const json = argv.includes('--json'); const repoRoot = git(['rev-parse', '--show-toplevel'], process.cwd())?.trim() ?? process.cwd(); const base = flags(argv, '--base')[0] ?? resolveBaseBranch(repoRoot); const explicit: number[] = []; for (const raw of flags(argv, '--issue')) { if (!/^\d+$/.test(raw)) { process.stderr.write(`gstack-issue-links: --issue must be a positive integer, got ${JSON.stringify(raw)}\n`); return EXIT.usage; } explicit.push(Number(raw)); } const runs = flags(argv, '--ci-run'); if (runs.some(r => !/^[A-Za-z0-9_.-]{1,64}$/.test(r))) { process.stderr.write('gstack-issue-links: --ci-run ids are alphanumeric\n'); return EXIT.usage; } const repoFlag = flags(argv, '--repo')[0]; const repoArgs = repoFlag ? ['--repo', repoFlag] : []; const binDir = path.dirname(Bun.fileURLToPath(import.meta.url)); const log = git(['log', '--format=%H%n%B%x00', `origin/${base}..HEAD`], repoRoot) ?? git(['log', '--format=%H%n%B%x00', `${base}..HEAD`], repoRoot); if (log === undefined) { process.stderr.write(`gstack-issue-links: cannot read commits ${base}..HEAD (fetch origin/${base} first)\n`); return EXIT.usage; } const mentions: IssueMention[] = []; for (const entry of log.split('\0')) { const nl = entry.indexOf('\n'); if (nl < 0) continue; mentions.push(...mentionsIn(entry.slice(nl + 1), `commit ${entry.slice(0, nl).trim().slice(0, 12)}`)); } const readable = (issue: number): boolean => { const r = spawnSync(path.join(binDir, 'gstack-issue-guard'), ['issue', String(issue)], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 && r.stdout.length > 0; }; const flakes: Array<{ issue: number; run: string }> = []; for (const run of runs) { const r = spawnSync('gh', ['issue', 'list', ...repoArgs, '--state', 'open', '--search', `"${run}" in:body`, '--limit', '20', '--json', 'number'], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000 }); if (r.status !== 0) { process.stderr.write(`gstack-issue-links: flake search for run ${run} failed: ${(r.stderr ?? '').trim()}\n`); continue; } try { for (const row of JSON.parse(r.stdout) as Array<{ number: number }>) flakes.push({ issue: row.number, run }); } catch { process.stderr.write(`gstack-issue-links: flake search for run ${run} returned no JSON\n`); } } const links = resolveLinks(mentions, explicit, readable, flakes); for (const n of links.unreadable) process.stderr.write(resultLine('gstack-issue-links', 'ISSUE_UNREADABLE', `#${n}`) + '\n'); const out = flags(argv, '--out')[0]; if (out) { fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, JSON.stringify(receiptIssues(links), null, 2) + '\n'); } if (json) process.stdout.write(JSON.stringify({ ...receiptIssues(links), cross: links.cross, unreadable: links.unreadable, lines: renderIssueLines(links) }) + '\n'); else { const lines = renderIssueLines(links); process.stdout.write(lines.length ? lines.join('\n') + '\n' : 'ISSUE_LINKS: none\n'); for (const m of mentions) process.stderr.write(` ${m.intent === 'fixes' ? 'closure' : 'reference'} ${m.repo ? `${m.repo}#` : '#'}${m.issue} ← ${m.source}\n`); } return links.unreadable.length ? EXIT.fail : EXIT.ok; } process.exit(main(process.argv.slice(2)));