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
17 changes: 12 additions & 5 deletions src/signals/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2506,7 +2506,7 @@ export function buildPreflightResult(
issueQuality?: IssueQualityReport | null | undefined,
): PreflightResult {
const lane = buildLaneAdvice(repo, input.repoFullName);
const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssueNumbers(truncateText(input.body ?? "", PREFLIGHT_LIMITS.bodyChars))])].sort(
const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssueNumbers(truncateText(input.body ?? "", PREFLIGHT_LIMITS.bodyChars), input.repoFullName)])].sort(
(left, right) => left - right,
);
// Flag an existing open-work cluster as a possible duplicate when it shares a
Expand Down Expand Up @@ -2621,7 +2621,7 @@ export function buildLocalDiffPreflightResult(
): LocalDiffPreflightResult {
/* v8 ignore next -- Undefined metadata arrays are normalized at API/MCP boundaries; local analysis tests cover empty metadata behavior. */
const changedFiles = [...new Set([...(input.changedFiles ?? []), ...(input.testFiles ?? [])])];
const linkedFromCommit = extractLinkedIssueNumbers([input.commitMessage, input.body, input.title].filter(Boolean).join("\n"));
const linkedFromCommit = extractLinkedIssueNumbers([input.commitMessage, input.body, input.title].filter(Boolean).join("\n"), input.repoFullName);
const base = buildPreflightResult(
{
...input,
Expand Down Expand Up @@ -5294,9 +5294,16 @@ function tokenize(value: string): string[] {
.filter((term) => term.length > 2 && !STOPWORDS.has(term));
}

function extractLinkedIssueNumbers(text: string): number[] {
const matches = [...text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi)];
return [...new Set(matches.map((match) => Number(match[1])).filter((value) => Number.isInteger(value) && value > 0))];
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).
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]));
}
return [...new Set(numbers.filter((value) => Number.isInteger(value) && value > 0))];
}

function outcomeSuccessPatterns(history: ContributorOutcomeHistory): OutcomePattern[] {
Expand Down
23 changes: 23 additions & 0 deletions test/unit/signals-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,29 @@ describe("signal coverage edge cases", () => {
});
});

it("recognizes GitHub's fully-qualified owner/repo#N closing reference, repo-scoped", () => {
const directRepo = repo("owner/direct");
const linkedIssuesFor = (body: string) =>
buildPreflightResult({ repoFullName: directRepo.fullName, title: "Fix cache invalidation", body }, directRepo, [issue(directRepo.fullName, 42, "Cache invalidation")], []);

// Same-repo fully-qualified closing ref (GitHub's documented `KEYWORD owner/repo#N` form) links issue 42.
const qualified = linkedIssuesFor("Fixes owner/direct#42");
expect(qualified.linkedIssues).toContain(42);
expect(qualified.findings.map((f) => f.code)).not.toContain("missing_linked_issue");

// …case-insensitively on owner/repo.
expect(linkedIssuesFor("Resolves Owner/Direct#42").linkedIssues).toContain(42);

// A cross-repo reference closes an issue elsewhere and must NOT spoof a same-repo link.
const crossRepo = linkedIssuesFor("Fixes other-org/other#42");
expect(crossRepo.linkedIssues).not.toContain(42);
expect(crossRepo.findings.map((f) => f.code)).toContain("missing_linked_issue");

// The bare `#N` form and word-boundary guard (#1988) are unchanged: `unfixes` is not a keyword.
expect(linkedIssuesFor("Closes #42").linkedIssues).toContain(42);
expect(linkedIssuesFor("unfixes owner/direct#42").linkedIssues).not.toContain(42);
});

it("covers issue quality, burden, bounties, noise, and reviewability edge decisions", () => {
const directRepo = repo("owner/direct");
const issueRepo = repo("owner/issues", { issueDiscoveryShare: 1 });
Expand Down
Loading