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
44 changes: 30 additions & 14 deletions apps/gittensory-miner-ui/src/lib/portfolio-queue.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -9,9 +12,17 @@ export type QueueStatus = (typeof QUEUE_STATUSES)[number];

export type QueueStatusCounts = Record<QueueStatus, number>;

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 };
Expand All @@ -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<string, unknown>;
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<string, unknown>;
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<PortfolioQueueResult> {
try {
Expand Down
148 changes: 127 additions & 21 deletions apps/gittensory-miner-ui/src/portfolio-queue.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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(<PortfolioQueueView result={{ ok: true, summary: fixtureSummary }} />);
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 <dt> 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(<PortfolioQueueView result={{ ok: true, summary: fixtureSummary }} />);
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(<PortfolioQueueView result={{ ok: true, summary: emptyPortfolioQueueSummary() }} />);
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", () => {
Expand All @@ -89,7 +103,7 @@ describe("PortfolioPage (#4306)", () => {
const loadPortfolioQueue = async (): Promise<PortfolioQueueResult> => ({ ok: true, summary: fixtureSummary });
render(<PortfolioPage loadPortfolioQueue={loadPortfolioQueue} />);
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"));
});
});

Expand All @@ -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 () => {
Expand All @@ -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<string, { repoFullName: string; byStatus: typeof byStatus; total: number }>();
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> = {}): 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");
});
Expand Down Expand Up @@ -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();
});
});
63 changes: 49 additions & 14 deletions apps/gittensory-miner-ui/src/routes/portfolio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,26 @@ 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";

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<QueueStatus, string> = {
queued: "Queued",
Expand Down Expand Up @@ -46,18 +57,42 @@ export function PortfolioQueueView({ result }: { result: PortfolioQueueResult |
);
}
return (
<dl className="grid gap-4 sm:grid-cols-3">
{(Object.keys(STATUS_LABELS) as QueueStatus[]).map((status) => (
<Card key={status}>
<CardContent className="p-4">
<dt className="text-token-2xs uppercase tracking-wider text-muted-foreground">{STATUS_LABELS[status]}</dt>
<dd className={`mt-1 text-token-3xl font-display font-semibold ${STATUS_TONE[status]}`}>
{summary.counts[status]}
</dd>
</CardContent>
</Card>
))}
</dl>
<div className="grid gap-6">
<dl className="grid gap-4 sm:grid-cols-3">
{(Object.keys(STATUS_LABELS) as QueueStatus[]).map((status) => (
<Card key={status}>
<CardContent className="p-4">
<dt className="text-token-2xs uppercase tracking-wider text-muted-foreground">{STATUS_LABELS[status]}</dt>
<dd className={`mt-1 text-token-3xl font-display font-semibold ${STATUS_TONE[status]}`}>
{summary.byStatus[status]}
</dd>
</CardContent>
</Card>
))}
</dl>
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead>Queued</TableHead>
<TableHead>In progress</TableHead>
<TableHead>Done</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.repos.map((repo) => (
<TableRow key={repo.repoFullName}>
<TableCell className="font-mono text-foreground">{repo.repoFullName}</TableCell>
<TableCell>{repo.byStatus.queued}</TableCell>
<TableCell>{repo.byStatus.in_progress}</TableCell>
<TableCell>{repo.byStatus.done}</TableCell>
<TableCell>{repo.total}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

Expand Down
Loading
Loading