diff --git a/packages/loopover-miner/lib/discover-cli.ts b/packages/loopover-miner/lib/discover-cli.ts index 2f24631a2..640d986d0 100644 --- a/packages/loopover-miner/lib/discover-cli.ts +++ b/packages/loopover-miner/lib/discover-cli.ts @@ -209,7 +209,12 @@ async function supplementWithDiscoveryIndex( const seen = new Set(fanOut.issues.map((issue) => dedupeKey(issue.repoFullName, issue.issueNumber))); const supplemented = aiAllowed - .filter((candidate) => !seen.has(dedupeKey(candidate.repoFullName, candidate.issueNumber))) + .filter((candidate) => { + const key = dedupeKey(candidate.repoFullName, candidate.issueNumber); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) // DiscoveryIndexCandidate is a near-superset of RawCandidateIssue; copy the real assignees through when the // hosted contract carried them (#7442), falling back to [] only when the served response genuinely omitted the // field — cast preserves pre-existing runtime shape rather than re-mapping. diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index 5b06f45ea..6560378ba 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -518,6 +518,42 @@ describe("runDiscover (#4247)", () => { ]); }); + it("REGRESSION (#10334): hosted discovery-index supplementation deduplicates repeated candidates", async () => { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue({ issueNumber: 1, title: "direct fan-out issue" })], + warnings: [], + rateLimitRemaining: 5000, + rateLimitResetAt: "2026-07-09T13:00:00.000Z", + })); + const queryDiscoveryIndex = vi.fn(async () => ({ + contractVersion: 1, + candidates: [ + indexCandidate({ issueNumber: 1, title: "stale hosted duplicate" }), + indexCandidate({ issueNumber: 2, title: "hosted issue" }), + indexCandidate({ issueNumber: 2, title: "hosted duplicate" }), + ], + nextCursor: null, + })); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const exitCode = await runDiscover(["acme/widgets", "--json"], { + nowMs: NOW, + env: { ...process.env, LOOPOVER_MINER_DISCOVERY_PLANE: "1" }, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), + fetchCandidateIssuesWithSummary, + queryDiscoveryIndex: queryDiscoveryIndex as never, + }); + + expect(exitCode).toBe(0); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload.ranked.map((e: { issueNumber: number }) => e.issueNumber).sort()).toEqual([1, 2]); + expect(portfolioQueue.listQueue("acme/widgets").map((e) => e.identifier).sort()).toEqual(["issue:1", "issue:2"]); + }); + it("REGRESSION (#7442): a discovery-index candidate assigned to its own repo owner is excluded once real assignees flow through", async () => { const portfolioQueue = tempQueueStore(); const fetchCandidateIssuesWithSummary = vi.fn(async () => ({