From bc7ecf5c21649bf83005c98cfcb0dbddacfb0318 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:03:09 -0700 Subject: [PATCH] fix(db): reject code-span linked issue matches --- src/db/repositories.ts | 18 ++++++++++++------ test/unit/db-parsers.test.ts | 6 ++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 2570a60d70..d2dd252dbc 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -7379,18 +7379,24 @@ export function extractLinkedIssueNumbersWithOverflow(text: string, repoFullName const normalizedLimit = Math.max(0, Math.floor(limit)); const target = repoFullName.toLowerCase(); - // Strip inline code spans before scanning: GitHub's own native closing-keyword linker does not treat - // backtick-wrapped text as a real "Closes #N" directive, and this repo's own PR template checklist item - // contains the literal example text "(e.g. `Closes #123`)" -- without this, every PR that keeps the - // unmodified template checklist would spuriously link to issue #123. - const withoutCodeSpans = text.replace(/`[^`\n]*`/g, " "); + // GitHub's native closing-keyword linker does not treat backtick-wrapped text as a real + // "Closes #N" directive, and this repo's own PR template contains "(e.g. `Closes #123`)". + // Keep the original text while rejecting regex hits that occur inside inline code spans; replacing + // spans with whitespace would let text on either side combine into a fake closing reference. + const inlineCodeSpanRanges = [...text.matchAll(/`[^`\n]*`/g)].map((match) => ({ + start: match.index!, + end: match.index! + match[0].length, + })); const linkedIssues: number[] = []; const seen = new Set(); // Matches both GitHub's bare `KEYWORD #N` and fully-qualified `KEYWORD owner/repo#N` closing syntax (#3862) -- // the qualified form only counts when owner/repo case-insensitively matches THIS repo; a reference to a // different repo closes an issue there, not here, and must not spoof a same-repo linked-issue match. - for (const match of withoutCodeSpans.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:([\w.-]+\/[\w.-]+)#|#)(\d+)\b/gi)) { + for (const match of text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:([\w.-]+\/[\w.-]+)#|#)(\d+)\b/gi)) { + const matchStart = match.index!; + const matchEnd = matchStart + match[0].length; + if (inlineCodeSpanRanges.some((range) => matchStart < range.end && matchEnd > range.start)) continue; const owner = match[1]; if (owner && owner.toLowerCase() !== target) continue; const value = Number(match[2]); diff --git a/test/unit/db-parsers.test.ts b/test/unit/db-parsers.test.ts index 848f073c20..f72a35d30f 100644 --- a/test/unit/db-parsers.test.ts +++ b/test/unit/db-parsers.test.ts @@ -68,6 +68,12 @@ describe("database row parser hardening", () => { expect(extractLinkedIssueNumbers(`Closes #42\n\n${templateLine}`, "owner/repo")).toEqual([42]); }); + it("REGRESSION: does not join closing keywords to issue references across inline code spans", () => { + expect(extractLinkedIssueNumbers("Fixes `not a directive` #42", "owner/repo")).toEqual([]); + expect(extractLinkedIssueNumbers("Resolves `not a directive` owner/repo#43", "owner/repo")).toEqual([]); + expect(extractLinkedIssueNumbers("Fixes #7, not `Fixes #8`, and closes #9", "owner/repo")).toEqual([7, 9]); + }); + it("recognizes the fully-qualified `Fixes owner/repo#N` closing syntax when owner/repo matches this repo (#3862)", () => { expect(extractLinkedIssueNumbers("Closes owner/repo#42", "owner/repo")).toEqual([42]); // Case-insensitive, matching GitHub's own repo-name matching.