diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 2e6ecb4399..adbb021191 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -395,6 +395,7 @@ describe step ordering via `dependsOn` but never actuate anything. - `computeMetadataPotential` — label-based upside estimate - `computeMetadataFeasibility` — comment load + issue age + title quality - `computeMetadataDupRisk` — same-repo title overlap inside a candidate batch +- `computeMetadataLaneFit` — label-only lane fit by default; honors optional `candidatePaths` via `computeLaneFit` - `buildMetadataRankInput` — composes freshness, competition, lane fit, and the metadata heuristics - `rankMetadataOpportunities` — sorts candidates with `rankOpportunities` diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 69e3ff4a4a..4fb8657df5 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -117,6 +117,7 @@ export { type ParsedMinerGoalSpec, } from "./miner-goal-spec.js"; export { + computeMetadataLaneFit, computeMinerGoalLaneFit, isMinerRepoTargetable, } from "./miner-goal-lane-fit.js"; diff --git a/packages/gittensory-engine/src/miner-goal-lane-fit.ts b/packages/gittensory-engine/src/miner-goal-lane-fit.ts index f36900a69e..1c336086c3 100644 --- a/packages/gittensory-engine/src/miner-goal-lane-fit.ts +++ b/packages/gittensory-engine/src/miner-goal-lane-fit.ts @@ -1,3 +1,4 @@ +import { computeLaneFit } from "./goal-model.js"; import type { MinerGoalSpec } from "./miner-goal-spec.js"; /** Whether a repo's miner goal spec permits autonomous targeting (explicit opt-out only). */ @@ -53,3 +54,33 @@ export function computeMinerGoalLaneFit( return clamp01(score); } + +function normalizeCandidatePaths(paths: readonly string[] | undefined): string[] { + if (!paths) return []; + const normalized: string[] = []; + for (const path of paths) { + if (typeof path !== "string") continue; + const trimmed = path.trim(); + if (trimmed) normalized.push(trimmed); + } + return normalized; +} + +/** + * Lane-fit for metadata-ranked issues. Uses full path+label {@link computeLaneFit} when + * `candidatePaths` are present; otherwise falls back to label-only {@link computeMinerGoalLaneFit}. + */ +export function computeMetadataLaneFit( + issue: { labels: readonly string[]; candidatePaths?: readonly string[] | undefined }, + spec: MinerGoalSpec, +): number { + const candidatePaths = normalizeCandidatePaths(issue.candidatePaths); + if (candidatePaths.length > 0) { + return computeLaneFit({ + candidatePaths, + candidateLabels: [...issue.labels], + goalSpec: spec, + }); + } + return computeMinerGoalLaneFit(issue, spec); +} diff --git a/packages/gittensory-engine/src/opportunity-metadata.ts b/packages/gittensory-engine/src/opportunity-metadata.ts index 602f5a781d..8155a4bc06 100644 --- a/packages/gittensory-engine/src/opportunity-metadata.ts +++ b/packages/gittensory-engine/src/opportunity-metadata.ts @@ -1,4 +1,4 @@ -import { computeMinerGoalLaneFit, isMinerRepoTargetable } from "./miner-goal-lane-fit.js"; +import { computeMetadataLaneFit, isMinerRepoTargetable } 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"; @@ -13,6 +13,8 @@ export type MetadataCandidateIssue = { issueNumber: number; title: string; labels: readonly string[]; + /** When present, lane fit uses path+label goal matching instead of labels alone. */ + candidatePaths?: readonly string[] | undefined; commentsCount: number; createdAt?: string | null | undefined; updatedAt?: string | null | undefined; @@ -207,7 +209,7 @@ export function buildMetadataRankInput( return { potential: computeMetadataPotential(issue), feasibility: computeMetadataFeasibility(issue, context.nowMs), - laneFit: computeMinerGoalLaneFit(issue, goalSpec), + laneFit: computeMetadataLaneFit(issue, goalSpec), freshness: computeOpportunityFreshness( /* v8 ignore next */ [{ state: "open", updatedAt: issue.updatedAt ?? null, createdAt: issue.createdAt ?? null }], diff --git a/packages/gittensory-engine/test/miner-goal-lane-fit.test.ts b/packages/gittensory-engine/test/miner-goal-lane-fit.test.ts index 0350f8b41f..12fdcd7416 100644 --- a/packages/gittensory-engine/test/miner-goal-lane-fit.test.ts +++ b/packages/gittensory-engine/test/miner-goal-lane-fit.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { DEFAULT_MINER_GOAL_SPEC } from "../dist/miner-goal-spec.js"; -import { computeMinerGoalLaneFit, isMinerRepoTargetable } from "../dist/miner-goal-lane-fit.js"; +import { computeMetadataLaneFit, computeMinerGoalLaneFit, isMinerRepoTargetable } from "../dist/miner-goal-lane-fit.js"; test("isMinerRepoTargetable respects minerEnabled opt-out", () => { assert.equal(isMinerRepoTargetable(DEFAULT_MINER_GOAL_SPEC), true); @@ -51,3 +51,57 @@ test("computeMinerGoalLaneFit ignores malformed label entries safely", () => { 1, ); }); + +test("computeMetadataLaneFit falls back to label-only lane fit when candidatePaths are absent", () => { + const spec = { ...DEFAULT_MINER_GOAL_SPEC, preferredLabels: ["bug"] }; + assert.equal(computeMetadataLaneFit({ labels: ["bug"] }, spec), 1); + assert.equal(computeMetadataLaneFit({ labels: ["feature"] }, spec), 0.25); +}); + +test("computeMetadataLaneFit uses computeLaneFit when candidatePaths are present", () => { + const spec = { + ...DEFAULT_MINER_GOAL_SPEC, + wantedPaths: ["src/**"], + preferredLabels: ["bug"], + }; + assert.equal( + computeMetadataLaneFit( + { labels: ["bug"], candidatePaths: ["src/app.ts"] }, + spec, + ), + 1, + ); + assert.equal( + computeMetadataLaneFit( + { labels: ["bug"], candidatePaths: ["docs/readme.md"] }, + spec, + ), + 0.5, + ); +}); + +test("computeMetadataLaneFit returns 0 when candidatePaths hit blockedPaths", () => { + const spec = { + ...DEFAULT_MINER_GOAL_SPEC, + blockedPaths: ["secrets/**"], + wantedPaths: ["src/**"], + }; + assert.equal( + computeMetadataLaneFit( + { labels: ["bug"], candidatePaths: ["secrets/api-keys.ts"] }, + spec, + ), + 0, + ); +}); + +test("computeMetadataLaneFit ignores blank or malformed candidatePaths entries", () => { + const spec = { ...DEFAULT_MINER_GOAL_SPEC, preferredLabels: ["bug"] }; + assert.equal( + computeMetadataLaneFit( + { labels: ["bug"], candidatePaths: ["", " ", 42 as unknown as string] }, + spec, + ), + 1, + ); +}); diff --git a/test/unit/miner-goal-lane-fit.test.ts b/test/unit/miner-goal-lane-fit.test.ts index 44e9ea321f..50b7c31dae 100644 --- a/test/unit/miner-goal-lane-fit.test.ts +++ b/test/unit/miner-goal-lane-fit.test.ts @@ -1,10 +1,59 @@ import { describe, expect, it } from "vitest"; import { + computeMetadataLaneFit, computeMinerGoalLaneFit, DEFAULT_MINER_GOAL_SPEC, isMinerRepoTargetable, } from "../../packages/gittensory-engine/src/index"; +describe("computeMetadataLaneFit", () => { + it("falls back to label-only lane fit when candidatePaths are absent or empty", () => { + const spec = { ...DEFAULT_MINER_GOAL_SPEC, preferredLabels: ["bug"] }; + expect(computeMetadataLaneFit({ labels: ["bug"] }, spec)).toBe(1); + expect(computeMetadataLaneFit({ labels: ["feature"] }, spec)).toBe(0.25); + expect(computeMetadataLaneFit({ labels: ["bug"], candidatePaths: [] }, spec)).toBe(1); + expect(computeMetadataLaneFit({ labels: ["bug"], candidatePaths: ["", " "] }, spec)).toBe(1); + }); + + it("uses path+label lane fit when candidatePaths are present", () => { + const spec = { + ...DEFAULT_MINER_GOAL_SPEC, + wantedPaths: ["src/**"], + preferredLabels: ["bug"], + }; + expect( + computeMetadataLaneFit({ labels: ["bug"], candidatePaths: ["src/app.ts"] }, spec), + ).toBe(1); + expect( + computeMetadataLaneFit({ labels: ["bug"], candidatePaths: ["docs/readme.md"] }, spec), + ).toBe(0.5); + }); + + it("returns 0 when candidatePaths hit blockedPaths", () => { + const spec = { + ...DEFAULT_MINER_GOAL_SPEC, + blockedPaths: ["secrets/**"], + wantedPaths: ["src/**"], + }; + expect( + computeMetadataLaneFit( + { labels: ["bug"], candidatePaths: ["secrets/api-keys.ts"] }, + spec, + ), + ).toBe(0); + }); + + it("ignores non-string candidatePaths entries before scoring", () => { + const spec = { ...DEFAULT_MINER_GOAL_SPEC, preferredLabels: ["bug"] }; + expect( + computeMetadataLaneFit( + { labels: ["bug"], candidatePaths: [42 as unknown as string, ""] }, + spec, + ), + ).toBe(1); + }); +}); + describe("computeMinerGoalLaneFit", () => { it("respects minerEnabled opt-out", () => { expect(isMinerRepoTargetable(DEFAULT_MINER_GOAL_SPEC)).toBe(true); diff --git a/test/unit/opportunity-metadata-signals.test.ts b/test/unit/opportunity-metadata-signals.test.ts index 3d9763b3a8..60488d9277 100644 --- a/test/unit/opportunity-metadata-signals.test.ts +++ b/test/unit/opportunity-metadata-signals.test.ts @@ -73,6 +73,41 @@ describe("opportunity metadata signals", () => { expect(input.potential).toBeGreaterThan(0); }); + it("buildMetadataRankInput honors candidatePaths for path-aware lane fit", () => { + const pathBlocked = buildMetadataRankInput( + { ...base, labels: ["bug"], candidatePaths: ["secrets/credentials.ts"] }, + [base], + { + nowMs: NOW, + goalSpecsByRepo: { + "acme/widgets": { + ...DEFAULT_MINER_GOAL_SPEC, + blockedPaths: ["secrets/**"], + wantedPaths: ["src/**"], + preferredLabels: ["bug"], + }, + }, + }, + ); + expect(pathBlocked.laneFit).toBe(0); + + const pathMatch = buildMetadataRankInput( + { ...base, labels: ["bug"], candidatePaths: ["src/app.ts"] }, + [base], + { + nowMs: NOW, + goalSpecsByRepo: { + "acme/widgets": { + ...DEFAULT_MINER_GOAL_SPEC, + wantedPaths: ["src/**"], + preferredLabels: ["bug"], + }, + }, + }, + ); + expect(pathMatch.laneFit).toBe(1); + }); + it("rankMetadataOpportunities keeps deterministic ordering for ties", () => { const tie = { potential: 0.8, feasibility: 0.8, laneFit: 1, freshness: 1, dupRisk: 0 }; const ranked = rankMetadataOpportunities(