Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/gittensory-miner/lib/portfolio-dashboard.d.ts
Original file line number Diff line number Diff line change
@@ -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;
105 changes: 105 additions & 0 deletions packages/gittensory-miner/lib/portfolio-dashboard.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
2 changes: 2 additions & 0 deletions packages/gittensory-miner/lib/portfolio-queue-cli.js
Original file line number Diff line number Diff line change
@@ -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 <owner/repo>] [--json]";
const QUEUE_NEXT_USAGE = "Usage: gittensory-miner queue next [--json]";
Expand Down Expand Up @@ -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;
}
90 changes: 90 additions & 0 deletions test/unit/miner-portfolio-dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});