diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 0eaa78fdd8..25f4f3b36e 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -70,6 +70,19 @@ describe step ordering via `dependsOn` but never actuate anything. `opportunityCompetitionFactor` in `src/signals/reward-risk.ts`, producing a `[0, 1]` signal suitable for the ranker's `dupRisk` input. +## Metadata opportunity signals + +`opportunity-metadata.ts` turns fan-out issue metadata into the five normalized ranker inputs: + +- `computeMetadataPotential` — label-based upside estimate +- `computeMetadataFeasibility` — comment load + issue age + title quality +- `computeMetadataDupRisk` — same-repo title overlap inside a candidate batch +- `buildMetadataRankInput` — composes freshness, competition, lane fit, and the metadata heuristics +- `rankMetadataOpportunities` — sorts candidates with `rankOpportunities` + +`computeOpportunityFreshness` and `computeOpportunityCompetition` mirror the hosted reward-risk helpers with pure, +injected-clock semantics for local miners. + ## AI Policy Map `scanAiPolicyText` and `resolveAiPolicyVerdict` provide the deterministic policy gate used by miner discovery. diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 538d5a64d5..4ef7d236bf 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -34,3 +34,16 @@ export { isMinerRepoTargetable, } from "./miner-goal-lane-fit.js"; export { computeOpportunityCompetition } from "./opportunity-competition.js"; +export { + computeOpportunityFreshness, + type FreshnessIssue, +} from "./opportunity-freshness.js"; +export { + buildMetadataRankInput, + computeMetadataDupRisk, + computeMetadataFeasibility, + computeMetadataPotential, + rankMetadataOpportunities, + type MetadataCandidateIssue, + type MetadataRankContext, +} from "./opportunity-metadata.js"; diff --git a/packages/gittensory-engine/src/opportunity-freshness.ts b/packages/gittensory-engine/src/opportunity-freshness.ts new file mode 100644 index 0000000000..6057673523 --- /dev/null +++ b/packages/gittensory-engine/src/opportunity-freshness.ts @@ -0,0 +1,55 @@ +export type FreshnessIssue = { + state: string; + updatedAt?: string | null; + createdAt?: string | null; +}; + +function round4(value: number): number { + return Math.round(value * 10000) / 10000; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +const STALE_AGE_DAYS = 9999; + +function pickTimestamp(issue: FreshnessIssue): string | null { + const updated = typeof issue.updatedAt === "string" ? issue.updatedAt.trim() : ""; + if (updated) return updated; + const created = typeof issue.createdAt === "string" ? issue.createdAt.trim() : ""; + return created || null; +} + +function issueAgeDays(value: string | null, nowMs: number): number { + if (!value) return STALE_AGE_DAYS; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return STALE_AGE_DAYS; + return Math.floor((nowMs - parsed) / 86_400_000); +} + +/* v8 ignore start -- Test-only export surface for branch coverage. */ +export const opportunityFreshnessInternals = { + pickTimestamp, + issueAgeDays, +}; +/* v8 ignore stop */ + +/** + * Compute a [0.05, 1] freshness factor from open issue timestamps, mirroring + * `opportunityFreshnessFactor` in `src/signals/reward-risk.ts` with an injected clock so the miner engine + * stays pure and testable. + */ +export function computeOpportunityFreshness( + issues: readonly FreshnessIssue[], + nowMs: number, +): 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"); + if (openIssues.length === 0) return 0; + const mostRecentAgeDays = Math.min( + ...openIssues.map((issue) => issueAgeDays(pickTimestamp(issue), nowMs)), + ); + return round4(clamp(Math.exp(-mostRecentAgeDays / 20), 0.05, 1)); +} diff --git a/packages/gittensory-engine/src/opportunity-metadata.ts b/packages/gittensory-engine/src/opportunity-metadata.ts new file mode 100644 index 0000000000..fc9c06d5fb --- /dev/null +++ b/packages/gittensory-engine/src/opportunity-metadata.ts @@ -0,0 +1,206 @@ +import { computeMinerGoalLaneFit } from "./miner-goal-lane-fit.js"; +import { DEFAULT_MINER_GOAL_SPEC, type MinerGoalSpec } from "./miner-goal-spec.js"; +import { computeOpportunityCompetition } from "./opportunity-competition.js"; +import { computeOpportunityFreshness } from "./opportunity-freshness.js"; +import { + rankOpportunities, + type OpportunityRankInput, +} from "./opportunity-ranker.js"; + +/** Metadata-only candidate issue shape produced by `@jsonbored/gittensory-miner` fan-out helpers. */ +export type MetadataCandidateIssue = { + repoFullName: string; + issueNumber: number; + title: string; + labels: readonly string[]; + commentsCount: number; + createdAt?: string | null | undefined; + updatedAt?: string | null | undefined; +}; + +export type MetadataRankContext = { + nowMs: number; + highRiskDuplicateClusters?: number | undefined; + openPullRequests?: number | undefined; + goalSpecsByRepo?: Readonly> | undefined; +}; + +const POSITIVE_LABELS = Object.freeze([ + "good first issue", + "help wanted", + "enhancement", + "feature", + "documentation", +]); +const NEGATIVE_LABELS = Object.freeze([ + "blocked", + "wontfix", + "duplicate", + "invalid", + "question", +]); + +function clamp01(value: number): number { + /* v8 ignore next -- Defensive guard for malformed adapter input; scores are always finite in practice. */ + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +function finiteNonNegativeInt(value: number): number { + /* v8 ignore next -- Defensive guard for malformed adapter input; counts are normalized before scoring. */ + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +} + +function normalizeLabels(labels: readonly string[]): string[] { + return labels + .filter((label): label is string => typeof label === "string") + .map((label) => label.trim().toLowerCase()) + .filter(Boolean); +} + +function normalizeTitle(title: string): string { + return title.replace(/\s+/g, " ").trim().toLowerCase(); +} + +function resolveGoalSpec(repoFullName: string, context: MetadataRankContext): MinerGoalSpec { + const target = repoFullName.trim().toLowerCase(); + const entries = context.goalSpecsByRepo ? Object.entries(context.goalSpecsByRepo) : []; + for (const [repo, spec] of entries) { + if (repo.trim().toLowerCase() === target) return spec; + } + return DEFAULT_MINER_GOAL_SPEC; +} + +const STALE_AGE_DAYS = 9999; + +function pickMetadataTimestamp(issue: MetadataCandidateIssue): string { + if (typeof issue.updatedAt === "string") { + const updated = issue.updatedAt.trim(); + if (updated) return updated; + } + if (typeof issue.createdAt === "string") { + const created = issue.createdAt.trim(); + if (created) return created; + } + return ""; +} + +function issueAgeDays(issue: MetadataCandidateIssue, nowMs: number): number { + const stamp = pickMetadataTimestamp(issue); + if (!stamp) return STALE_AGE_DAYS; + const parsed = Date.parse(stamp); + if (!Number.isFinite(parsed)) return STALE_AGE_DAYS; + return Math.max(0, Math.floor((nowMs - parsed) / 86_400_000)); +} + +/** + * Estimate reward potential from issue labels alone. Explicitly negative labels collapse the score; common + * contribution labels raise it; everything else keeps a neutral baseline. + */ +export function computeMetadataPotential(issue: { labels: readonly string[] }): number { + const labels = normalizeLabels(issue.labels); + if (labels.some((label) => NEGATIVE_LABELS.includes(label))) return 0; + let score = 0.45; + if (labels.some((label) => POSITIVE_LABELS.includes(label))) score += 0.35; + if (labels.includes("bug")) score += 0.1; + if (labels.includes("refactor")) score += 0.05; + return clamp01(score); +} + +/** + * Estimate achievability from metadata-only cues: lower discussion load and fresher issues score higher. + */ +export function computeMetadataFeasibility(issue: MetadataCandidateIssue, nowMs: number): number { + if (!Number.isFinite(nowMs)) return 0; + const comments = finiteNonNegativeInt(issue.commentsCount); + const commentScore = clamp01(1 - comments / 25); + const ageDays = issueAgeDays(issue, nowMs); + const ageScore = clamp01(Math.exp(-ageDays / 45)); + const titleLength = normalizeTitle(issue.title).length; + let titleScore = 0.4; + if (titleLength >= 8) { + titleScore = 1; + } else if (titleLength >= 4) { + titleScore = 0.7; + } + return clamp01(commentScore * 0.45 + ageScore * 0.35 + titleScore * 0.2); +} + +function titlesOverlap(left: string, right: string): boolean { + /* v8 ignore next -- Empty titles are filtered before overlap checks run. */ + if (!left || !right) return false; + if (left === right) return true; + let shorter = left; + let longer = right; + if (left.length > right.length) { + shorter = right; + longer = left; + } + return longer.includes(shorter) && shorter.length >= 12; +} + +/* v8 ignore start -- Test-only export surface for branch coverage. */ +export const opportunityMetadataInternals = { + titlesOverlap, + normalizeLabels, + resolveGoalSpec, + pickMetadataTimestamp, +}; +/* v8 ignore stop */ + +/** + * Estimate duplicate-work risk inside a metadata-only candidate batch by looking for overlapping titles in the + * same repository. This is intentionally conservative: any strong overlap raises dupRisk toward 1. + */ +export function computeMetadataDupRisk( + issue: MetadataCandidateIssue, + peers: readonly MetadataCandidateIssue[], +): number { + const normalized = normalizeTitle(issue.title); + if (!normalized) return 1; + let overlaps = 0; + for (const peer of peers) { + if (peer.issueNumber === issue.issueNumber && peer.repoFullName === issue.repoFullName) continue; + if (peer.repoFullName.trim().toLowerCase() !== issue.repoFullName.trim().toLowerCase()) continue; + if (titlesOverlap(normalized, normalizeTitle(peer.title))) overlaps += 1; + } + if (overlaps === 0) return 0; + return clamp01(overlaps / (overlaps + 1)); +} + +/** Build the five ranker inputs for one metadata candidate. Pure. */ +export function buildMetadataRankInput( + issue: MetadataCandidateIssue, + peers: readonly MetadataCandidateIssue[], + context: MetadataRankContext, +): OpportunityRankInput { + const goalSpec = resolveGoalSpec(issue.repoFullName, context); + const repoCompetition = computeOpportunityCompetition( + context.highRiskDuplicateClusters ?? 0, + context.openPullRequests ?? 0, + ); + const batchDupRisk = computeMetadataDupRisk(issue, peers); + return { + potential: computeMetadataPotential(issue), + feasibility: computeMetadataFeasibility(issue, context.nowMs), + laneFit: computeMinerGoalLaneFit(issue, goalSpec), + freshness: computeOpportunityFreshness( + [{ state: "open", updatedAt: issue.updatedAt ?? null, createdAt: issue.createdAt ?? null }], + context.nowMs, + ), + dupRisk: clamp01(Math.max(batchDupRisk, repoCompetition)), + }; +} + +/** Rank metadata-only candidates with the shared opportunity ranker. Pure. */ +export function rankMetadataOpportunities( + candidates: readonly T[], + context: MetadataRankContext, +): Array { + const annotated = candidates.map((candidate) => ({ + ...candidate, + ...buildMetadataRankInput(candidate, candidates, context), + })); + return rankOpportunities(annotated) as Array; +} diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index dd7b102c51..0f77256300 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -20,6 +20,10 @@ metadata across target repos, and `searchCandidateIssues` does the same from a G paths hard-skip repos whose `AI-USAGE.md` or `CONTRIBUTING.md` explicitly bans AI-generated PRs. They perform GitHub GET requests only, never clone source, never upload source, and never write to GitHub. +The package also includes a metadata-only ranker: `rankCandidateIssues` composes deterministic engine signals +(potential, feasibility, lane fit, freshness, dup risk) and returns fan-out candidates sorted by `rankScore`. +It never clones source and never writes to GitHub. + ## Install From a local checkout: diff --git a/packages/gittensory-miner/lib/opportunity-ranker.d.ts b/packages/gittensory-miner/lib/opportunity-ranker.d.ts new file mode 100644 index 0000000000..efc76dee3a --- /dev/null +++ b/packages/gittensory-miner/lib/opportunity-ranker.d.ts @@ -0,0 +1,36 @@ +import type { MinerGoalSpec } from "@jsonbored/gittensory-engine"; +import type { RawCandidateIssue } from "./opportunity-fanout.js"; + +export type RankedCandidateIssue = RawCandidateIssue & { + potential: number; + feasibility: number; + laneFit: number; + freshness: number; + dupRisk: number; + rankScore: number; +}; + +export type RankCandidateIssuesOptions = { + nowMs?: number; + highRiskDuplicateClusters?: number; + openPullRequests?: number; + goalSpecsByRepo?: Record; + goalSpecContentByRepo?: Record; +}; + +export type RankedCandidateSummary = { + issues: RankedCandidateIssue[]; + skippedInvalid: number; + usedDefaultGoalSpec: boolean; + defaultGoalSpec: MinerGoalSpec; +}; + +export function rankCandidateIssues( + candidates: RawCandidateIssue[], + options?: RankCandidateIssuesOptions, +): RankedCandidateIssue[]; + +export function rankCandidateIssuesWithSummary( + candidates: RawCandidateIssue[], + options?: RankCandidateIssuesOptions, +): RankedCandidateSummary; diff --git a/packages/gittensory-miner/lib/opportunity-ranker.js b/packages/gittensory-miner/lib/opportunity-ranker.js new file mode 100644 index 0000000000..a98de2cb03 --- /dev/null +++ b/packages/gittensory-miner/lib/opportunity-ranker.js @@ -0,0 +1,105 @@ +import { + DEFAULT_MINER_GOAL_SPEC, + parseMinerGoalSpecContent, + rankMetadataOpportunities, +} from "@jsonbored/gittensory-engine"; + +function finiteEpochMs(value) { + return Number.isFinite(value) ? value : Date.now(); +} + +function finiteNonNegativeInt(value) { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.floor(value)); +} + +function normalizeCandidate(candidate) { + if (!candidate || typeof candidate !== "object") return null; + const repoFullName = + typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; + const issueNumber = candidate.issueNumber; + const title = typeof candidate.title === "string" ? candidate.title.trim() : ""; + if (!repoFullName || !Number.isInteger(issueNumber) || issueNumber <= 0 || !title) return null; + const labels = Array.isArray(candidate.labels) + ? candidate.labels + .filter((label) => typeof label === "string" && label.trim()) + .map((label) => label.trim()) + : []; + return { + owner: typeof candidate.owner === "string" ? candidate.owner : repoFullName.split("/")[0] ?? "", + repo: typeof candidate.repo === "string" ? candidate.repo : repoFullName.split("/")[1] ?? "", + repoFullName, + issueNumber, + title, + labels, + commentsCount: Number.isFinite(candidate.commentsCount) ? candidate.commentsCount : 0, + createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : null, + updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : null, + htmlUrl: typeof candidate.htmlUrl === "string" ? candidate.htmlUrl : null, + aiPolicyAllowed: candidate.aiPolicyAllowed !== false, + aiPolicySource: + candidate.aiPolicySource === "AI-USAGE.md" || + candidate.aiPolicySource === "CONTRIBUTING.md" || + candidate.aiPolicySource === "none" + ? candidate.aiPolicySource + : "none", + }; +} + +function buildGoalSpecsByRepo(options = {}) { + const goalSpecsByRepo = { ...(options.goalSpecsByRepo ?? {}) }; + const rawContentByRepo = options.goalSpecContentByRepo ?? {}; + for (const [repoFullName, content] of Object.entries(rawContentByRepo)) { + if (typeof content !== "string" || !content.trim()) continue; + goalSpecsByRepo[repoFullName] = parseMinerGoalSpecContent(content).spec; + } + return goalSpecsByRepo; +} + +function buildRankContext(options = {}) { + return { + nowMs: finiteEpochMs(options.nowMs), + highRiskDuplicateClusters: finiteNonNegativeInt(options.highRiskDuplicateClusters), + openPullRequests: finiteNonNegativeInt(options.openPullRequests), + goalSpecsByRepo: buildGoalSpecsByRepo(options), + }; +} + +function collectCandidates(candidates) { + const input = Array.isArray(candidates) ? candidates : []; + let skippedInvalid = 0; + const normalized = []; + const seen = new Set(); + for (const candidate of input) { + const entry = normalizeCandidate(candidate); + if (!entry) { + skippedInvalid += 1; + continue; + } + const key = `${entry.repoFullName.toLowerCase()}#${entry.issueNumber}`; + if (seen.has(key)) continue; + seen.add(key); + normalized.push(entry); + } + return { normalized, skippedInvalid }; +} + +/** + * Rank metadata-only fan-out candidates locally. Never clones source, never uploads metadata, and never writes to + * GitHub — it only composes deterministic engine signals and returns the sorted list. + */ +export function rankCandidateIssues(candidates, options = {}) { + const { normalized } = collectCandidates(candidates); + return rankMetadataOpportunities(normalized, buildRankContext(options)); +} + +export function rankCandidateIssuesWithSummary(candidates, options = {}) { + const { normalized, skippedInvalid } = collectCandidates(candidates); + const ranked = rankMetadataOpportunities(normalized, buildRankContext(options)); + return { + issues: ranked, + skippedInvalid, + usedDefaultGoalSpec: Object.keys(buildGoalSpecsByRepo(options)).length === 0, + defaultGoalSpec: DEFAULT_MINER_GOAL_SPEC, + }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 7e175d4c58..c5fd345fa3 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -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/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" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.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/opportunity-ranker.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-opportunity-ranker.test.ts b/test/unit/miner-opportunity-ranker.test.ts new file mode 100644 index 0000000000..22523ede78 --- /dev/null +++ b/test/unit/miner-opportunity-ranker.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { + rankCandidateIssues, + rankCandidateIssuesWithSummary, +} from "../../packages/gittensory-miner/lib/opportunity-ranker.js"; + +const NOW = Date.parse("2026-07-03T12:00:00.000Z"); + +function rawIssue(overrides: Record = {}) { + return { + owner: "acme", + repo: "widgets", + repoFullName: "acme/widgets", + issueNumber: 42, + title: "Add queue retry helper", + labels: ["help wanted"], + commentsCount: 1, + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-02T00:00:00.000Z", + htmlUrl: "https://github.com/acme/widgets/issues/42", + aiPolicyAllowed: true as const, + aiPolicySource: "CONTRIBUTING.md" as const, + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("rankCandidateIssues (#2302 follow-up)", () => { + it("ranks valid fan-out candidates and annotates rankScore", () => { + const ranked = rankCandidateIssues( + [ + rawIssue({ issueNumber: 1, labels: ["question"] }), + rawIssue({ issueNumber: 2, labels: ["help wanted", "good first issue"] }), + rawIssue({ issueNumber: 3, labels: ["enhancement"] }), + ], + { nowMs: NOW }, + ); + + expect(ranked[0]?.issueNumber).toBe(2); + expect(ranked.at(-1)?.issueNumber).toBe(1); + expect(ranked.every((entry) => entry.rankScore >= 0)).toBe(true); + }); + + it("deduplicates repo/issue pairs and drops malformed entries", () => { + const ranked = rankCandidateIssues( + [ + rawIssue(), + rawIssue(), + { ...rawIssue(), issueNumber: "nope" as unknown as number }, + { ...rawIssue(), repoFullName: "" }, + null as unknown as ReturnType, + ], + { nowMs: NOW }, + ); + expect(ranked).toHaveLength(1); + expect(ranked[0]?.issueNumber).toBe(42); + }); + + it("parses per-repo goal-spec YAML content when ranking", () => { + const ranked = rankCandidateIssues([rawIssue({ labels: ["feature"] })], { + nowMs: NOW, + goalSpecContentByRepo: { + "acme/widgets": "preferredLabels: [feature]\nissueDiscoveryPolicy: encouraged\n", + }, + }); + expect(ranked[0]?.laneFit).toBeGreaterThanOrEqual(0.85); + }); + + it("raises dupRisk when repo-level contention inputs are provided", () => { + const calm = rankCandidateIssues([rawIssue()], { nowMs: NOW, highRiskDuplicateClusters: 0, openPullRequests: 4 }); + const busy = rankCandidateIssues([rawIssue()], { nowMs: NOW, highRiskDuplicateClusters: 4, openPullRequests: 4 }); + expect(busy[0]?.dupRisk).toBeGreaterThan(calm[0]?.dupRisk ?? 0); + expect(busy[0]?.rankScore ?? 1).toBeLessThan(calm[0]?.rankScore ?? 0); + }); + + it("summary reports skipped invalid rows and default goal-spec usage", () => { + const summary = rankCandidateIssuesWithSummary( + [rawIssue(), { bad: true } as unknown as ReturnType, rawIssue({ issueNumber: 0 })], + { + nowMs: NOW, + }, + ); + expect(summary.issues).toHaveLength(1); + expect(summary.skippedInvalid).toBe(2); + expect(summary.usedDefaultGoalSpec).toBe(true); + expect(summary.defaultGoalSpec.minerEnabled).toBe(true); + }); + + it("summary does not count deduplicated valid rows as skipped invalid", () => { + const summary = rankCandidateIssuesWithSummary([rawIssue(), rawIssue()], { nowMs: NOW }); + expect(summary.issues).toHaveLength(1); + expect(summary.skippedInvalid).toBe(0); + }); + + it("prefers fresher, better-labeled opportunities over stale question threads", () => { + const ranked = rankCandidateIssues( + [ + rawIssue({ + issueNumber: 10, + labels: ["question"], + updatedAt: "2023-01-01T00:00:00.000Z", + commentsCount: 30, + }), + rawIssue({ + issueNumber: 11, + labels: ["help wanted", "bug"], + updatedAt: "2026-07-03T08:00:00.000Z", + commentsCount: 0, + }), + ], + { nowMs: NOW }, + ); + expect(ranked[0]?.issueNumber).toBe(11); + }); + + it("never mutates the input array", () => { + const input = [rawIssue(), rawIssue({ issueNumber: 43, title: "Second issue" })]; + const snapshot = structuredClone(input); + rankCandidateIssues(input, { nowMs: NOW }); + expect(input).toEqual(snapshot); + }); + + it("returns an empty list for non-array input", () => { + expect(rankCandidateIssues(undefined as never, { nowMs: NOW })).toEqual([]); + }); + + it("accepts pre-parsed goal specs and normalizes ai policy metadata", () => { + const ranked = rankCandidateIssues( + [ + rawIssue({ + aiPolicySource: "none", + labels: ["documentation"], + }), + ], + { + nowMs: NOW, + goalSpecsByRepo: { + "acme/widgets": { + minerEnabled: true, + wantedPaths: [], + blockedPaths: [], + preferredLabels: ["documentation"], + blockedLabels: [], + maxConcurrentClaims: 2, + issueDiscoveryPolicy: "neutral", + }, + }, + }, + ); + expect(ranked[0]?.laneFit).toBe(1); + expect(ranked[0]?.aiPolicySource).toBe("none"); + }); + + it("derives owner/repo fields and ignores blank goal-spec YAML content", () => { + const ranked = rankCandidateIssues( + [ + { + repoFullName: "acme/widgets", + issueNumber: 99, + title: "Derived owner repo fields", + labels: ["help wanted"], + commentsCount: 1, + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-02T00:00:00.000Z", + aiPolicyAllowed: true, + aiPolicySource: "AI-USAGE.md", + } as unknown as ReturnType, + ], + { + nowMs: NOW, + goalSpecContentByRepo: { "acme/widgets": " " }, + }, + ); + expect(ranked[0]?.owner).toBe("acme"); + expect(ranked[0]?.repo).toBe("widgets"); + expect(ranked[0]?.aiPolicySource).toBe("AI-USAGE.md"); + }); + + it("uses Date.now when nowMs is not finite", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-03T12:00:00.000Z")); + const ranked = rankCandidateIssues([rawIssue()], { nowMs: Number.NaN }); + expect(ranked[0]?.freshness).toBeGreaterThan(0); + vi.useRealTimers(); + }); +}); diff --git a/test/unit/opportunity-branch-internals.test.ts b/test/unit/opportunity-branch-internals.test.ts new file mode 100644 index 0000000000..e492837158 --- /dev/null +++ b/test/unit/opportunity-branch-internals.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { opportunityFreshnessInternals } from "../../packages/gittensory-engine/src/opportunity-freshness"; +import { opportunityMetadataInternals } from "../../packages/gittensory-engine/src/opportunity-metadata"; +import { DEFAULT_MINER_GOAL_SPEC } from "../../packages/gittensory-engine/src/miner-goal-spec"; + +const NOW = Date.parse("2026-07-03T12:00:00.000Z"); + +describe("opportunity branch internals", () => { + it("pickTimestamp prefers updatedAt, then createdAt, then null", () => { + const { pickTimestamp } = opportunityFreshnessInternals; + expect( + pickTimestamp({ + state: "open", + updatedAt: "2026-07-03T00:00:00.000Z", + createdAt: "2020-01-01T00:00:00.000Z", + }), + ).toBe("2026-07-03T00:00:00.000Z"); + expect( + pickTimestamp({ + state: "open", + updatedAt: " ", + createdAt: "2026-07-03T00:00:00.000Z", + }), + ).toBe("2026-07-03T00:00:00.000Z"); + expect( + pickTimestamp({ + state: "open", + updatedAt: null, + createdAt: null, + }), + ).toBeNull(); + expect( + pickTimestamp({ + state: "open", + updatedAt: 123 as unknown as string, + createdAt: "2026-07-03T00:00:00.000Z", + }), + ).toBe("2026-07-03T00:00:00.000Z"); + }); + + it("issueAgeDays floors invalid timestamps to stale age", () => { + const { issueAgeDays } = opportunityFreshnessInternals; + expect(issueAgeDays(null, NOW)).toBe(9999); + expect(issueAgeDays("not-a-date", NOW)).toBe(9999); + expect(issueAgeDays("2026-07-03T00:00:00.000Z", NOW)).toBeGreaterThanOrEqual(0); + }); + + it("titlesOverlap covers empty, exact, orientation, and substring guards", () => { + const { titlesOverlap } = opportunityMetadataInternals; + expect(titlesOverlap("", "anything")).toBe(false); + expect(titlesOverlap("anything", "")).toBe(false); + expect(titlesOverlap("same title here", "same title here")).toBe(true); + expect(titlesOverlap("queue retry helper", "queue retry helper for workers")).toBe(true); + expect(titlesOverlap("queue retry helper for workers", "queue retry helper")).toBe(true); + expect(titlesOverlap("alpha beta gamma", "delta epsilon zeta")).toBe(false); + expect(titlesOverlap("tiny extra words", "tiny")).toBe(false); + }); + + it("normalizeLabels and resolveGoalSpec cover adapter edge branches", () => { + const { normalizeLabels, resolveGoalSpec } = opportunityMetadataInternals; + expect(normalizeLabels([" ", null as unknown as string, " Bug "])).toEqual(["bug"]); + expect( + resolveGoalSpec("acme/widgets", { + nowMs: NOW, + goalSpecsByRepo: { + "other/repo": DEFAULT_MINER_GOAL_SPEC, + "ACME/Widgets": { + minerEnabled: true, + wantedPaths: [], + blockedPaths: [], + preferredLabels: ["feature"], + blockedLabels: [], + maxConcurrentClaims: 1, + issueDiscoveryPolicy: "encouraged", + }, + }, + }).preferredLabels, + ).toEqual(["feature"]); + }); +}); diff --git a/test/unit/opportunity-freshness.test.ts b/test/unit/opportunity-freshness.test.ts new file mode 100644 index 0000000000..a72d4aa74f --- /dev/null +++ b/test/unit/opportunity-freshness.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { computeOpportunityFreshness } from "../../packages/gittensory-engine/src/opportunity-freshness"; + +const NOW = Date.parse("2026-07-03T12:00:00.000Z"); + +describe("computeOpportunityFreshness", () => { + it("prefers updatedAt over createdAt and treats blank timestamps as missing", () => { + expect( + computeOpportunityFreshness( + [{ state: "open", updatedAt: "2026-07-03T00:00:00.000Z", createdAt: "2020-01-01T00:00:00.000Z" }], + NOW, + ), + ).toBeGreaterThan(0.8); + expect( + computeOpportunityFreshness( + [{ state: "open", updatedAt: " ", createdAt: "2026-07-03T00:00:00.000Z" }], + NOW, + ), + ).toBeGreaterThan(0.8); + }); + + it("accepts uppercase state labels and treats missing timestamps as stale", () => { + expect( + computeOpportunityFreshness([{ state: "OPEN", updatedAt: "2026-07-03T00:00:00.000Z" }], NOW), + ).toBeGreaterThan(0.8); + expect(computeOpportunityFreshness([{ state: "open", updatedAt: "", createdAt: "" }], NOW)).toBe(0.05); + expect( + computeOpportunityFreshness([{ state: "open", createdAt: "not-a-date", updatedAt: "also-bad" }], NOW), + ).toBe(0.05); + }); + + it("ignores non-open issues and rejects non-finite clocks", () => { + expect( + computeOpportunityFreshness( + [{ state: "closed", updatedAt: "2026-07-03T00:00:00.000Z" }], + NOW, + ), + ).toBe(0); + expect(computeOpportunityFreshness([], NOW)).toBe(0); + expect(computeOpportunityFreshness([{ state: "open", updatedAt: "2026-07-03T00:00:00.000Z" }], Number.NaN)).toBe( + 0, + ); + }); + + it("falls back cleanly when timestamps are absent or non-string", () => { + expect( + computeOpportunityFreshness( + [{ state: "open", updatedAt: null, createdAt: " " }], + NOW, + ), + ).toBe(0.05); + expect( + computeOpportunityFreshness( + [{ state: "open", updatedAt: 123 as unknown as string, createdAt: "2026-07-03T00:00:00.000Z" }], + NOW, + ), + ).toBeGreaterThan(0.8); + expect( + computeOpportunityFreshness( + [{ state: undefined as unknown as string, updatedAt: "2026-07-03T00:00:00.000Z" }], + NOW, + ), + ).toBe(0); + expect( + computeOpportunityFreshness( + [{ state: "open", updatedAt: "2099-01-01T00:00:00.000Z" }], + NOW, + ), + ).toBe(1); + }); +}); diff --git a/test/unit/opportunity-metadata-signals.test.ts b/test/unit/opportunity-metadata-signals.test.ts new file mode 100644 index 0000000000..16525ef2c4 --- /dev/null +++ b/test/unit/opportunity-metadata-signals.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from "vitest"; +import { + buildMetadataRankInput, + computeMetadataDupRisk, + computeMetadataFeasibility, + computeMetadataPotential, + opportunityMetadataInternals, + rankMetadataOpportunities, +} from "../../packages/gittensory-engine/src/opportunity-metadata"; +import { DEFAULT_MINER_GOAL_SPEC } from "../../packages/gittensory-engine/src/miner-goal-spec"; +import { computeOpportunityCompetition } from "../../packages/gittensory-engine/src/opportunity-competition"; +import { computeOpportunityFreshness } from "../../packages/gittensory-engine/src/opportunity-freshness"; + +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("opportunity metadata signals", () => { + it("potential rewards contribution-friendly labels and rejects terminal labels", () => { + expect(computeMetadataPotential({ labels: ["wontfix"] })).toBe(0); + expect(computeMetadataPotential({ labels: ["help wanted", "bug"] })).toBeGreaterThan(0.7); + expect(computeMetadataPotential({ labels: [] })).toBeCloseTo(0.45, 5); + }); + + it("feasibility degrades for noisy or stale metadata", () => { + const quiet = computeMetadataFeasibility(base, NOW); + const noisy = computeMetadataFeasibility( + { ...base, commentsCount: 99, updatedAt: "2023-01-01T00:00:00.000Z", title: "x" }, + NOW, + ); + expect(quiet).toBeGreaterThan(noisy); + expect(computeMetadataFeasibility(base, Number.NaN)).toBe(0); + }); + + it("dupRisk only counts same-repo title overlaps and ignores short titles", () => { + const peers = [ + { ...base, issueNumber: 11, title: "Improve queue retry semantics for pump" }, + { ...base, issueNumber: 12, title: "Docs typo" }, + ]; + expect(computeMetadataDupRisk(base, peers)).toBeGreaterThan(0); + expect(computeMetadataDupRisk({ ...base, title: "ab" }, peers)).toBe(0); + expect(computeMetadataDupRisk({ ...base, repoFullName: "acme/other" }, peers)).toBe(0); + }); + + it("buildMetadataRankInput applies repo-specific goal specs case-insensitively", () => { + const input = buildMetadataRankInput( + { ...base, labels: ["feature"] }, + [base], + { + nowMs: NOW, + goalSpecsByRepo: { + "ACME/Widgets": { + minerEnabled: true, + wantedPaths: [], + blockedPaths: [], + preferredLabels: ["feature"], + blockedLabels: [], + maxConcurrentClaims: 1, + issueDiscoveryPolicy: "encouraged", + }, + }, + }, + ); + expect(input.laneFit).toBeGreaterThanOrEqual(0.85); + expect(input.potential).toBeGreaterThan(0); + }); + + it("rankMetadataOpportunities keeps deterministic ordering for ties", () => { + const tie = { potential: 0.8, feasibility: 0.8, laneFit: 1, freshness: 1, dupRisk: 0 }; + const ranked = rankMetadataOpportunities( + [ + { ...base, issueNumber: 1, ...tie }, + { ...base, issueNumber: 2, ...tie }, + ], + { nowMs: NOW }, + ); + expect(ranked.map((entry) => entry.issueNumber)).toEqual([1, 2]); + }); + + it("freshness and competition helpers stay pure with injected clocks and safe inputs", () => { + expect(computeOpportunityFreshness([{ state: "closed", updatedAt: "2026-07-03T00:00:00.000Z" }], NOW)).toBe(0); + expect(computeOpportunityCompetition(Number.NaN, 3)).toBe(0); + expect(computeOpportunityCompetition(1, 0)).toBe(1); + expect(computeOpportunityFreshness([{ state: "open", updatedAt: "2026-07-03T00:00:00.000Z" }], NOW)).toBeGreaterThan( + 0.8, + ); + expect( + computeOpportunityFreshness([{ state: "open", createdAt: "not-a-date", updatedAt: "also-bad" }], NOW), + ).toBe(0.05); + }); + + it("buildMetadataRankInput uses repo competition when it exceeds batch overlap", () => { + const input = buildMetadataRankInput(base, [base], { + nowMs: NOW, + highRiskDuplicateClusters: 5, + openPullRequests: 5, + }); + expect(input.dupRisk).toBe(1); + }); + + it("computeMetadataPotential adds a small bonus for refactor-labeled work", () => { + const baseline = computeMetadataPotential({ labels: [] }); + const refactor = computeMetadataPotential({ labels: ["refactor"] }); + expect(refactor).toBeGreaterThan(baseline); + }); + + it("covers feasibility title-length branches and invalid issue timestamps", () => { + expect( + computeMetadataFeasibility( + { ...base, title: "abcd", commentsCount: Number.NaN, updatedAt: "not-a-date" }, + NOW, + ), + ).toBeGreaterThan(0); + expect(computeMetadataFeasibility({ ...base, title: "abc" }, NOW)).toBeLessThan( + computeMetadataFeasibility({ ...base, title: "abcdefgh" }, NOW), + ); + expect( + computeMetadataFeasibility({ ...base, updatedAt: null, createdAt: "2026-07-03T00:00:00.000Z" }, NOW), + ).toBeGreaterThan(0); + expect( + computeMetadataFeasibility({ ...base, updatedAt: "not-a-date", createdAt: null }, NOW), + ).toBeLessThan(computeMetadataFeasibility(base, NOW)); + }); + + it("treats blank titles as maximum dup risk and exact title matches as overlaps", () => { + const peers = [{ ...base, issueNumber: 11, title: base.title }]; + expect(computeMetadataDupRisk({ ...base, title: " " }, peers)).toBe(1); + expect(computeMetadataDupRisk(base, peers)).toBeGreaterThan(0); + }); + + it("ignores non-string labels and uses createdAt when updatedAt is absent for freshness", () => { + const input = buildMetadataRankInput( + { + ...base, + labels: [null as unknown as string, " BUG "], + updatedAt: null, + createdAt: "2026-07-03T00:00:00.000Z", + }, + [base], + { nowMs: NOW }, + ); + expect(input.potential).toBeGreaterThan(0.5); + expect(input.freshness).toBeGreaterThan(0.8); + expect(computeOpportunityFreshness([], Number.NaN)).toBe(0); + expect( + computeOpportunityFreshness([{ state: "open", createdAt: "2026-07-03T00:00:00.000Z" }], NOW), + ).toBeGreaterThan(0.8); + }); + + it("only counts substring overlaps when the shared segment is at least 12 characters", () => { + const shared = "queue retry helper"; + expect( + computeMetadataDupRisk( + { ...base, title: `${shared} for worker` }, + [{ ...base, issueNumber: 11, title: shared }], + ), + ).toBeGreaterThan(0); + expect( + computeMetadataDupRisk( + { ...base, title: "tiny" }, + [{ ...base, issueNumber: 11, title: "tiny extra" }], + ), + ).toBe(0); + }); + + it("combines bug and positive labels without exceeding one", () => { + expect(computeMetadataPotential({ labels: ["help wanted", "bug"] })).toBeLessThanOrEqual(1); + expect(buildMetadataRankInput(base, [base], { nowMs: NOW }).dupRisk).toBe(0); + }); + + it("matches duplicate titles case-insensitively within the same repo slug", () => { + expect( + computeMetadataDupRisk( + { ...base, repoFullName: "Acme/Widgets", title: "Queue Retry Helper" }, + [{ ...base, repoFullName: "acme/widgets", issueNumber: 11, title: "queue retry helper" }], + ), + ).toBeGreaterThan(0); + }); + + it("uses the higher of batch overlap and repo-level competition for dupRisk", () => { + const crowded = buildMetadataRankInput( + { ...base, title: "queue retry helper for workers" }, + [base, { ...base, issueNumber: 2, title: "queue retry helper" }], + { nowMs: NOW, highRiskDuplicateClusters: 4, openPullRequests: 4 }, + ); + const overlapOnly = buildMetadataRankInput( + { ...base, title: "queue retry helper for workers" }, + [base, { ...base, issueNumber: 2, title: "queue retry helper" }], + { nowMs: NOW, highRiskDuplicateClusters: 0, openPullRequests: 10 }, + ); + expect(crowded.dupRisk).toBe(1); + expect(overlapOnly.dupRisk).toBeGreaterThan(0); + }); + + it("ranks an empty metadata list without error", () => { + expect(rankMetadataOpportunities([], { nowMs: NOW })).toEqual([]); + }); + + it("covers remaining label, goal-spec, and overlap branches", () => { + expect(computeMetadataPotential({ labels: ["bug"] })).toBeCloseTo(0.55, 5); + expect(computeMetadataPotential({ labels: ["documentation"] })).toBeCloseTo(0.8, 5); + expect(computeMetadataPotential({ labels: ["good first issue"] })).toBeCloseTo(0.8, 5); + expect( + computeMetadataDupRisk( + { ...base, title: "queue retry helper" }, + [{ ...base, issueNumber: 11, title: "queue retry helper for workers" }], + ), + ).toBeGreaterThan(0); + expect( + computeMetadataDupRisk( + { ...base, title: "alpha beta gamma" }, + [{ ...base, issueNumber: 11, title: "delta epsilon zeta" }], + ), + ).toBe(0); + expect(computeMetadataFeasibility({ ...base, title: "1234" }, NOW)).toBeGreaterThan( + computeMetadataFeasibility({ ...base, title: "123" }, NOW), + ); + expect( + buildMetadataRankInput(base, [base], { + nowMs: NOW, + goalSpecsByRepo: { "other/repo": DEFAULT_MINER_GOAL_SPEC }, + }).laneFit, + ).toBeGreaterThan(0); + expect( + computeMetadataDupRisk( + { ...base, title: "unique discovery target title" }, + [{ ...base, issueNumber: 11, title: " " }], + ), + ).toBe(0); + expect( + computeMetadataDupRisk( + { ...base, title: "queue retry helper" }, + [ + { ...base, issueNumber: 11, title: "queue retry helper" }, + { ...base, issueNumber: 12, title: "queue retry helper" }, + ], + ), + ).toBeGreaterThan(0.5); + }); + + it("computeMetadataFeasibility uses the long-title branch at eight characters", () => { + expect(computeMetadataFeasibility({ ...base, title: "12345678" }, NOW)).toBeGreaterThan( + computeMetadataFeasibility({ ...base, title: "1234567" }, NOW), + ); + }); + + it("computeMetadataDupRisk skips the source issue when scanning peers", () => { + expect( + computeMetadataDupRisk(base, [ + base, + { ...base, issueNumber: 11, title: "Improve queue retry semantics today" }, + ]), + ).toBeGreaterThan(0); + expect( + computeMetadataDupRisk(base, [{ ...base, issueNumber: 11, title: "Totally unrelated issue title" }]), + ).toBe(0); + expect( + computeMetadataDupRisk( + { ...base, issueNumber: 5, repoFullName: "acme/other" }, + [{ ...base, issueNumber: 5, title: base.title }], + ), + ).toBe(0); + }); + + it("pickMetadataTimestamp covers updatedAt, createdAt, and empty fallbacks", () => { + const { pickMetadataTimestamp } = opportunityMetadataInternals; + expect(pickMetadataTimestamp({ ...base, updatedAt: "2026-07-02T00:00:00.000Z" })).toBe( + "2026-07-02T00:00:00.000Z", + ); + expect( + pickMetadataTimestamp({ ...base, updatedAt: " ", createdAt: "2026-07-01T00:00:00.000Z" }), + ).toBe("2026-07-01T00:00:00.000Z"); + expect(pickMetadataTimestamp({ ...base, updatedAt: null, createdAt: null })).toBe(""); + expect( + pickMetadataTimestamp({ + ...base, + updatedAt: 123 as unknown as string, + createdAt: "2026-07-01T00:00:00.000Z", + }), + ).toBe("2026-07-01T00:00:00.000Z"); + }); +});