diff --git a/src/services/score-breakdown.ts b/src/services/score-breakdown.ts index 493da228d5..66dfc49d7c 100644 --- a/src/services/score-breakdown.ts +++ b/src/services/score-breakdown.ts @@ -77,6 +77,28 @@ function openPrBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown }; } +// Sibling of openPrBreakdown for the issue-discovery channel: the open-issue spam gate (#808) zeroes the +// score once a contributor's concurrent open-issue count exceeds their earned allowance. Explained here so a +// miner in the issue-discovery lane sees the same actionable breakdown the open-PR gate already provides. +function openIssueBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { openIssueMultiplier } = preview.scoreEstimate; + const { openIssueCount, openIssueThreshold } = preview.gates; + const band = bandForMultiplier(openIssueMultiplier); + return { + component: "openIssueMultiplier", + band, + summary: + openIssueMultiplier >= 1 + ? `Open issue count (${openIssueCount}) is within the current allowance (${openIssueThreshold}).` + : `Open issue count (${openIssueCount}) exceeds the current allowance (${openIssueThreshold}), so the open-issue spam gate blocks scoring.`, + lever: + openIssueMultiplier >= 1 + ? "Keep concurrent open issues within the allowance to stay clear of the open-issue spam gate." + : "Close or resolve excess open issues to drop back within the open-issue spam threshold.", + leverageScore: openIssueMultiplier >= 1 ? 5 : 100, + }; +} + function credibilityBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { const { credibilityMultiplier } = preview.scoreEstimate; const { credibilityObserved, credibilityFloor } = preview.gates; @@ -216,6 +238,7 @@ export function explainScoreBreakdown(preview: ScorePreviewResult): ScoreBreakdo credibilityBreakdown(preview), reviewPenaltyBreakdown(preview), openPrBreakdown(preview), + openIssueBreakdown(preview), ].map((entry) => ({ ...entry, summary: sanitizePublicComment(entry.summary), diff --git a/test/unit/score-breakdown.test.ts b/test/unit/score-breakdown.test.ts index f3d99840b3..5f82c5740b 100644 --- a/test/unit/score-breakdown.test.ts +++ b/test/unit/score-breakdown.test.ts @@ -82,6 +82,7 @@ describe("explainScoreBreakdown", () => { "credibilityMultiplier", "reviewPenaltyMultiplier", "openPrMultiplier", + "openIssueMultiplier", ]), ); for (const component of breakdown.components) { @@ -92,6 +93,31 @@ describe("explainScoreBreakdown", () => { expect(breakdown.highestLeverageLever.component).toBeTruthy(); expect(breakdown.highestLeverageLever.lever).toMatch(/merge|close|credibility|open PR|linked issue|density|review/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" }); + }); + + it("explains an over-threshold open-issue count as a blocked open-issue spam gate", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openIssueCount: 50, + linkedIssueMode: "standard", + }, + }); + + const breakdown = explainScoreBreakdown(preview); + const openIssue = breakdown.components.find((entry) => entry.component === "openIssueMultiplier"); + expect(openIssue).toMatchObject({ band: "blocked" }); + expect(openIssue?.summary).toMatch(/exceeds the current allowance/i); + expect(openIssue?.lever).toMatch(/close or resolve/i); + expect(JSON.stringify(breakdown)).not.toMatch(FORBIDDEN); }); it("prioritizes open PR blocking as the highest leverage lever", () => {