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
28 changes: 28 additions & 0 deletions packages/gittensory-miner/lib/portfolio-discovery.d.ts
Original file line number Diff line number Diff line change
@@ -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;
95 changes: 95 additions & 0 deletions packages/gittensory-miner/lib/portfolio-discovery.js
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
186 changes: 186 additions & 0 deletions test/unit/miner-portfolio-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
Loading