diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 9b0bb89479..7fab36c5bb 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -40,6 +40,10 @@ export { computeMinerGoalLaneFit, isMinerRepoTargetable, } from "./miner-goal-lane-fit.js"; +export { + computeOpportunityFreshness, + type FreshnessIssue, +} from "./opportunity-freshness.js"; export { computeOpportunityCompetition } from "./opportunity-competition.js"; export { classifyContributorFit, @@ -47,10 +51,6 @@ export { type ContributorFitCheck, type ContributorFitProfile, } from "./contributor-fit.js"; -export { - computeOpportunityFreshness, - type FreshnessIssue, -} from "./opportunity-freshness.js"; export { buildMetadataRankInput, computeMetadataDupRisk, diff --git a/packages/gittensory-engine/src/opportunity-freshness.ts b/packages/gittensory-engine/src/opportunity-freshness.ts index 6057673523..943ee78f49 100644 --- a/packages/gittensory-engine/src/opportunity-freshness.ts +++ b/packages/gittensory-engine/src/opportunity-freshness.ts @@ -12,22 +12,31 @@ function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } -const STALE_AGE_DAYS = 9999; +function isParseableTimestamp(value: string): boolean { + return Number.isFinite(Date.parse(value)); +} function pickTimestamp(issue: FreshnessIssue): string | null { const updated = typeof issue.updatedAt === "string" ? issue.updatedAt.trim() : ""; - if (updated) return updated; + if (updated && isParseableTimestamp(updated)) return updated; + const created = typeof issue.createdAt === "string" ? issue.createdAt.trim() : ""; - return created || null; + if (created && isParseableTimestamp(created)) return created; + + return null; } function issueAgeDays(value: string | null, nowMs: number): number { - if (!value) return STALE_AGE_DAYS; + if (!value) return 0; const parsed = Date.parse(value); - if (!Number.isFinite(parsed)) return STALE_AGE_DAYS; + if (!Number.isFinite(parsed)) return 0; return Math.floor((nowMs - parsed) / 86_400_000); } +function isOpenIssue(issue: FreshnessIssue): boolean { + return typeof issue?.state === "string" && issue.state.trim().toLowerCase() === "open"; +} + /* v8 ignore start -- Test-only export surface for branch coverage. */ export const opportunityFreshnessInternals = { pickTimestamp, @@ -46,10 +55,14 @@ export function computeOpportunityFreshness( ): number { /* v8 ignore next -- Caller supplies a finite epoch; non-finite clocks degrade to zero freshness. */ if (!Number.isFinite(nowMs)) return 0; - const openIssues = issues.filter((issue) => issue?.state?.toLowerCase() === "open"); + const openIssues = issues.filter(isOpenIssue); if (openIssues.length === 0) return 0; - const mostRecentAgeDays = Math.min( - ...openIssues.map((issue) => issueAgeDays(pickTimestamp(issue), nowMs)), - ); + + let mostRecentAgeDays = Number.POSITIVE_INFINITY; + for (const issue of openIssues) { + const ageDays = issueAgeDays(pickTimestamp(issue), nowMs); + if (ageDays < mostRecentAgeDays) mostRecentAgeDays = ageDays; + } + return round4(clamp(Math.exp(-mostRecentAgeDays / 20), 0.05, 1)); } diff --git a/packages/gittensory-engine/test/opportunity-freshness.test.ts b/packages/gittensory-engine/test/opportunity-freshness.test.ts new file mode 100644 index 0000000000..6e0172391e --- /dev/null +++ b/packages/gittensory-engine/test/opportunity-freshness.test.ts @@ -0,0 +1,80 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { computeOpportunityFreshness } from "../dist/index.js"; + +const nowMs = Date.parse("2026-07-03T00:00:00.000Z"); + +test("barrel: the public entrypoint re-exports the freshness scorer API", () => { + assert.equal(typeof computeOpportunityFreshness, "function"); +}); + +test("computeOpportunityFreshness returns 0 when no open issues exist", () => { + assert.equal(computeOpportunityFreshness([], nowMs), 0); + assert.equal( + computeOpportunityFreshness([{ state: "closed", updatedAt: "2026-07-01T00:00:00.000Z" }], nowMs), + 0, + ); +}); + +test("computeOpportunityFreshness decays with issue age", () => { + const fresh = computeOpportunityFreshness( + [{ state: "open", updatedAt: "2026-07-01T00:00:00.000Z" }], + nowMs, + ); + assert.ok(fresh > 0.7); + + const stale = computeOpportunityFreshness( + [{ state: "open", createdAt: "2023-01-01T00:00:00.000Z" }], + nowMs, + ); + assert.ok(stale <= 0.05); +}); + +test("computeOpportunityFreshness uses the most recently updated open issue", () => { + const score = computeOpportunityFreshness( + [ + { state: "open", updatedAt: "2023-01-01T00:00:00.000Z" }, + { state: "open", updatedAt: "2026-07-01T00:00:00.000Z" }, + ], + nowMs, + ); + assert.ok(score > 0.7); +}); + +test("computeOpportunityFreshness normalizes issue state case and blank timestamps", () => { + assert.ok( + computeOpportunityFreshness([{ state: "OPEN", updatedAt: "2026-07-01T00:00:00.000Z" }], nowMs) > 0.7, + ); + assert.ok( + computeOpportunityFreshness([{ state: " open ", updatedAt: "2026-07-01T00:00:00.000Z" }], nowMs) > 0.7, + ); + const score = computeOpportunityFreshness( + [{ state: "open", updatedAt: "", createdAt: "2026-07-01T00:00:00.000Z" }], + nowMs, + ); + assert.ok(score > 0.7); +}); + +test("computeOpportunityFreshness falls back from malformed updatedAt to createdAt", () => { + const stale = computeOpportunityFreshness( + [{ state: "open", updatedAt: "not-a-date", createdAt: "2023-01-01T00:00:00.000Z" }], + nowMs, + ); + assert.ok(stale <= 0.05); + + const fresh = computeOpportunityFreshness( + [{ state: "open", updatedAt: "not-a-date", createdAt: "2026-07-01T00:00:00.000Z" }], + nowMs, + ); + assert.ok(fresh > 0.7); +}); + +test("computeOpportunityFreshness handles large open-issue lists without spreading into Math.min", () => { + const issues = Array.from({ length: 200_000 }, (_, index) => ({ + state: "open", + updatedAt: index === 0 ? "2026-07-01T00:00:00.000Z" : "2023-01-01T00:00:00.000Z", + })); + const score = computeOpportunityFreshness(issues, nowMs); + assert.ok(score > 0.7); +});