From 9e15246b995e942e3d9a9f00b3f890dcfffe760b Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:37:09 -0700 Subject: [PATCH] feat(miner): persist ranked-candidates snapshots and serve them locally Adds packages/gittensory-miner/lib/ranked-candidates.js: a new snapshot store that discover-cli.js now populates on every real run with the full per-issue ranking breakdown (rankScore/laneFit/ freshness/potential/feasibility/dupRisk), replaced wholesale each run. Nothing durable held this before -- discover --json printed it but never persisted it. Adds apps/gittensory-miner-ui/vite-ranked-candidates-api.ts: a read-only GET /api/ranked-candidates endpoint over that store, authenticated the same way as every other /api/* route via #4858's authPlugin. This is the prerequisite for #4859 (extension live-fetch) -- the extension's opportunity badge needs exactly this per-issue breakdown to replace its manual copy/paste workflow, and no data source existed for it until now. --- .../src/ranked-candidates-api.test.ts | 162 ++++++++++++ .../vite-ranked-candidates-api.ts | 100 +++++++ apps/gittensory-miner-ui/vite.config.ts | 2 + .../gittensory-miner/docs/env-reference.md | 1 + .../gittensory-miner/lib/discover-cli.d.ts | 2 + packages/gittensory-miner/lib/discover-cli.js | 28 ++ .../lib/ranked-candidates.d.ts | 51 ++++ .../gittensory-miner/lib/ranked-candidates.js | 178 +++++++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-discover-cli.test.ts | 163 ++++++++++++ test/unit/miner-ranked-candidates.test.ts | 249 ++++++++++++++++++ 11 files changed, 937 insertions(+), 1 deletion(-) create mode 100644 apps/gittensory-miner-ui/src/ranked-candidates-api.test.ts create mode 100644 apps/gittensory-miner-ui/vite-ranked-candidates-api.ts create mode 100644 packages/gittensory-miner/lib/ranked-candidates.d.ts create mode 100644 packages/gittensory-miner/lib/ranked-candidates.js create mode 100644 test/unit/miner-ranked-candidates.test.ts diff --git a/apps/gittensory-miner-ui/src/ranked-candidates-api.test.ts b/apps/gittensory-miner-ui/src/ranked-candidates-api.test.ts new file mode 100644 index 0000000000..324b5aa37a --- /dev/null +++ b/apps/gittensory-miner-ui/src/ranked-candidates-api.test.ts @@ -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 { + 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 = {}; + 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 = {}): 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((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"); + }); +}); diff --git a/apps/gittensory-miner-ui/vite-ranked-candidates-api.ts b/apps/gittensory-miner-ui/vite-ranked-candidates-api.ts new file mode 100644 index 0000000000..6df32ce2a5 --- /dev/null +++ b/apps/gittensory-miner-ui/vite-ranked-candidates-api.ts @@ -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; + /** 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, + 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); + }, + }; +} diff --git a/apps/gittensory-miner-ui/vite.config.ts b/apps/gittensory-miner-ui/vite.config.ts index b8a4d54b4d..6acd3b18f3 100644 --- a/apps/gittensory-miner-ui/vite.config.ts +++ b/apps/gittensory-miner-ui/vite.config.ts @@ -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({ @@ -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. diff --git a/packages/gittensory-miner/docs/env-reference.md b/packages/gittensory-miner/docs/env-reference.md index 5d751c860b..f74b2e6800 100644 --- a/packages/gittensory-miner/docs/env-reference.md +++ b/packages/gittensory-miner/docs/env-reference.md @@ -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) | diff --git a/packages/gittensory-miner/lib/discover-cli.d.ts b/packages/gittensory-miner/lib/discover-cli.d.ts index 45f5f80130..33e612ba05 100644 --- a/packages/gittensory-miner/lib/discover-cli.d.ts +++ b/packages/gittensory-miner/lib/discover-cli.d.ts @@ -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 = | { @@ -65,6 +66,7 @@ export type RunDiscoverOptions = { initPortfolioQueue?: () => PortfolioQueueStore; initPolicyDocCache?: () => PolicyDocCacheStore; initPolicyVerdictCache?: () => PolicyVerdictCacheStore; + initRankedCandidatesStore?: () => RankedCandidatesStore; fetchCandidateIssuesWithSummary?: ( targets: FanoutTarget[], githubToken: string, diff --git a/packages/gittensory-miner/lib/discover-cli.js b/packages/gittensory-miner/lib/discover-cli.js index c92a4a1ac2..5c09e17797 100644 --- a/packages/gittensory-miner/lib/discover-cli.js +++ b/packages/gittensory-miner/lib/discover-cli.js @@ -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 = @@ -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 { @@ -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, @@ -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(); } } diff --git a/packages/gittensory-miner/lib/ranked-candidates.d.ts b/packages/gittensory-miner/lib/ranked-candidates.d.ts new file mode 100644 index 0000000000..caa22ca9b9 --- /dev/null +++ b/packages/gittensory-miner/lib/ranked-candidates.d.ts @@ -0,0 +1,51 @@ +export type RankedCandidateInput = { + repoFullName: string; + issueNumber: number; + title?: string; + htmlUrl?: string | null; + rankScore: number; + laneFit?: number; + freshness?: number; + potential?: number; + feasibility?: number; + dupRisk?: number; +}; + +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 RankedCandidatesSaveResult = { + count: number; + rankedAt: string; +}; + +export type RankedCandidatesStore = { + dbPath: string; + saveRankedCandidates(candidates: RankedCandidateInput[], nowMs?: number): RankedCandidatesSaveResult; + listRankedCandidates(): RankedCandidateRow[]; + close(): void; +}; + +export function resolveRankedCandidatesDbPath(env?: Record): string; + +export function initRankedCandidatesStore(dbPath?: string): RankedCandidatesStore; + +export function saveRankedCandidates( + candidates: RankedCandidateInput[], + nowMs?: number, +): RankedCandidatesSaveResult; + +export function listRankedCandidates(): RankedCandidateRow[]; + +export function closeDefaultRankedCandidatesStore(): void; diff --git a/packages/gittensory-miner/lib/ranked-candidates.js b/packages/gittensory-miner/lib/ranked-candidates.js new file mode 100644 index 0000000000..25ef075ec1 --- /dev/null +++ b/packages/gittensory-miner/lib/ranked-candidates.js @@ -0,0 +1,178 @@ +import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; +import { applySchemaMigrations } from "./schema-version.js"; + +// Last-discover-run ranked-candidates snapshot (#4859 prerequisite): `discover-cli.js`'s runDiscover already +// computes the FULL per-issue ranking breakdown (rankScore/laneFit/freshness/potential/feasibility/dupRisk, via +// opportunity-ranker.js) and prints it to stdout with `--json`, but nothing durable ever stores it -- the +// portfolio queue only carries a single derived `priority` number, not the per-dimension detail. The browser +// extension's opportunity badge (apps/gittensory-miner-extension/opportunity-badge.js) needs exactly that detail +// to render its "why" reasoning, and today can only get it via a manual copy/paste of `discover --json`'s output +// (#4859's whole premise). This module gives that output a durable home so a local HTTP endpoint can serve it. +// +// Deliberately a SNAPSHOT, not a ledger: each real (non-dry-run) discover invocation REPLACES the whole table +// wholesale (this run's candidates are what's live-fetchable now; a stale prior run's rows would be actively +// misleading, not historically useful the way an append-only ledger's rows are). No forge (api_base_url) scoping +// either -- unlike the portfolio-queue/claim-ledger/governor-state stores, which track ongoing state across many +// runs and many repos over time, this is a disposable "the miner's current opinion" cache for one local +// operator's browsing session; if a later run targets a different forge, replacing the whole snapshot is exactly +// the right behavior, not a gap. + +const defaultDbFileName = "ranked-candidates.sqlite3"; +let defaultRankedCandidatesStore = null; + +export function resolveRankedCandidatesDbPath(env = process.env) { + return resolveLocalStoreDbPath(defaultDbFileName, "GITTENSORY_MINER_RANKED_CANDIDATES_DB", env); +} + +function normalizeDbPath(dbPath) { + return normalizeLocalStoreDbPath(dbPath, resolveRankedCandidatesDbPath(), "invalid_ranked_candidates_db_path"); +} + +function normalizeFiniteRankDimension(value, fallback) { + return Number.isFinite(value) ? value : fallback; +} + +function normalizeCandidate(candidate) { + if (!candidate || typeof candidate !== "object") throw new Error("invalid_ranked_candidate"); + const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) throw new Error("invalid_ranked_candidate"); + const issueNumber = candidate.issueNumber; + if (!Number.isInteger(issueNumber) || issueNumber <= 0) throw new Error("invalid_ranked_candidate"); + const rankScore = Number(candidate.rankScore); + if (!Number.isFinite(rankScore)) throw new Error("invalid_ranked_candidate"); + return { + repoFullName: `${owner}/${repo}`, + issueNumber, + title: typeof candidate.title === "string" ? candidate.title : "", + htmlUrl: typeof candidate.htmlUrl === "string" ? candidate.htmlUrl : null, + rankScore, + // A dimension the ranker didn't supply degrades to the SAME neutral defaults opportunity-ranker.js's own + // normalizeCandidate uses for a missing signal (0 for a benefit dimension, 1 -- max risk -- for dupRisk), + // rather than silently coercing a non-finite value to 0 across the board. + laneFit: normalizeFiniteRankDimension(candidate.laneFit, 0), + freshness: normalizeFiniteRankDimension(candidate.freshness, 0), + potential: normalizeFiniteRankDimension(candidate.potential, 0), + feasibility: normalizeFiniteRankDimension(candidate.feasibility, 0), + dupRisk: normalizeFiniteRankDimension(candidate.dupRisk, 1), + }; +} + +function rowToCandidate(row) { + return { + repoFullName: row.repo_full_name, + issueNumber: row.issue_number, + title: row.title, + htmlUrl: row.html_url, + rankScore: row.rank_score, + laneFit: row.lane_fit, + freshness: row.freshness, + potential: row.potential, + feasibility: row.feasibility, + dupRisk: row.dup_risk, + rankedAt: row.ranked_at, + }; +} + +/** + * Opens the 100% local/client-side ranked-candidates snapshot store. The database only lives on this machine; + * this module never uploads, syncs, or phones home with its contents. + */ +export function initRankedCandidatesStore(dbPath = resolveRankedCandidatesDbPath()) { + const resolvedPath = normalizeDbPath(dbPath); + const db = openLocalStoreDb(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS miner_ranked_candidates ( + repo_full_name TEXT NOT NULL, + issue_number INTEGER NOT NULL, + title TEXT NOT NULL, + html_url TEXT, + rank_score REAL NOT NULL, + lane_fit REAL NOT NULL, + freshness REAL NOT NULL, + potential REAL NOT NULL, + feasibility REAL NOT NULL, + dup_risk REAL NOT NULL, + ranked_at TEXT NOT NULL, + PRIMARY KEY (repo_full_name, issue_number) + ) + `); + // Schema-version convention (#4832): stamp the baseline. No post-baseline migrations yet -- this is a new store. + applySchemaMigrations(db, []); + + const deleteAllStatement = db.prepare("DELETE FROM miner_ranked_candidates"); + const insertStatement = db.prepare(` + INSERT INTO miner_ranked_candidates + (repo_full_name, issue_number, title, html_url, rank_score, lane_fit, freshness, potential, feasibility, dup_risk, ranked_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const listStatement = db.prepare("SELECT * FROM miner_ranked_candidates ORDER BY rank_score DESC"); + + // Atomic replace: a reader between the DELETE and the INSERTs must never observe an empty table mid-write. + // node:sqlite's DatabaseSync has no `.transaction()` helper (unlike better-sqlite3) -- mirrors + // portfolio-queue.js's batchClaim: explicit BEGIN IMMEDIATE/COMMIT, ROLLBACK + rethrow on failure. + function replaceAll(normalizedCandidates, rankedAt) { + db.exec("BEGIN IMMEDIATE"); + try { + deleteAllStatement.run(); + for (const candidate of normalizedCandidates) { + insertStatement.run( + candidate.repoFullName, + candidate.issueNumber, + candidate.title, + candidate.htmlUrl, + candidate.rankScore, + candidate.laneFit, + candidate.freshness, + candidate.potential, + candidate.feasibility, + candidate.dupRisk, + rankedAt, + ); + } + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + } + + return { + dbPath: resolvedPath, + /** Replaces the whole snapshot wholesale with this run's ranked candidates. `nowMs` is caller-supplied + * (never reads the clock internally) so tests get a deterministic `rankedAt`. */ + saveRankedCandidates(candidates, nowMs) { + const normalized = (Array.isArray(candidates) ? candidates : []).map(normalizeCandidate); + const rankedAt = new Date(Number.isFinite(nowMs) ? nowMs : Date.now()).toISOString(); + replaceAll(normalized, rankedAt); + return { count: normalized.length, rankedAt }; + }, + /** Every candidate from the last saved run, highest rankScore first. Empty (not an error) before any + * discover run has ever saved a snapshot, or if the last run found zero candidates. */ + listRankedCandidates() { + return listStatement.all().map(rowToCandidate); + }, + close() { + db.close(); + }, + }; +} + +function getDefaultRankedCandidatesStore() { + defaultRankedCandidatesStore ??= initRankedCandidatesStore(); + return defaultRankedCandidatesStore; +} + +export function saveRankedCandidates(candidates, nowMs) { + return getDefaultRankedCandidatesStore().saveRankedCandidates(candidates, nowMs); +} + +export function listRankedCandidates() { + return getDefaultRankedCandidatesStore().listRankedCandidates(); +} + +export function closeDefaultRankedCandidatesStore() { + if (!defaultRankedCandidatesStore) return; + defaultRankedCandidatesStore.close(); + defaultRankedCandidatesStore = null; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index f49d79878e..723b5bce11 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -38,7 +38,7 @@ ], "scripts": { "benchmark": "node scripts/benchmark.mjs", - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/ranked-candidates.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@loopover/engine": "*", diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index e947167022..d3f7775fe1 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -8,6 +8,7 @@ import { closeDefaultPortfolioQueueStore, initPortfolioQueueStore, } from "../../packages/gittensory-miner/lib/portfolio-queue.js"; +import { initRankedCandidatesStore } from "../../packages/gittensory-miner/lib/ranked-candidates.js"; import { parseDiscoverArgs, renderDiscoverSummary, @@ -49,6 +50,15 @@ function tempPolicyVerdictCacheStore() { return store; } +// Same reasoning as tempPolicyDocCacheStore above, for the ranked-candidates snapshot store (#4859 prerequisite). +function tempRankedCandidatesStore() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-discover-cli-rc-")); + roots.push(root); + const store = initRankedCandidatesStore(join(root, "ranked-candidates.sqlite3")); + stores.push(store); + return store; +} + function fanOutIssue(overrides: Record = {}) { return { owner: "acme", @@ -325,6 +335,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), fetchCandidateIssuesWithSummary, searchCandidateIssuesWithSummary, }); @@ -353,6 +364,7 @@ describe("runDiscover (#4247)", () => { const initPortfolioQueue = vi.fn(); const initPolicyDocCache = vi.fn(); const initPolicyVerdictCache = vi.fn(); + const initRankedCandidatesStore = vi.fn(); const fetchCandidateIssuesWithSummary = vi.fn(async (targets, token, fanOutOptions) => { expect(fanOutOptions).toMatchObject({ policyDocCache: null, policyVerdictCache: null }); return { @@ -369,6 +381,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue, initPolicyDocCache, initPolicyVerdictCache, + initRankedCandidatesStore, fetchCandidateIssuesWithSummary, }); @@ -376,6 +389,7 @@ describe("runDiscover (#4247)", () => { expect(initPortfolioQueue).not.toHaveBeenCalled(); expect(initPolicyDocCache).not.toHaveBeenCalled(); expect(initPolicyVerdictCache).not.toHaveBeenCalled(); + expect(initRankedCandidatesStore).not.toHaveBeenCalled(); const payload = JSON.parse(String(log.mock.calls[0]?.[0])); expect(payload.outcome).toBe("dry_run"); expect(payload.fanOutCount).toBe(1); @@ -388,6 +402,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue, initPolicyDocCache, initPolicyVerdictCache, + initRankedCandidatesStore, fetchCandidateIssuesWithSummary, }); expect(textExitCode).toBe(0); @@ -470,6 +485,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), fetchCandidateIssuesWithSummary, searchCandidateIssuesWithSummary, }); @@ -495,6 +511,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), fetchCandidateIssuesWithSummary, }); @@ -544,6 +561,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), fetchCandidateIssuesWithSummary, }); @@ -571,6 +589,7 @@ describe("runDiscover (#4247)", () => { fetchCandidateIssuesWithSummary, initPolicyDocCache: () => tempPolicyDocCacheStore(), initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), }); expect(exitCode).toBe(0); @@ -605,6 +624,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), fetchCandidateIssuesWithSummary, }, ); @@ -644,6 +664,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), fetchCandidateIssuesWithSummary, forge: { tokenEnvVar: "FORGE_PAT" }, }); @@ -675,6 +696,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), fetchCandidateIssuesWithSummary, githubToken: "explicit-token", apiBaseUrl: "https://programmatic.example.com", @@ -721,6 +743,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), fetchCandidateIssuesWithSummary, rankCandidateIssuesWithSummary, goalSpecContentByRepo, @@ -758,6 +781,7 @@ describe("runDiscover (#4247)", () => { fetchCandidateIssuesWithSummary, initPortfolioQueue: () => portfolioQueue, initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), }); expect(exitCode).toBe(0); expect(existsSync(cacheDbPath)).toBe(true); @@ -789,6 +813,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue: () => portfolioQueue, initPolicyDocCache, initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), fetchCandidateIssuesWithSummary, }); @@ -826,6 +851,7 @@ describe("runDiscover (#4247)", () => { fetchCandidateIssuesWithSummary, initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), }); expect(exitCode).toBe(0); expect(existsSync(cacheDbPath)).toBe(true); @@ -857,6 +883,7 @@ describe("runDiscover (#4247)", () => { initPortfolioQueue: () => portfolioQueue, initPolicyDocCache: () => tempPolicyDocCacheStore(), initPolicyVerdictCache, + initRankedCandidatesStore: () => tempRankedCandidatesStore(), fetchCandidateIssuesWithSummary, }); @@ -870,6 +897,142 @@ describe("runDiscover (#4247)", () => { expect.objectContaining({ policyVerdictCache: null }), ); }); + + it("#4859 prerequisite: persists the full ranked-candidates snapshot after a real (non-dry-run) discover", async () => { + const portfolioQueue = tempQueueStore(); + const rankedCandidatesStore = tempRankedCandidatesStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [ + fanOutIssue({ issueNumber: 1, title: "Add retry helper" }), + fanOutIssue({ issueNumber: 2, title: "Fix flaky test" }), + ], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + const exitCode = await runDiscover(["acme/widgets"], { + nowMs: NOW, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => rankedCandidatesStore, + fetchCandidateIssuesWithSummary, + }); + + expect(exitCode).toBe(0); + const snapshot = rankedCandidatesStore.listRankedCandidates(); + expect(snapshot.map((entry) => entry.issueNumber).sort()).toEqual([1, 2]); + expect(snapshot.every((entry) => entry.rankedAt === new Date(NOW).toISOString())).toBe(true); + // Every field opportunity-badge.js's badge needs must survive the round trip, not just rankScore. + expect(snapshot[0]).toMatchObject({ + repoFullName: "acme/widgets", + title: expect.any(String), + rankScore: expect.any(Number), + laneFit: expect.any(Number), + freshness: expect.any(Number), + potential: expect.any(Number), + feasibility: expect.any(Number), + dupRisk: expect.any(Number), + }); + }); + + it("opens and closes the default on-disk ranked-candidates store when no override is supplied", async () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-discover-cli-rc-default-")); + roots.push(root); + const rankedCandidatesDbPath = join(root, "ranked-candidates.sqlite3"); + const previousDbPath = process.env.GITTENSORY_MINER_RANKED_CANDIDATES_DB; + process.env.GITTENSORY_MINER_RANKED_CANDIDATES_DB = rankedCandidatesDbPath; + try { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue()], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + // No initRankedCandidatesStore override: runDiscover opens the default on-disk store at the env path and + // closes it in its finally block. Reopening the same file confirms the default code path wrote the snapshot. + const exitCode = await runDiscover(["acme/widgets"], { + nowMs: NOW, + fetchCandidateIssuesWithSummary, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + }); + expect(exitCode).toBe(0); + expect(existsSync(rankedCandidatesDbPath)).toBe(true); + + const reopened = initRankedCandidatesStore(rankedCandidatesDbPath); + stores.push(reopened); + expect(reopened.listRankedCandidates()).toHaveLength(1); + } finally { + if (previousDbPath === undefined) delete process.env.GITTENSORY_MINER_RANKED_CANDIDATES_DB; + else process.env.GITTENSORY_MINER_RANKED_CANDIDATES_DB = previousDbPath; + } + }); + + it("REGRESSION: an unopenable ranked-candidates store degrades to no snapshot instead of failing discovery", async () => { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue()], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const initRankedCandidatesStore = vi.fn(() => { + throw new Error("disk full"); + }); + + const exitCode = await runDiscover(["acme/widgets"], { + nowMs: NOW, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore, + fetchCandidateIssuesWithSummary, + }); + + // Same discipline as the two caches above: a nice-to-have, not a requirement, so an open failure must never + // abort discovery's actual job (fan out, rank, enqueue). + expect(exitCode).toBe(0); + expect(initRankedCandidatesStore).toHaveBeenCalledTimes(1); + }); + + it("REGRESSION: a save failure on an otherwise-open ranked-candidates store still doesn't fail discovery", async () => { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue()], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const saveRankedCandidates = vi.fn(() => { + throw new Error("disk full mid-write"); + }); + + const exitCode = await runDiscover(["acme/widgets"], { + nowMs: NOW, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => ({ + dbPath: ":memory:", + saveRankedCandidates, + listRankedCandidates: () => [], + close: () => undefined, + }), + fetchCandidateIssuesWithSummary, + }); + + expect(exitCode).toBe(0); + expect(saveRankedCandidates).toHaveBeenCalledTimes(1); + }); }); describe("gittensory-miner discover CLI entrypoint (#4247)", () => { diff --git a/test/unit/miner-ranked-candidates.test.ts b/test/unit/miner-ranked-candidates.test.ts new file mode 100644 index 0000000000..2da3e5db25 --- /dev/null +++ b/test/unit/miner-ranked-candidates.test.ts @@ -0,0 +1,249 @@ +import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + closeDefaultRankedCandidatesStore, + initRankedCandidatesStore, + listRankedCandidates, + resolveRankedCandidatesDbPath, + saveRankedCandidates, +} from "../../packages/gittensory-miner/lib/ranked-candidates.js"; + +const roots: string[] = []; + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-ranked-candidates-")); + roots.push(root); + return root; +} + +afterEach(() => { + closeDefaultRankedCandidatesStore(); + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +const fullCandidate = { + repoFullName: "acme/widgets", + issueNumber: 42, + title: "Fix the flaky retry logic", + htmlUrl: "https://github.com/acme/widgets/issues/42", + rankScore: 0.81, + laneFit: 0.9, + freshness: 0.7, + potential: 0.85, + feasibility: 0.6, + dupRisk: 0.1, +}; + +describe("gittensory-miner ranked-candidates store (#4859 prerequisite)", () => { + it("resolves the DB path from env override, miner config dir, XDG config, then the home default", () => { + expect(resolveRankedCandidatesDbPath({ GITTENSORY_MINER_RANKED_CANDIDATES_DB: "/custom/ranked.sqlite3" })).toBe( + "/custom/ranked.sqlite3", + ); + expect(resolveRankedCandidatesDbPath({ GITTENSORY_MINER_CONFIG_DIR: "/custom/config" })).toBe( + "/custom/config/ranked-candidates.sqlite3", + ); + expect(resolveRankedCandidatesDbPath({ XDG_CONFIG_HOME: "/xdg" })).toBe( + "/xdg/gittensory-miner/ranked-candidates.sqlite3", + ); + expect(resolveRankedCandidatesDbPath({})).toMatch(/\/\.config\/gittensory-miner\/ranked-candidates\.sqlite3$/); + }); + + it("creates the SQLite table on first use, with owner-only file permissions, and reads [] before any save", () => { + const dbPath = join(tempRoot(), "nested", "ranked-candidates.sqlite3"); + const store = initRankedCandidatesStore(dbPath); + try { + expect(existsSync(dbPath)).toBe(true); + expect(statSync(dbPath).mode & 0o077).toBe(0); + expect(store.listRankedCandidates()).toEqual([]); + + const db = new DatabaseSync(dbPath, { readOnly: true }); + try { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'miner_ranked_candidates'") + .get(); + expect(row).toEqual({ name: "miner_ranked_candidates" }); + } finally { + db.close(); + } + } finally { + store.close(); + } + }); + + it("round-trips a full candidate and sorts by rankScore descending", () => { + const dbPath = join(tempRoot(), "ranked-candidates.sqlite3"); + const store = initRankedCandidatesStore(dbPath); + try { + const lowerScore = { ...fullCandidate, issueNumber: 43, rankScore: 0.2 }; + const result = store.saveRankedCandidates([lowerScore, fullCandidate], Date.parse("2026-07-13T12:00:00.000Z")); + expect(result).toEqual({ count: 2, rankedAt: "2026-07-13T12:00:00.000Z" }); + + const rows = store.listRankedCandidates(); + expect(rows).toEqual([ + { ...fullCandidate, rankedAt: "2026-07-13T12:00:00.000Z" }, + { ...lowerScore, rankedAt: "2026-07-13T12:00:00.000Z" }, + ]); + } finally { + store.close(); + } + }); + + it("defaults missing rank-dimension fields to the same neutral values opportunity-ranker.js uses (0, dupRisk 1)", () => { + const dbPath = join(tempRoot(), "ranked-candidates.sqlite3"); + const store = initRankedCandidatesStore(dbPath); + try { + store.saveRankedCandidates( + [{ repoFullName: "acme/widgets", issueNumber: 1, rankScore: 0.5 }], + Date.parse("2026-07-13T12:00:00.000Z"), + ); + const [row] = store.listRankedCandidates(); + expect(row).toEqual({ + repoFullName: "acme/widgets", + issueNumber: 1, + title: "", + htmlUrl: null, + rankScore: 0.5, + laneFit: 0, + freshness: 0, + potential: 0, + feasibility: 0, + dupRisk: 1, + rankedAt: "2026-07-13T12:00:00.000Z", + }); + } finally { + store.close(); + } + }); + + it("replaces the whole snapshot atomically -- a second save wipes the first, never accumulates", () => { + const dbPath = join(tempRoot(), "ranked-candidates.sqlite3"); + const store = initRankedCandidatesStore(dbPath); + try { + store.saveRankedCandidates([fullCandidate], Date.parse("2026-07-13T12:00:00.000Z")); + expect(store.listRankedCandidates()).toHaveLength(1); + + const secondRun = { ...fullCandidate, issueNumber: 99, title: "A different issue" }; + store.saveRankedCandidates([secondRun], Date.parse("2026-07-13T13:00:00.000Z")); + const rows = store.listRankedCandidates(); + expect(rows).toHaveLength(1); + expect(rows[0]?.issueNumber).toBe(99); + expect(rows[0]?.rankedAt).toBe("2026-07-13T13:00:00.000Z"); + } finally { + store.close(); + } + }); + + it("replacing with an empty array clears the snapshot entirely", () => { + const dbPath = join(tempRoot(), "ranked-candidates.sqlite3"); + const store = initRankedCandidatesStore(dbPath); + try { + store.saveRankedCandidates([fullCandidate], Date.parse("2026-07-13T12:00:00.000Z")); + store.saveRankedCandidates([], Date.parse("2026-07-13T14:00:00.000Z")); + expect(store.listRankedCandidates()).toEqual([]); + } finally { + store.close(); + } + }); + + it("a non-array candidates argument degrades to an empty save rather than throwing", () => { + const dbPath = join(tempRoot(), "ranked-candidates.sqlite3"); + const store = initRankedCandidatesStore(dbPath); + try { + // @ts-expect-error -- deliberately wrong shape to exercise the Array.isArray guard. + const result = store.saveRankedCandidates(null, Date.parse("2026-07-13T12:00:00.000Z")); + expect(result).toEqual({ count: 0, rankedAt: "2026-07-13T12:00:00.000Z" }); + } finally { + store.close(); + } + }); + + it("rejects a candidate with an invalid repoFullName, missing/non-positive issueNumber, or non-finite rankScore", () => { + const dbPath = join(tempRoot(), "ranked-candidates.sqlite3"); + const store = initRankedCandidatesStore(dbPath); + try { + // @ts-expect-error -- a non-object array entry, to exercise normalizeCandidate's own guard directly. + expect(() => store.saveRankedCandidates([null])).toThrow("invalid_ranked_candidate"); + // repoFullName entirely absent (non-string), the other side of the `typeof === "string"` ternary. + // @ts-expect-error -- repoFullName deliberately omitted to exercise that guard directly. + expect(() => store.saveRankedCandidates([{ issueNumber: 1, rankScore: 0.5 }])).toThrow( + "invalid_ranked_candidate", + ); + expect(() => store.saveRankedCandidates([{ ...fullCandidate, repoFullName: "not-a-repo" }])).toThrow( + "invalid_ranked_candidate", + ); + expect(() => store.saveRankedCandidates([{ ...fullCandidate, issueNumber: 0 }])).toThrow( + "invalid_ranked_candidate", + ); + expect(() => store.saveRankedCandidates([{ ...fullCandidate, issueNumber: 1.5 }])).toThrow( + "invalid_ranked_candidate", + ); + expect(() => store.saveRankedCandidates([{ ...fullCandidate, rankScore: Number.NaN }])).toThrow( + "invalid_ranked_candidate", + ); + // An invalid entry mid-array must abort the WHOLE save (no partial write), verified by the table staying + // empty after a rejected call that had one valid entry ahead of the bad one. + expect(() => + store.saveRankedCandidates([fullCandidate, { ...fullCandidate, issueNumber: -1 }]), + ).toThrow("invalid_ranked_candidate"); + expect(store.listRankedCandidates()).toEqual([]); + } finally { + store.close(); + } + }); + + it("rolls back the whole transaction on a genuine SQL-level failure (a duplicate repo+issue within one save)", () => { + // Both entries individually pass normalizeCandidate (nothing there checks for array-internal duplicates), so + // this is the one realistic way to reach the PRIMARY KEY constraint -- and therefore replaceAll's own + // BEGIN IMMEDIATE/COMMIT/ROLLBACK transaction wrapper, which nothing else in this file exercises. + const dbPath = join(tempRoot(), "ranked-candidates.sqlite3"); + const store = initRankedCandidatesStore(dbPath); + try { + store.saveRankedCandidates([fullCandidate], Date.parse("2026-07-13T12:00:00.000Z")); + expect(store.listRankedCandidates()).toHaveLength(1); + + expect(() => + store.saveRankedCandidates( + [{ ...fullCandidate, title: "first" }, { ...fullCandidate, title: "duplicate" }], + Date.parse("2026-07-13T13:00:00.000Z"), + ), + ).toThrow(); + + // The prior snapshot survives: the failed transaction's DELETE was rolled back along with its INSERTs, so + // the store is neither left empty nor partially written -- exactly the pre-save state. + const rows = store.listRankedCandidates(); + expect(rows).toHaveLength(1); + expect(rows[0]?.rankedAt).toBe("2026-07-13T12:00:00.000Z"); + } finally { + store.close(); + } + }); + + it("defaults nowMs to the real clock when not injected", () => { + const dbPath = join(tempRoot(), "ranked-candidates.sqlite3"); + const store = initRankedCandidatesStore(dbPath); + try { + const before = Date.now(); + const result = store.saveRankedCandidates([fullCandidate]); + const after = Date.now(); + const rankedAtMs = Date.parse(result.rankedAt); + expect(rankedAtMs).toBeGreaterThanOrEqual(before); + expect(rankedAtMs).toBeLessThanOrEqual(after); + } finally { + store.close(); + } + }); + + it("module-level convenience functions operate on the lazily-opened default store", () => { + const root = tempRoot(); + vi.stubEnv("GITTENSORY_MINER_RANKED_CANDIDATES_DB", join(root, "ranked-candidates.sqlite3")); + const result = saveRankedCandidates([fullCandidate], Date.parse("2026-07-13T12:00:00.000Z")); + expect(result.count).toBe(1); + expect(listRankedCandidates()).toEqual([{ ...fullCandidate, rankedAt: "2026-07-13T12:00:00.000Z" }]); + }); +});