Skip to content
Closed
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
20 changes: 13 additions & 7 deletions packages/gittensory-engine/src/signals/predicted-gate-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
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 {
Expand Down
27 changes: 18 additions & 9 deletions packages/gittensory-mcp/lib/local-branch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 20 additions & 9 deletions test/unit/local-branch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
4 changes: 4 additions & 0 deletions test/unit/predicted-gate-engine-branch-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down