diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 395226521f..f69f0adf89 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -165,3 +165,4 @@ export { type MetadataRankContext, } from "./opportunity-metadata.js"; export { pickTopMetadataOpportunities } from "./metadata-top-pick.js"; +export { rankMetadataOpportunitiesAtOrAboveScore } from "./metadata-min-score.js"; diff --git a/packages/gittensory-engine/src/metadata-min-score.ts b/packages/gittensory-engine/src/metadata-min-score.ts new file mode 100644 index 0000000000..7bff6964fe --- /dev/null +++ b/packages/gittensory-engine/src/metadata-min-score.ts @@ -0,0 +1,21 @@ +import { + rankMetadataOpportunities, + type MetadataCandidateIssue, + type MetadataRankContext, +} from "./opportunity-metadata.js"; +import type { OpportunityRankInput } from "./opportunity-ranker.js"; + +/** + * Rank metadata candidates and keep only those whose rank score is at or above `minScore`. + * Non-finite thresholds return an empty list. Pure — delegates to {@link rankMetadataOpportunities}. + */ +export function rankMetadataOpportunitiesAtOrAboveScore( + candidates: readonly T[], + context: MetadataRankContext, + minScore: number, +): Array { + if (!Number.isFinite(minScore)) return []; + if (candidates.length === 0) return []; + const threshold = Math.min(1, Math.max(0, minScore)); + return rankMetadataOpportunities(candidates, context).filter((entry) => entry.rankScore >= threshold); +} diff --git a/test/unit/metadata-min-score.test.ts b/test/unit/metadata-min-score.test.ts new file mode 100644 index 0000000000..845f992799 --- /dev/null +++ b/test/unit/metadata-min-score.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_MINER_GOAL_SPEC } from "../../packages/gittensory-engine/src/miner-goal-spec"; +import { rankMetadataOpportunitiesAtOrAboveScore } from "../../packages/gittensory-engine/src/metadata-min-score"; + +const NOW = Date.parse("2026-07-03T12:00:00.000Z"); + +const base = { + repoFullName: "acme/widgets", + issueNumber: 10, + title: "Improve queue retry semantics", + labels: ["help wanted"], + commentsCount: 2, + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-02T12:00:00.000Z", +}; + +describe("rankMetadataOpportunitiesAtOrAboveScore", () => { + const candidates = [ + { ...base, issueNumber: 1, labels: ["wontfix"] }, + { ...base, issueNumber: 2, labels: ["help wanted"] }, + { ...base, issueNumber: 3, labels: ["help wanted", "bug"] }, + ]; + + it("keeps only metadata candidates at or above the score threshold in rank order", () => { + const filtered = rankMetadataOpportunitiesAtOrAboveScore(candidates, { nowMs: NOW }, 0.1); + expect(filtered.map((entry) => entry.issueNumber)).toEqual([3, 2]); + expect(filtered.every((entry) => entry.rankScore >= 0.1)).toBe(true); + }); + + it("returns every targetable candidate when the threshold is zero", () => { + expect( + rankMetadataOpportunitiesAtOrAboveScore(candidates, { nowMs: NOW }, 0).map((entry) => entry.issueNumber), + ).toEqual([3, 2, 1]); + }); + + it("skips miner-disabled repos before applying the score threshold", () => { + const ranked = rankMetadataOpportunitiesAtOrAboveScore( + [ + { ...base, issueNumber: 1, repoFullName: "acme/disabled" }, + { ...base, issueNumber: 2, labels: ["help wanted"] }, + ], + { + nowMs: NOW, + goalSpecsByRepo: { + "acme/disabled": { ...DEFAULT_MINER_GOAL_SPEC, minerEnabled: false }, + }, + }, + 0, + ); + expect(ranked.map((entry) => entry.issueNumber)).toEqual([2]); + }); + + it("returns an empty array for a non-finite threshold or no candidates", () => { + expect(rankMetadataOpportunitiesAtOrAboveScore(candidates, { nowMs: NOW }, Number.NaN)).toEqual([]); + expect(rankMetadataOpportunitiesAtOrAboveScore(candidates, { nowMs: NOW }, Number.POSITIVE_INFINITY)).toEqual( + [], + ); + expect(rankMetadataOpportunitiesAtOrAboveScore([], { nowMs: NOW }, 0.5)).toEqual([]); + }); + + it("clamps out-of-range thresholds before filtering", () => { + expect( + rankMetadataOpportunitiesAtOrAboveScore(candidates, { nowMs: NOW }, -0.5).map((entry) => entry.issueNumber), + ).toEqual([3, 2, 1]); + expect( + rankMetadataOpportunitiesAtOrAboveScore(candidates, { nowMs: NOW }, 1.5).map((entry) => entry.issueNumber), + ).toEqual([]); + }); + + it("is exported from the package barrel", async () => { + const barrel = await import("../../packages/gittensory-engine/src/index"); + expect(typeof barrel.rankMetadataOpportunitiesAtOrAboveScore).toBe("function"); + expect( + barrel.rankMetadataOpportunitiesAtOrAboveScore(candidates, { nowMs: NOW }, 0.1).map( + (entry: { issueNumber: number }) => entry.issueNumber, + ), + ).toEqual([3, 2]); + }); +});