diff --git a/src/services/score-breakdown.ts b/src/services/score-breakdown.ts index 69e2a7f95..0cbb3f50b 100644 --- a/src/services/score-breakdown.ts +++ b/src/services/score-breakdown.ts @@ -197,6 +197,62 @@ function issueMultiplierBreakdown(preview: ScorePreviewResult): ScoreMultiplierB return { component: "issueMultiplier", band, summary, lever, leverageScore }; } +// Sibling of issueMultiplierBreakdown: branch/base eligibility gates the standard linked-issue multiplier +// even when issue metadata looks plausible (#90 / #178). Surfaced here so miners see the same actionable +// breakdown other eligibility gates already provide. +function branchEligibilityBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const branch = preview.branchEligibility; + if (!branch.required || branch.status === "not_required") { + return { + component: "branchEligibility", + band: "neutral", + summary: "Branch eligibility is not required for this preview (no standard linked-issue lane).", + lever: "Use standard linked-issue mode when branch/base proof is required for issue-solving PRs.", + leverageScore: 0, + }; + } + if ( + branch.status === "eligible" && + branch.evidence === "provided" && + !branch.stale && + branch.source !== "user_supplied" + ) { + return { + component: "branchEligibility", + band: "full", + summary: "Branch/base eligibility is confirmed for standard linked-issue scoring.", + lever: "Keep branch and base metadata aligned with the repo's registered eligibility rules.", + leverageScore: 5, + }; + } + if (branch.status === "ineligible") { + return { + component: "branchEligibility", + band: "blocked", + summary: branch.reason + ? `Branch/base eligibility is confirmed ineligible (${branch.reason}).` + : "Branch/base eligibility is confirmed ineligible; standard linked-issue scoring is blocked.", + lever: "Use an eligible branch or remove linked-issue assumptions before relying on this preview.", + leverageScore: 90, + }; + } + const summary = + branch.evidence === "missing" + ? "Branch eligibility evidence is missing; standard linked-issue multiplier assumptions are not confirmed." + : branch.stale + ? "Branch eligibility evidence is stale; standard linked-issue multiplier assumptions need refresh." + : branch.status === "unknown" && branch.source === "user_supplied" + ? "Branch eligibility evidence is user-supplied; verified metadata is required for standard linked-issue scoring." + : branch.status === "unknown" + ? "Branch eligibility is unknown; standard linked-issue multiplier assumptions are not confirmed." + : branch.source === "user_supplied" + ? "Branch eligibility evidence is user-supplied; verified metadata is required for standard linked-issue scoring." + : "Branch eligibility is not confirmed; standard linked-issue multiplier assumptions are not applied."; + const lever = "Refresh branch/base eligibility metadata before relying on linked-issue assumptions."; + const leverageScore = branch.evidence === "missing" || branch.status === "unknown" ? 75 : 65; + return { component: "branchEligibility", band: "reduced", summary, lever, leverageScore }; +} + function reviewPenaltyBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { const { reviewPenaltyMultiplier } = preview.scoreEstimate; const band = bandForMultiplier(reviewPenaltyMultiplier, false); @@ -312,6 +368,7 @@ export function explainScoreBreakdown(preview: ScorePreviewResult): ScoreBreakdo contributionBonusBreakdown(preview), labelMultiplierBreakdown(preview), issueMultiplierBreakdown(preview), + branchEligibilityBreakdown(preview), credibilityBreakdown(preview), reviewPenaltyBreakdown(preview), reviewCollateralBreakdown(preview), diff --git a/test/unit/score-breakdown.test.ts b/test/unit/score-breakdown.test.ts index 4a6038612..d1a7c9807 100644 --- a/test/unit/score-breakdown.test.ts +++ b/test/unit/score-breakdown.test.ts @@ -79,6 +79,7 @@ describe("explainScoreBreakdown", () => { "contributionBonus", "labelMultiplier", "issueMultiplier", + "branchEligibility", "credibilityMultiplier", "reviewPenaltyMultiplier", "reviewCollateralMultiplier", @@ -94,7 +95,7 @@ describe("explainScoreBreakdown", () => { expect(["full", "reduced", "neutral", "blocked"]).toContain(component.band); } expect(breakdown.highestLeverageLever.component).toBeTruthy(); - expect(breakdown.highestLeverageLever.lever).toMatch(/merge|close|credibility|open PR|linked issue|density|review/i); + expect(breakdown.highestLeverageLever.lever).toMatch(/merge|close|credibility|open PR|linked issue|density|review|branch/i); expect(JSON.stringify(breakdown)).not.toMatch(FORBIDDEN); // No open issues → within the allowance → full band on the open-issue gate. expect(breakdown.components.find((entry) => entry.component === "openIssueMultiplier")).toMatchObject({ band: "full" }); @@ -122,6 +123,186 @@ describe("explainScoreBreakdown", () => { expect(JSON.stringify(blocked)).not.toMatch(FORBIDDEN); }); + it("explains branch eligibility as neutral (not required), full (eligible), blocked (ineligible), and reduced (unknown/missing/stale)", () => { + const notRequired = explainScoreBreakdown( + buildScorePreview({ + repo, + snapshot, + input: { repoFullName: repo.fullName, sourceTokenScore: 40, totalTokenScore: 60, sourceLines: 80, linkedIssueMode: "none" }, + }), + ); + expect(notRequired.components.find((entry) => entry.component === "branchEligibility")).toMatchObject({ band: "neutral" }); + + const eligible = explainScoreBreakdown( + buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + linkedIssueMode: "standard", + linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [3], solvedByPullRequests: [44] }, + branchEligibility: { status: "eligible", source: "github_metadata", checkedAt: "2026-05-30T00:00:00.000Z" }, + }, + }), + ); + expect(eligible.components.find((entry) => entry.component === "branchEligibility")).toMatchObject({ band: "full" }); + + const userSuppliedEligible = explainScoreBreakdown( + buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + linkedIssueMode: "standard", + linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [3], solvedByPullRequests: [44] }, + branchEligibility: { status: "eligible", source: "user_supplied" }, + }, + }), + ); + expect(userSuppliedEligible.components.find((entry) => entry.component === "branchEligibility")).toMatchObject({ + band: "reduced", + summary: expect.stringMatching(/user-supplied/i), + }); + + const ineligible = explainScoreBreakdown( + buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + linkedIssueMode: "standard", + linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [3], solvedByPullRequests: [44] }, + branchEligibility: { status: "ineligible", source: "github_metadata", reason: "head branch is not eligible" }, + }, + }), + ); + const ineligibleBranch = ineligible.components.find((entry) => entry.component === "branchEligibility"); + expect(ineligibleBranch).toMatchObject({ band: "blocked", leverageScore: 90 }); + expect(ineligibleBranch?.summary).toMatch(/head branch is not eligible/i); + expect(ineligibleBranch?.lever).toMatch(/eligible branch/i); + + const ineligibleNoReason = explainScoreBreakdown( + buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + linkedIssueMode: "standard", + linkedIssueContext: { status: "raw", source: "github_cache", issueNumbers: [12] }, + branchEligibility: { status: "ineligible", source: "github_metadata" }, + }, + }), + ); + expect(ineligibleNoReason.components.find((entry) => entry.component === "branchEligibility")?.summary).toMatch( + /confirmed ineligible; standard linked-issue scoring is blocked/i, + ); + + const unknownMetadata = explainScoreBreakdown( + buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + linkedIssueMode: "standard", + linkedIssueContext: { status: "plausible", source: "github_cache", issueNumbers: [4] }, + branchEligibility: { status: "unknown", source: "github_metadata" }, + }, + }), + ); + expect(unknownMetadata.components.find((entry) => entry.component === "branchEligibility")?.summary).toMatch(/unknown/i); + + const missing = explainScoreBreakdown( + buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + linkedIssueMode: "standard", + linkedIssueContext: { status: "raw", source: "github_cache", issueNumbers: [12] }, + }, + }), + ); + expect(missing.components.find((entry) => entry.component === "branchEligibility")).toMatchObject({ + band: "reduced", + summary: expect.stringMatching(/evidence is missing/i), + }); + + const stale = explainScoreBreakdown( + buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + linkedIssueMode: "standard", + linkedIssueContext: { status: "plausible", source: "github_cache", issueNumbers: [4] }, + branchEligibility: { status: "eligible", source: "local_metadata", stale: true, checkedAt: "2026-05-01T00:00:00.000Z" }, + }, + }), + ); + expect(stale.components.find((entry) => entry.component === "branchEligibility")?.summary).toMatch(/stale/i); + + const userSupplied = explainScoreBreakdown( + buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + linkedIssueMode: "standard", + linkedIssueContext: { status: "plausible", source: "github_cache", issueNumbers: [4] }, + branchEligibility: { status: "unknown" }, + }, + }), + ); + expect(userSupplied.components.find((entry) => entry.component === "branchEligibility")?.summary).toMatch(/user-supplied/i); + expect(JSON.stringify(ineligible)).not.toMatch(FORBIDDEN); + }); + + it("prioritizes ineligible branch metadata above invalid linked-issue context", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 80, + totalTokenScore: 120, + sourceLines: 60, + openPrCount: 0, + credibility: 1, + linkedIssueMode: "standard", + linkedIssueContext: { status: "invalid", source: "github_cache", issueNumbers: [9], reason: "Issue #9 is closed." }, + branchEligibility: { status: "ineligible", source: "github_metadata", reason: "head branch is not eligible" }, + }, + }); + + const breakdown = explainScoreBreakdown(preview); + expect(breakdown.components.find((entry) => entry.component === "branchEligibility")).toMatchObject({ band: "blocked" }); + expect(breakdown.highestLeverageLever.component).toBe("branchEligibility"); + }); + it("explains an over-threshold open-issue count as a blocked open-issue spam gate", () => { const preview = buildScorePreview({ repo, @@ -374,6 +555,7 @@ describe("explainScoreBreakdown", () => { credibility: 1, linkedIssueMode: "standard", linkedIssueContext: { status: "invalid", source: "github_cache", issueNumbers: [9], reason: "Issue #9 is closed." }, + branchEligibility: { status: "eligible", source: "github_metadata" }, }, });