From aceb0293beb825ba72998d15d888d01abb0f5f80 Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:28:43 -0500 Subject: [PATCH] feat(miner-portfolio): read-only portfolio dashboard view (#4287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add packages/gittensory-miner/lib/portfolio-dashboard.js: a read-only aggregate view of the miner's OWN local portfolio-queue backlog, in the same three-layer shape as manage-status.js (pure collect -> pure render -> thin CLI glue) but scoped to the queue rather than per-PR manage state. - collectPortfolioDashboard(sources, { nowMs }): a pure aggregator over an injected portfolio-queue store (mirrors collectManageStatus). Read-only — never mutates queue state. Returns counts by status (queued/in_progress/done) globally and per repo, plus the oldest queued item's age when a clock is given. - renderPortfolioDashboardTable + a --json path (mirrors renderManageStatusTable). - runPortfolioDashboard: CLI glue wired as 'gittensory-miner queue dashboard' (one new case in runQueueCli's dispatch, alongside list/next/done). The extension-panel half is a forward dependency, not delivered here: the miner's queue is a local SQLite file with no local-reachable channel a GitHub-page content script can read today; the pure collector is factored to be reusable once one exists. Adds the hand-written .d.ts. Closes #4287 --- .../lib/portfolio-dashboard.d.ts | 30 +++++ .../lib/portfolio-dashboard.js | 105 ++++++++++++++++++ .../lib/portfolio-queue-cli.js | 2 + test/unit/miner-portfolio-dashboard.test.ts | 90 +++++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 packages/gittensory-miner/lib/portfolio-dashboard.d.ts create mode 100644 packages/gittensory-miner/lib/portfolio-dashboard.js create mode 100644 test/unit/miner-portfolio-dashboard.test.ts diff --git a/packages/gittensory-miner/lib/portfolio-dashboard.d.ts b/packages/gittensory-miner/lib/portfolio-dashboard.d.ts new file mode 100644 index 0000000000..2e39565e44 --- /dev/null +++ b/packages/gittensory-miner/lib/portfolio-dashboard.d.ts @@ -0,0 +1,30 @@ +export interface PortfolioRepoSummary { + repoFullName: string; + byStatus: { queued: number; in_progress: number; done: number }; + total: number; +} + +export interface PortfolioDashboardSummary { + total: number; + byStatus: { queued: number; in_progress: number; done: number }; + repos: PortfolioRepoSummary[]; + oldestQueuedAgeMs: number | null; +} + +export interface PortfolioDashboardSources { + portfolioQueue: { listQueue(repoFullName?: string | null): unknown[] }; +} + +export function collectPortfolioDashboard( + sources: PortfolioDashboardSources, + options?: { nowMs?: number }, +): PortfolioDashboardSummary; + +export function renderPortfolioDashboardTable(summary: PortfolioDashboardSummary | null | undefined): string; + +export function parsePortfolioDashboardArgs(args?: string[]): { json: boolean } | { error: string }; + +export function runPortfolioDashboard( + args?: string[], + options?: { initPortfolioQueue?: () => { listQueue(repoFullName: string | null): unknown[]; close(): void }; nowMs?: number }, +): number; diff --git a/packages/gittensory-miner/lib/portfolio-dashboard.js b/packages/gittensory-miner/lib/portfolio-dashboard.js new file mode 100644 index 0000000000..05fdf61a0a --- /dev/null +++ b/packages/gittensory-miner/lib/portfolio-dashboard.js @@ -0,0 +1,105 @@ +// Read-only portfolio-queue dashboard (#4287). Aggregates the miner's OWN local portfolio-queue backlog +// (packages/gittensory-miner/lib/portfolio-queue.js) into summary stats — counts by status globally and per repo, +// plus the oldest queued item's age. Same three-layer shape as manage-status.js (pure collect → pure render → thin +// CLI glue), but scoped to the backlog/queue rather than per-PR manage state. 100% client-side, read-only — it never +// mutates queue state and never gates or enforces anything. +// +// The extension-panel half named in the issue is a forward dependency, not delivered here: the miner's queue is a +// local SQLite file with no local-reachable channel a GitHub-page content script can read today. The pure +// collector below is factored so it is directly reusable once such a channel exists. + +import { initPortfolioQueueStore } from "./portfolio-queue.js"; + +const QUEUE_STATUS_KEYS = ["queued", "in_progress", "done"]; + +function emptyCounts() { + return { queued: 0, in_progress: 0, done: 0 }; +} + +/** + * Pure aggregator over an injected portfolio-queue store (mirrors manage-status.js's `collectManageStatus`). + * Read-only. Returns global + per-repo status counts and, when a clock is supplied via `options.nowMs`, the age in + * ms of the oldest still-`queued` item (null when no clock is given or nothing is queued). + */ +export function collectPortfolioDashboard(sources, options = {}) { + const portfolioQueue = sources?.portfolioQueue; + if (!portfolioQueue || typeof portfolioQueue.listQueue !== "function") throw new Error("invalid_portfolio_queue"); + const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : null; + + const byStatus = emptyCounts(); + const perRepo = new Map(); + let total = 0; + let oldestQueuedMs = null; + + for (const entry of portfolioQueue.listQueue(null)) { + const status = entry?.status; + if (!QUEUE_STATUS_KEYS.includes(status)) continue; + const repoFullName = typeof entry.repoFullName === "string" ? entry.repoFullName : ""; + total += 1; + byStatus[status] += 1; + let repo = perRepo.get(repoFullName); + if (!repo) { + repo = { repoFullName, byStatus: emptyCounts(), total: 0 }; + perRepo.set(repoFullName, repo); + } + repo.byStatus[status] += 1; + repo.total += 1; + if (status === "queued") { + const ms = Date.parse(entry.enqueuedAt); + if (Number.isFinite(ms) && (oldestQueuedMs === null || ms < oldestQueuedMs)) oldestQueuedMs = ms; + } + } + + const repos = [...perRepo.values()].sort((left, right) => left.repoFullName.localeCompare(right.repoFullName)); + const oldestQueuedAgeMs = nowMs !== null && oldestQueuedMs !== null ? Math.max(0, nowMs - oldestQueuedMs) : null; + return { total, byStatus, repos, oldestQueuedAgeMs }; +} + +/** Plain-text render of a dashboard summary (mirrors manage-status.js's `renderManageStatusTable`). */ +export function renderPortfolioDashboardTable(summary) { + if (!summary || summary.total === 0) return "portfolio queue is empty"; + const age = summary.oldestQueuedAgeMs !== null ? ` oldest-queued: ${Math.round(summary.oldestQueuedAgeMs / 60000)}m` : ""; + const header = ["repo".padEnd(28), "queued".padStart(7), "in_prog".padStart(8), "done".padStart(6), "total".padStart(6)].join(" "); + const lines = summary.repos.map((repo) => + [ + repo.repoFullName.padEnd(28), + String(repo.byStatus.queued).padStart(7), + String(repo.byStatus.in_progress).padStart(8), + String(repo.byStatus.done).padStart(6), + String(repo.total).padStart(6), + ].join(" "), + ); + return [ + `total: ${summary.total} queued: ${summary.byStatus.queued} in_progress: ${summary.byStatus.in_progress} done: ${summary.byStatus.done}${age}`, + "", + header, + ...lines, + ].join("\n"); +} + +export function parsePortfolioDashboardArgs(args = []) { + for (const token of args) { + if (token === "--json") continue; + if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; + return { error: "Usage: gittensory-miner queue dashboard [--json]" }; + } + return { json: args.includes("--json") }; +} + +/** CLI glue for `gittensory-miner queue dashboard [--json]` (mirrors manage-status.js's `runManageStatus`). */ +export function runPortfolioDashboard(args = [], options = {}) { + const parsed = parsePortfolioDashboardArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + const ownsQueue = options.initPortfolioQueue === undefined; + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + try { + const summary = collectPortfolioDashboard({ portfolioQueue }, { nowMs: Number.isFinite(options.nowMs) ? options.nowMs : Date.now() }); + console.log(parsed.json ? JSON.stringify(summary, null, 2) : renderPortfolioDashboardTable(summary)); + return 0; + } finally { + if (ownsQueue) portfolioQueue.close(); + } +} diff --git a/packages/gittensory-miner/lib/portfolio-queue-cli.js b/packages/gittensory-miner/lib/portfolio-queue-cli.js index 3214d5869f..2492fb32df 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-cli.js +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.js @@ -1,4 +1,5 @@ import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import { runPortfolioDashboard } from "./portfolio-dashboard.js"; const QUEUE_LIST_USAGE = "Usage: gittensory-miner queue list [--repo ] [--json]"; const QUEUE_NEXT_USAGE = "Usage: gittensory-miner queue next [--json]"; @@ -210,6 +211,7 @@ export function runQueueCli(subcommand, args, options = {}) { if (subcommand === "list") return runQueueList(args, options); if (subcommand === "next") return runQueueNext(args, options); if (subcommand === "done") return runQueueDone(args, options); + if (subcommand === "dashboard") return runPortfolioDashboard(args, options); console.error(`Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); return 2; } diff --git a/test/unit/miner-portfolio-dashboard.test.ts b/test/unit/miner-portfolio-dashboard.test.ts new file mode 100644 index 0000000000..f55d11914b --- /dev/null +++ b/test/unit/miner-portfolio-dashboard.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + collectPortfolioDashboard, + parsePortfolioDashboardArgs, + renderPortfolioDashboardTable, + runPortfolioDashboard, +} from "../../packages/gittensory-miner/lib/portfolio-dashboard.js"; + +const mockQueue = (entries: unknown[]): { listQueue: () => unknown[]; close: () => void } => ({ listQueue: () => entries, close: () => {} }); +const NOW = Date.parse("2026-07-10T00:00:00.000Z"); + +afterEach(() => vi.restoreAllMocks()); + +describe("collectPortfolioDashboard (#4287)", () => { + it("throws when the injected portfolio queue is unusable", () => { + expect(() => collectPortfolioDashboard({} as never)).toThrow("invalid_portfolio_queue"); + }); + + it("aggregates counts by status globally and per repo, skipping unknown statuses and coercing a non-string repo", () => { + const summary = collectPortfolioDashboard( + { + portfolioQueue: mockQueue([ + { repoFullName: "acme/b", status: "queued", enqueuedAt: "2026-07-03T00:00:00.000Z" }, + { repoFullName: "acme/b", status: "in_progress", enqueuedAt: "2026-07-02T00:00:00.000Z" }, + { repoFullName: "acme/a", status: "queued", enqueuedAt: "2026-07-01T00:00:00.000Z" }, // earliest + { repoFullName: "acme/a", status: "queued", enqueuedAt: "2026-07-05T00:00:00.000Z" }, // later + { repoFullName: "acme/a", status: "done", enqueuedAt: "2026-07-04T00:00:00.000Z" }, + { status: "queued", enqueuedAt: "not-a-date" }, // missing repo → "", malformed date skipped for oldest + { repoFullName: 42, status: "queued", enqueuedAt: "2026-07-06T00:00:00.000Z" }, // non-string repo → "" + { repoFullName: "acme/a", status: "bogus" }, // unknown status → skipped entirely + ]), + }, + { nowMs: NOW }, + ); + expect(summary.total).toBe(7); + expect(summary.byStatus).toEqual({ queued: 5, in_progress: 1, done: 1 }); + expect(summary.repos.map((r) => r.repoFullName)).toEqual(["", "acme/a", "acme/b"]); // sorted + expect(summary.repos.find((r) => r.repoFullName === "acme/a")).toEqual({ repoFullName: "acme/a", byStatus: { queued: 2, in_progress: 0, done: 1 }, total: 3 }); + // oldest queued is acme/a's 2026-07-01 → 9 days before NOW + expect(summary.oldestQueuedAgeMs).toBe(9 * 24 * 60 * 60 * 1000); + }); + + it("reports a null oldest-queued age when no clock is supplied, and when nothing is queued", () => { + const entries = [{ repoFullName: "a/b", status: "queued", enqueuedAt: "2026-07-01T00:00:00.000Z" }]; + expect(collectPortfolioDashboard({ portfolioQueue: mockQueue(entries) }).oldestQueuedAgeMs).toBeNull(); // no nowMs + expect( + collectPortfolioDashboard({ portfolioQueue: mockQueue([{ repoFullName: "a/b", status: "done", enqueuedAt: "x" }]) }, { nowMs: NOW }).oldestQueuedAgeMs, + ).toBeNull(); // nothing queued + }); +}); + +describe("renderPortfolioDashboardTable (#4287)", () => { + it("renders the empty message for an empty (or missing) summary", () => { + expect(renderPortfolioDashboardTable({ total: 0, byStatus: { queued: 0, in_progress: 0, done: 0 }, repos: [], oldestQueuedAgeMs: null })).toBe("portfolio queue is empty"); + expect(renderPortfolioDashboardTable(null)).toBe("portfolio queue is empty"); + }); + + it("renders totals, per-repo rows, and the oldest-queued age when present", () => { + 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 }); + expect(withAge).toContain("total: 2"); + expect(withAge).toContain("oldest-queued: 60m"); + expect(withAge).toContain("acme/a"); + 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 }); + expect(noAge).not.toContain("oldest-queued"); + }); +}); + +describe("parsePortfolioDashboardArgs (#4287)", () => { + it("accepts --json, rejects unknown options and stray positionals", () => { + expect(parsePortfolioDashboardArgs([])).toEqual({ json: false }); + expect(parsePortfolioDashboardArgs(["--json"])).toEqual({ json: true }); + expect(parsePortfolioDashboardArgs(["--nope"])).toEqual({ error: expect.stringContaining("Unknown option") }); + expect(parsePortfolioDashboardArgs(["extra"])).toEqual({ error: expect.stringContaining("Usage: gittensory-miner queue dashboard") }); + }); +}); + +describe("runPortfolioDashboard (#4287)", () => { + it("prints a table (and --json) from the injected store, and errors on a bad arg", () => { + const store = mockQueue([{ repoFullName: "acme/a", status: "queued", enqueuedAt: "2026-07-09T00:00:00.000Z" }]); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect(runPortfolioDashboard([], { initPortfolioQueue: () => store, nowMs: NOW })).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toContain("acme/a"); + log.mockClear(); + expect(runPortfolioDashboard(["--json"], { initPortfolioQueue: () => store, nowMs: NOW })).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0])).total).toBe(1); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(runPortfolioDashboard(["--bad"], { initPortfolioQueue: () => store })).toBe(2); + expect(String(err.mock.calls[0]?.[0])).toContain("Unknown option"); + }); +});