diff --git a/packages/gittensory-miner/lib/portfolio-discovery.d.ts b/packages/gittensory-miner/lib/portfolio-discovery.d.ts new file mode 100644 index 0000000000..bed57cb072 --- /dev/null +++ b/packages/gittensory-miner/lib/portfolio-discovery.d.ts @@ -0,0 +1,28 @@ +import type { EventLedger } from "./event-ledger.js"; +import type { PortfolioQueueStore } from "./portfolio-queue.js"; + +export type EnqueueRankedDiscoveryInput = { + repoFullName: string; + issueNumber: number; + title: string; + labels?: string[]; + rankScore: number; +}; + +export type EnqueueRankedDiscoveryOptions = { + queueStore: PortfolioQueueStore; + eventLedger?: EventLedger; + minRankScore?: number | null; +}; + +export type EnqueueRankedDiscoverySummary = { + enqueued: number; + skippedBelowMinRank: number; + skippedInvalid: number; + eventsAppended: number; +}; + +export function enqueueRankedDiscovery( + rankedIssues: readonly EnqueueRankedDiscoveryInput[], + options: EnqueueRankedDiscoveryOptions, +): EnqueueRankedDiscoverySummary; diff --git a/packages/gittensory-miner/lib/portfolio-discovery.js b/packages/gittensory-miner/lib/portfolio-discovery.js new file mode 100644 index 0000000000..89abca883a --- /dev/null +++ b/packages/gittensory-miner/lib/portfolio-discovery.js @@ -0,0 +1,95 @@ +/** Local orchestration: materialize ranked fan-out rows into the portfolio queue (#2292). */ + +function normalizeMinRankScore(minRankScore) { + if (minRankScore === undefined || minRankScore === null) return 0; + if (typeof minRankScore !== "number" || !Number.isFinite(minRankScore) || minRankScore < 0) { + throw new Error("invalid_min_rank_score"); + } + return minRankScore; +} + +function normalizeRankedIssue(issue) { + if (!issue || typeof issue !== "object") return null; + const repoFullName = typeof issue.repoFullName === "string" ? issue.repoFullName.trim() : ""; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) return null; + if (!Number.isInteger(issue.issueNumber) || issue.issueNumber <= 0) return null; + if (typeof issue.rankScore !== "number" || !Number.isFinite(issue.rankScore) || issue.rankScore < 0) { + return null; + } + const title = typeof issue.title === "string" ? issue.title.trim() : ""; + if (!title) return null; + const labels = Array.isArray(issue.labels) + ? issue.labels.filter((label) => typeof label === "string" && label.trim()).map((label) => label.trim()) + : []; + return { + repoFullName: `${owner}/${repo}`, + issueNumber: issue.issueNumber, + title, + labels, + rankScore: issue.rankScore, + }; +} + +/** + * Enqueue ranked discovery rows into the local portfolio backlog. Uses each row's `rankScore` as queue priority + * (the #2292 placeholder field). Optionally appends `discovered_issue` audit events when an event ledger is supplied. + * Never calls GitHub — callers rank locally first via `rankCandidateIssues`. + */ +export function enqueueRankedDiscovery(rankedIssues, options = {}) { + if (!Array.isArray(rankedIssues)) throw new Error("invalid_ranked_issues"); + const queueStore = options.queueStore; + if (!queueStore || typeof queueStore.enqueue !== "function") throw new Error("invalid_queue_store"); + + let eventLedger = null; + if (options.eventLedger !== undefined) { + eventLedger = options.eventLedger; + if (!eventLedger || typeof eventLedger.appendEvent !== "function") { + throw new Error("invalid_event_ledger"); + } + } + + const minRankScore = normalizeMinRankScore(options.minRankScore); + + const summary = { + enqueued: 0, + skippedBelowMinRank: 0, + skippedInvalid: 0, + eventsAppended: 0, + }; + + for (const issue of rankedIssues) { + const normalized = normalizeRankedIssue(issue); + if (!normalized) { + summary.skippedInvalid += 1; + continue; + } + if (normalized.rankScore < minRankScore) { + summary.skippedBelowMinRank += 1; + continue; + } + + queueStore.enqueue({ + repoFullName: normalized.repoFullName, + identifier: `issue:${normalized.issueNumber}`, + priority: normalized.rankScore, + }); + summary.enqueued += 1; + + if (eventLedger) { + eventLedger.appendEvent({ + type: "discovered_issue", + repoFullName: normalized.repoFullName, + payload: { + issueNumber: normalized.issueNumber, + rankScore: normalized.rankScore, + title: normalized.title, + labels: normalized.labels, + }, + }); + summary.eventsAppended += 1; + } + } + + return summary; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index f2aa9fe4d8..922b022f79 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,7 +31,7 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/portfolio-queue.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/manage-status.js && node --check lib/status.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/manage-status.js && node --check lib/status.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-portfolio-discovery.test.ts b/test/unit/miner-portfolio-discovery.test.ts new file mode 100644 index 0000000000..0bb2e7c2cc --- /dev/null +++ b/test/unit/miner-portfolio-discovery.test.ts @@ -0,0 +1,186 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + closeDefaultEventLedger, + initEventLedger, +} from "../../packages/gittensory-miner/lib/event-ledger.js"; +import { enqueueRankedDiscovery } from "../../packages/gittensory-miner/lib/portfolio-discovery.js"; +import type { EnqueueRankedDiscoveryInput } from "../../packages/gittensory-miner/lib/portfolio-discovery.d.ts"; +import { + closeDefaultPortfolioQueueStore, + initPortfolioQueueStore, +} from "../../packages/gittensory-miner/lib/portfolio-queue.js"; + +const roots: string[] = []; +const stores: Array<{ close(): void }> = []; + +function tempQueueStore() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-portfolio-discovery-")); + roots.push(root); + const store = initPortfolioQueueStore(join(root, "portfolio-queue.sqlite3")); + stores.push(store); + return store; +} + +function tempEventLedger() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-portfolio-discovery-ledger-")); + roots.push(root); + const ledger = initEventLedger(join(root, "event-ledger.sqlite3")); + stores.push(ledger); + return ledger; +} + +function rankedIssue(overrides: Partial = {}): EnqueueRankedDiscoveryInput { + return { + repoFullName: "acme/widgets", + issueNumber: 42, + title: "Add queue retry helper", + labels: ["help wanted"], + rankScore: 50, + ...overrides, + }; +} + +afterEach(() => { + for (const store of stores.splice(0)) store.close(); + closeDefaultPortfolioQueueStore(); + closeDefaultEventLedger(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("gittensory-miner portfolio discovery (#2292)", () => { + it("returns a zero summary for empty input without touching the queue", () => { + const queueStore = tempQueueStore(); + expect(enqueueRankedDiscovery([], { queueStore })).toEqual({ + enqueued: 0, + skippedBelowMinRank: 0, + skippedInvalid: 0, + eventsAppended: 0, + }); + expect(queueStore.listQueue()).toEqual([]); + }); + + it("enqueues ranked rows using rankScore as portfolio priority", () => { + const queueStore = tempQueueStore(); + const summary = enqueueRankedDiscovery( + [ + rankedIssue({ issueNumber: 1, rankScore: 10 }), + rankedIssue({ issueNumber: 2, rankScore: 90 }), + rankedIssue({ issueNumber: 3, rankScore: 40 }), + ], + { queueStore }, + ); + expect(summary).toEqual({ + enqueued: 3, + skippedBelowMinRank: 0, + skippedInvalid: 0, + eventsAppended: 0, + }); + expect(queueStore.dequeueNext()?.identifier).toBe("issue:2"); + expect(queueStore.dequeueNext()?.identifier).toBe("issue:3"); + expect(queueStore.dequeueNext()?.identifier).toBe("issue:1"); + }); + + it("skips rows below minRankScore without enqueueing them", () => { + const queueStore = tempQueueStore(); + const summary = enqueueRankedDiscovery( + [ + rankedIssue({ issueNumber: 1, rankScore: 5 }), + rankedIssue({ issueNumber: 2, rankScore: 25 }), + ], + { queueStore, minRankScore: 20 }, + ); + expect(summary).toEqual({ + enqueued: 1, + skippedBelowMinRank: 1, + skippedInvalid: 0, + eventsAppended: 0, + }); + expect(queueStore.listQueue().map((entry) => entry.identifier)).toEqual(["issue:2"]); + }); + + it("skips malformed ranked rows instead of throwing", () => { + const queueStore = tempQueueStore(); + const summary = enqueueRankedDiscovery( + [ + rankedIssue({ issueNumber: 1, rankScore: 30 }), + { repoFullName: "bad", issueNumber: 2, title: "x", rankScore: 40 }, + rankedIssue({ issueNumber: 3, title: "", rankScore: 50 }), + ] as EnqueueRankedDiscoveryInput[], + { queueStore }, + ); + expect(summary).toEqual({ + enqueued: 1, + skippedBelowMinRank: 0, + skippedInvalid: 2, + eventsAppended: 0, + }); + expect(queueStore.listQueue()[0]?.identifier).toBe("issue:1"); + }); + + it("refreshes priority for done items but leaves in_progress rows unchanged", () => { + const queueStore = tempQueueStore(); + enqueueRankedDiscovery([rankedIssue({ issueNumber: 7, rankScore: 10 })], { queueStore }); + expect(queueStore.dequeueNext()).toMatchObject({ identifier: "issue:7", status: "in_progress", priority: 10 }); + + enqueueRankedDiscovery([rankedIssue({ issueNumber: 8, rankScore: 5 })], { queueStore }); + queueStore.markDone("acme/widgets", "issue:8"); + + enqueueRankedDiscovery( + [ + rankedIssue({ issueNumber: 7, rankScore: 99 }), + rankedIssue({ issueNumber: 8, rankScore: 88 }), + ], + { queueStore }, + ); + + expect(queueStore.listQueue("acme/widgets").find((entry) => entry.identifier === "issue:7")).toMatchObject({ + status: "in_progress", + priority: 10, + }); + expect(queueStore.listQueue("acme/widgets").find((entry) => entry.identifier === "issue:8")).toMatchObject({ + status: "queued", + priority: 88, + }); + }); + + it("appends discovered_issue audit events when an event ledger is supplied", () => { + const queueStore = tempQueueStore(); + const eventLedger = tempEventLedger(); + const summary = enqueueRankedDiscovery([rankedIssue({ issueNumber: 12, rankScore: 33 })], { + queueStore, + eventLedger, + }); + expect(summary.eventsAppended).toBe(1); + expect(eventLedger.readEvents()).toEqual([ + expect.objectContaining({ + seq: 1, + type: "discovered_issue", + repoFullName: "acme/widgets", + payload: { + issueNumber: 12, + rankScore: 33, + title: "Add queue retry helper", + labels: ["help wanted"], + }, + }), + ]); + }); + + it("rejects invalid rankedIssues, queue store, event ledger, or minRankScore", () => { + const queueStore = tempQueueStore(); + expect(() => enqueueRankedDiscovery(null as never, { queueStore })).toThrow("invalid_ranked_issues"); + expect(() => enqueueRankedDiscovery([], { queueStore: null as never })).toThrow("invalid_queue_store"); + expect(() => + enqueueRankedDiscovery([], { queueStore, eventLedger: null as never }), + ).toThrow("invalid_event_ledger"); + expect(() => + enqueueRankedDiscovery([], { queueStore, eventLedger: {} as never }), + ).toThrow("invalid_event_ledger"); + expect(() => + enqueueRankedDiscovery([], { queueStore, minRankScore: Number.NaN }), + ).toThrow("invalid_min_rank_score"); + }); +});