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
8 changes: 4 additions & 4 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,17 @@ 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,
type ContributorFit,
type ContributorFitCheck,
type ContributorFitProfile,
} from "./contributor-fit.js";
export {
computeOpportunityFreshness,
type FreshnessIssue,
} from "./opportunity-freshness.js";
export {
buildMetadataRankInput,
computeMetadataDupRisk,
Expand Down
31 changes: 22 additions & 9 deletions packages/gittensory-engine/src/opportunity-freshness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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));
}
80 changes: 80 additions & 0 deletions packages/gittensory-engine/test/opportunity-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});