From cb2133b918cf699cb3fbd40edff8f2b4a179b5b3 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:52:18 +0200 Subject: [PATCH] feat(engine): honor candidatePaths in metadata lane-fit scoring When discovery metadata includes touched paths, rank metadata candidates with the same path+label goal model used in analyze instead of labels alone. Co-authored-by: Cursor --- packages/gittensory-engine/README.md | 1 + packages/gittensory-engine/src/index.ts | 1 + .../src/miner-goal-lane-fit.ts | 31 ++++++++++ .../src/opportunity-metadata.ts | 6 +- .../test/miner-goal-lane-fit.test.ts | 56 ++++++++++++++++++- .../unit/opportunity-metadata-signals.test.ts | 35 ++++++++++++ 6 files changed, 127 insertions(+), 3 deletions(-) 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/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(