Skip to content

Commit bf12935

Browse files
committed
fix(portfolio): key the queue dashboard per-repo map by (apiBaseUrl, repoFullName)
collectPortfolioDashboard grouped queue rows into perRepo keyed purely by repoFullName, so two forge hosts sharing a repo name (e.g. the same repo on api.github.com and a self-hosted GHE) had their independent backlogs silently merged into one entry with summed counts — a data-loss bug the --json output couldn't recover either, since the host was discarded before either output format saw it. Key perRepo by the composite (apiBaseUrl, repoFullName) and carry apiBaseUrl on each repo entry so the two hosts stay separate with correct, non-merged counts. renderPortfolioDashboardTable gains a host column, and renderQueueTable gains one too so a reader of `queue list`'s plain-text output can supply the --api-base-url a follow-up done/release/requeue needs to disambiguate two rows sharing a repo+identifier across hosts. Single-host installs render identically modulo the new column/field. Closes #7225
1 parent 0959d4b commit bf12935

5 files changed

Lines changed: 82 additions & 8 deletions

File tree

packages/loopover-miner/lib/portfolio-dashboard.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export interface PortfolioRepoSummary {
2+
apiBaseUrl: string;
23
repoFullName: string;
34
byStatus: { queued: number; in_progress: number; done: number };
45
total: number;

packages/loopover-miner/lib/portfolio-dashboard.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,17 @@ export function collectPortfolioDashboard(sources, options = {}) {
3636
const status = entry?.status;
3737
if (!QUEUE_STATUS_KEYS.includes(status)) continue;
3838
const repoFullName = typeof entry.repoFullName === "string" ? entry.repoFullName : "";
39+
// #7225: key per-repo backlogs by (apiBaseUrl, repoFullName) so two forge hosts sharing a repo name keep
40+
// independent counts instead of silently merging. The composite map key uses "\n" — never valid in either
41+
// component — so distinct (host, repo) pairs can never collide.
42+
const apiBaseUrl = typeof entry.apiBaseUrl === "string" ? entry.apiBaseUrl : "";
3943
total += 1;
4044
byStatus[status] += 1;
41-
let repo = perRepo.get(repoFullName);
45+
const key = `${apiBaseUrl}\n${repoFullName}`;
46+
let repo = perRepo.get(key);
4247
if (!repo) {
43-
repo = { repoFullName, byStatus: emptyCounts(), total: 0 };
44-
perRepo.set(repoFullName, repo);
48+
repo = { apiBaseUrl, repoFullName, byStatus: emptyCounts(), total: 0 };
49+
perRepo.set(key, repo);
4550
}
4651
repo.byStatus[status] += 1;
4752
repo.total += 1;
@@ -51,7 +56,9 @@ export function collectPortfolioDashboard(sources, options = {}) {
5156
}
5257
}
5358

54-
const repos = [...perRepo.values()].sort((left, right) => left.repoFullName.localeCompare(right.repoFullName));
59+
const repos = [...perRepo.values()].sort(
60+
(left, right) => left.repoFullName.localeCompare(right.repoFullName) || left.apiBaseUrl.localeCompare(right.apiBaseUrl),
61+
);
5562
const oldestQueuedAgeMs = nowMs !== null && oldestQueuedMs !== null ? Math.max(0, nowMs - oldestQueuedMs) : null;
5663
return { total, byStatus, repos, oldestQueuedAgeMs };
5764
}
@@ -60,10 +67,11 @@ export function collectPortfolioDashboard(sources, options = {}) {
6067
export function renderPortfolioDashboardTable(summary) {
6168
if (!summary || summary.total === 0) return "portfolio queue is empty";
6269
const age = summary.oldestQueuedAgeMs !== null ? ` oldest-queued: ${Math.round(summary.oldestQueuedAgeMs / 60000)}m` : "";
63-
const header = ["repo".padEnd(28), "queued".padStart(7), "in_prog".padStart(8), "done".padStart(6), "total".padStart(6)].join(" ");
70+
const header = ["repo".padEnd(28), "host".padEnd(30), "queued".padStart(7), "in_prog".padStart(8), "done".padStart(6), "total".padStart(6)].join(" ");
6471
const lines = summary.repos.map((repo) =>
6572
[
6673
repo.repoFullName.padEnd(28),
74+
String(repo.apiBaseUrl).padEnd(30),
6775
String(repo.byStatus.queued).padStart(7),
6876
String(repo.byStatus.in_progress).padStart(8),
6977
String(repo.byStatus.done).padStart(6),

packages/loopover-miner/lib/portfolio-queue-cli.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ export function renderQueueTable(entries) {
202202
const header = [
203203
"repo".padEnd(24),
204204
"identifier".padEnd(16),
205+
// #7225: surface the host so a reader of the plain-text table can supply the `--api-base-url` a follow-up
206+
// done/release/requeue needs to disambiguate two rows sharing a repo+identifier across forge hosts.
207+
"host".padEnd(30),
205208
"status".padEnd(12),
206209
"pri".padStart(4),
207210
"enqueued-at".padEnd(24),
@@ -210,6 +213,7 @@ export function renderQueueTable(entries) {
210213
[
211214
entry.repoFullName.padEnd(24),
212215
entry.identifier.padEnd(16),
216+
display(entry.apiBaseUrl).padEnd(30),
213217
entry.status.padEnd(12),
214218
display(entry.priority).padStart(4),
215219
display(entry.enqueuedAt).padEnd(24),

test/unit/miner-portfolio-dashboard.test.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,31 @@ describe("collectPortfolioDashboard (#4287)", () => {
3535
expect(summary.total).toBe(7);
3636
expect(summary.byStatus).toEqual({ queued: 5, in_progress: 1, done: 1 });
3737
expect(summary.repos.map((r) => r.repoFullName)).toEqual(["", "acme/a", "acme/b"]); // sorted
38-
expect(summary.repos.find((r) => r.repoFullName === "acme/a")).toEqual({ repoFullName: "acme/a", byStatus: { queued: 2, in_progress: 0, done: 1 }, total: 3 });
38+
expect(summary.repos.find((r) => r.repoFullName === "acme/a")).toEqual({ apiBaseUrl: "", repoFullName: "acme/a", byStatus: { queued: 2, in_progress: 0, done: 1 }, total: 3 });
3939
// oldest queued is acme/a's 2026-07-01 → 9 days before NOW
4040
expect(summary.oldestQueuedAgeMs).toBe(9 * 24 * 60 * 60 * 1000);
4141
});
4242

43+
it("keeps two forge hosts sharing a repo name as distinct, non-merged per-repo entries (#7225)", () => {
44+
const summary = collectPortfolioDashboard(
45+
{
46+
portfolioQueue: mockQueue([
47+
{ apiBaseUrl: "https://api.github.com", repoFullName: "acme/widgets", status: "queued", enqueuedAt: "2026-07-01T00:00:00.000Z" },
48+
{ apiBaseUrl: "https://api.github.com", repoFullName: "acme/widgets", status: "queued", enqueuedAt: "2026-07-02T00:00:00.000Z" },
49+
{ apiBaseUrl: "https://ghe.example.com/api/v3", repoFullName: "acme/widgets", status: "done", enqueuedAt: "2026-07-03T00:00:00.000Z" },
50+
]),
51+
},
52+
{ nowMs: NOW },
53+
);
54+
// Same repoFullName across two hosts → two entries, tie-broken by apiBaseUrl (github.com before ghe.example.com).
55+
expect(summary.repos).toEqual([
56+
{ apiBaseUrl: "https://api.github.com", repoFullName: "acme/widgets", byStatus: { queued: 2, in_progress: 0, done: 0 }, total: 2 },
57+
{ apiBaseUrl: "https://ghe.example.com/api/v3", repoFullName: "acme/widgets", byStatus: { queued: 0, in_progress: 0, done: 1 }, total: 1 },
58+
]);
59+
// The two hosts' backlogs stay independent instead of collapsing into one { queued: 2, done: 1, total: 3 } entry.
60+
expect(summary.total).toBe(3);
61+
});
62+
4363
it("reports a null oldest-queued age when no clock is supplied, and when nothing is queued", () => {
4464
const entries = [{ repoFullName: "a/b", status: "queued", enqueuedAt: "2026-07-01T00:00:00.000Z" }];
4565
expect(collectPortfolioDashboard({ portfolioQueue: mockQueue(entries) }).oldestQueuedAgeMs).toBeNull(); // no nowMs
@@ -56,13 +76,28 @@ describe("renderPortfolioDashboardTable (#4287)", () => {
5676
});
5777

5878
it("renders totals, per-repo rows, and the oldest-queued age when present", () => {
59-
const withAge = renderPortfolioDashboardTable({ total: 2, byStatus: { queued: 2, in_progress: 0, done: 0 }, repos: [{ repoFullName: "acme/a", byStatus: { queued: 2, in_progress: 0, done: 0 }, total: 2 }], oldestQueuedAgeMs: 3_600_000 });
79+
const withAge = renderPortfolioDashboardTable({ total: 2, byStatus: { queued: 2, in_progress: 0, done: 0 }, repos: [{ apiBaseUrl: "https://api.github.com", repoFullName: "acme/a", byStatus: { queued: 2, in_progress: 0, done: 0 }, total: 2 }], oldestQueuedAgeMs: 3_600_000 });
6080
expect(withAge).toContain("total: 2");
6181
expect(withAge).toContain("oldest-queued: 60m");
6282
expect(withAge).toContain("acme/a");
63-
const noAge = renderPortfolioDashboardTable({ total: 1, byStatus: { queued: 0, in_progress: 1, done: 0 }, repos: [{ repoFullName: "acme/a", byStatus: { queued: 0, in_progress: 1, done: 0 }, total: 1 }], oldestQueuedAgeMs: null });
83+
const noAge = renderPortfolioDashboardTable({ total: 1, byStatus: { queued: 0, in_progress: 1, done: 0 }, repos: [{ apiBaseUrl: "https://api.github.com", repoFullName: "acme/a", byStatus: { queued: 0, in_progress: 1, done: 0 }, total: 1 }], oldestQueuedAgeMs: null });
6484
expect(noAge).not.toContain("oldest-queued");
6585
});
86+
87+
it("shows a host column so two same-named repos on different forge hosts are distinguishable (#7225)", () => {
88+
const table = renderPortfolioDashboardTable({
89+
total: 2,
90+
byStatus: { queued: 2, in_progress: 0, done: 0 },
91+
repos: [
92+
{ apiBaseUrl: "https://api.github.com", repoFullName: "acme/widgets", byStatus: { queued: 1, in_progress: 0, done: 0 }, total: 1 },
93+
{ apiBaseUrl: "https://ghe.example.com/api/v3", repoFullName: "acme/widgets", byStatus: { queued: 1, in_progress: 0, done: 0 }, total: 1 },
94+
],
95+
oldestQueuedAgeMs: null,
96+
});
97+
expect(table).toContain("host");
98+
expect(table).toContain("https://api.github.com");
99+
expect(table).toContain("https://ghe.example.com/api/v3");
100+
});
66101
});
67102

68103
describe("parsePortfolioDashboardArgs (#4287)", () => {

test/unit/miner-portfolio-queue-cli.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,32 @@ describe("loopover-miner portfolio queue CLI (#2292)", () => {
7878
expect(renderQueueTable(entries)).toContain("issue:7");
7979
});
8080

81+
it("renderQueueTable distinguishes two same-repo/identifier rows on different forge hosts (#7225)", () => {
82+
const entries: QueueEntry[] = [
83+
{
84+
apiBaseUrl: "https://api.github.com",
85+
repoFullName: "acme/widgets",
86+
identifier: "issue:1",
87+
status: "queued",
88+
priority: 10,
89+
enqueuedAt: "2026-07-04T12:00:00.000Z",
90+
},
91+
{
92+
apiBaseUrl: "https://ghe.example.com/api/v3",
93+
repoFullName: "acme/widgets",
94+
identifier: "issue:1",
95+
status: "in_progress",
96+
priority: 10,
97+
enqueuedAt: "2026-07-04T12:00:00.000Z",
98+
},
99+
];
100+
const table = renderQueueTable(entries);
101+
// Both rows share repo+identifier, so only the host column tells them apart for a `--api-base-url` follow-up.
102+
expect(table).toContain("host");
103+
expect(table).toContain("https://api.github.com");
104+
expect(table).toContain("https://ghe.example.com/api/v3");
105+
});
106+
81107
it("runQueueList prints table and JSON output", () => {
82108
const portfolioQueue = tempQueueStore();
83109
portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:1", priority: 10 });

0 commit comments

Comments
 (0)