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
11 changes: 11 additions & 0 deletions apps/loopover-miner-ui/src/lib/demo-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
DEMO_LEDGERS_SUMMARY,
DEMO_PORTFOLIO_QUEUE_SUMMARY,
DEMO_RANKED_CANDIDATES,
DEMO_RUN_STATES,
getDemoGovernorState,
getDemoPortfolioQueueItems,
Expand Down Expand Up @@ -39,6 +40,16 @@ describe("demo fixtures shape (#5963)", () => {
}
});

it("DEMO_RANKED_CANDIDATES is non-empty and every row's scores fall in the valid 0..1 fraction range", () => {
expect(DEMO_RANKED_CANDIDATES.length).toBeGreaterThan(0);
for (const row of DEMO_RANKED_CANDIDATES) {
for (const score of [row.rankScore, row.laneFit, row.freshness, row.potential, row.feasibility, row.dupRisk]) {
expect(score).toBeGreaterThanOrEqual(0);
expect(score).toBeLessThanOrEqual(1);
}
}
});

it("DEMO_LEDGERS_SUMMARY's claim byStatus counts sum to its total", () => {
const { total, byStatus } = DEMO_LEDGERS_SUMMARY.claims;
expect(byStatus.active + byStatus.released + byStatus.expired).toBe(total);
Expand Down
52 changes: 48 additions & 4 deletions apps/loopover-miner-ui/src/lib/demo-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
// build-time constant, so the "off" branch (the real fetch calls) is dead-code-eliminated from a demo bundle and
// vice versa -- a production self-host build never carries this module's data.
//
// Scope: the five REST fetchers backing the three main dashboard routes (run-history, ledgers, portfolio +
// its queue actions, governor). discover/attempt/chat are NOT covered here -- those trigger a real coding-agent
// iteration or ground against a live MCP connection, and fabricating a convincing multi-minute agent run is a
// separate, much larger content-design task than tabular summary data (tracked as follow-up work, not this PR).
// Scope: the six REST fetchers backing the four main dashboard routes (run-history, ledgers, portfolio + its
// queue actions, governor, ranked-candidates (#7675)). discover/attempt/chat are NOT covered here -- those
// trigger a real coding-agent iteration or ground against a live MCP connection, and fabricating a convincing
// multi-minute agent run is a separate, much larger content-design task than tabular summary data (tracked as
// follow-up work, not this PR).
//
// Every value below is entirely synthetic -- no real repo, run, ledger entry, or account referenced anywhere.

Expand All @@ -17,6 +18,7 @@ import type { LedgersSummary } from "./ledgers";
import type { PortfolioQueueSummary } from "./portfolio-queue";
import type { PortfolioQueueActionItem } from "./portfolio-queue-actions";
import type { GovernorPauseState } from "./governor";
import type { RankedCandidateRow } from "./ranked-candidates";

export function isDemoMode(): boolean {
return import.meta.env.VITE_DEMO_MODE === "1";
Expand Down Expand Up @@ -49,6 +51,48 @@ export const DEMO_RUN_STATES: RunStateRow[] = [
},
];

export const DEMO_RANKED_CANDIDATES: RankedCandidateRow[] = [
{
repoFullName: "acme/widgets",
issueNumber: 214,
title: "Add retry helper for flaky forge requests",
htmlUrl: "https://github.com/acme/widgets/issues/214",
rankScore: 0.87,
laneFit: 0.92,
freshness: 0.81,
potential: 0.88,
feasibility: 0.79,
dupRisk: 0.05,
rankedAt: "2026-07-18T14:05:00.000Z",
},
{
repoFullName: "acme/api-gateway",
issueNumber: 58,
title: "Cache the OpenAPI schema between requests",
htmlUrl: "https://github.com/acme/api-gateway/issues/58",
rankScore: 0.74,
laneFit: 0.7,
freshness: 0.66,
potential: 0.8,
feasibility: 0.72,
dupRisk: 0.12,
rankedAt: "2026-07-18T13:50:00.000Z",
},
{
repoFullName: "northwind/inventory",
issueNumber: 133,
title: "Fix pagination cursor drift on the audit feed",
htmlUrl: null,
rankScore: 0.52,
laneFit: 0.48,
freshness: 0.55,
potential: 0.6,
feasibility: 0.5,
dupRisk: 0.35,
rankedAt: "2026-07-18T12:20:00.000Z",
},
];

export const DEMO_LEDGERS_SUMMARY: LedgersSummary = {
claims: { total: 18, byStatus: { active: 3, released: 12, expired: 3 } },
events: {
Expand Down
78 changes: 78 additions & 0 deletions apps/loopover-miner-ui/src/lib/ranked-candidates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Read-only client for the local ranked-candidates API (#7675). The dashboard is a browser app and the miner's
// discovery-ranking store is a `node:sqlite` file on disk, so the view never touches SQL — it fetches the dev
// server's local read-only endpoint (see `vite-ranked-candidates-api.ts`), which itself calls into
// `packages/loopover-miner/lib/ranked-candidates.js`'s existing exports. Mirrors `lib/run-history.ts`'s shape.
//
// This surfaces the SAME per-issue discovery breakdown (laneFit/freshness/potential/feasibility/dupRisk) the
// browser extension's opportunity badge already reads from this endpoint (#4859 prerequisite) — no ranking
// logic duplicated here, strictly a read-only view of already-existing data.

import { DEMO_RANKED_CANDIDATES, isDemoMode } from "./demo-data";

export const RANKED_CANDIDATES_API_PATH = "/api/ranked-candidates";

/** One ranked-candidate row as served by the local API — mirrors `ranked-candidates.js`'s row shape. */
export type RankedCandidateRow = {
repoFullName: string;
issueNumber: number;
title: string;
htmlUrl: string | null;
rankScore: number;
laneFit: number;
freshness: number;
potential: number;
feasibility: number;
dupRisk: number;
rankedAt: string;
};

export type RankedCandidatesResult = { ok: true; candidates: RankedCandidateRow[] } | { ok: false; error: string };

function isRankedCandidateRow(value: unknown): value is RankedCandidateRow {
if (typeof value !== "object" || value === null) return false;
const row = value as Record<string, unknown>;
return (
typeof row.repoFullName === "string" &&
typeof row.issueNumber === "number" &&
typeof row.title === "string" &&
(row.htmlUrl === null || typeof row.htmlUrl === "string") &&
typeof row.rankScore === "number" &&
typeof row.laneFit === "number" &&
typeof row.freshness === "number" &&
typeof row.potential === "number" &&
typeof row.feasibility === "number" &&
typeof row.dupRisk === "number" &&
typeof row.rankedAt === "string"
);
}

/** Stable React key / identity for a ranked-candidate row. */
export function rankedCandidateRowKey(row: Pick<RankedCandidateRow, "repoFullName" | "issueNumber">): string {
return `${row.repoFullName}#${row.issueNumber}`;
}

/** Render a 0..1 score fraction as a whole-percent string ("0.81" -> "81%"). */
export function formatScorePercent(score: number): string {
return `${Math.round(score * 100)}%`;
}

/** Fetch the local ranked-candidates rows. Failures (server down, malformed payload) surface as a typed error
* result — the view renders them as a message, never a crash. `fetchImpl` is injectable for tests. */
export async function fetchRankedCandidates(fetchImpl: typeof fetch = fetch): Promise<RankedCandidatesResult> {
if (isDemoMode()) return { ok: true, candidates: DEMO_RANKED_CANDIDATES };
try {
const response = await fetchImpl(RANKED_CANDIDATES_API_PATH);
if (!response.ok) return { ok: false, error: `local ranked-candidates API responded ${response.status}` };
const payload: unknown = await response.json();
const candidates = (payload as { candidates?: unknown }).candidates;
if (!Array.isArray(candidates) || !candidates.every(isRankedCandidateRow)) {
return { ok: false, error: "local ranked-candidates API returned an unexpected payload shape" };
}
return { ok: true, candidates };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : "failed to reach the local ranked-candidates API",
};
}
}
Loading
Loading