diff --git a/packages/gittensory-engine/src/signals/predicted-gate-engine.ts b/packages/gittensory-engine/src/signals/predicted-gate-engine.ts index fc086bcafd..68df362c06 100644 --- a/packages/gittensory-engine/src/signals/predicted-gate-engine.ts +++ b/packages/gittensory-engine/src/signals/predicted-gate-engine.ts @@ -899,15 +899,21 @@ export function tokenize(value: string): string[] { } function extractLinkedIssueNumbers(text: string, repoFullName: string): number[] { - const numbers = [...text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi)].map((match) => Number(match[1])); - // GitHub also auto-closes via the fully-qualified `KEYWORD owner/repo#N` form (e.g. Renovate/Dependabot bodies). - // Count it only when owner/repo case-insensitively equals THIS repo — a reference to a different repo closes an - // issue elsewhere, not here, so it must not spoof a same-repo link. Same `\b`-anchored keywords as above (#1988). + // Strip inline code spans before scanning — mirrors src/db/repositories.ts (#4039) so the PR template's + // literal `(e.g. \`Closes #123\`)` checklist example does not spuriously link issue #123. + const withoutCodeSpans = text.replace(/`[^`\n]*`/g, " "); const target = repoFullName.toLowerCase(); - for (const match of text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+([\w.-]+\/[\w.-]+)#(\d+)\b/gi)) { - if (match[1]!.toLowerCase() === target) numbers.push(Number(match[2])); + const linkedIssues: number[] = []; + const seen = new Set(); + for (const match of withoutCodeSpans.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:([\w.-]+\/[\w.-]+)#|#)(\d+)\b/gi)) { + const owner = match[1]; + if (owner && owner.toLowerCase() !== target) continue; + const value = Number(match[2]); + if (!Number.isInteger(value) || value <= 0 || seen.has(value)) continue; + seen.add(value); + linkedIssues.push(value); } - return [...new Set(numbers.filter((value) => Number.isInteger(value) && value > 0))]; + return linkedIssues; } function isMaintainerAssociation(value: string | null | undefined): boolean { diff --git a/packages/gittensory-mcp/lib/local-branch.js b/packages/gittensory-mcp/lib/local-branch.js index 492e8b4dcf..4421c33401 100644 --- a/packages/gittensory-mcp/lib/local-branch.js +++ b/packages/gittensory-mcp/lib/local-branch.js @@ -56,7 +56,7 @@ export function collectLocalBranchMetadata(input) { const ciStatusHints = input.ciStatusHints ?? collectCiStatusHints(cwd, baseRef, changedFiles); const commitMessages = input.commitMessages ?? collectCommitMessages(cwd, baseRef); const title = input.title ?? titleFromBranch(branchName) ?? firstCommitTitle(commitMessages); - const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssues([branchName, title, input.body, ...commitMessages].filter(Boolean).join("\n"))])].sort( + const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssues([branchName, title, input.body, ...commitMessages].filter(Boolean).join("\n"), repoFullName)])].sort( (left, right) => left - right, ); const payload = { @@ -557,14 +557,23 @@ function assertSourceUploadDisabled() { } } -// Word-boundary the closing keywords (as the server-side extractors in src/db/repositories.ts and -// src/signals/engine.ts already do) so a keyword embedded in a longer word does not spuriously link an -// issue: without \b, `hotfix 5` / `prefixes 12` matched the `fix`/`fixes` substring and captured the -// trailing number. The bare `#` branch stays boundary-free so `#123` still matches anywhere. -export function extractLinkedIssues(text) { - const issues = []; - for (const match of String(text).matchAll(/(?:\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)|#)\s*#?(\d+)/gi)) issues.push(Number(match[1])); - return issues.filter((issue) => Number.isInteger(issue) && issue > 0); +// Mirror src/db/repositories.ts extractLinkedIssueNumbersWithOverflow: strip inline code spans before +// scanning (PR template checklist contains `(e.g. \`Closes #123\`)`), honor qualified owner/repo#N only +// when it matches THIS repo, and word-boundary closing keywords so `hotfix 5` does not spoof a link. +export function extractLinkedIssues(text, repoFullName = "") { + const target = String(repoFullName).toLowerCase(); + const withoutCodeSpans = String(text).replace(/`[^`\n]*`/g, " "); + const linkedIssues = []; + const seen = new Set(); + for (const match of withoutCodeSpans.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:([\w.-]+\/[\w.-]+)#|#)(\d+)\b/gi)) { + const owner = match[1]; + if (owner && owner.toLowerCase() !== target) continue; + const value = Number(match[2]); + if (!Number.isInteger(value) || value <= 0 || seen.has(value)) continue; + seen.add(value); + linkedIssues.push(value); + } + return linkedIssues; } function statusFromCode(code) { diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index dc93def9b8..22524ece13 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -1797,16 +1797,27 @@ describe("local MCP git metadata collection", () => { it("extracts linked issues only from standalone closing keywords, not keyword substrings", async () => { // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. const { extractLinkedIssues } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); - // Standalone closing keywords (hash optional, as this client-side extractor allows) and bare #refs link. - expect(extractLinkedIssues("fixes #5")).toEqual([5]); - expect(extractLinkedIssues("Closes 12 and resolves #34")).toEqual([12, 34]); - expect(extractLinkedIssues("see #7")).toEqual([7]); - expect(extractLinkedIssues("closes#3")).toEqual([3]); + const repo = "owner/repo"; + // Standalone closing keywords (hash optional) link when scoped to this repo. + expect(extractLinkedIssues("fixes #5", repo)).toEqual([5]); + expect(extractLinkedIssues("Closes #12 and resolves #34", repo)).toEqual([12, 34]); + expect(extractLinkedIssues("closes #3", repo)).toEqual([3]); + expect(extractLinkedIssues("Closes owner/repo#42", repo)).toEqual([42]); + expect(extractLinkedIssues("Fixes Owner/Repo#7", repo)).toEqual([7]); + // Bare `#N` without a closing keyword does not link (server parity). + expect(extractLinkedIssues("see #7", repo)).toEqual([]); + // A different repo's qualified reference must not spoof a same-repo link (#3862). + expect(extractLinkedIssues("Resolves other/repo#99", repo)).toEqual([]); // Regression: a closing keyword embedded in a longer word must NOT capture a trailing number. - expect(extractLinkedIssues("hotfix 5")).toEqual([]); - expect(extractLinkedIssues("prefixes 12")).toEqual([]); - expect(extractLinkedIssues("unclosed 9")).toEqual([]); - expect(extractLinkedIssues("no references here")).toEqual([]); + expect(extractLinkedIssues("hotfix 5", repo)).toEqual([]); + expect(extractLinkedIssues("prefixes 12", repo)).toEqual([]); + expect(extractLinkedIssues("unclosed 9", repo)).toEqual([]); + expect(extractLinkedIssues("no references here", repo)).toEqual([]); + // REGRESSION (#4039): ignore closing keywords inside inline code spans (PR template checklist example). + const templateLine = + "- [ ] I linked a currently open issue this PR resolves (e.g. `Closes #123`) — a linked open issue is required for every contributor PR."; + expect(extractLinkedIssues(templateLine, repo)).toEqual([]); + expect(extractLinkedIssues(`Closes #42\n\n${templateLine}`, repo)).toEqual([42]); }); it("parses remotes, changed-file stats, linked issues, and refuses source upload mode", async () => { diff --git a/test/unit/predicted-gate-engine-branch-coverage.test.ts b/test/unit/predicted-gate-engine-branch-coverage.test.ts index afad05107e..eadebc5fa4 100644 --- a/test/unit/predicted-gate-engine-branch-coverage.test.ts +++ b/test/unit/predicted-gate-engine-branch-coverage.test.ts @@ -380,6 +380,10 @@ describe("predicted-gate engine branch coverage (#2283)", () => { expect(internals.extractLinkedIssueNumbers("closes other/repo#9", REPO.fullName)).not.toContain(9); expect(internals.extractLinkedIssueNumbers(`closes ${REPO.fullName}#9`, REPO.fullName)).toContain(9); + const templateLine = + "- [ ] I linked a currently open issue this PR resolves (e.g. `Closes #123`) — a linked open issue is required for every contributor PR."; + expect(internals.extractLinkedIssueNumbers(templateLine, REPO.fullName)).toEqual([]); + expect(internals.extractLinkedIssueNumbers(`Closes #42\n\n${templateLine}`, REPO.fullName)).toEqual([42]); const issueQuality: IssueQualityReport = { repoFullName: REPO.fullName,