From 492aef32b9edb8e067e9f929235541f106b04af6 Mon Sep 17 00:00:00 2001 From: Jeff <158072326+jeffrey701@users.noreply.github.com> Date: Fri, 17 Jul 2026 05:07:38 -0400 Subject: [PATCH] fix(miner): scope the managed-PR queue row to the polled forge host --- packages/loopover-miner/lib/manage-poll.js | 23 +++++++++--- test/unit/miner-manage-poll.test.ts | 42 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/loopover-miner/lib/manage-poll.js b/packages/loopover-miner/lib/manage-poll.js index 0bde3714ba..52f8ca4bb4 100644 --- a/packages/loopover-miner/lib/manage-poll.js +++ b/packages/loopover-miner/lib/manage-poll.js @@ -5,6 +5,7 @@ import { formatManagedPrIdentifier, } from "./manage-status.js"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { resolveGitHubToken } from "./github-token-resolution.js"; @@ -105,13 +106,27 @@ export function parseManagePollArgs(args = []) { }; } -function ensureManagedPrRow(portfolioQueue, repoFullName, prNumber) { +/** The forge host a managed-PR row belongs to. Mirrors portfolio-queue-manager.js's own fold (and every + * store's `normalizeApiBaseUrl`): omitted/blank → the github.com default, so a single-forge caller is + * unaffected. Used only to COMPARE hosts here; `enqueue` still does its own normalization/validation. */ +function resolveManagedRowApiBaseUrl(apiBaseUrl) { + return typeof apiBaseUrl === "string" && apiBaseUrl.trim() ? apiBaseUrl.trim() : DEFAULT_FORGE_CONFIG.apiBaseUrl; +} + +function ensureManagedPrRow(portfolioQueue, repoFullName, prNumber, apiBaseUrl) { const identifier = formatManagedPrIdentifier(prNumber); + // `listQueue(repoFullName)` is forge-BLIND, so the existence check has to compare the host too: the queue's + // composite (api_base_url, repo_full_name, identifier) key exists precisely so two hosts serving the same + // owner/repo name never collide (#5563). Without this scoping, the same repo+PR-number already tracked on + // ANOTHER host suppresses this host's row entirely. + const targetApiBaseUrl = resolveManagedRowApiBaseUrl(apiBaseUrl); const exists = portfolioQueue .listQueue(repoFullName) - .some((entry) => entry.identifier === identifier); + .some((entry) => entry.identifier === identifier && resolveManagedRowApiBaseUrl(entry.apiBaseUrl) === targetApiBaseUrl); if (!exists) { - portfolioQueue.enqueue({ repoFullName, identifier, priority: 0 }); + // Thread the SAME apiBaseUrl the CI poll above used, so the row is scoped to the host it was polled from + // instead of silently defaulting to github.com. + portfolioQueue.enqueue({ repoFullName, identifier, priority: 0, apiBaseUrl }); } } @@ -155,7 +170,7 @@ export async function recordManagePollSnapshot(input, options = {}) { }); if ((options.ensurePortfolioRow ?? true) && portfolioQueue) { - ensureManagedPrRow(portfolioQueue, repoFullName, input.prNumber); + ensureManagedPrRow(portfolioQueue, repoFullName, input.prNumber, options.apiBaseUrl); } const event = eventLedger.appendEvent({ diff --git a/test/unit/miner-manage-poll.test.ts b/test/unit/miner-manage-poll.test.ts index 32f1ad283e..e3c7da62a7 100644 --- a/test/unit/miner-manage-poll.test.ts +++ b/test/unit/miner-manage-poll.test.ts @@ -23,6 +23,7 @@ import { closeDefaultPortfolioQueueStore, initPortfolioQueueStore, } from "../../packages/loopover-miner/lib/portfolio-queue.js"; +import { DEFAULT_FORGE_CONFIG } from "../../packages/loopover-miner/lib/forge-config.js"; const roots: string[] = []; const stores: Array<{ close(): void }> = []; @@ -138,6 +139,47 @@ describe("loopover-miner manage poll (#2323/#2325)", () => { ]); }); + it("REGRESSION: scopes the managed-PR queue row to the polled forge host instead of silently defaulting to github.com (#6764)", async () => { + const { portfolioQueue, eventLedger } = tempStores(); + const apiBaseUrl = "https://ghe.acme.example/api/v3"; + const pollCheckRuns = vi.fn().mockResolvedValue(pollResult("success")); + + await recordManagePollSnapshot( + { repoFullName: "acme/widgets", prNumber: 12, branch: "fix/ci" }, + { eventLedger, portfolioQueue, pollCheckRuns, githubToken: "token", apiBaseUrl }, + ); + + // The CI poll is already host-scoped two lines away; the queue row it ensures must match it. + expect(pollCheckRuns).toHaveBeenCalledWith("acme/widgets", 12, expect.objectContaining({ apiBaseUrl })); + expect(portfolioQueue.listQueue("acme/widgets")).toEqual([ + expect.objectContaining({ apiBaseUrl, identifier: "pr:12", status: "queued", priority: 0 }), + ]); + }); + + it("REGRESSION: a same-named repo+PR already tracked on ANOTHER forge host does not suppress this host's row (#6764)", async () => { + const { portfolioQueue, eventLedger } = tempStores(); + const otherHost = "https://ghe.acme.example/api/v3"; + // The SAME owner/repo + PR number, already queued against a DIFFERENT forge host. The queue's composite + // (api_base_url, repo_full_name, identifier) key exists precisely so these two never collide (#5563). + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "pr:12", priority: 0, apiBaseUrl: otherHost }); + + await recordManagePollSnapshot( + { repoFullName: "acme/widgets", prNumber: 12, branch: "fix/ci" }, + { + eventLedger, + portfolioQueue, + pollCheckRuns: vi.fn().mockResolvedValue(pollResult("success")), + githubToken: "token", + }, + ); + + // Before the fix, the forge-blind existence check matched the other host's row on identifier alone and + // skipped creating this host's row entirely. + const rows = portfolioQueue.listQueue("acme/widgets"); + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.apiBaseUrl).sort()).toEqual([DEFAULT_FORGE_CONFIG.apiBaseUrl, otherHost].sort()); + }); + it("runManagePoll prints summary and JSON output with injected stores", async () => { const { portfolioQueue, eventLedger } = tempStores(); const pollCheckRuns = vi.fn().mockResolvedValue(pollResult("success"));