Files
gstack/lib/issue-links.ts
T
Garry Tanandcapy-ai[bot] a8decce8ac v1.91.262.0 fix(release-cut,ship-queue): the weekly release accepts a train-gated commit; cross-repo refs, run ids, pr-body section merge, pregate by tree (#3483)
* fix(release-cut,ship-queue): the weekly release accepts a train-gated commit, and five lane frictions seen in operation

scripts/release-cut.ts gates a main commit either by a successful backstop check
run or by a green/merging/merged train record in refs/notes/gstack-trains whose
merged[] names its sha or tree (CANDIDATE: ... gated by train <nonce>); a red
check, a running run, the required check and the freeze are judged as before.
--required-check, --backstop-check and --notes-ref make the names configurable.
lib/issue-links.ts reads (name #N[, #M]), (name PR #N) and (name issue #N) as
cross-repo references. lib/gstack-post.ts allowlists an 11-digit run id after a
run word or chained after one, and pr-body merges by ## section into the PR's
current body (--replace overwrites). gstack-pregate mirrors each record under
<state root>/pregate/by-tree/<tree>.json and ready reads the mirror for its tree
when the checkout's record is missing or for another tree. docs/troubleshooting.md
gains #redact-run-id-as-phone, #gate-error-no-base and #gstack-post-pr-body-merge.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (pass 1)

Fence-length-aware section splitting in gstack-post pr-body (an unclosed
receipt fence or a four-backtick block no longer exposes a heading to the
merge), the run-id allowlist drops generic words (id, job, attempt,
workflow), ready accepts a by-tree pregate mirror only from a complete run
against origin/<base> under a reviewed policy and prints no home path,
release-cut skips non-object notes and never substitutes train evidence
for the required check when both names are equal, issue-links treats
closing/cf./e.g./v1.2 as words rather than repositories.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (pass 2)

An unclosed receipt fence makes the body from that line on one section
the merge never matches, so posted leading prose cannot replace it and a
closed receipt before it stays a receipt; the by-tree pregate mirror must
hold the tests stage (what --stage tests --remote writes) rather than
every check; empty sha/tree strings in a train note are not evidence;
(v1.2.3-rc1 #301) is a version word, not a repository.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: CANDIDATE line names its gate; issue-links reference documents the (name #N) form

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-queue): a PREGATE_ENVIRONMENT local record is final; no mirror stands in for it

Merging origin/main b004c06 (#3480) into this branch created a semantic
conflict between two individually correct parents: #3480 made
verifyPregate return PREGATE_ENVIRONMENT (instead of PREGATE_INCOMPLETE)
when every incomplete check was caused by the environment, and this
branch's by-tree mirror reader in ready() treated only PREGATE_LANE_FAILED
and PREGATE_INCOMPLETE as final. Merged, a local record whose secrets scan
never finished fell through to a same-tree tests-only mirror and ready
passed without a secrets scan, against the rule this branch documents
("a record whose own checks failed or stayed incomplete is never
overridden").

ready() now keeps a PREGATE_ENVIRONMENT record final as well.

Regression in test/gstack-ship-queue.test.ts ("one READY block ..."): an
environment-incomplete secrets record beside a passing same-tree mirror
is refused with (PREGATE_ENVIRONMENT) and no "from the state root"
suffix. Red on the bare merge 28b64d7 (expected exit 3, received 0),
green with this fix.

Found by the pass-4 delta review of the merge.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.262.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-27 01:45:08 +00:00

203 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Issue links for /ship Step 18 (plan C6). Pure classification over commit
* messages and explicit flags; the bin (bin/gstack-issue-links) owns the
* gh / gstack-issue-guard calls.
*
* Intent rules:
* - `Fixes #N` is written only on explicit closure intent: a `Fixes|Closes|
* Resolves #N` trailer line in a commit of the branch, or `--issue N`.
* - A bare `#N` mention, a `Refs|See|Part of|Partially fixes|Related` line, a
* deferred ask (`defer`, `follow-up`) and every cross-repo id
* (`owner/repo#N`) are `Refs #N` — never auto-closed.
* - A parenthesised named reference, `(name #N, #M)`, `(name PR #N)` or
* `(name issue #N)` where name is one token that is not a closure,
* reference, filler or version word (`closing`, `cf.`, `e.g.`, `v1.2`
* name nothing), is a cross-repo reference to `name`: rendered as
* `Refs name#N`, never a same-repo Refs line or a promotion candidate.
* - A closure issue that cannot be read through gstack-issue-guard is
* downgraded to `Refs #N` with ISSUE_UNREADABLE (never Fixes).
* - 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 #N` and `Contributed by @author`
* (lib/contributor-mode.ts credit lines) 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 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;
const CROSS = /\b([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#(\d+)\b/g;
const BARE = /(?<![A-Za-z0-9_./-])#(\d+)\b/g;
/** `(name #N[, #M]…)`, `(name PR #N)`, `(name issue #N)`: the name, then every number. */
const NAMED = /\(([A-Za-z][\w.-]*(?:\/[\w.-]+)?)\s+(?:(?:PR|issue)\s+)?(#\d+(?:,\s*#\d+)*)\)/gi;
/** Words that open a reference in parentheses without naming a repository. */
const NOT_A_NAME = /^(?:fix(?:es|ed|ing)?|clos(?:e[sd]?|ing)|resolv(?:e[sd]?|ing)|address(?:es|ed|ing)?|implement(?:s|ed|ing)?|supersed(?:es|ed|ing)|replac(?:es|ed|ing)|track(?:s|ed|ing)?|block(?:s|ed|ing)?|dup|duplicates?|bugs?|upstream|refs?|see|related|partial(?:ly)?|part|context|follow-?up|defer(?:red)?|prs?|issues?|gh|via|from|after|since|with|in|by|for|also|cf\.?|e\.?g\.?|i\.?e\.?|and|or|per|of|to|on|at|as|is|was|the|an?|this|that|not|no|vs\.?|like|v\d[\w.-]*)$/i;
/** Mentions in one commit message: trailer lines carry closure intent, everything else is a reference. */
export function mentionsIn(message: string, source = 'commit'): IssueMention[] {
const out: IssueMention[] = [];
const seen = new Set<string>();
const add = (m: IssueMention) => { const k = `${m.repo ?? ''}#${m.issue}:${m.intent}`; if (!seen.has(k)) { seen.add(k); out.push(m); } };
for (const raw of message.split('\n')) {
const line = raw.replace(/\s+$/, '');
const trailer = CLOSURE.exec(line);
if (trailer && !PARTIAL.test(line)) { add({ issue: Number(trailer[1]), intent: 'fixes', source: `${source}: ${line.trim()}` }); continue; }
const rest = line.replace(NAMED, (whole, name: string, numbers: string) => {
if (NOT_A_NAME.test(name)) return whole;
for (const n of numbers.matchAll(/#(\d+)/g)) add({ issue: Number(n[1]), intent: 'refs', repo: name, source: `${source}: ${line.trim()}` });
return ' ';
});
for (const c of rest.matchAll(CROSS)) add({ issue: Number(c[2]), intent: 'refs', repo: c[1], source: `${source}: ${line.trim()}` });
const stripped = rest.replace(CROSS, '');
for (const b of stripped.matchAll(BARE)) add({ issue: Number(b[1]), intent: 'refs', source: `${source}: ${line.trim()}` });
}
return out;
}
/**
* 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: FlakeLink[] = [], related: RelatedPr[] = []): IssueLinks {
const fixes = new Set<number>(explicit);
const refs = new Set<number>();
const cross = new Set<string>();
for (const m of mentions) {
if (m.repo) { cross.add(`${m.repo}#${m.issue}`); continue; }
if (m.intent === 'fixes') fixes.add(m.issue); else refs.add(m.issue);
}
const unreadable: number[] = [];
for (const n of [...fixes]) if (!readable(n)) { fixes.delete(n); refs.add(n); unreadable.push(n); }
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),
...(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[] = [];
for (const n of links.fixes) lines.push(`Fixes #${n}`);
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 ${prId(r)}` : `Related: ${prId(r)} (@${r.author})`);
if (r.credit) lines.push(`Contributed by @${r.author}`);
}
return lines;
}
/** 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 ${prId(r)} ← ${closes} by @${r.author}; owner confirmed --supersedes` : `Related: ${prId(r)} (@${r.author}) ← ${closes}`);
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 } : {}) };
}