diff --git a/src/signals/contributor-open-pr-monitor.ts b/src/signals/contributor-open-pr-monitor.ts index 30769503b..48ffe571c 100644 --- a/src/signals/contributor-open-pr-monitor.ts +++ b/src/signals/contributor-open-pr-monitor.ts @@ -58,7 +58,10 @@ export async function buildContributorOpenPrMonitor(env: Env, login: string): Pr const pendingScenarios: ContributorOpenPrMonitor["pendingScenarios"] = []; const packets: ContributorOpenPrNextStepPacket[] = []; - for (const [repoFullName, repoOpen] of byRepo.entries()) { + for (const repoOpen of byRepo.values()) { + // The bucket is keyed case-insensitively (see groupByRepo); use a PR's original repoFullName casing for + // the case-sensitive DB lookups below so the per-repo open-PR set stays whole and queries still resolve. + const repoFullName = repoOpen[0]!.repoFullName; const repo = repositories.find((entry) => entry.fullName.toLowerCase() === repoFullName.toLowerCase()) ?? null; const roleContext = buildRoleContext({ login, @@ -215,9 +218,13 @@ function buildMonitorGuidance(packets: ContributorOpenPrNextStepPacket[], cleanu function groupByRepo(pullRequests: PullRequestRecord[]): Map { const map = new Map(); for (const pr of pullRequests) { - const bucket = map.get(pr.repoFullName) ?? []; + // Key case-insensitively (GitHub repo names are case-insensitive), matching the registered-repo and + // repo-lookup handling elsewhere in buildContributorOpenPrMonitor — otherwise case-variant repoFullName + // values for one repo split into separate groups, under-counting open PRs and missing cross-case duplicates. + const key = pr.repoFullName.toLowerCase(); + const bucket = map.get(key) ?? []; bucket.push(pr); - map.set(pr.repoFullName, bucket); + map.set(key, bucket); } return map; } diff --git a/test/unit/contributor-open-pr-monitor.test.ts b/test/unit/contributor-open-pr-monitor.test.ts index 6edde2683..54311296d 100644 --- a/test/unit/contributor-open-pr-monitor.test.ts +++ b/test/unit/contributor-open-pr-monitor.test.ts @@ -184,6 +184,29 @@ describe("contributor open PR monitor", () => { expect(monitor.guidance.length).toBeGreaterThan(0); }); + it("groups case-variant repoFullName for one repo into a single open-PR set", async () => { + const env = createTestEnv(); + vi.spyOn(repositories, "listRepositories").mockResolvedValue([ + { fullName: "entrius/allways-ui", owner: "entrius", name: "allways-ui", isInstalled: true, isRegistered: true, isPrivate: false }, + ] as Awaited>); + // The same repo arrives under two casings — these must be one group, not two. + vi.spyOn(repositories, "listContributorPullRequests").mockResolvedValue([ + pr({ number: 30, repoFullName: "entrius/allways-ui" }), + pr({ number: 31, repoFullName: "Entrius/Allways-UI" }), + ]); + const listPrSpy = vi.spyOn(repositories, "listPullRequests").mockResolvedValue([pr({ number: 30 }), pr({ number: 31 })]); + vi.spyOn(repositories, "listPullRequestReviews").mockResolvedValue([]); + vi.spyOn(repositories, "listCheckSummaries").mockResolvedValue([]); + vi.spyOn(repositories, "listPullRequestFiles").mockResolvedValue([]); + + const monitor = await buildContributorOpenPrMonitor(env, "miner-a"); + expect(monitor.openPrCount).toBe(2); + // One merged group → the case-sensitive per-repo query runs only against a real repo casing, never the + // case-variant. Before the fix the two casings split into two groups and queried both. + expect(listPrSpy).toHaveBeenCalledWith(env, "entrius/allways-ui"); + expect(listPrSpy).not.toHaveBeenCalledWith(env, "Entrius/Allways-UI"); + }); + it("keeps public monitor output free of forbidden private language", async () => { const env = createTestEnv(); vi.spyOn(repositories, "listRepositories").mockResolvedValue([