diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 3f2076ae46..0dede67fc4 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -187,3 +187,4 @@ export { pickTopMetadataOpportunities } from "./metadata-top-pick.js"; export { rankMetadataOpportunitiesAtOrAboveScore } from "./metadata-min-score.js"; export { pickTopMetadataOpportunitiesAtOrAboveScore } from "./metadata-top-min-score.js"; export { bestMetadataOpportunity } from "./metadata-best-pick.js"; +export { bestMetadataOpportunityAtOrAboveScore } from "./metadata-best-min-score.js"; diff --git a/packages/gittensory-engine/src/metadata-best-min-score.ts b/packages/gittensory-engine/src/metadata-best-min-score.ts new file mode 100644 index 0000000000..cef0bf331f --- /dev/null +++ b/packages/gittensory-engine/src/metadata-best-min-score.ts @@ -0,0 +1,16 @@ +import { rankMetadataOpportunitiesAtOrAboveScore } from "./metadata-min-score.js"; +import type { MetadataCandidateIssue, MetadataRankContext } from "./opportunity-metadata.js"; +import type { OpportunityRankInput } from "./opportunity-ranker.js"; + +/** + * Return the highest-scoring metadata candidate at or above `minScore`, or `null` when none qualify. + * Non-finite thresholds return `null`. Pure — delegates to {@link rankMetadataOpportunitiesAtOrAboveScore}. + */ +export function bestMetadataOpportunityAtOrAboveScore( + candidates: readonly T[], + context: MetadataRankContext, + minScore: number, +): (T & OpportunityRankInput & { rankScore: number }) | null { + const survivors = rankMetadataOpportunitiesAtOrAboveScore(candidates, context, minScore); + return survivors[0] ?? null; +} diff --git a/test/unit/metadata-best-min-score.test.ts b/test/unit/metadata-best-min-score.test.ts new file mode 100644 index 0000000000..67faf6d240 --- /dev/null +++ b/test/unit/metadata-best-min-score.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_MINER_GOAL_SPEC } from "../../packages/gittensory-engine/src/miner-goal-spec"; +import { bestMetadataOpportunityAtOrAboveScore } from "../../packages/gittensory-engine/src/metadata-best-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("bestMetadataOpportunityAtOrAboveScore", () => { + const candidates = [ + { ...base, issueNumber: 1, labels: ["wontfix"] }, + { ...base, issueNumber: 2, labels: ["help wanted"] }, + { ...base, issueNumber: 3, labels: ["help wanted", "bug"] }, + { ...base, issueNumber: 4, labels: ["help wanted", "documentation"] }, + ]; + + it("returns null for an empty candidate list", () => { + expect(bestMetadataOpportunityAtOrAboveScore([], { nowMs: NOW }, 0.1)).toBeNull(); + }); + + it("returns null when the score threshold excludes every candidate", () => { + expect(bestMetadataOpportunityAtOrAboveScore(candidates, { nowMs: NOW }, 1)).toBeNull(); + }); + + it("returns null for a non-finite threshold", () => { + expect(bestMetadataOpportunityAtOrAboveScore(candidates, { nowMs: NOW }, Number.NaN)).toBeNull(); + expect( + bestMetadataOpportunityAtOrAboveScore(candidates, { nowMs: NOW }, Number.POSITIVE_INFINITY), + ).toBeNull(); + }); + + it("returns the highest-scoring survivor at or above the threshold", () => { + const best = bestMetadataOpportunityAtOrAboveScore(candidates, { nowMs: NOW }, 0.1); + expect(best?.issueNumber).toBe(3); + expect(best?.rankScore).toBeGreaterThanOrEqual(0.1); + }); + + it("returns null when every candidate is miner-disabled", () => { + expect( + bestMetadataOpportunityAtOrAboveScore( + [{ ...base, issueNumber: 1, repoFullName: "acme/disabled" }], + { + nowMs: NOW, + goalSpecsByRepo: { + "acme/disabled": { ...DEFAULT_MINER_GOAL_SPEC, minerEnabled: false }, + }, + }, + 0, + ), + ).toBeNull(); + }); + + it("breaks score ties by input order among qualifying candidates", () => { + const tie = { potential: 0.8, feasibility: 0.8, laneFit: 1, freshness: 1, dupRisk: 0 }; + const best = bestMetadataOpportunityAtOrAboveScore( + [ + { ...base, issueNumber: 1, ...tie }, + { ...base, issueNumber: 2, ...tie }, + ], + { nowMs: NOW }, + 0.05, + ); + expect(best?.issueNumber).toBe(1); + }); + + it("is exported from the package barrel", async () => { + const barrel = await import("../../packages/gittensory-engine/src/index"); + expect(typeof barrel.bestMetadataOpportunityAtOrAboveScore).toBe("function"); + expect( + barrel.bestMetadataOpportunityAtOrAboveScore(candidates, { nowMs: NOW }, 0.1)?.issueNumber, + ).toBe(3); + }); +});