fix(issue-links): promotion candidates, Related PRs, validated flake issues, body/receipt consistency (Lane D: D1-D3)

D1: the GBRA-40 fix wave's gbrain #6279 body had no `## Issues` section and no
receipt (it was not composed by /ship, so gstack-issue-links never ran), and
#6252 appeared only as a subject parenthetical `(#6252)`, which the closure
rule correctly treats as Refs. The rule is kept. Same-repo references are now
printed as promotion candidates (`(promote: fix #N)`, `candidates` in --json),
and `gstack-ship-receipt write --issues ... --body` refuses a body that lacks a
line its issues object records (ISSUE_LINKS_MISSING), so a /ship body can no
longer drop its closing lines.

D2: open PRs by other authors whose closing set overlaps this PR's Fixes are
`Related: #N (@author)` (paged GraphQL closedByPullRequestsReferences; viewer
and this branch excluded; cross-repo PRs keep owner/repo#N). `Supersedes` and
`Contributed by` only with --supersedes/--credit; --no-auto-links skips the
lookup; the stderr preview names each added line's source. A failed lookup is
ISSUE_LOOKUP_FAILED (installer form with docs anchor) and keeps the other lines.

D3: a --ci-run match becomes a Flake line only for a bot-opened issue of this
repository that names that run (flake:json first_seen.run_id or this repo's
actions/runs URL) and a test; duplicates of one test keep the lowest number;
every refused match prints its reason.

Files: bin/gstack-issue-links, bin/gstack-ship-receipt, lib/issue-links.ts,
lib/ship-receipt.ts, lib/headless-artifacts.ts (ship-receipt issues.related),
lib/result-codes.ts (ISSUE_LOOKUP_FAILED, ISSUE_LINKS_MISSING,
resultLineWithDocs), bin/gstack-doctor-components.sh (regenerated),
docs/pregate.md, docs/troubleshooting.md, test/ship-issue-intent.test.ts,
test/helpers/pregate-fixture.ts (gh shim: repo view, api graphql, full issue rows).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-15 00:01:32 +00:00
1 parent 47d639abff
commit 12b1af80d9
11 files changed
+502 -52

No files matched your search

+2
View File
@@ -162,6 +162,8 @@ gstack_result_anchor() {
PREGATE_STALE) printf '%s' 'pregate-stale' ;;
PREGATE_REMOTE_UNCLEARED) printf '%s' 'pregate-remote-uncleared' ;;
ISSUE_UNREADABLE) printf '%s' 'issue-unreadable' ;;
ISSUE_LOOKUP_FAILED) printf '%s' 'issue-lookup-failed' ;;
ISSUE_LINKS_MISSING) printf '%s' 'issue-links-missing' ;;
PREREG_INCOMPLETE) printf '%s' 'prereg-incomplete' ;;
EVAL_PRICE_MISSING) printf '%s' 'eval-price-missing' ;;
EVAL_DRY_RUN_FAILED) printf '%s' 'eval-dry-run-failed' ;;
+101 -20
View File
@@ -2,38 +2,52 @@
/**
* 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]
* gstack-issue-links [--base <ref>] [--issue <n>]... [--ci-run <id>]... [--supersedes <pr>]... [--credit <pr>]...
* [--no-auto-links] [--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>)`.
* (ISSUE_UNREADABLE). Same-repo references are listed as promotion
* candidates (`fix #N` → --issue N). Each --ci-run id is searched among open
* issues whose body names it and kept only for bot flake issues of this repo
* naming that run and a test: `Flake: #N (run <id>)`. Open PRs by other
* authors closing a fixed issue are `Related: #N (@author)` unless
* --no-auto-links; --supersedes / --credit record the owner's confirmation.
* Prints the `## Issues` lines; --json / --out emit the receipt's `issues`
* object for `gstack-ship-receipt write --issues @file`. Logic: lib/issue-links.ts.
* object for `gstack-ship-receipt write --issues @file`, which refuses a body
* missing any of its lines (ISSUE_LINKS_MISSING).
* 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 { lineSources, mentionsIn, promotionCandidates, receiptIssues, relatedPrs, renderIssueLines, resolveLinks, selectFlakes, type ClosingPr, type FlakeLink, type IssueMention, type IssueRow } from '../lib/issue-links';
import { resultLine, resultLineWithDocs } 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>)
--ci-run <id> a failing CI run id (repeatable); bot flake issues of this repo naming it and a test become Flake: #N (run <id>)
--supersedes <pr> owner confirmed this PR supersedes a Related PR: Supersedes #N (@author) (repeatable)
--credit <pr> owner confirmed crediting a Related PR's author: Contributed by @author (#N) (repeatable)
--no-auto-links no Related lookup (open PRs by other authors closing a fixed issue)
--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
--out <file> write the issues object (fixes, refs, flakes, related) 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.
Same-repo references print as promotion candidates; the owner's \`fix #N\` reply reruns with --issue N.
Exit 0 with links or none (a failed lookup prints ISSUE_LOOKUP_FAILED); 1 when a closure issue was unreadable (its link is Refs); 2 usage.
${exitTableHelp()}`;
const MAX_PAGES = 10;
const CLOSING_QUERY = 'query($owner:String!,$name:String!,$number:Int!,$after:String){viewer{login} repository(owner:$owner,name:$name){issue(number:$number){closedByPullRequestsReferences(first:50,after:$after,includeClosedPrs:false){nodes{number state headRefName author{login} repository{nameWithOwner}} pageInfo{hasNextPage endCursor}}}}}';
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]!);
@@ -44,6 +58,48 @@ function git(args: string[], cwd: string): string | undefined {
return r.status === 0 ? r.stdout : undefined;
}
/** gh calls whose failures are reported once as ISSUE_LOOKUP_FAILED and collected for --json. */
function ghLookups(cwd: string) {
const unavailable: string[] = [];
const failed = (what: string, why: string) => {
unavailable.push(`${what}: ${why}`);
process.stderr.write(resultLineWithDocs('gstack-issue-links', 'ISSUE_LOOKUP_FAILED', `${what}: ${why}`) + '\n');
};
function json<T>(what: string, args: string[]): T | undefined {
const r = spawnSync('gh', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0) { failed(what, (r.stderr ?? '').trim().split('\n')[0] || `gh exited ${r.status}`); return undefined; }
try { return JSON.parse(r.stdout) as T; } catch { failed(what, 'gh returned no JSON'); return undefined; }
}
return { unavailable, failed, json };
}
type ClosingPage = { data?: { viewer?: { login?: string }; repository?: { issue?: { closedByPullRequestsReferences?: { nodes?: ClosingPr[]; pageInfo?: { hasNextPage?: boolean; endCursor?: string } } } } } };
/** Every open PR closing each fixed issue, page by page; undefined when any page fails. */
function closingPrs(gh: ReturnType<typeof ghLookups>, repo: string, fixes: number[]): { closing: Map<number, ClosingPr[]>; viewer: string } | undefined {
const [owner, name] = repo.split('/');
const closing = new Map<number, ClosingPr[]>();
let viewer = '';
for (const issue of fixes) {
const what = `open PRs closing #${issue}`;
const prs: ClosingPr[] = [];
let after: string | undefined;
for (let page = 1; ; page++) {
if (page > MAX_PAGES) { gh.failed(what, `more than ${MAX_PAGES} pages`); return undefined; }
const res = gh.json<ClosingPage>(what, ['api', 'graphql', '-f', `query=${CLOSING_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${issue}`, ...(after ? ['-f', `after=${after}`] : [])]);
if (!res) return undefined;
const conn = res.data?.repository?.issue?.closedByPullRequestsReferences;
if (!conn) { gh.failed(what, 'the reply has no closedByPullRequestsReferences'); return undefined; }
viewer ||= res.data?.viewer?.login ?? '';
prs.push(...(conn.nodes ?? []));
if (!conn.pageInfo?.hasNextPage || !conn.pageInfo.endCursor) break;
after = conn.pageInfo.endCursor;
}
closing.set(issue, prs);
}
return { closing, viewer };
}
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');
@@ -56,8 +112,9 @@ function main(argv: string[]): number {
}
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 confirmed = { supersedes: flags(argv, '--supersedes'), credit: flags(argv, '--credit') };
if ([...confirmed.supersedes, ...confirmed.credit].some(r => !/^\d+$/.test(r))) { process.stderr.write('gstack-issue-links: --supersedes and --credit take a PR number\n'); return EXIT.usage; }
const autoLinks = !argv.includes('--no-auto-links');
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);
@@ -72,22 +129,46 @@ function main(argv: string[]): number {
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 gh = ghLookups(repoRoot);
const needRepo = runs.length > 0 || explicit.length > 0 || (autoLinks && mentions.some(m => m.intent === 'fixes'));
const repo = flags(argv, '--repo')[0] ?? (needRepo ? gh.json<{ nameWithOwner?: string }>('repository', ['repo', 'view', '--json', 'nameWithOwner'])?.nameWithOwner : undefined);
const flakes: FlakeLink[] = [];
const rejectedFlakes: Array<{ issue: number; run: string; reason: string }> = [];
for (const run of repo ? runs : []) {
const rows = gh.json<IssueRow[]>(`flake issues naming run ${run}`, ['issue', 'list', '--repo', repo!, '--state', 'open', '--search', `"${run}" in:body`, '--limit', '100', '--json', 'number,title,body,author']);
if (!rows) continue;
const picked = selectFlakes(run, repo!, rows);
for (const f of picked.flakes) if (!flakes.some(x => x.issue === f.issue)) flakes.push(f);
rejectedFlakes.push(...picked.rejected.map(x => ({ ...x, run })));
}
const links = resolveLinks(mentions, explicit, readable, flakes);
const found = autoLinks && repo && links.fixes.length ? closingPrs(gh, repo, links.fixes) : undefined;
if (found) {
const related = relatedPrs(found.closing, { repo: repo!, viewer: found.viewer, branch: git(['branch', '--show-current'], repoRoot)?.trim() || undefined });
if (related.length) links.related = related;
}
for (const [kind, list] of Object.entries(confirmed)) {
for (const raw of list) {
const row = links.related?.find(r => !r.repo && r.pr === Number(raw));
if (!row) { process.stderr.write(`gstack-issue-links: --${kind} #${raw} is not a Related PR (${(links.related ?? []).map(r => `${r.repo ?? ''}#${r.pr}`).join(', ') || 'none found'})\n`); return EXIT.usage; }
if (kind === 'supersedes') row.supersedes = true; else row.credit = true;
}
}
for (const n of links.unreadable) process.stderr.write(resultLine('gstack-issue-links', 'ISSUE_UNREADABLE', `#${n}`) + '\n');
const candidates = promotionCandidates(mentions, links);
const lines = renderIssueLines(links);
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');
if (json) process.stdout.write(JSON.stringify({ ...receiptIssues(links), cross: links.cross, unreadable: links.unreadable, candidates, flake_tests: links.flakes, rejected_flakes: rejectedFlakes, unavailable: gh.unavailable, lines }) + '\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`);
for (const m of mentions) {
const promote = candidates.some(c => c.issue === m.issue) && m.intent === 'refs' && !m.repo ? ` (promote: fix #${m.issue})` : '';
process.stderr.write(` ${m.intent === 'fixes' ? 'closure' : 'reference'} ${m.repo ? `${m.repo}#` : '#'}${m.issue} ← ${m.source}${promote}\n`);
}
for (const line of lineSources(links)) process.stderr.write(` ${line}\n`);
for (const r of rejectedFlakes) process.stderr.write(` not a flake link: #${r.issue} (run ${r.run}) ← ${r.reason}\n`);
}
return links.unreadable.length ? EXIT.fail : EXIT.ok;
}
+12 -6
View File
@@ -20,13 +20,13 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts';
import { resultLine } from '../lib/result-codes';
import { resultLine, resultLineWithDocs } from '../lib/result-codes';
import { versionAtRef } from '../lib/restamp';
import { readPregateFile, verifyPregate } from '../lib/pregate/index';
import { resolveBaseBranch } from '../lib/ship-policy';
import {
computeCensus, extractReceipt, orderReceipt, placeReceiptFirst, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt,
type CensusPr,
computeCensus, extractReceipt, missingIssueLines, orderReceipt, placeReceiptFirst, receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock,
validateReceipt, type CensusPr,
} from '../lib/ship-receipt';
const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt)
@@ -41,7 +41,7 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate <jso
--session-kind --artifacts-consumed <n>/<m> --run --thread --receipt <tree-receipt.json> --policy --preregistration
--history --queue-mode --pregate <pregate.json> (C8: refused unless current, green and every requires-remote obligation
carries a receipt: PREGATE_STALE / PREGATE_LANE_FAILED / PREGATE_INCOMPLETE / PREGATE_REMOTE_UNCLEARED) --issues <json|@file>
(C6: gstack-issue-links --json) --out <file> --body <pr-body.md>. Defaults: head=HEAD, base=origin/HEAD's branch, version=the
(C6: gstack-issue-links --out/--json; with --body, refused with ISSUE_LINKS_MISSING unless the body carries every Fixes/Refs/Flake/Related line) --out <file> --body <pr-body.md>. Defaults: head=HEAD, base=origin/HEAD's branch, version=the
version source at HEAD, session_kind=$GSTACK_SESSION_KIND (gstack-session-kind), tree/gate_reuse/gated_tree/predecessor
from --receipt when given.
--json: JSON only on stdout.
@@ -94,6 +94,7 @@ function cmdWrite(rest: string[], json: boolean): number {
}
let input: Partial<ShipReceipt>;
try {
const issuesFile = jsonArg(flag(rest, '--issues'));
input = {
schema_version: 1, pr: flag(rest, '--pr') !== undefined ? Number(flag(rest, '--pr')) : undefined, head,
version: flag(rest, '--version') ?? ('version' in atHead ? atHead.version : undefined), base: flag(rest, '--base') ?? resolveBaseBranch(repoRoot),
@@ -103,7 +104,7 @@ function cmdWrite(rest: string[], json: boolean): number {
spend_usd: flag(rest, '--spend-usd') !== undefined ? (Number.isFinite(Number(flag(rest, '--spend-usd'))) ? Number(flag(rest, '--spend-usd')) : flag(rest, '--spend-usd')) : undefined,
session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'),
policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'),
...pregate, issues: jsonArg(flag(rest, '--issues')) as ShipReceipt['issues'],
...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined,
};
} catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; }
if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; }
@@ -114,10 +115,15 @@ function cmdWrite(rest: string[], json: boolean): number {
process.stderr.write('gstack-ship-receipt write: nothing written\n');
return EXIT.fail;
}
const body = flag(rest, '--body');
const missing = body && receipt.issues ? missingIssueLines(fs.existsSync(body) ? fs.readFileSync(body, 'utf8') : '', receipt.issues) : [];
if (missing.length) {
process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', 'ISSUE_LINKS_MISSING', `${body} lacks ${missing.map(l => JSON.stringify(l)).join(', ')}`) + '\n');
return EXIT.fail;
}
const block = renderReceiptBlock(receipt);
const out = flag(rest, '--out');
if (out) fs.writeFileSync(out, block);
const body = flag(rest, '--body');
if (body) fs.writeFileSync(body, placeReceiptFirst(fs.existsSync(body) ? fs.readFileSync(body, 'utf8') : '', receipt));
process.stderr.write(receiptSummaryLine(receipt) + '\n');
process.stdout.write(json ? JSON.stringify(receipt) + '\n' : block);
+32 -9
View File
@@ -13,7 +13,7 @@ Three bins, all over `lib/`:
|---|---|---|
| `gstack-regen` | `.gstack/generated.json` | generated files regenerate, never hand-merge |
| `gstack-pregate` | `.gstack/pregate.json` | two-stage cheap gate, `pregate.json` record, publication check |
| `gstack-issue-links` | — | `Fixes #N` only on explicit closure intent |
| `gstack-issue-links` | — | `Fixes #N` only on explicit closure intent; Related PRs and validated flake issues |
## Trust boundary
@@ -339,14 +339,37 @@ held-out and transcript layout.
## `gstack-issue-links`
```
gstack-issue-links [--base <ref>] [--issue <n>]... [--ci-run <id>]... [--out issues.json] [--json]
gstack-issue-links [--base <ref>] [--issue <n>]... [--ci-run <id>]... [--supersedes <pr>]... [--credit <pr>]...
[--no-auto-links] [--repo <owner/repo>] [--out issues.json] [--json]
```
Reads `origin/<base>..HEAD`. `Fixes #N` only on explicit closure intent: a
`Fixes`/`Closes`/`Resolves #N` trailer line or `--issue N`. A bare mention, a
partial fix, a deferred ask and every cross-repo `owner/repo#N` are `Refs`. Each
closure issue is read through `gstack-issue-guard`; one that cannot be read is
`Refs #N (unreadable …)` with `ISSUE_UNREADABLE` (exit 1), never `Fixes`. Each
`--ci-run <id>` is searched among open issues whose body names it and rendered
`Flake: #N (run <id>)`. The printed lines go under `## Issues` in the PR body;
`--out` writes the object `gstack-ship-receipt write --issues @file` embeds.
`Fixes`/`Closes`/`Resolves #N` trailer line or `--issue N`. A bare mention
(a subject's `(#N)` included), a partial fix, a deferred ask and every
cross-repo `owner/repo#N` are `Refs`. Same-repo references are printed as
promotion candidates (`reference #N ← <commit line> (promote: fix #N)` on
stderr, `candidates` in `--json`): the owner's `fix #N` reply reruns with
`--issue N`; an unattended run keeps them `Refs` and lists them in the
handoff. Each closure issue is read through `gstack-issue-guard`; one that
cannot be read is `Refs #N (unreadable …)` with `ISSUE_UNREADABLE` (exit 1),
never `Fixes`.
Each `--ci-run <id>` is searched among open issues whose body names it and
rendered `Flake: #N (run <id>)` only for a bot-opened issue of this repository
that names that run (its `flake:json` `first_seen.run_id`, or this repository's
`actions/runs/<id>` URL) and a test (`flake:json` `file › test`, or a
`Flake: <test>` title). Two issues for the same test keep the lower number;
every refused match is printed with its reason.
Open PRs by other authors (not the `gh` user, not this branch) whose closing
set overlaps this PR's `Fixes` are `Related: #N (@author)`, read page by page
from each issue's closing-PR references, in unattended runs too. `Supersedes #N
(@author)` replaces that line and `Contributed by @author (#N)` is added only
with `--supersedes N` / `--credit N`, after the owner confirms.
`--no-auto-links` skips the lookup. A failed lookup prints `ISSUE_LOOKUP_FAILED`
and keeps the other lines. The stderr preview names the source of each line.
The printed lines go under `## Issues` in the PR body; `--out` writes the
object (`fixes`, `refs`, `flakes`, `related`) `gstack-ship-receipt write
--issues @file` embeds. With `--body`, that write refuses a body missing any of
those lines (`ISSUE_LINKS_MISSING`).
+25
View File
@@ -2410,6 +2410,31 @@ could not be read through `gstack-issue-guard`.
**Fix.** Check `gh auth status` and the number. Until it reads, the link is
rendered as `Refs #N`, never `Fixes`.
<a id="issue-lookup-failed"></a>
### `ISSUE_LOOKUP_FAILED`
**Meaning.** `gstack-issue-links` could not resolve the repository, list the open
PRs that close an issue this PR fixes, or search the flake issues naming a
`--ci-run` id. The closure and reference lines are still printed; the
`Related:` or `Flake:` lines that lookup would have added may be missing, and
`--json` lists each failure under `unavailable`.
**Fix.** Check `gh auth status` and pass `--repo <owner/repo>` when the remote
is not a GitHub repository, then rerun. If it still fails, say in the handoff
which lookup is missing; never present the body as complete.
<a id="issue-links-missing"></a>
### `ISSUE_LINKS_MISSING`
**Meaning.** `gstack-ship-receipt write --issues @issues.json --body <file>`
found that the body lacks a `Fixes`, `Refs`, `Flake:`, `Related:`,
`Supersedes` or `Contributed by` line the issues object records, so the PR
body and its receipt would disagree. Nothing was written.
**Fix.** Paste every line `gstack-issue-links` printed under `## Issues` in the
body (or rerun `gstack-issue-links --out` if the issue set changed), then rerun
the receipt write.
## Eval plans, lane ownership, evidence verification, the owner brief
Codes printed by `bin/gstack-eval-plan`, `bin/gstack-lane-check`,
+3 -2
View File
@@ -81,7 +81,8 @@ export interface ShipReceipt {
// C-pregate (C8): the pre-gate's verdict against this head and its remote obligations (publication requires their receipts).
pregate?: string; requires_remote?: string[];
// C-pregate (C6): issue links the body carries, by intent.
issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }> };
// Related open PRs by other authors (Lane D): `supersedes` / `credit` only on the owner's confirmation.
issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> };
}
export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'incomplete', 'interrupted', 'refused'];
@@ -196,7 +197,7 @@ export const ARTIFACT_SCHEMAS = {
session_kind: str, artifacts_consumed: { ...str, description: '<n>/<m>' }, run: str,
thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str,
pregate: { ...str, description: 'pass|warn|requires-remote cleared … @<tree> from pregate.json, current for this head' }, requires_remote: strList,
issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } } } },
issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } },
},
},
} as const satisfies Record<string, Schema>;
+119 -8
View File
@@ -11,13 +11,29 @@
* (`owner/repo#N`) are `Refs #N` — never auto-closed.
* - A closure issue that cannot be read through gstack-issue-guard is
* downgraded to `Refs #N` with ISSUE_UNREADABLE (never Fixes).
* - Failing CI run ids map to bot-opened flake issues whose body names the
* run id: `Flake: #N (run <id>)`.
* - Same-repo references are promotion candidates: the owner's `fix #N`
* reply becomes `--issue N`; unattended runs keep them as Refs.
* - Failing CI run ids map to bot-opened flake issues of this repository
* that name the run (their `flake:json` first_seen.run_id or this repo's
* actions/runs/<id> URL) and a test (`flake:json` file › test or a
* `Flake: <test>` title): `Flake: #N (run <id>)`. Duplicate test names
* keep the lowest issue number.
* - Open PRs by other authors that close an issue this PR fixes are
* `Related: #N (@author)`; `Supersedes` and `Contributed by` only with the
* owner's confirmation (--supersedes / --credit).
*/
export type IssueIntent = 'fixes' | 'refs';
export interface IssueMention { issue: number; intent: IssueIntent; repo?: string; source: string }
export interface IssueLinks { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; cross: string[]; unreadable: number[] }
export interface FlakeLink { issue: number; run: string; test?: string }
export interface RelatedPr { pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }
export interface IssueLinks {
fixes: number[]; refs: number[]; flakes: FlakeLink[]; cross: string[]; unreadable: number[]; related?: RelatedPr[];
}
/** One `gh issue list --json number,title,body,author` row. */
export interface IssueRow { number: number; title?: string; body?: string; author?: { login?: string; is_bot?: boolean } }
/** One `closedByPullRequestsReferences` node of an issue this PR fixes. */
export interface ClosingPr { number: number; state?: string; headRefName?: string; author?: { login?: string } | null; repository?: { nameWithOwner?: string } }
const CLOSURE = /^\s*(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s*:?\s+(?:#|GH-)(\d+)\s*$/i;
const PARTIAL = /\b(?:partial(?:ly)?|part of|defer(?:red)?|follow-?up|related|refs?|see|context)\b/i;
@@ -44,7 +60,7 @@ export function mentionsIn(message: string, source = 'commit'): IssueMention[] {
* Resolve the final link set. `readable(n)` is the gstack-issue-guard probe
* for a closure candidate; a false answer downgrades it to Refs.
*/
export function resolveLinks(mentions: IssueMention[], explicit: number[], readable: (issue: number) => boolean, flakes: Array<{ issue: number; run: string }> = []): IssueLinks {
export function resolveLinks(mentions: IssueMention[], explicit: number[], readable: (issue: number) => boolean, flakes: FlakeLink[] = [], related: RelatedPr[] = []): IssueLinks {
const fixes = new Set<number>(explicit);
const refs = new Set<number>();
const cross = new Set<string>();
@@ -57,9 +73,88 @@ export function resolveLinks(mentions: IssueMention[], explicit: number[], reada
for (const n of fixes) refs.delete(n);
for (const f of flakes) refs.delete(f.issue);
const asc = (a: number, b: number) => a - b;
return { fixes: [...fixes].sort(asc), refs: [...refs].sort(asc), flakes: [...flakes].sort((a, b) => a.issue - b.issue), cross: [...cross].sort(), unreadable: unreadable.sort(asc) };
return {
fixes: [...fixes].sort(asc), refs: [...refs].sort(asc), flakes: [...flakes].sort((a, b) => a.issue - b.issue), cross: [...cross].sort(), unreadable: unreadable.sort(asc),
...(related.length ? { related } : {}),
};
}
/** Same-repo references the owner may promote with `fix #N` (unreadable closure issues excluded), with the first line naming each. */
export function promotionCandidates(mentions: IssueMention[], links: IssueLinks): Array<{ issue: number; source: string }> {
const out: Array<{ issue: number; source: string }> = [];
for (const m of mentions) {
if (!m.repo && links.refs.includes(m.issue) && !links.unreadable.includes(m.issue) && !out.some(c => c.issue === m.issue)) out.push({ issue: m.issue, source: m.source });
}
return out.sort((a, b) => a.issue - b.issue);
}
const isBot = (author: IssueRow['author']): boolean => author?.is_bot === true || /^app\/|\[bot\]$/.test(author?.login ?? '');
const FLAKE_JSON = /<!--\s*flake:json\s*-->\s*```json\s*\r?\n([\s\S]*?)\r?\n```/;
const RUN_URL = /github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/actions\/runs\/(\d+)(?!\d)/g;
/** The test an issue is a flake of and the runs it names; undefined test when it names none. */
function flakeIdentity(row: IssueRow): { test?: string; jsonRun?: string } {
const block = FLAKE_JSON.exec(row.body ?? '');
if (block) {
try {
const j = JSON.parse(block[1]!) as { file?: unknown; test?: unknown; first_seen?: { run_id?: unknown } };
if (typeof j.file === 'string' && j.file) {
return { test: typeof j.test === 'string' && j.test ? `${j.file} › ${j.test}` : j.file, jsonRun: j.first_seen?.run_id === undefined ? undefined : String(j.first_seen.run_id) };
}
} catch { /* an unparsable block names no test; the title may */ }
}
const title = /^flake:\s*(\S.*)$/i.exec((row.title ?? '').trim());
return { test: title?.[1] };
}
/**
* The open issues a `"<run>" in:body` search returned, narrowed to bot-opened
* flake issues of `repo` that name this run and a test. Every other row is
* rejected with its reason; two issues for the same test keep the lowest number.
*/
export function selectFlakes(run: string, repo: string, rows: IssueRow[]): { flakes: FlakeLink[]; rejected: Array<{ issue: number; reason: string }> } {
const flakes: FlakeLink[] = [];
const rejected: Array<{ issue: number; reason: string }> = [];
for (const row of [...rows].sort((a, b) => a.number - b.number)) {
if (!isBot(row.author)) { rejected.push({ issue: row.number, reason: `opened by @${row.author?.login ?? 'unknown'}, not a bot` }); continue; }
const { test, jsonRun } = flakeIdentity(row);
if (!test) { rejected.push({ issue: row.number, reason: 'names no test (no flake:json file, no "Flake: <test>" title)' }); continue; }
const urls = [...(row.body ?? '').matchAll(RUN_URL)].filter(m => m[2] === run).map(m => m[1]!);
if (jsonRun !== run && !urls.some(u => u.toLowerCase() === repo.toLowerCase())) {
rejected.push({ issue: row.number, reason: urls.length ? `names run ${run} of ${[...new Set(urls)].join(', ')}, not ${repo}` : `does not name run ${run} of ${repo}` });
continue;
}
const first = flakes.find(f => f.test === test);
if (first) { rejected.push({ issue: row.number, reason: `duplicate of #${first.issue} (${test})` }); continue; }
flakes.push({ issue: row.number, run, test });
}
return { flakes, rejected };
}
/**
* Open PRs by other authors whose closing set overlaps `fixes`. `closing`
* maps each fixed issue to the PRs that close it; this branch's own PR and the
* viewer's PRs are excluded. A PR in another repository keeps its repo.
*/
export function relatedPrs(closing: Map<number, ClosingPr[]>, ctx: { repo: string; viewer: string; branch?: string }): RelatedPr[] {
const byKey = new Map<string, RelatedPr>();
for (const [issue, prs] of closing) {
for (const pr of prs) {
const prRepo = pr.repository?.nameWithOwner;
const same = !prRepo || prRepo.toLowerCase() === ctx.repo.toLowerCase();
const author = pr.author?.login ?? 'ghost';
if ((pr.state ?? 'OPEN') !== 'OPEN' || author.toLowerCase() === ctx.viewer.toLowerCase() || (same && ctx.branch && pr.headRefName === ctx.branch)) continue;
const key = `${same ? '' : prRepo}#${pr.number}`;
const row = byKey.get(key) ?? { pr: pr.number, ...(same ? {} : { repo: prRepo }), author, closes: [] };
if (!row.closes.includes(issue)) row.closes.push(issue);
byKey.set(key, row);
}
}
return [...byKey.values()].map(r => ({ ...r, closes: r.closes.sort((a, b) => a - b) })).sort((a, b) => (a.repo ?? '').localeCompare(b.repo ?? '') || a.pr - b.pr);
}
const prId = (r: RelatedPr) => `${r.repo ?? ''}#${r.pr}`;
/** The `## Issues` lines for the PR body; an empty set renders nothing. */
export function renderIssueLines(links: IssueLinks): string[] {
const lines: string[] = [];
@@ -67,10 +162,26 @@ export function renderIssueLines(links: IssueLinks): string[] {
for (const n of links.refs) lines.push(links.unreadable.includes(n) ? `Refs #${n} (unreadable through gstack-issue-guard; not auto-closed)` : `Refs #${n}`);
for (const c of links.cross) lines.push(`Refs ${c}`);
for (const f of links.flakes) lines.push(`Flake: #${f.issue} (run ${f.run})`);
for (const r of links.related ?? []) {
lines.push(`${r.supersedes ? 'Supersedes' : 'Related:'} ${prId(r)} (@${r.author})`);
if (r.credit) lines.push(`Contributed by @${r.author} (${prId(r)})`);
}
return lines;
}
/** The receipt's `issues` field (lib/headless-artifacts.ts ship-receipt schema). */
export function receiptIssues(links: IssueLinks): { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }> } {
return { fixes: links.fixes, refs: links.refs, flakes: links.flakes };
/** Where each added line came from, for the PR-body preview (one `<line> ← <source>` per Related/Supersedes/Contributed/Flake line). */
export function lineSources(links: IssueLinks): string[] {
const out: string[] = [];
for (const f of links.flakes) out.push(`Flake: #${f.issue} (run ${f.run}) ← bot flake issue for ${f.test ?? 'an unnamed test'}`);
for (const r of links.related ?? []) {
const closes = `open PR closing ${r.closes.map(n => `#${n}`).join(', ')}`;
out.push(`${r.supersedes ? 'Supersedes' : 'Related:'} ${prId(r)} (@${r.author}) ← ${closes}${r.supersedes ? '; owner confirmed --supersedes' : ''}`);
if (r.credit) out.push(`Contributed by @${r.author} (${prId(r)}) ← ${closes}; owner confirmed --credit`);
}
return out;
}
/** The receipt's `issues` field (lib/headless-artifacts.ts ship-receipt schema); `related` only when a PR is listed. */
export function receiptIssues(links: IssueLinks): { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: RelatedPr[] } {
return { fixes: links.fixes, refs: links.refs, flakes: links.flakes.map(f => ({ issue: f.issue, run: f.run })), ...(links.related ? { related: links.related } : {}) };
}
+16
View File
@@ -404,6 +404,16 @@ export const RESULT_CODES = {
summary: 'an issue a closure trailer or --issue names could not be read through gstack-issue-guard, so its link is Refs, never Fixes',
fix: 'check `gh auth status` and the issue number; rerun gstack-issue-links once the issue reads',
},
ISSUE_LOOKUP_FAILED: {
anchor: 'issue-lookup-failed',
summary: 'gstack-issue-links could not read the repository, the open PRs closing a fixed issue, or the flake issues naming a CI run, so those Related or Flake lines may be missing',
fix: 'check `gh auth status` and the repository (`--repo <owner/repo>`), then rerun gstack-issue-links; report the gap in the handoff if it still fails',
},
ISSUE_LINKS_MISSING: {
anchor: 'issue-links-missing',
summary: 'the PR body lacks a line gstack-issue-links wrote to issues.json, so the body and the ship receipt would disagree about what the PR closes or relates to',
fix: 'paste every line gstack-issue-links printed under `## Issues` in the body, then rerun gstack-ship-receipt write; nothing was written',
},
// PR E (multi-agent wave): gstack-eval-plan, gstack-lane-check, gstack-evidence verify,
// gstack-owner-brief, gstack-contributor-mode, gstack-banned-terms.
PREREG_INCOMPLETE: {
@@ -571,6 +581,12 @@ export function resultCodeSuffix(code: ResultCodeName): string {
return `(${code}; ${TROUBLESHOOTING_DOC}#${RESULT_CODES[code].anchor})`;
}
/** The installer form: `<context>: <summary>[ — <detail>]; fix: <fix> (<CODE>; <doc>#anchor)`. */
export function resultLineWithDocs(context: string, code: ResultCodeName, detail?: string): string {
const row: ResultCode = RESULT_CODES[code];
return `${context}: ${row.summary}${detail ? ` — ${detail}` : ''}; fix: ${row.fix} ${resultCodeSuffix(code)}`;
}
/** One error line: `<context>: <summary>[ (<detail>)]; fix: <fix> (<CODE>)`. */
export function resultLine(context: string, code: ResultCodeName, detail?: string): string {
const row: ResultCode = RESULT_CODES[code];
+15
View File
@@ -7,6 +7,7 @@
* thread id against an owner-maintained thread cohort).
*/
import { ARTIFACT_SCHEMAS, checkSchema, type ShipReceipt, type ValidationError } from './headless-artifacts';
import { renderIssueLines } from './issue-links';
export const RECEIPT_FENCE = 'gstack-ship-receipt';
const KEY_ORDER: Array<keyof ShipReceipt> = [
@@ -54,6 +55,20 @@ export function placeReceiptFirst(body: string, receipt: ShipReceipt): string {
return block + (stripped ? '\n' + stripped : '');
}
/** The receipt's `issues` object from a gstack-issue-links --out or --json file (its other keys are preview-only). */
export function receiptIssuesFrom(file: Record<string, unknown>): ShipReceipt['issues'] {
const issues: Record<string, unknown> = {};
for (const key of ['fixes', 'refs', 'flakes', 'related']) if (file[key] !== undefined) issues[key] = file[key];
return issues as ShipReceipt['issues'];
}
/** Lines the receipt's issues imply that the body does not carry (a line may continue, e.g. `Refs #N (unreadable …)`). */
export function missingIssueLines(body: string, issues: NonNullable<ShipReceipt['issues']>): string[] {
const have = body.split(/\r?\n/).map(l => l.trim());
const expected = renderIssueLines({ fixes: issues.fixes ?? [], refs: issues.refs ?? [], flakes: issues.flakes ?? [], cross: [], unreadable: [], ...(issues.related ? { related: issues.related } : {}) });
return expected.filter(e => !have.some(l => l === e || l.startsWith(`${e} `)));
}
/** The first lines of the completion message to the coordinator: identifiers only. */
export function receiptSummaryLine(r: ShipReceipt): string {
const parts = [`pr=${r.pr ?? '-'}`, `head=${r.head.slice(0, 12)}`, `version=${r.version ?? '-'}`, `base=${r.base}`];
+20 -5
View File
@@ -160,11 +160,24 @@ export function cleanup(repo: FixtureRepo): void {
fs.rmSync(repo.root, { recursive: true, force: true });
}
/** A `gh` on PATH answering `gh issue view <n> --json ...` from a map and `gh issue list` / `gh run view` from fixed data. */
export function ghShim(repo: FixtureRepo, data: { issues?: Record<string, unknown>; issueList?: unknown[]; runs?: Record<string, unknown> }): { PATH: string } {
/**
* A `gh` on PATH answering `gh issue view <n> --json ...` from a map, `gh issue list` / `gh run view` from fixed data,
* `gh repo view` from `repo` (null fails) and the closing-PR `gh api graphql` query from `closing` (issue → pages of
* nodes; `after=c<i>` reads page i; a missing issue fails like an API error).
*/
export function ghShim(repo: FixtureRepo, data: { issues?: Record<string, unknown>; issueList?: unknown[]; runs?: Record<string, unknown>; repo?: string | null; viewer?: string; closing?: Record<string, unknown[][]> }): { PATH: string } {
const dir = path.join(repo.root, 'gh-shim');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'data.json'), JSON.stringify({ issues: {}, issueList: [], runs: {}, ...data }));
fs.writeFileSync(path.join(dir, 'data.json'), JSON.stringify({ issues: {}, issueList: [], runs: {}, repo: 'owner/repo', viewer: 'me', closing: {}, ...data }));
fs.writeFileSync(path.join(dir, 'api.js'), [
'const d = require(process.argv[2]); const a = process.argv.slice(3);',
'const arg = k => { const i = a.findIndex(x => x.startsWith(k + "=")); return i < 0 ? undefined : a[i].slice(k.length + 1); };',
'if (a[0] === "repo") { if (d.repo === null) { console.error("no git remotes found"); process.exit(1); } console.log(JSON.stringify({ nameWithOwner: d.repo })); process.exit(0); }',
'const pages = d.closing[arg("number")]; if (!pages) { console.error("HTTP 502: Bad Gateway (https://api.github.com/graphql)"); process.exit(1); }',
'const i = Number((arg("after") || "c0").slice(1));',
'console.log(JSON.stringify({ data: { viewer: { login: d.viewer }, repository: { issue: { closedByPullRequestsReferences: { nodes: pages[i] || [], pageInfo: { hasNextPage: i + 1 < pages.length, endCursor: "c" + (i + 1) } } } } } }));',
'',
].join('\n'));
const script = [
'#!/bin/sh',
`DATA="${path.join(dir, 'data.json')}"`,
@@ -172,8 +185,10 @@ export function ghShim(repo: FixtureRepo, data: { issues?: Record<string, unknow
' node -e "const d=require(process.argv[1]);const p=d.issues[process.argv[2]];if(!p){console.error(\'GraphQL: Could not resolve to an Issue\');process.exit(1)}console.log(JSON.stringify(p))" "$DATA" "$3"',
' exit $?',
'fi',
'# issue list: entries whose body contains the quoted --search token, numbers only (what gstack-issue-links asks for)',
'if [ "$1" = "issue" ] && [ "$2" = "list" ]; then node -e "const a=process.argv.slice(2);const q=(a[a.indexOf(\'--search\')+1]||\'\').replace(/^\\"|\\" in:body$/g,\'\');console.log(JSON.stringify(require(process.argv[1]).issueList.filter(i=>!q||String(i.body||\'\').includes(q)).map(i=>({number:i.number}))))" "$DATA" "$@"; exit $?; fi',
'# issue list: entries whose body contains the quoted --search token (number, title, body, author: what gstack-issue-links asks for)',
'if [ "$1" = "issue" ] && [ "$2" = "list" ]; then node -e "const a=process.argv.slice(2);const q=(a[a.indexOf(\'--search\')+1]||\'\').replace(/^\\"|\\" in:body$/g,\'\');console.log(JSON.stringify(require(process.argv[1]).issueList.filter(i=>!q||String(i.body||\'\').includes(q))))" "$DATA" "$@"; exit $?; fi',
`if [ "$1" = "repo" ] && [ "$2" = "view" ]; then exec node "${path.join(dir, 'api.js')}" "$DATA" repo; fi`,
`if [ "$1" = "api" ] && [ "$2" = "graphql" ]; then shift 2; exec node "${path.join(dir, 'api.js')}" "$DATA" "$@"; fi`,
'if [ "$1" = "run" ] && [ "$2" = "view" ]; then',
' node -e "const d=require(process.argv[1]);const p=d.runs[process.argv[2]];if(!p){console.error(\'run not found\');process.exit(1)}console.log(JSON.stringify(p))" "$DATA" "$3"',
' exit $?',
+157 -2
View File
@@ -8,12 +8,13 @@ import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { mentionsIn, renderIssueLines, resolveLinks } from '../lib/issue-links';
import { lineSources, mentionsIn, promotionCandidates, relatedPrs, renderIssueLines, resolveLinks, selectFlakes, type ClosingPr, type IssueRow } from '../lib/issue-links';
import { cleanup, commit, ghShim, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
const fixture = () => { const r = makeFixtureRepo(); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const BOT = { login: 'app/github-actions', is_bot: true };
describe('issue intent (pure)', () => {
test('trailers carry closure; bare mentions, partial fixes, deferred asks and cross-repo ids are references', () => {
@@ -37,7 +38,11 @@ describe('gstack-issue-links (bin)', () => {
test('reads origin/<base>..HEAD, probes closure issues through gstack-issue-guard, maps CI run ids to flake issues, writes the receipt object', () => {
const repo = fixture();
commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }, 'fix add\n\nSee #12 for context, partially fixes #13.\n\nFixes #14\nCloses #15\nRefs other/repo#9\n');
const env = ghShim(repo, { issues: { '14': { title: 't', body: 'b', comments: [] }, '16': { title: 'u', body: '', comments: [] } }, issueList: [{ number: 40, body: 'Flake in run 123456' }, { number: 41, body: 'unrelated' }] });
const env = ghShim(repo, {
issues: { '14': { title: 't', body: 'b', comments: [] }, '16': { title: 'u', body: '', comments: [] } },
issueList: [{ number: 40, title: 'Flake: test/util.test.js', author: BOT, body: 'Flake in https://github.com/owner/repo/actions/runs/123456' }, { number: 41, body: 'unrelated' }],
closing: { '14': [[]], '16': [[]] },
});
const out = path.join(repo.work, '.gstack/tmp/issues.json');
const r = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--ci-run', '123456', '--issue', '16', '--out', out], env);
expect(r.status).toBe(EXIT.fail);
@@ -64,3 +69,153 @@ describe('gstack-issue-links (bin)', () => {
expect(runBin(none, 'gstack-issue-links', ['--help']).out).toContain('Exit codes: 0 ok · 1 fail · 2 usage · 3 refused or needs a flag');
});
});
const flakeJson = (run: number, file: string, test: string) =>
`failed on master\n\n<!-- flake:json -->\n\`\`\`json\n${JSON.stringify({ schema: 'flake/v1', file, test, first_seen: { run_id: run } }, null, 2)}\n\`\`\`\n`;
describe('promotion candidates (D1)', () => {
test('a subject parenthetical is a Refs candidate the owner promotes with fix #N; a Fixes: trailer closes', () => {
const subject = 'sync groups: a member after an uncommitted member is cancelled, never published ahead of it (#6252)\n\nContributor PR #6253 took the counting.\n';
const mentions = mentionsIn(subject, 'commit a33b20ea8a');
const links = resolveLinks(mentions, [], () => true);
expect(renderIssueLines(links)).toEqual(['Refs #6252', 'Refs #6253']);
expect(promotionCandidates(mentions, links)).toEqual([
{ issue: 6252, source: 'commit a33b20ea8a: sync groups: a member after an uncommitted member is cancelled, never published ahead of it (#6252)' },
{ issue: 6253, source: 'commit a33b20ea8a: Contributor PR #6253 took the counting.' },
]);
const promoted = resolveLinks(mentions, [6252], () => true);
expect(renderIssueLines(promoted)).toEqual(['Fixes #6252', 'Refs #6253']);
expect(promotionCandidates(mentions, promoted).map(c => c.issue)).toEqual([6253]);
expect(renderIssueLines(resolveLinks(mentionsIn(`${subject}\nFixes: #6252\n`), [], () => true))).toEqual(['Fixes #6252', 'Refs #6253']);
const unreadable = resolveLinks(mentionsIn('Fixes #9'), [], () => false);
expect(promotionCandidates(mentionsIn('Fixes #9'), unreadable)).toEqual([]);
});
});
describe('flake issues (D3): bot-opened, this repository, this run, a named test', () => {
const repo = 'garrytan/gbrain';
const rows: IssueRow[] = [
{ number: 6304, title: 'Flake: test/bounded-child-exec.test.ts › keeps stdout', author: BOT, body: flakeJson(37769719459, 'test/bounded-child-exec.test.ts', 'keeps stdout') },
{ number: 6310, title: 'CI is red again', author: { login: 'andreineacsu', is_bot: false }, body: 'see https://github.com/garrytan/gbrain/actions/runs/37769719459' },
{ number: 6311, title: 'Flake: test/a.test.ts', author: { login: 'github-actions[bot]' }, body: 'first seen https://github.com/other/fork/actions/runs/37769719459' },
{ number: 6312, title: 'nightly summary', author: BOT, body: 'run https://github.com/garrytan/gbrain/actions/runs/37769719459' },
{ number: 6313, title: 'Flake: test/b.test.ts', author: BOT, body: 'run https://github.com/garrytan/gbrain/actions/runs/377697194590' },
{ number: 6490, title: 'Flake: test/write-gate-perf.slow.test.ts › p95', author: BOT, body: flakeJson(37769719459, 'test/write-gate-perf.slow.test.ts', 'p95') },
{ number: 6570, title: 'Flake: test/write-gate-perf.slow.test.ts › p95', author: BOT, body: flakeJson(37769719459, 'test/write-gate-perf.slow.test.ts', 'p95') },
{ number: 6600, title: 'Flake: test/c.test.ts', author: { login: 'app/capy-ai', is_bot: true }, body: 'Failed in https://github.com/GarryTan/GBrain/actions/runs/37769719459.' },
];
test('keeps only qualifying issues and says why each other one was refused', () => {
const picked = selectFlakes('37769719459', repo, [...rows].reverse());
expect(picked.flakes).toEqual([
{ issue: 6304, run: '37769719459', test: 'test/bounded-child-exec.test.ts › keeps stdout' },
{ issue: 6490, run: '37769719459', test: 'test/write-gate-perf.slow.test.ts › p95' },
{ issue: 6600, run: '37769719459', test: 'test/c.test.ts' },
]);
expect(picked.rejected).toEqual([
{ issue: 6310, reason: 'opened by @andreineacsu, not a bot' },
{ issue: 6311, reason: 'names run 37769719459 of other/fork, not garrytan/gbrain' },
{ issue: 6312, reason: 'names no test (no flake:json file, no "Flake: <test>" title)' },
{ issue: 6313, reason: 'does not name run 37769719459 of garrytan/gbrain' },
{ issue: 6570, reason: 'duplicate of #6490 (test/write-gate-perf.slow.test.ts › p95)' },
]);
expect(selectFlakes('1', repo, [{ number: 1, title: 'Flake: x', author: BOT, body: '<!-- flake:json -->\n```json\n{not json\n```' }]).rejected).toEqual([{ issue: 1, reason: 'does not name run 1 of garrytan/gbrain' }]);
});
});
describe('related PRs (D2)', () => {
const pr = (number: number, login: string, extra: Partial<ClosingPr> = {}): ClosingPr => ({ number, state: 'OPEN', headRefName: `b${number}`, author: { login }, repository: { nameWithOwner: 'garrytan/gbrain' }, ...extra });
test('open PRs by other authors closing a fixed issue are Related; own, own-branch and closed PRs are not; Supersedes and credit only when confirmed', () => {
const closing = new Map<number, ClosingPr[]>([
[6252, [pr(6253, 'andreineacsu'), pr(6279, 'garrytan'), pr(6290, 'someone', { headRefName: 'feature' }), pr(6291, 'x', { state: 'CLOSED' }), pr(12, 'forker', { repository: { nameWithOwner: 'forker/gbrain' } })]],
[6275, [pr(6253, 'andreineacsu'), pr(6299, 'other', { author: null })]],
]);
const related = relatedPrs(closing, { repo: 'GarryTan/gbrain', viewer: 'GarryTan', branch: 'feature' });
expect(related).toEqual([
{ pr: 6253, author: 'andreineacsu', closes: [6252, 6275] },
{ pr: 6299, author: 'ghost', closes: [6275] },
{ pr: 12, repo: 'forker/gbrain', author: 'forker', closes: [6252] },
]);
const links = resolveLinks(mentionsIn('Fixes #6252\nFixes #6275'), [], () => true, [], related);
expect(renderIssueLines(links)).toEqual(['Fixes #6252', 'Fixes #6275', 'Related: #6253 (@andreineacsu)', 'Related: #6299 (@ghost)', 'Related: forker/gbrain#12 (@forker)']);
related[0]!.supersedes = true;
related[0]!.credit = true;
expect(renderIssueLines(links).slice(2, 4)).toEqual(['Supersedes #6253 (@andreineacsu)', 'Contributed by @andreineacsu (#6253)']);
expect(lineSources(links)[0]).toBe('Supersedes #6253 (@andreineacsu) ← open PR closing #6252, #6275; owner confirmed --supersedes');
expect(resolveLinks([], [], () => true, [], [])).not.toHaveProperty('related');
});
});
describe('gstack-issue-links (bin): related PRs, flake validation, lookup failures', () => {
const setup = (data: Parameters<typeof ghShim>[1]) => {
const repo = fixture();
commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }, 'fix: cancel members after an uncommitted head (#30)\n\nFixes #14\n');
return { repo, env: ghShim(repo, { issues: { '14': { title: 't', body: 'b', comments: [] }, '30': { title: 'c', body: '', comments: [] } }, ...data }) };
};
test('pages through open PRs closing a fixed issue, prints Related lines with their sources, and records owner-confirmed credit', () => {
const { repo, env } = setup({ closing: { '14': [[{ number: 7, state: 'OPEN', headRefName: 'contrib', author: { login: 'alice' } }, { number: 8, state: 'OPEN', headRefName: 'feature', author: { login: 'bob' } }], [{ number: 9, state: 'OPEN', headRefName: 'x', author: { login: 'me' } }, { number: 11, state: 'OPEN', headRefName: 'y', author: { login: 'carol' }, repository: { nameWithOwner: 'carol/repo' } }]] } });
const r = runBin(repo, 'gstack-issue-links', ['--base', 'main'], env);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toBe('Fixes #14\nRefs #30\nRelated: #7 (@alice)\nRelated: carol/repo#11 (@carol)\n');
expect(r.err).toContain('reference #30 ← commit ');
expect(r.err).toContain('(promote: fix #30)');
expect(r.err).toContain(' Related: #7 (@alice) ← open PR closing #14\n');
const out = path.join(repo.work, '.gstack/tmp/issues.json');
const credited = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--supersedes', '7', '--credit', '7', '--out', out, '--json'], env);
expect(credited.status).toBe(EXIT.ok);
const j = JSON.parse(credited.out);
expect(j.lines).toEqual(['Fixes #14', 'Refs #30', 'Supersedes #7 (@alice)', 'Contributed by @alice (#7)', 'Related: carol/repo#11 (@carol)']);
expect(j.candidates.map((c: { issue: number }) => c.issue)).toEqual([30]);
expect(JSON.parse(fs.readFileSync(out, 'utf8'))).toEqual({ fixes: [14], refs: [30], flakes: [], related: [{ pr: 7, author: 'alice', closes: [14], supersedes: true, credit: true }, { pr: 11, repo: 'carol/repo', author: 'carol', closes: [14] }] });
expect(runBin(repo, 'gstack-issue-links', ['--base', 'main', '--supersedes', '99'], env)).toMatchObject({ status: EXIT.usage, err: expect.stringContaining('--supersedes #99 is not a Related PR (#7, carol/repo#11)') });
});
test('--no-auto-links makes no Related lookup; a failed lookup is ISSUE_LOOKUP_FAILED with its anchor and leaves the closure lines', () => {
const { repo, env } = setup({});
const off = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--no-auto-links'], env);
expect(off).toMatchObject({ status: EXIT.ok, out: 'Fixes #14\nRefs #30\n' });
expect(off.err).not.toContain('ISSUE_LOOKUP_FAILED');
const failed = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--json'], env);
expect(failed.status).toBe(EXIT.ok);
expect(JSON.parse(failed.out)).toMatchObject({ fixes: [14], unavailable: ['open PRs closing #14: HTTP 502: Bad Gateway (https://api.github.com/graphql)'] });
expect(failed.err).toContain('gstack-issue-links: gstack-issue-links could not read');
expect(failed.err).toContain('(ISSUE_LOOKUP_FAILED; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#issue-lookup-failed)');
const noRepo = ghShim(repo, { repo: null, issues: { '14': { title: 't', body: 'b', comments: [] } } });
const r = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--ci-run', '123456', '--json'], noRepo);
expect(JSON.parse(r.out)).toMatchObject({ flakes: [], unavailable: ['repository: no git remotes found'] });
});
test('--ci-run keeps the bot flake issue and lists the refused matches', () => {
const { repo, env } = setup({ closing: { '14': [[]] }, issueList: [
{ number: 40, title: 'Flake: test/util.test.js', author: BOT, body: 'https://github.com/owner/repo/actions/runs/123456' },
{ number: 41, title: 'me too', author: { login: 'dev', is_bot: false }, body: 'https://github.com/owner/repo/actions/runs/123456' },
{ number: 42, title: 'Flake: test/util.test.js', author: BOT, body: 'https://github.com/elsewhere/repo/actions/runs/123456' },
] });
const r = runBin(repo, 'gstack-issue-links', ['--base', 'main', '--ci-run', '123456'], env);
expect(r.out).toBe('Fixes #14\nRefs #30\nFlake: #40 (run 123456)\n');
expect(r.err).toContain(' Flake: #40 (run 123456) ← bot flake issue for test/util.test.js\n');
expect(r.err).toContain(' not a flake link: #41 (run 123456) ← opened by @dev, not a bot\n');
expect(r.err).toContain(' not a flake link: #42 (run 123456) ← names run 123456 of elsewhere/repo, not owner/repo\n');
});
test('gstack-ship-receipt write refuses a body missing a printed issue line (ISSUE_LINKS_MISSING) and embeds the issues without the lines', () => {
const { repo, env } = setup({ closing: { '14': [[{ number: 7, state: 'OPEN', headRefName: 'contrib', author: { login: 'alice' } }]] } });
const out = path.join(repo.work, '.gstack/tmp/issues.json');
expect(runBin(repo, 'gstack-issue-links', ['--base', 'main', '--out', out], env).status).toBe(EXIT.ok);
const body = path.join(repo.root, 'body.md');
fs.writeFileSync(body, '## Summary\nx\n\n## Issues\nFixes #14\nRefs #30 (a context mention)\n');
const args = ['write', '--base', 'main', '--version', '1.0.0', '--session-kind', 'interactive', '--issues', `@${out}`, '--body', body, '--json'];
const refused = runBin(repo, 'gstack-ship-receipt', args, env);
expect(refused.status).toBe(EXIT.fail);
expect(refused.err).toContain(`— ${body} lacks "Related: #7 (@alice)"; fix: `);
expect(refused.err).toContain('(ISSUE_LINKS_MISSING; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#issue-links-missing)');
expect(fs.readFileSync(body, 'utf8').startsWith('## Summary')).toBe(true);
fs.appendFileSync(body, 'Related: #7 (@alice)\n');
const ok = runBin(repo, 'gstack-ship-receipt', args, env);
expect(ok.status).toBe(EXIT.ok);
expect(JSON.parse(ok.out).issues).toEqual({ fixes: [14], refs: [30], flakes: [], related: [{ pr: 7, author: 'alice', closes: [14] }] });
expect(fs.readFileSync(body, 'utf8').startsWith('```gstack-ship-receipt')).toBe(true);
});
});