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
162 changes: 162 additions & 0 deletions apps/gittensory-miner-ui/src/ranked-candidates-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, expect, it, vi } from "vitest";

import {
handleRankedCandidatesRequest,
rankedCandidatesApiPlugin,
type RankedCandidatesApiDeps,
} from "../vite-ranked-candidates-api";

const candidates = [
{
repoFullName: "acme/widgets",
issueNumber: 1,
title: "Add retry helper",
htmlUrl: "https://github.com/acme/widgets/issues/1",
rankScore: 0.81,
laneFit: 0.9,
freshness: 0.7,
potential: 0.85,
feasibility: 0.6,
dupRisk: 0.1,
rankedAt: "2026-07-13T12:00:00.000Z",
},
];

function deps(overrides: Partial<RankedCandidatesApiDeps> = {}): RankedCandidatesApiDeps {
return {
loadRankedCandidatesModule: async () => ({
resolveRankedCandidatesDbPath: () => "/home/miner/.config/gittensory-miner/ranked-candidates.sqlite3",
listRankedCandidates: () => candidates,
}),
fileExists: () => true,
...overrides,
};
}

describe("handleRankedCandidatesRequest (#4859 prerequisite)", () => {
it("serves the last discover run's ranked candidates via the existing ranked-candidates.js exports", async () => {
const handled = await handleRankedCandidatesRequest("GET", "/api/ranked-candidates", deps());
expect(handled).toEqual({ status: 200, body: JSON.stringify({ candidates }) });
});

it("serves an empty snapshot on a fresh install WITHOUT initializing the store (no DB file => no listRankedCandidates call)", async () => {
let listed = false;
const handled = await handleRankedCandidatesRequest(
"GET",
"/api/ranked-candidates",
deps({
loadRankedCandidatesModule: async () => ({
resolveRankedCandidatesDbPath: () => "/nowhere/ranked-candidates.sqlite3",
listRankedCandidates: () => {
listed = true;
return candidates;
},
}),
fileExists: () => false,
}),
);
expect(handled).toEqual({ status: 200, body: JSON.stringify({ candidates: [] }) });
expect(listed).toBe(false);
});

it("falls through (null) for other paths and non-GET methods", async () => {
expect(await handleRankedCandidatesRequest("GET", "/api/other", deps())).toBeNull();
expect(await handleRankedCandidatesRequest("POST", "/api/ranked-candidates", deps())).toBeNull();
});

it("treats a method-less request (method undefined) the same as GET", async () => {
const handled = await handleRankedCandidatesRequest(undefined, "/api/ranked-candidates", deps());
expect(handled).toEqual({ status: 200, body: JSON.stringify({ candidates }) });
});

it("surfaces a store read failure as a 500 with a safe message", async () => {
const handled = await handleRankedCandidatesRequest(
"GET",
"/api/ranked-candidates",
deps({
loadRankedCandidatesModule: async () => {
throw new Error("sqlite locked");
},
}),
);
expect(handled).toEqual({ status: 500, body: JSON.stringify({ error: "sqlite locked" }) });
});
});

type CapturedRequestHandler = (
req: { method?: string; url?: string },
res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void },
next: () => void,
) => void;

function fakeResponse() {
const headers: Record<string, string> = {};
let statusCode = 0;
let ended: string | undefined;
return {
res: {
get statusCode() {
return statusCode;
},
set statusCode(value: number) {
statusCode = value;
},
setHeader: (k: string, v: string) => {
headers[k] = v;
},
end: (body: string) => {
ended = body;
},
},
headers,
getEnded: () => ended,
getStatus: () => statusCode,
};
}

describe("rankedCandidatesApiPlugin (#4859 prerequisite)", () => {
function captureMiddleware(overrides: Partial<RankedCandidatesApiDeps> = {}): CapturedRequestHandler {
let captured: CapturedRequestHandler | undefined;
const plugin = rankedCandidatesApiPlugin(deps(overrides));
const server = { middlewares: { use: (fn: CapturedRequestHandler) => (captured = fn) } };
// @ts-expect-error -- the test double only implements the subset of Vite's ViteDevServer this plugin reads.
plugin.configureServer(server);
if (!captured) throw new Error("rankedCandidatesApiPlugin did not register a middleware");
return captured;
}

it("serves the real (injected) store's candidates for a matching GET request", async () => {
const middleware = captureMiddleware();
const { res, getEnded, getStatus } = fakeResponse();
let calledNext = false;
middleware({ method: "GET", url: "/api/ranked-candidates" }, res, () => {
calledNext = true;
});
await vi.waitFor(() => expect(getEnded()).toBeDefined());
expect(getStatus()).toBe(200);
expect(JSON.parse(getEnded() ?? "{}")).toEqual({ candidates });
expect(calledNext).toBe(false);
});

it("falls through to next() for a non-matching request", async () => {
const middleware = captureMiddleware();
const { res } = fakeResponse();
let calledNext = false;
await new Promise<void>((resolve) => {
middleware({ method: "GET", url: "/api/other" }, res, () => {
calledNext = true;
resolve();
});
});
expect(calledNext).toBe(true);
});

it("also attaches via configurePreviewServer for `vite preview`", () => {
let captured: CapturedRequestHandler | undefined;
const plugin = rankedCandidatesApiPlugin(deps());
const server = { middlewares: { use: (fn: CapturedRequestHandler) => (captured = fn) } };
// @ts-expect-error -- same partial test double as configureServer above.
plugin.configurePreviewServer(server);
expect(captured).toBeTypeOf("function");
});
});
100 changes: 100 additions & 0 deletions apps/gittensory-miner-ui/vite-ranked-candidates-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { existsSync } from "node:fs";
import type { Plugin } from "vite";

// Local read-only ranked-candidates API (#4859 prerequisite): the browser extension's opportunity badge
// (apps/gittensory-miner-extension/opportunity-badge.js) needs the miner's last discover run's full per-issue
// ranking breakdown to replace its manual copy/paste workflow with a live fetch. Bridges the browser app to
// packages/gittensory-miner/lib/ranked-candidates.js's EXISTING exports (resolveRankedCandidatesDbPath/
// listRankedCandidates) -- no ranking logic duplicated in the UI layer, strictly read-only.
//
// Authenticated the same way as every other /api/* route: vite-auth.ts's authPlugin runs first in the Connect
// chain (#4858), so this file needs no auth logic of its own.
//
// Same read-only fresh-install rule as the sibling GET endpoints: `listRankedCandidates()` lazily initializes
// the default store, which would CREATE the SQLite file -- a write -- on a fresh install or before the first
// discover run. So the handler checks the resolved DB path for existence first and serves an empty snapshot
// without ever touching the store when no DB exists yet.

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;
};

type RankedCandidatesModule = {
resolveRankedCandidatesDbPath: () => string;
listRankedCandidates: () => RankedCandidateRow[];
};

export type RankedCandidatesApiDeps = {
/** Import of `packages/gittensory-miner/lib/ranked-candidates.js` — injectable so tests never touch a real store. */
loadRankedCandidatesModule: () => Promise<RankedCandidatesModule>;
/** File-existence probe for the fresh-install fast path. */
fileExists: (path: string) => boolean;
};

const defaultDeps: RankedCandidatesApiDeps = {
loadRankedCandidatesModule: () =>
import("../../packages/gittensory-miner/lib/ranked-candidates.js") as Promise<RankedCandidatesModule>,
fileExists: existsSync,
};

/** The request handler, factored out of the Vite plugin shape so tests drive it directly (mirrors the sibling
* API files' handleXRequest pattern). Returns the JSON body + status for a GET, or null when the request is
* not for this endpoint (caller falls through). */
export async function handleRankedCandidatesRequest(
method: string | undefined,
url: string | undefined,
deps: RankedCandidatesApiDeps = defaultDeps,
): Promise<{ status: number; body: string } | null> {
if (url !== "/api/ranked-candidates" || (method !== undefined && method !== "GET")) return null;
try {
const rankedCandidates = await deps.loadRankedCandidatesModule();
if (!deps.fileExists(rankedCandidates.resolveRankedCandidatesDbPath())) {
return { status: 200, body: JSON.stringify({ candidates: [] }) };
}
return { status: 200, body: JSON.stringify({ candidates: rankedCandidates.listRankedCandidates() }) };
} catch (error) {
const message = error instanceof Error ? error.message : "failed to read the local ranked-candidates snapshot";
return { status: 500, body: JSON.stringify({ error: message }) };
}
}

/** Vite dev/preview middleware serving the local read-only ranked-candidates endpoint. */
export function rankedCandidatesApiPlugin(deps: RankedCandidatesApiDeps = defaultDeps): Plugin {
const attach = (middlewares: {
use: (
fn: (
req: { method?: string; url?: string },
res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void },
next: () => void,
) => void,
) => void;
}) => {
middlewares.use((req, res, next) => {
void handleRankedCandidatesRequest(req.method, req.url, deps).then((handled) => {
if (!handled) return next();
res.statusCode = handled.status;
res.setHeader("Content-Type", "application/json");
res.end(handled.body);
});
});
};
return {
name: "gittensory-miner-ui:ranked-candidates-api",
configureServer(server) {
attach(server.middlewares);
},
configurePreviewServer(server) {
attach(server.middlewares);
},
};
}
2 changes: 2 additions & 0 deletions apps/gittensory-miner-ui/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { authPlugin } from "./vite-auth";
import { governorApiPlugin } from "./vite-governor-api";
import { ledgersApiPlugin } from "./vite-ledgers-api";
import { portfolioQueueApiPlugin } from "./vite-portfolio-queue-api";
import { rankedCandidatesApiPlugin } from "./vite-ranked-candidates-api";
import { runStateApiPlugin } from "./vite-run-state-api";

export default defineConfig({
Expand All @@ -23,6 +24,7 @@ export default defineConfig({
portfolioQueueApiPlugin(),
ledgersApiPlugin(),
governorApiPlugin(),
rankedCandidatesApiPlugin(),
],
server: {
// Offset from gittensory-ui (5173) so both apps can run side-by-side locally.
Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-miner/docs/env-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Generated by `npm run miner:env-reference`. Do not edit manually.
| `GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB` | `lib/policy-verdict-cache.js` | (none) |
| `GITTENSORY_MINER_PORTFOLIO_QUEUE_DB` | `lib/portfolio-queue.js` | (none) |
| `GITTENSORY_MINER_PREDICTION_LEDGER_DB` | `lib/prediction-ledger.js` | `""` |
| `GITTENSORY_MINER_RANKED_CANDIDATES_DB` | `lib/ranked-candidates.js` | (none) |
| `GITTENSORY_MINER_REPLAY_SNAPSHOT_DB` | `lib/replay-snapshot.js` | (none) |
| `GITTENSORY_MINER_REPO_CLONE_DIR` | `lib/repo-clone.js` | `""` |
| `GITTENSORY_MINER_RUN_STATE_DB` | `lib/run-state.js` | (none) |
Expand Down
2 changes: 2 additions & 0 deletions packages/gittensory-miner/lib/discover-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { PolicyDocCacheStore } from "./policy-doc-cache.js";
import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js";
import type { EnqueueRankedDiscoverySummary } from "./portfolio-discovery.js";
import type { PortfolioQueueStore } from "./portfolio-queue.js";
import type { RankedCandidatesStore } from "./ranked-candidates.js";

export type ParsedDiscoverArgs =
| {
Expand Down Expand Up @@ -65,6 +66,7 @@ export type RunDiscoverOptions = {
initPortfolioQueue?: () => PortfolioQueueStore;
initPolicyDocCache?: () => PolicyDocCacheStore;
initPolicyVerdictCache?: () => PolicyVerdictCacheStore;
initRankedCandidatesStore?: () => RankedCandidatesStore;
fetchCandidateIssuesWithSummary?: (
targets: FanoutTarget[],
githubToken: string,
Expand Down
28 changes: 28 additions & 0 deletions packages/gittensory-miner/lib/discover-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { initPolicyDocCacheStore } from "./policy-doc-cache.js";
import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js";
import { enqueueRankedDiscovery } from "./portfolio-discovery.js";
import { initPortfolioQueueStore } from "./portfolio-queue.js";
import { initRankedCandidatesStore } from "./ranked-candidates.js";
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";

const DISCOVER_USAGE =
Expand Down Expand Up @@ -240,6 +241,22 @@ export async function runDiscover(args, options = {}) {
policyVerdictCache = null;
ownsPolicyVerdictCache = false;
}

// Snapshot of this run's full ranked output (#4859 prerequisite), so a local HTTP endpoint (and eventually the
// miner-ui/browser-extension live-fetch it's meant for) can serve the same per-issue breakdown `--json` prints,
// without the operator re-running discover or hand-pasting its output. Same "own try/catch, degrade to null"
// discipline as the two caches above: a corrupt/unwritable snapshot store must never abort discovery's actual
// job (fan out, rank, enqueue). Unlike the caches, this store is a WRITE target, not a read optimization -- the
// save call itself gets its own try/catch below for the same reason.
let rankedCandidatesStore = null;
let ownsRankedCandidatesStore = false;
try {
ownsRankedCandidatesStore = options.initRankedCandidatesStore === undefined;
rankedCandidatesStore = (options.initRankedCandidatesStore ?? initRankedCandidatesStore)();
} catch {
rankedCandidatesStore = null;
ownsRankedCandidatesStore = false;
}
const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache, policyVerdictCache };

try {
Expand All @@ -258,6 +275,16 @@ export async function runDiscover(args, options = {}) {
});
const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: portfolioQueue, apiBaseUrl });

try {
// Optional chaining rather than an `if (rankedCandidatesStore)` guard: a null store (open failed above)
// short-circuits to a no-op read, so the same try/catch below also covers the open-failed case without a
// second explicit branch.
rankedCandidatesStore?.saveRankedCandidates(rankedSummary.issues, options.nowMs);
} catch {
// Non-fatal: the ranked-candidates snapshot is a nice-to-have for the local HTTP endpoint, not a
// requirement for discover's own job (fan out, rank, enqueue), which already succeeded above.
}

const result = {
fanOutCount: fanOut.issues.length,
warnings: fanOut.warnings,
Expand All @@ -280,5 +307,6 @@ export async function runDiscover(args, options = {}) {
if (ownsPortfolioQueue && portfolioQueue) portfolioQueue.close();
if (ownsPolicyDocCache && policyDocCache) policyDocCache.close();
if (ownsPolicyVerdictCache && policyVerdictCache) policyVerdictCache.close();
if (ownsRankedCandidatesStore && rankedCandidatesStore) rankedCandidatesStore.close();
}
}
Loading
Loading