Skip to content

Commit 91aee5d

Browse files
author
andriypolandki
committed
feat(scoring): surface branch eligibility in the score breakdown
1 parent bbfc46b commit 91aee5d

2 files changed

Lines changed: 215 additions & 1 deletion

File tree

src/services/score-breakdown.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,57 @@ function issueMultiplierBreakdown(preview: ScorePreviewResult): ScoreMultiplierB
172172
return { component: "issueMultiplier", band, summary, lever, leverageScore };
173173
}
174174

175+
// Sibling of issueMultiplierBreakdown: branch/base eligibility gates the standard linked-issue multiplier
176+
// even when issue metadata looks plausible (#90 / #178). Surfaced here so miners see the same actionable
177+
// breakdown other eligibility gates already provide.
178+
function branchEligibilityBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown {
179+
const branch = preview.branchEligibility;
180+
if (!branch.required || branch.status === "not_required") {
181+
return {
182+
component: "branchEligibility",
183+
band: "neutral",
184+
summary: "Branch eligibility is not required for this preview (no standard linked-issue lane).",
185+
lever: "Use standard linked-issue mode when branch/base proof is required for issue-solving PRs.",
186+
leverageScore: 0,
187+
};
188+
}
189+
if (branch.status === "eligible" && branch.evidence === "provided" && !branch.stale) {
190+
return {
191+
component: "branchEligibility",
192+
band: "full",
193+
summary: "Branch/base eligibility is confirmed for standard linked-issue scoring.",
194+
lever: "Keep branch and base metadata aligned with the repo's registered eligibility rules.",
195+
leverageScore: 5,
196+
};
197+
}
198+
if (branch.status === "ineligible") {
199+
return {
200+
component: "branchEligibility",
201+
band: "blocked",
202+
summary: branch.reason
203+
? `Branch/base eligibility is confirmed ineligible (${branch.reason}).`
204+
: "Branch/base eligibility is confirmed ineligible; standard linked-issue scoring is blocked.",
205+
lever: "Use an eligible branch or remove linked-issue assumptions before relying on this preview.",
206+
leverageScore: 90,
207+
};
208+
}
209+
const summary =
210+
branch.evidence === "missing"
211+
? "Branch eligibility evidence is missing; standard linked-issue multiplier assumptions are not confirmed."
212+
: branch.stale
213+
? "Branch eligibility evidence is stale; standard linked-issue multiplier assumptions need refresh."
214+
: branch.status === "unknown" && branch.source === "user_supplied"
215+
? "Branch eligibility evidence is user-supplied; verified metadata is required for standard linked-issue scoring."
216+
: branch.status === "unknown"
217+
? "Branch eligibility is unknown; standard linked-issue multiplier assumptions are not confirmed."
218+
: branch.source === "user_supplied"
219+
? "Branch eligibility evidence is user-supplied; verified metadata is required for standard linked-issue scoring."
220+
: "Branch eligibility is not confirmed; standard linked-issue multiplier assumptions are not applied.";
221+
const lever = "Refresh branch/base eligibility metadata before relying on linked-issue assumptions.";
222+
const leverageScore = branch.evidence === "missing" || branch.status === "unknown" ? 75 : 65;
223+
return { component: "branchEligibility", band: "reduced", summary, lever, leverageScore };
224+
}
225+
175226
function reviewPenaltyBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown {
176227
const { reviewPenaltyMultiplier } = preview.scoreEstimate;
177228
const band = bandForMultiplier(reviewPenaltyMultiplier, false);
@@ -267,6 +318,7 @@ export function explainScoreBreakdown(preview: ScorePreviewResult): ScoreBreakdo
267318
contributionBonusBreakdown(preview),
268319
labelMultiplierBreakdown(preview),
269320
issueMultiplierBreakdown(preview),
321+
branchEligibilityBreakdown(preview),
270322
credibilityBreakdown(preview),
271323
reviewPenaltyBreakdown(preview),
272324
openPrBreakdown(preview),

test/unit/score-breakdown.test.ts

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ describe("explainScoreBreakdown", () => {
7979
"contributionBonus",
8080
"labelMultiplier",
8181
"issueMultiplier",
82+
"branchEligibility",
8283
"credibilityMultiplier",
8384
"reviewPenaltyMultiplier",
8485
"openPrMultiplier",
@@ -92,7 +93,7 @@ describe("explainScoreBreakdown", () => {
9293
expect(["full", "reduced", "neutral", "blocked"]).toContain(component.band);
9394
}
9495
expect(breakdown.highestLeverageLever.component).toBeTruthy();
95-
expect(breakdown.highestLeverageLever.lever).toMatch(/merge|close|credibility|open PR|linked issue|density|review/i);
96+
expect(breakdown.highestLeverageLever.lever).toMatch(/merge|close|credibility|open PR|linked issue|density|review|branch/i);
9697
expect(JSON.stringify(breakdown)).not.toMatch(FORBIDDEN);
9798
// No open issues → within the allowance → full band on the open-issue gate.
9899
expect(breakdown.components.find((entry) => entry.component === "openIssueMultiplier")).toMatchObject({ band: "full" });
@@ -120,6 +121,166 @@ describe("explainScoreBreakdown", () => {
120121
expect(JSON.stringify(blocked)).not.toMatch(FORBIDDEN);
121122
});
122123

124+
it("explains branch eligibility as neutral (not required), full (eligible), blocked (ineligible), and reduced (unknown/missing/stale)", () => {
125+
const notRequired = explainScoreBreakdown(
126+
buildScorePreview({
127+
repo,
128+
snapshot,
129+
input: { repoFullName: repo.fullName, sourceTokenScore: 40, totalTokenScore: 60, sourceLines: 80, linkedIssueMode: "none" },
130+
}),
131+
);
132+
expect(notRequired.components.find((entry) => entry.component === "branchEligibility")).toMatchObject({ band: "neutral" });
133+
134+
const eligible = explainScoreBreakdown(
135+
buildScorePreview({
136+
repo,
137+
snapshot,
138+
input: {
139+
repoFullName: repo.fullName,
140+
sourceTokenScore: 40,
141+
totalTokenScore: 60,
142+
sourceLines: 80,
143+
linkedIssueMode: "standard",
144+
linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [3], solvedByPullRequests: [44] },
145+
branchEligibility: { status: "eligible", source: "github_metadata", checkedAt: "2026-05-30T00:00:00.000Z" },
146+
},
147+
}),
148+
);
149+
expect(eligible.components.find((entry) => entry.component === "branchEligibility")).toMatchObject({ band: "full" });
150+
151+
const ineligible = explainScoreBreakdown(
152+
buildScorePreview({
153+
repo,
154+
snapshot,
155+
input: {
156+
repoFullName: repo.fullName,
157+
sourceTokenScore: 40,
158+
totalTokenScore: 60,
159+
sourceLines: 80,
160+
linkedIssueMode: "standard",
161+
linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [3], solvedByPullRequests: [44] },
162+
branchEligibility: { status: "ineligible", source: "github_metadata", reason: "head branch is not eligible" },
163+
},
164+
}),
165+
);
166+
const ineligibleBranch = ineligible.components.find((entry) => entry.component === "branchEligibility");
167+
expect(ineligibleBranch).toMatchObject({ band: "blocked", leverageScore: 90 });
168+
expect(ineligibleBranch?.summary).toMatch(/head branch is not eligible/i);
169+
expect(ineligibleBranch?.lever).toMatch(/eligible branch/i);
170+
171+
const ineligibleNoReason = explainScoreBreakdown(
172+
buildScorePreview({
173+
repo,
174+
snapshot,
175+
input: {
176+
repoFullName: repo.fullName,
177+
sourceTokenScore: 40,
178+
totalTokenScore: 60,
179+
sourceLines: 80,
180+
linkedIssueMode: "standard",
181+
linkedIssueContext: { status: "raw", source: "github_cache", issueNumbers: [12] },
182+
branchEligibility: { status: "ineligible", source: "github_metadata" },
183+
},
184+
}),
185+
);
186+
expect(ineligibleNoReason.components.find((entry) => entry.component === "branchEligibility")?.summary).toMatch(
187+
/confirmed ineligible; standard linked-issue scoring is blocked/i,
188+
);
189+
190+
const unknownMetadata = explainScoreBreakdown(
191+
buildScorePreview({
192+
repo,
193+
snapshot,
194+
input: {
195+
repoFullName: repo.fullName,
196+
sourceTokenScore: 40,
197+
totalTokenScore: 60,
198+
sourceLines: 80,
199+
linkedIssueMode: "standard",
200+
linkedIssueContext: { status: "plausible", source: "github_cache", issueNumbers: [4] },
201+
branchEligibility: { status: "unknown", source: "github_metadata" },
202+
},
203+
}),
204+
);
205+
expect(unknownMetadata.components.find((entry) => entry.component === "branchEligibility")?.summary).toMatch(/unknown/i);
206+
207+
const missing = explainScoreBreakdown(
208+
buildScorePreview({
209+
repo,
210+
snapshot,
211+
input: {
212+
repoFullName: repo.fullName,
213+
sourceTokenScore: 40,
214+
totalTokenScore: 60,
215+
sourceLines: 80,
216+
linkedIssueMode: "standard",
217+
linkedIssueContext: { status: "raw", source: "github_cache", issueNumbers: [12] },
218+
},
219+
}),
220+
);
221+
expect(missing.components.find((entry) => entry.component === "branchEligibility")).toMatchObject({
222+
band: "reduced",
223+
summary: expect.stringMatching(/evidence is missing/i),
224+
});
225+
226+
const stale = explainScoreBreakdown(
227+
buildScorePreview({
228+
repo,
229+
snapshot,
230+
input: {
231+
repoFullName: repo.fullName,
232+
sourceTokenScore: 40,
233+
totalTokenScore: 60,
234+
sourceLines: 80,
235+
linkedIssueMode: "standard",
236+
linkedIssueContext: { status: "plausible", source: "github_cache", issueNumbers: [4] },
237+
branchEligibility: { status: "eligible", source: "local_metadata", stale: true, checkedAt: "2026-05-01T00:00:00.000Z" },
238+
},
239+
}),
240+
);
241+
expect(stale.components.find((entry) => entry.component === "branchEligibility")?.summary).toMatch(/stale/i);
242+
243+
const userSupplied = explainScoreBreakdown(
244+
buildScorePreview({
245+
repo,
246+
snapshot,
247+
input: {
248+
repoFullName: repo.fullName,
249+
sourceTokenScore: 40,
250+
totalTokenScore: 60,
251+
sourceLines: 80,
252+
linkedIssueMode: "standard",
253+
linkedIssueContext: { status: "plausible", source: "github_cache", issueNumbers: [4] },
254+
branchEligibility: {},
255+
},
256+
}),
257+
);
258+
expect(userSupplied.components.find((entry) => entry.component === "branchEligibility")?.summary).toMatch(/user-supplied/i);
259+
expect(JSON.stringify(ineligible)).not.toMatch(FORBIDDEN);
260+
});
261+
262+
it("prioritizes ineligible branch metadata above invalid linked-issue context", () => {
263+
const preview = buildScorePreview({
264+
repo,
265+
snapshot,
266+
input: {
267+
repoFullName: repo.fullName,
268+
sourceTokenScore: 80,
269+
totalTokenScore: 120,
270+
sourceLines: 60,
271+
openPrCount: 0,
272+
credibility: 1,
273+
linkedIssueMode: "standard",
274+
linkedIssueContext: { status: "invalid", source: "github_cache", issueNumbers: [9], reason: "Issue #9 is closed." },
275+
branchEligibility: { status: "ineligible", source: "github_metadata", reason: "head branch is not eligible" },
276+
},
277+
});
278+
279+
const breakdown = explainScoreBreakdown(preview);
280+
expect(breakdown.components.find((entry) => entry.component === "branchEligibility")).toMatchObject({ band: "blocked" });
281+
expect(breakdown.highestLeverageLever.component).toBe("branchEligibility");
282+
});
283+
123284
it("explains an over-threshold open-issue count as a blocked open-issue spam gate", () => {
124285
const preview = buildScorePreview({
125286
repo,
@@ -254,6 +415,7 @@ describe("explainScoreBreakdown", () => {
254415
credibility: 1,
255416
linkedIssueMode: "standard",
256417
linkedIssueContext: { status: "invalid", source: "github_cache", issueNumbers: [9], reason: "Issue #9 is closed." },
418+
branchEligibility: { status: "eligible", source: "github_metadata" },
257419
},
258420
});
259421

0 commit comments

Comments
 (0)