diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index 181410d40..57177ce38 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -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' ;; diff --git a/bin/gstack-issue-links b/bin/gstack-issue-links index afbaa07ec..f0fb03145 100755 --- a/bin/gstack-issue-links +++ b/bin/gstack-issue-links @@ -2,38 +2,52 @@ /** * gstack-issue-links — issue links by intent for /ship Step 18 (plan C6). * - * gstack-issue-links [--base ] [--issue ]... [--ci-run ]... [--repo ] [--out ] [--json] + * gstack-issue-links [--base ] [--issue ]... [--ci-run ]... [--supersedes ]... [--credit ]... + * [--no-auto-links] [--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 )`. + * (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 )`. 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 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 ) + --ci-run a failing CI run id (repeatable); bot flake issues of this repo naming it and a test become Flake: #N (run ) + --supersedes owner confirmed this PR supersedes a Related PR: Supersedes #N (@author) (repeatable) + --credit 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 repository for gh (default: the current remote) - --out write the issues object (fixes, refs, flakes) for gstack-ship-receipt write --issues @file + --out 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(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, repo: string, fixes: number[]): { closing: Map; viewer: string } | undefined { + const [owner, name] = repo.split('/'); + const closing = new Map(); + 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(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(`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; } diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 237eb1c06..f8441f1a7 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -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 / --run --thread --receipt --policy --preregistration --history --queue-mode --pregate (C8: refused unless current, green and every requires-remote obligation carries a receipt: PREGATE_STALE / PREGATE_LANE_FAILED / PREGATE_INCOMPLETE / PREGATE_REMOTE_UNCLEARED) --issues - (C6: gstack-issue-links --json) --out --body . 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 --body . 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; 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); diff --git a/docs/pregate.md b/docs/pregate.md index 7d96e44c6..757b1d373 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -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 ] [--issue ]... [--ci-run ]... [--out issues.json] [--json] +gstack-issue-links [--base ] [--issue ]... [--ci-run ]... [--supersedes ]... [--credit ]... + [--no-auto-links] [--repo ] [--out issues.json] [--json] ``` Reads `origin/..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 ` is searched among open issues whose body names it and rendered -`Flake: #N (run )`. 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 ← (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 ` is searched among open issues whose body names it and +rendered `Flake: #N (run )` 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/` URL) and a test (`flake:json` `file › test`, or a +`Flake: ` 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`). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f5073c73f..ac9f89665 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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`. + +### `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 ` 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. + + +### `ISSUE_LINKS_MISSING` + +**Meaning.** `gstack-ship-receipt write --issues @issues.json --body ` +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`, diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 846cddaad..18f57352d 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -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: '/' }, 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 … @ 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; diff --git a/lib/issue-links.ts b/lib/issue-links.ts index df71ba4f1..4535364fb 100644 --- a/lib/issue-links.ts +++ b/lib/issue-links.ts @@ -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 )`. + * - 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/ URL) and a test (`flake:json` file › test or a + * `Flake: ` title): `Flake: #N (run )`. 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(explicit); const refs = new Set(); const cross = new Set(); @@ -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*```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 `"" 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: " 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, ctx: { repo: string; viewer: string; branch?: string }): RelatedPr[] { + const byKey = new Map(); + 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 ` ← ` 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 } : {}) }; } diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 7a600bc97..b02cb94e6 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -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 `), 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: `: [ — ]; fix: (; #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: `: [ ()]; fix: ()`. */ export function resultLine(context: string, code: ResultCodeName, detail?: string): string { const row: ResultCode = RESULT_CODES[code]; diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index f4dcd3f7d..3bd17b167 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -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 = [ @@ -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): ShipReceipt['issues'] { + const issues: Record = {}; + 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): 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}`]; diff --git a/test/helpers/pregate-fixture.ts b/test/helpers/pregate-fixture.ts index 3226e90b5..ebc08ed91 100644 --- a/test/helpers/pregate-fixture.ts +++ b/test/helpers/pregate-fixture.ts @@ -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 --json ...` from a map and `gh issue list` / `gh run view` from fixed data. */ -export function ghShim(repo: FixtureRepo, data: { issues?: Record; issueList?: unknown[]; runs?: Record }): { PATH: string } { +/** + * A `gh` on PATH answering `gh issue view --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` reads page i; a missing issue fails like an API error). + */ +export function ghShim(repo: FixtureRepo, data: { issues?: Record; issueList?: unknown[]; runs?: Record; repo?: string | null; viewer?: string; closing?: Record }): { 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!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 $?', diff --git a/test/ship-issue-intent.test.ts b/test/ship-issue-intent.test.ts index e65b281ec..ce6cbb127 100644 --- a/test/ship-issue-intent.test.ts +++ b/test/ship-issue-intent.test.ts @@ -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/..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\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: " 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: '\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 => ({ 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([ + [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[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); + }); +});