diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a112e0abc4..effd9a1389 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,6 +1,6 @@ { "packages/loopover-mcp": "3.14.1", - "packages/loopover-engine": "3.14.1", + "packages/loopover-engine": "3.15.0", "packages/loopover-miner": "3.14.1", "packages/loopover-ui-kit": "1.2.0" } diff --git a/packages/loopover-engine/src/reward-risk.ts b/packages/loopover-engine/src/reward-risk.ts index c474bf8985..f1ec83fe9d 100644 --- a/packages/loopover-engine/src/reward-risk.ts +++ b/packages/loopover-engine/src/reward-risk.ts @@ -287,7 +287,7 @@ export function buildRepoRewardRisk(args: { const labels = bestFitLabels(args.repo); const competitionFactor = opportunityCompetitionFactor(collisions.summary.highRiskCount, queueHealth.signals.openPullRequests); const freshnessFactor = opportunityFreshnessFactor(args.issues, args.nowMs ?? Date.now()); - const currentOpenPrCount = nonNegative(args.outcomeHistory.totals.openPullRequests); + const currentOpenPrCount = nonNegative(repoOutcome?.openPullRequests ?? args.outcomeHistory.totals.openPullRequests); const currentOpenIssueCount = nonNegative(repoOutcome?.openIssues ?? args.outcomeHistory.totals.openIssues); /* v8 ignore next -- Credibility fallback order protects sparse private snapshots; behavior is covered through scoring profile tests. */ const credibility = repoOutcome?.credibility && repoOutcome.credibility > 0 ? repoOutcome.credibility : args.scoringProfile?.evidence.credibilityAssumption ?? args.outcomeHistory.totals.credibility ?? 0.8; diff --git a/test/unit/reward-risk-freshness.test.ts b/test/unit/reward-risk-freshness.test.ts index 4e91fcc723..35dddc8b04 100644 --- a/test/unit/reward-risk-freshness.test.ts +++ b/test/unit/reward-risk-freshness.test.ts @@ -172,6 +172,46 @@ describe("reward-risk freshness parity with loopover-engine", () => { // 2 days old -> round4(exp(-2/20)) -- a concrete pin so a formula drift can't slip through as "still equal". expect(first.rewardUpside.opportunityFactors.freshnessFactor).toBe(0.9048); }); + + it("scopes a repo's open-PR risk count to that repo, not the contributor's portfolio-wide total (#8865)", () => { + const repoA = repo("owner/repo-a"); + const repoB = repo("owner/repo-b"); + // dev has 2 open PRs in repoA and 3 in repoB: the portfolio-wide total is 5, but repoA's own is 2. + const pullRequests = [ + pr(repoA.fullName, 101, "A-1"), + pr(repoA.fullName, 102, "A-2"), + pr(repoB.fullName, 201, "B-1"), + pr(repoB.fullName, 202, "B-2"), + pr(repoB.fullName, 203, "B-3"), + ]; + const twoRepoHistory = buildContributorOutcomeHistory({ + login: "dev", + profile, + repositories: [repoA, repoB], + pullRequests, + issues: [], + repoStats: [], + }); + // Guard the fixture's own premise so the assertion below can't pass for the wrong reason. + expect(twoRepoHistory.totals.openPullRequests).toBe(5); + expect(twoRepoHistory.repoOutcomes.find((outcome) => outcome.repoFullName === repoA.fullName)?.openPullRequests).toBe(2); + + const report = buildRepoRewardRisk({ + login: "dev", + repo: repoA, + repoFullName: repoA.fullName, + profile, + outcomeHistory: twoRepoHistory, + scoringSnapshot: scoringSnapshot(), + issues: [], + pullRequests: [], + nowMs: Date.parse("2026-07-10T00:00:00.000Z"), + }); + + // Before the fix currentOpenPrCount read the portfolio-wide total (5); it must reflect repoA's own + // open-PR count (2), matching how the same report already scopes open issues and offered actions. + expect(report.riskBreakdown.openPullRequests).toBe(2); + }); }); describe("bestFitLabels keyword anchoring", () => {