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
1 change: 1 addition & 0 deletions packages/gittensory-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export {
type ParsedMinerGoalSpec,
} from "./miner-goal-spec.js";
export {
computeMetadataLaneFit,
computeMinerGoalLaneFit,
isMinerRepoTargetable,
} from "./miner-goal-lane-fit.js";
Expand Down
31 changes: 31 additions & 0 deletions packages/gittensory-engine/src/miner-goal-lane-fit.ts
Original file line number Diff line number Diff line change
@@ -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). */
Expand Down Expand Up @@ -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);
}
6 changes: 4 additions & 2 deletions packages/gittensory-engine/src/opportunity-metadata.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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 }],
Expand Down
56 changes: 55 additions & 1 deletion packages/gittensory-engine/test/miner-goal-lane-fit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
);
});
49 changes: 49 additions & 0 deletions test/unit/miner-goal-lane-fit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
35 changes: 35 additions & 0 deletions test/unit/opportunity-metadata-signals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading