Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
// 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]);
Expand Down
6 changes: 6 additions & 0 deletions test/unit/db-parsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down