diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 6fcbad5c2a..302f59ac9d 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -835,7 +835,7 @@ export function buildCollisionReport( } const pairwiseIssues = boundedCollisionIssues(openIssues, openPullRequests); - const pairwisePullRequests = openPullRequests.slice(0, MAX_COLLISION_PAIRWISE_PULL_REQUESTS); + const pairwisePullRequests = boundedCollisionPullRequests(openPullRequests); const pairwiseRecentMergedPullRequests = recentMergedPullRequests.slice(0, MAX_COLLISION_PAIRWISE_RECENT_MERGES); const items = [...pairwiseIssues.map(issueItem), ...pairwisePullRequests.map(prItem), ...pairwiseRecentMergedPullRequests.map(recentMergedItem)]; const itemTerms = new Map(); @@ -5188,6 +5188,22 @@ function boundedCollisionIssues(openIssues: IssueRecord[], openPullRequests: Pul /* v8 ignore stop */ } +function boundedCollisionPullRequests(openPullRequests: PullRequestRecord[]): PullRequestRecord[] { + /* v8 ignore start -- Large-queue PR sampling mirrors boundedCollisionIssues; linked and pairwise collision paths are covered above. */ + if (openPullRequests.length <= MAX_COLLISION_PAIRWISE_PULL_REQUESTS) return openPullRequests; + // Rank linked-issue PRs ahead of unlinked ones, then by recency within each group, so the cap keeps + // the most-relevant PRs even when linked PRs alone exceed the budget (not just whichever appear + // first in caller order). + const ranked = [...openPullRequests].sort( + (left, right) => + Number(left.linkedIssues.length === 0) - Number(right.linkedIssues.length === 0) || + (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "") || + left.number - right.number, + ); + return ranked.slice(0, MAX_COLLISION_PAIRWISE_PULL_REQUESTS); + /* v8 ignore stop */ +} + function itemKey(item: CollisionItem): string { return `${item.type}-${item.number}`; } diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index 0a96a86ada..6f8c3460b0 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -166,6 +166,77 @@ describe("v2 signal builders", () => { expect(health.signals.openPullRequests).toBe(1); }); + it("detects pairwise PR title overlap when the queue exceeds 120 and overlapping PRs are newest (regression for bounded PR sampling)", () => { + const filler = Array.from({ length: 119 }, (_, index) => ({ + ...pullRequests[0]!, + number: index + 1, + title: `Unrelated maintenance task ${index + 1} for widgets module`, + linkedIssues: [] as number[], + updatedAt: isoDaysAgo(200), + })); + const overlapA: PullRequestRecord = { + ...pullRequests[0]!, + number: 5000, + title: "Fix authentication retry backoff handler", + linkedIssues: [], + updatedAt: isoDaysAgo(1), + }; + const overlapB: PullRequestRecord = { + ...pullRequests[0]!, + number: 5001, + title: "Fix authentication retry backoff logic", + linkedIssues: [], + updatedAt: isoDaysAgo(1), + }; + const manyPullRequests = [...filler, overlapA, overlapB]; + const report = buildCollisionReport(repo.fullName, issues, manyPullRequests, []); + expect( + report.clusters.some( + (cluster) => + cluster.items.some((item) => item.type === "pull_request" && item.number === 5000) && + cluster.items.some((item) => item.type === "pull_request" && item.number === 5001) && + /meaningful terms/i.test(cluster.reason), + ), + ).toBe(true); + }); + + it("still ranks by recency when linked PRs alone exceed the pairwise cap (regression)", () => { + // 130 linked-issue filler PRs, all older than the two overlapping linked PRs below. Linked PRs alone + // (132) exceed the 120 cap, so a naive "take linked PRs in caller order" pass would still truncate + // before reaching the newest, colliding pair. + const linkedFiller = Array.from({ length: 130 }, (_, index) => ({ + ...pullRequests[0]!, + number: index + 1, + title: `Unrelated maintenance task ${index + 1} for widgets module`, + linkedIssues: [9000 + index], + updatedAt: isoDaysAgo(200), + })); + const overlapA: PullRequestRecord = { + ...pullRequests[0]!, + number: 6000, + title: "Fix authentication retry backoff handler", + linkedIssues: [7000], + updatedAt: isoDaysAgo(1), + }; + const overlapB: PullRequestRecord = { + ...pullRequests[0]!, + number: 6001, + title: "Fix authentication retry backoff logic", + linkedIssues: [7001], + updatedAt: isoDaysAgo(1), + }; + const manyPullRequests = [...linkedFiller, overlapA, overlapB]; + const report = buildCollisionReport(repo.fullName, issues, manyPullRequests, []); + expect( + report.clusters.some( + (cluster) => + cluster.items.some((item) => item.type === "pull_request" && item.number === 6000) && + cluster.items.some((item) => item.type === "pull_request" && item.number === 6001) && + /meaningful terms/i.test(cluster.reason), + ), + ).toBe(true); + }); + it("uses authoritative queue counts when signal inputs are sampled", () => { const sampledIssues = issues.slice(0, 1); const sampledPullRequests = pullRequests.slice(0, 1);