mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
* v1.91.77.0 feat(ship): two-stage gstack-pregate, gstack-regen registry, intent-based issue links (multi-agent wave C-pregate) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate): keep the bin-driving pregate tests off the Windows subset; pin files are free-only for eval selection The three C-pregate tests that spawn bin/gstack-regen, bin/gstack-pregate, bin/gstack-issue-links and bin/gstack-ship-receipt do so through test/helpers/pregate-fixture.ts runBin, so the Windows curation's source scan of the test file never saw a bin/ spawn and shipped them to windows-latest, where CreateProcess cannot exec a shebang (14 failures in windows-free-shard 2 and 5). Each is now a KNOWN_WINDOWS_INCOMPATIBLE entry with the precise reason. .gstack/generated.json and .gstack/pregate.json join FREE_ONLY_PR_FILES: no paid case reads gstack's own pin files (the tools read origin/<base> of the repo they run in, and paid cases run in fixture repos), so touching them no longer restores every gate case. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(harness-version): record the FREE_ONLY_PR_FILES pin-file entries as non-behavioral scripts/test-pr-profile.ts is a pinned shared-harness file; the two .gstack pin-file entries change selection only, never a case verdict. 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>
96 lines
5.9 KiB
TypeScript
Executable File
96 lines
5.9 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* gstack-issue-links — issue links by intent for /ship Step 18 (plan C6).
|
|
*
|
|
* gstack-issue-links [--base <ref>] [--issue <n>]... [--ci-run <id>]... [--repo <owner/repo>] [--out <issues.json>] [--json]
|
|
*
|
|
* Reads `git log origin/<base>..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 <id>)`.
|
|
* 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 <ref> base branch (default: origin/HEAD, else main); commits origin/<base>..HEAD are read
|
|
--issue <n> explicit closure intent (repeatable); still read through gstack-issue-guard
|
|
--ci-run <id> a failing CI run id (repeatable); open issues naming it become Flake: #N (run <id>)
|
|
--repo <owner/repo> repository for gh (default: the current remote)
|
|
--out <file> 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)));
|