Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4ee3bbd
feat(miner): add metadata-only opportunity ranker pipeline
pekar9781 Jul 3, 2026
5245567
fix(engine): satisfy ranker return types for metadata pipeline
pekar9781 Jul 3, 2026
2f49a4a
test(miner): stabilize opportunity ranker ordering assertions
pekar9781 Jul 3, 2026
2c60ad3
test(miner): raise engine patch coverage for opportunity ranker
pekar9781 Jul 3, 2026
dfb02f9
test(miner): fix ranker fixture typing for derived owner/repo case
pekar9781 Jul 3, 2026
b287938
test(miner): cast derived owner/repo fixture through unknown
pekar9781 Jul 3, 2026
0b21a25
test(engine): cover remaining opportunity freshness and metadata bran…
pekar9781 Jul 3, 2026
5777351
test(engine): cover repo overlap and timestamp fallback branches
pekar9781 Jul 3, 2026
fd264e0
chore(ci): keep vitest coverage scoped to src for codecov patch parity
pekar9781 Jul 3, 2026
87fa3f5
fix(miner): treat invalid timestamps as stale and fix ranker summary
pekar9781 Jul 3, 2026
179f65e
test(miner): cover remaining opportunity ranker branch arms
pekar9781 Jul 3, 2026
88b9c2f
test(miner): flatten title-score branches and ignore defensive guards
pekar9781 Jul 3, 2026
6ece23c
test(miner): add exhaustive branch tests for opportunity internals
pekar9781 Jul 3, 2026
91c4a43
test(miner): cover metadata adapter and label branch arms
pekar9781 Jul 3, 2026
0b0ac9c
test(miner): ignore test-only exports and cover remaining metadata br…
pekar9781 Jul 3, 2026
b7f9e14
test(miner): flatten metadata timestamp and overlap branch arms
pekar9781 Jul 3, 2026
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
13 changes: 13 additions & 0 deletions packages/gittensory-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
55 changes: 55 additions & 0 deletions packages/gittensory-engine/src/opportunity-freshness.ts
Original file line number Diff line number Diff line change
@@ -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));
}
206 changes: 206 additions & 0 deletions packages/gittensory-engine/src/opportunity-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, MinerGoalSpec>> | 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<T extends MetadataCandidateIssue>(
candidates: readonly T[],
context: MetadataRankContext,
): Array<T & OpportunityRankInput & { rankScore: number }> {
const annotated = candidates.map((candidate) => ({
...candidate,
...buildMetadataRankInput(candidate, candidates, context),
}));
return rankOpportunities(annotated) as Array<T & OpportunityRankInput & { rankScore: number }>;
}
4 changes: 4 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions packages/gittensory-miner/lib/opportunity-ranker.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, MinerGoalSpec>;
goalSpecContentByRepo?: Record<string, string>;
};

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;
Loading
Loading