From b17b954fd18fff234aa4a6273e32b7b4fa2ab0e1 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Sun, 12 Jul 2026 18:33:14 -0400 Subject: [PATCH] fix(miner-ui): reunify the portfolio view with the CLI's own richer dashboard (#4846) The portfolio view re-implemented its own narrower, global-only aggregation instead of reusing the CLI's own richer, per-repo `queue dashboard` output. The local API middleware now calls collectPortfolioDashboard directly (the same aggregator the CLI and the read-only MCP tool already use), and the view renders a per-repo table alongside the existing global summary cards. --- .../src/lib/portfolio-queue.ts | 44 ++++-- .../src/portfolio-queue.test.tsx | 148 +++++++++++++++--- .../src/routes/portfolio.tsx | 63 ++++++-- .../vite-portfolio-queue-api.ts | 61 +++++--- 4 files changed, 241 insertions(+), 75 deletions(-) diff --git a/apps/gittensory-miner-ui/src/lib/portfolio-queue.ts b/apps/gittensory-miner-ui/src/lib/portfolio-queue.ts index c0081a7432..0aa67614b3 100644 --- a/apps/gittensory-miner-ui/src/lib/portfolio-queue.ts +++ b/apps/gittensory-miner-ui/src/lib/portfolio-queue.ts @@ -1,5 +1,8 @@ -// Read-only client for the local portfolio-queue API (#4306). The view only needs summary cards, so the -// middleware returns pre-aggregated counts and never republishes raw queue identifiers or priority metadata. +// Read-only client for the local portfolio-queue API (#4306, richer per-repo detail added by #4846). The +// middleware now serves the SAME per-repo dashboard shape the CLI's `queue dashboard` command computes +// (packages/gittensory-miner/lib/portfolio-dashboard.js's collectPortfolioDashboard) instead of a narrower +// global-only aggregate, so the miner-ui and the CLI share one data path rather than maintaining two. It still +// never republishes raw queue identifiers or rank-derived priorities — only status counts, grouped by repo. export const PORTFOLIO_QUEUE_API_PATH = "/api/portfolio-queue"; @@ -9,9 +12,17 @@ export type QueueStatus = (typeof QUEUE_STATUSES)[number]; export type QueueStatusCounts = Record; +export type PortfolioRepoSummary = { + repoFullName: string; + byStatus: QueueStatusCounts; + total: number; +}; + export type PortfolioQueueSummary = { total: number; - counts: QueueStatusCounts; + byStatus: QueueStatusCounts; + repos: PortfolioRepoSummary[]; + oldestQueuedAgeMs: number | null; }; export type PortfolioQueueResult = { ok: true; summary: PortfolioQueueSummary } | { ok: false; error: string }; @@ -22,26 +33,31 @@ function isQueueStatusCounts(value: unknown): value is QueueStatusCounts { return QUEUE_STATUSES.every((status) => typeof counts[status] === "number"); } +function isPortfolioRepoSummary(value: unknown): value is PortfolioRepoSummary { + if (typeof value !== "object" || value === null) return false; + const repo = value as Record; + return typeof repo.repoFullName === "string" && isQueueStatusCounts(repo.byStatus) && typeof repo.total === "number"; +} + function isPortfolioQueueSummary(value: unknown): value is PortfolioQueueSummary { if (typeof value !== "object" || value === null) return false; const summary = value as Record; - return typeof summary.total === "number" && isQueueStatusCounts(summary.counts); + return ( + typeof summary.total === "number" && + isQueueStatusCounts(summary.byStatus) && + Array.isArray(summary.repos) && + summary.repos.every(isPortfolioRepoSummary) && + (summary.oldestQueuedAgeMs === null || typeof summary.oldestQueuedAgeMs === "number") + ); } export const emptyPortfolioQueueSummary = (): PortfolioQueueSummary => ({ total: 0, - counts: { queued: 0, in_progress: 0, done: 0 }, + byStatus: { queued: 0, in_progress: 0, done: 0 }, + repos: [], + oldestQueuedAgeMs: null, }); -export function summarizePortfolioQueueStatuses(statuses: QueueStatus[]): PortfolioQueueSummary { - const summary = emptyPortfolioQueueSummary(); - for (const status of statuses) { - summary.total += 1; - summary.counts[status] += 1; - } - return summary; -} - /** Fetch the local queue summary; failures surface as a typed error result the view renders, never a crash. */ export async function fetchPortfolioQueue(fetchImpl: typeof fetch = fetch): Promise { try { diff --git a/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx b/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx index 6475aa2e5c..67d53bcd58 100644 --- a/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx +++ b/apps/gittensory-miner-ui/src/portfolio-queue.test.tsx @@ -5,15 +5,20 @@ import { emptyPortfolioQueueSummary, fetchPortfolioQueue, PORTFOLIO_QUEUE_API_PATH, - summarizePortfolioQueueStatuses, type PortfolioQueueResult, + type PortfolioQueueSummary, } from "./lib/portfolio-queue"; import { PortfolioPage, PortfolioQueueView } from "./routes/portfolio"; import { handlePortfolioQueueRequest, type PortfolioQueueApiDeps } from "../vite-portfolio-queue-api"; -const fixtureSummary = { +const fixtureSummary: PortfolioQueueSummary = { total: 4, - counts: { queued: 2, in_progress: 1, done: 1 }, + byStatus: { queued: 2, in_progress: 1, done: 1 }, + repos: [ + { repoFullName: "acme/another-repo", byStatus: { queued: 1, in_progress: 0, done: 1 }, total: 2 }, + { repoFullName: "acme/secret-repo", byStatus: { queued: 1, in_progress: 1, done: 0 }, total: 2 }, + ], + oldestQueuedAgeMs: 5_400_000, }; const rawQueueRows = [ @@ -47,30 +52,39 @@ const rawQueueRows = [ }, ]; -describe("summarizePortfolioQueueStatuses (#4306)", () => { - it("counts queue statuses without retaining row identifiers", () => { - expect(summarizePortfolioQueueStatuses(["queued", "in_progress", "done", "queued"])).toEqual(fixtureSummary); - }); - - it("summarizes an empty queue to zeros", () => { +describe("emptyPortfolioQueueSummary (#4306)", () => { + it("summarizes an empty queue to zeros with no repos", () => { expect(emptyPortfolioQueueSummary()).toEqual({ total: 0, - counts: { queued: 0, in_progress: 0, done: 0 }, + byStatus: { queued: 0, in_progress: 0, done: 0 }, + repos: [], + oldestQueuedAgeMs: null, }); }); }); -describe("PortfolioQueueView (#4306)", () => { - it("renders one card per status with the aggregated counts", () => { +describe("PortfolioQueueView (#4306, per-repo detail added by #4846)", () => { + it("renders one card per status with the aggregated global counts", () => { render(); - expect(screen.getByText("Queued").nextSibling?.textContent).toBe("2"); - expect(screen.getByText("In progress").nextSibling?.textContent).toBe("1"); - expect(screen.getByText("Done").nextSibling?.textContent).toBe("1"); + // Scoped to
since the per-repo table below also has "Queued"/"In progress"/"Done" column headers. + expect(screen.getByText("Queued", { selector: "dt" }).nextSibling?.textContent).toBe("2"); + expect(screen.getByText("In progress", { selector: "dt" }).nextSibling?.textContent).toBe("1"); + expect(screen.getByText("Done", { selector: "dt" }).nextSibling?.textContent).toBe("1"); + }); + + it("renders one table row per repo with its own status breakdown and total", () => { + render(); + expect(screen.getByRole("columnheader", { name: "Repository" })).toBeTruthy(); + expect(screen.getByText("acme/another-repo")).toBeTruthy(); + expect(screen.getByText("acme/secret-repo")).toBeTruthy(); + // header + 2 repo rows + expect(screen.getAllByRole("row")).toHaveLength(3); }); it("renders the fresh-install empty state without erroring", () => { render(); expect(screen.getByText(/No queued work yet/i)).toBeTruthy(); + expect(screen.queryByRole("table")).toBeNull(); }); it("renders an error message when the local API is unreachable", () => { @@ -89,7 +103,7 @@ describe("PortfolioPage (#4306)", () => { const loadPortfolioQueue = async (): Promise => ({ ok: true, summary: fixtureSummary }); render(); expect(screen.getByRole("heading", { name: "Portfolio queue" })).toBeTruthy(); - await waitFor(() => expect(screen.getByText("Queued").nextSibling?.textContent).toBe("2")); + await waitFor(() => expect(screen.getByText("Queued", { selector: "dt" }).nextSibling?.textContent).toBe("2")); }); }); @@ -116,7 +130,19 @@ describe("fetchPortfolioQueue (#4306)", () => { ok: false, }); expect( - await fetchPortfolioQueue(async () => jsonResponse(200, { summary: { total: 1, counts: { queued: "1" } } })), + await fetchPortfolioQueue(async () => jsonResponse(200, { summary: { total: 1, byStatus: { queued: "1" } } })), + ).toMatchObject({ ok: false }); + expect( + await fetchPortfolioQueue(async () => + jsonResponse(200, { + summary: { + total: 1, + byStatus: { queued: 1, in_progress: 0, done: 0 }, + repos: "nope", + oldestQueuedAgeMs: null, + }, + }), + ), ).toMatchObject({ ok: false }); expect( await fetchPortfolioQueue(async () => { @@ -126,23 +152,88 @@ describe("fetchPortfolioQueue (#4306)", () => { }); }); -describe("handlePortfolioQueueRequest (#4306)", () => { +// Test-local re-implementation of collectPortfolioDashboard's aggregation, used only as the fake behind +// loadPortfolioDashboardModule below. The API handler tests here exercise WIRING (does the handler call +// listQueue and pass the right sources/nowMs into the dashboard aggregator, and does it serialize whatever +// comes back without leaking raw rows) -- the aggregation algorithm's own correctness (sorting, per-repo +// grouping, oldest-queued-age math, edge cases) is exhaustively covered by +// test/unit/miner-portfolio-dashboard.test.ts. The real portfolio-dashboard.js module cannot be imported +// directly here: it transitively pulls in `node:sqlite` via portfolio-queue.js, which this app's Vite +// client/test environment cannot bundle (the same reason the real handler loads it dynamically). +function fakeCollectPortfolioDashboard( + sources: { portfolioQueue: { listQueue: () => Array<{ repoFullName: string; status: string; enqueuedAt: string }> } }, + options: { nowMs: number }, +): PortfolioQueueSummary { + const byStatus = { queued: 0, in_progress: 0, done: 0 }; + const perRepo = new Map(); + let total = 0; + let oldestQueuedMs: number | null = null; + for (const entry of sources.portfolioQueue.listQueue()) { + const status = entry.status as "queued" | "in_progress" | "done"; + total += 1; + byStatus[status] += 1; + let repo = perRepo.get(entry.repoFullName); + if (!repo) { + repo = { repoFullName: entry.repoFullName, byStatus: { queued: 0, in_progress: 0, done: 0 }, total: 0 }; + perRepo.set(entry.repoFullName, repo); + } + repo.byStatus[status] += 1; + repo.total += 1; + if (status === "queued") { + const ms = Date.parse(entry.enqueuedAt); + if (oldestQueuedMs === null || ms < oldestQueuedMs) oldestQueuedMs = ms; + } + } + const repos = [...perRepo.values()].sort((a, b) => a.repoFullName.localeCompare(b.repoFullName)); + return { + total, + byStatus, + repos, + oldestQueuedAgeMs: oldestQueuedMs === null ? null : options.nowMs - oldestQueuedMs, + }; +} + +describe("handlePortfolioQueueRequest (#4306, reunified with the CLI's queue dashboard by #4846)", () => { const rows = rawQueueRows; + const NOW_MS = Date.parse("2026-07-10T07:00:00.000Z"); + function deps(overrides: Partial = {}): PortfolioQueueApiDeps { return { loadPortfolioQueueModule: async () => ({ resolvePortfolioQueueDbPath: () => "/home/miner/.config/gittensory-miner/portfolio-queue.sqlite3", listQueue: () => rows, }), + loadPortfolioDashboardModule: async () => ({ collectPortfolioDashboard: fakeCollectPortfolioDashboard }), fileExists: () => true, + now: () => NOW_MS, ...overrides, }; } - it("serves only aggregate counts and omits raw queue metadata", async () => { + it("serves the same per-repo dashboard shape the CLI's queue dashboard computes, with repo names but no raw identifiers or priorities", async () => { const handled = await handlePortfolioQueueRequest("GET", "/api/portfolio-queue", deps()); - expect(handled).toEqual({ status: 200, body: JSON.stringify({ summary: fixtureSummary }) }); - expect(handled?.body).not.toContain("private-org/secret-repo"); + expect(handled?.status).toBe(200); + const body = JSON.parse(handled?.body ?? "{}") as { summary: PortfolioQueueSummary }; + expect(body.summary).toEqual({ + total: 4, + byStatus: { queued: 2, in_progress: 1, done: 1 }, + repos: [ + { + repoFullName: "private-org/another-repo", + byStatus: { queued: 1, in_progress: 0, done: 1 }, + total: 2, + }, + { + repoFullName: "private-org/secret-repo", + byStatus: { queued: 1, in_progress: 1, done: 0 }, + total: 2, + }, + ], + oldestQueuedAgeMs: 5_400_000, + }); + // Repo names ARE exposed (matching the CLI's own dashboard, which already prints them locally), but + // per-item identifiers and rank-derived priorities never cross the wire. + expect(handled?.body).toContain("private-org/secret-repo"); expect(handled?.body).not.toContain("issue:12"); expect(handled?.body).not.toContain("priority"); }); @@ -184,4 +275,19 @@ describe("handlePortfolioQueueRequest (#4306)", () => { ); expect(handled).toEqual({ status: 500, body: JSON.stringify({ error: "sqlite locked" }) }); }); + + it("returns null oldestQueuedAgeMs when nothing is queued (only in_progress/done items present)", async () => { + const handled = await handlePortfolioQueueRequest( + "GET", + "/api/portfolio-queue", + deps({ + loadPortfolioQueueModule: async () => ({ + resolvePortfolioQueueDbPath: () => "/home/miner/.config/gittensory-miner/portfolio-queue.sqlite3", + listQueue: () => [rows[1]!, rows[2]!], // in_progress + done only, no queued row + }), + }), + ); + const body = JSON.parse(handled?.body ?? "{}") as { summary: PortfolioQueueSummary }; + expect(body.summary.oldestQueuedAgeMs).toBeNull(); + }); }); diff --git a/apps/gittensory-miner-ui/src/routes/portfolio.tsx b/apps/gittensory-miner-ui/src/routes/portfolio.tsx index bda0aa7d5d..babeeb5b68 100644 --- a/apps/gittensory-miner-ui/src/routes/portfolio.tsx +++ b/apps/gittensory-miner-ui/src/routes/portfolio.tsx @@ -2,6 +2,14 @@ import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { Card, CardContent, CardHeader } from "@jsonbored/gittensory-ui-kit/components/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@jsonbored/gittensory-ui-kit/components/table"; import { fetchPortfolioQueue, type PortfolioQueueResult, type QueueStatus } from "../lib/portfolio-queue"; @@ -9,8 +17,11 @@ export const Route = createFileRoute("/portfolio")({ component: PortfolioPage, }); -// Portfolio/queue summary cards (#4306): read-only counts by status over the local `miner_portfolio_queue` -// store. Same 4-state pattern as the run-history view (loading / error / fresh-install empty / populated). +// Portfolio/queue summary cards + per-repo table (#4306, reunified with the CLI's own richer `queue dashboard` +// by #4846): read-only counts by status over the local `miner_portfolio_queue` store, now broken out per repo +// exactly as `gittensory-miner queue dashboard` already shows -- the miner-ui no longer maintains a narrower, +// global-only aggregation. Same 4-state pattern as the run-history view (loading / error / fresh-install empty +// / populated). const STATUS_LABELS: Record = { queued: "Queued", @@ -46,18 +57,42 @@ export function PortfolioQueueView({ result }: { result: PortfolioQueueResult | ); } return ( -
- {(Object.keys(STATUS_LABELS) as QueueStatus[]).map((status) => ( - - -
{STATUS_LABELS[status]}
-
- {summary.counts[status]} -
-
-
- ))} -
+
+
+ {(Object.keys(STATUS_LABELS) as QueueStatus[]).map((status) => ( + + +
{STATUS_LABELS[status]}
+
+ {summary.byStatus[status]} +
+
+
+ ))} +
+ + + + Repository + Queued + In progress + Done + Total + + + + {summary.repos.map((repo) => ( + + {repo.repoFullName} + {repo.byStatus.queued} + {repo.byStatus.in_progress} + {repo.byStatus.done} + {repo.total} + + ))} + +
+
); } diff --git a/apps/gittensory-miner-ui/vite-portfolio-queue-api.ts b/apps/gittensory-miner-ui/vite-portfolio-queue-api.ts index b2245d9717..d2bbcf3a9f 100644 --- a/apps/gittensory-miner-ui/vite-portfolio-queue-api.ts +++ b/apps/gittensory-miner-ui/vite-portfolio-queue-api.ts @@ -4,53 +4,57 @@ import type { Plugin } from "vite"; // Local read-only portfolio-queue API (#4306) — the sibling of `vite-run-state-api.ts` (#4305), same shape for // the same reason: the dashboard is a browser app while the queue store is a `node:sqlite` file on disk, so the // dev server bridges the two by calling into `packages/gittensory-miner/lib/portfolio-queue.js`'s EXISTING -// exports (`resolvePortfolioQueueDbPath`/`listQueue`). It aggregates server-side so the HTTP surface never -// republishes raw queue identifiers or rank-derived priorities. +// exports (`resolvePortfolioQueueDbPath`/`listQueue`). +// +// Reunified with the CLI's own richer dashboard (#4846): the aggregation is now +// `packages/gittensory-miner/lib/portfolio-dashboard.js`'s `collectPortfolioDashboard` -- the SAME pure +// aggregator `gittensory-miner queue dashboard` and the read-only MCP tool already use -- instead of a +// narrower global-only re-implementation, so the miner-ui and the CLI share one data path. It still aggregates +// server-side so the HTTP surface never republishes raw queue identifiers or rank-derived priorities; only +// status counts, grouped globally and per repo, cross the wire. // // Same read-only fresh-install rule as the run-state endpoint: `listQueue()` lazily initializes the default // store, which would CREATE the SQLite file — so the handler probes the resolved DB path first and serves an // empty summary without ever touching the store when no DB exists yet. +import type { PortfolioDashboardSummary } from "../../packages/gittensory-miner/lib/portfolio-dashboard.js"; + type PortfolioQueueModule = { resolvePortfolioQueueDbPath: () => string; - listQueue: () => Array<{ status: string }>; + listQueue: (repoFullName?: string | null) => Array<{ repoFullName: string; status: string; enqueuedAt: string }>; +}; + +type PortfolioDashboardModule = { + collectPortfolioDashboard: ( + sources: { portfolioQueue: { listQueue: PortfolioQueueModule["listQueue"] } }, + options: { nowMs: number }, + ) => PortfolioDashboardSummary; }; export type PortfolioQueueApiDeps = { /** Import of `packages/gittensory-miner/lib/portfolio-queue.js` — injectable so tests never touch a real store. */ loadPortfolioQueueModule: () => Promise; + /** Import of `packages/gittensory-miner/lib/portfolio-dashboard.js` — dynamic for the same reason as + * `loadPortfolioQueueModule` (it transitively pulls in `node:sqlite` via portfolio-queue.js, which the UI's + * client-side test/build environment cannot bundle) and injectable so tests never touch a real store. */ + loadPortfolioDashboardModule: () => Promise; /** File-existence probe for the fresh-install fast path. */ fileExists: (path: string) => boolean; + /** Clock for `oldestQueuedAgeMs` — injectable so tests get deterministic ages. */ + now: () => number; }; -type QueueStatus = "queued" | "in_progress" | "done"; -type QueueStatusCounts = Record; -type PortfolioQueueSummary = { total: number; counts: QueueStatusCounts }; - -const QUEUE_STATUSES = ["queued", "in_progress", "done"] as const; - -function emptyPortfolioQueueSummary(): PortfolioQueueSummary { - return { total: 0, counts: { queued: 0, in_progress: 0, done: 0 } }; -} - -function isQueueStatus(value: string): value is QueueStatus { - return (QUEUE_STATUSES as readonly string[]).includes(value); -} - -function summarizePortfolioQueueRows(rows: Array<{ status: string }>): PortfolioQueueSummary { - const summary = emptyPortfolioQueueSummary(); - for (const row of rows) { - if (!isQueueStatus(row.status)) continue; - summary.total += 1; - summary.counts[row.status] += 1; - } - return summary; +function emptyPortfolioQueueSummary(): PortfolioDashboardSummary { + return { total: 0, byStatus: { queued: 0, in_progress: 0, done: 0 }, repos: [], oldestQueuedAgeMs: null }; } const defaultDeps: PortfolioQueueApiDeps = { loadPortfolioQueueModule: () => import("../../packages/gittensory-miner/lib/portfolio-queue.js") as Promise, + loadPortfolioDashboardModule: () => + import("../../packages/gittensory-miner/lib/portfolio-dashboard.js") as Promise, fileExists: existsSync, + now: () => Date.now(), }; /** Request handler factored out of the Vite plugin shape so tests drive it directly (mirrors the run-state API). */ @@ -65,7 +69,12 @@ export async function handlePortfolioQueueRequest( if (!deps.fileExists(queue.resolvePortfolioQueueDbPath())) { return { status: 200, body: JSON.stringify({ summary: emptyPortfolioQueueSummary() }) }; } - return { status: 200, body: JSON.stringify({ summary: summarizePortfolioQueueRows(queue.listQueue()) }) }; + const { collectPortfolioDashboard } = await deps.loadPortfolioDashboardModule(); + const summary = collectPortfolioDashboard( + { portfolioQueue: { listQueue: queue.listQueue } }, + { nowMs: deps.now() }, + ); + return { status: 200, body: JSON.stringify({ summary }) }; } catch (error) { const message = error instanceof Error ? error.message : "failed to read local portfolio queue"; return { status: 500, body: JSON.stringify({ error: message }) };