diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 7b77b4fef6..fe665240bb 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -7099,6 +7099,21 @@ }, "currentEstimatedScore": { "type": "number" + }, + "opportunityFactors": { + "type": "object", + "properties": { + "competitionFactor": { + "type": "number" + }, + "freshnessFactor": { + "type": "number" + } + }, + "required": [ + "competitionFactor", + "freshnessFactor" + ] } }, "required": [ @@ -7110,7 +7125,8 @@ "labelMultiplier", "issueMultiplier", "estimatedScoreIfClean", - "currentEstimatedScore" + "currentEstimatedScore", + "opportunityFactors" ] }, "scoreBlockers": { @@ -7278,11 +7294,21 @@ "items": { "type": "string" } + }, + "severity": { + "type": "string", + "enum": [ + "critical", + "warning", + "tip", + "info" + ] } }, "required": [ "actionKind", "repoFullName", + "severity", "priorityScore", "laneValueScore", "scoreabilityScore", @@ -12431,6 +12457,32 @@ "items": { "type": "string" } + }, + "eligibilityGap": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "prsToUnlock": { + "type": "number" + }, + "estimatedScoreAtThreshold": { + "type": "number" + }, + "recommendation": { + "type": "string" + } + }, + "required": [ + "repoFullName", + "prsToUnlock", + "estimatedScoreAtThreshold", + "recommendation" + ] + } } }, "required": [ @@ -12442,7 +12494,8 @@ "repoAnalyses", "reasoning", "actionImpact", - "nextActions" + "nextActions", + "eligibilityGap" ] }, "MaintainerNoiseReport": { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index cb4e03c0d2..356566f9ac 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -2032,6 +2032,7 @@ export const RewardRiskActionSchema = z "maintainer_cut_readiness", ]), repoFullName: z.string(), + severity: z.enum(["critical", "warning", "tip", "info"]), priorityScore: z.number(), laneValueScore: z.number(), scoreabilityScore: z.number(), @@ -2062,6 +2063,10 @@ export const RepoRewardRiskSchema = z issueMultiplier: z.number(), estimatedScoreIfClean: z.number(), currentEstimatedScore: z.number(), + opportunityFactors: z.object({ + competitionFactor: z.number(), + freshnessFactor: z.number(), + }), }), scoreBlockers: z.array(z.string()), riskBreakdown: z.object({ @@ -2267,6 +2272,14 @@ export const ContributorRewardRiskStrategySchema = z reasoning: z.array(z.string()), actionImpact: z.array(z.string()), nextActions: z.array(z.string()), + eligibilityGap: z.array( + z.object({ + repoFullName: z.string(), + prsToUnlock: z.number(), + estimatedScoreAtThreshold: z.number(), + recommendation: z.string(), + }), + ), }) .openapi("ContributorRewardRiskStrategy"); diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index 0f7c5bc0a5..a417babbaf 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -1046,6 +1046,7 @@ function withSituationalAction( const waitAction: RewardRiskAction = { actionKind: "land_existing_prs", repoFullName: scorePreview.repoFullName, + severity: "tip", /* v8 ignore next -- The wait action is only prepended when ranked actions exist; fallback protects sparse score previews. */ priorityScore: Math.max(95, actions[0]?.priorityScore ?? 0), laneValueScore: 0, diff --git a/src/signals/reward-risk.ts b/src/signals/reward-risk.ts index de9f923f98..07c34199f0 100644 --- a/src/signals/reward-risk.ts +++ b/src/signals/reward-risk.ts @@ -39,6 +39,9 @@ export type RewardRiskActionKind = | "maintainer_lane_improve_repo" | "maintainer_cut_readiness"; +/** Severity tier for a reward/risk action, from most to least urgent. */ +export type RewardRiskActionSeverity = "critical" | "warning" | "tip" | "info"; + const ACTION_RANK: Record = { cleanup_existing_prs: 0, land_existing_prs: 1, @@ -52,6 +55,8 @@ const ACTION_RANK: Record = { export type RewardRiskAction = { actionKind: RewardRiskActionKind; repoFullName: string; + /** Severity tier: critical = eligibility blocker; warning = active penalty; tip = multiplier opportunity; info = planning context. */ + severity: RewardRiskActionSeverity; priorityScore: number; laneValueScore: number; scoreabilityScore: number; @@ -80,6 +85,13 @@ export type RepoRewardRisk = { issueMultiplier: number; estimatedScoreIfClean: number; currentEstimatedScore: number; + /** Explicit opportunity factors: competition and freshness of available work. */ + opportunityFactors: { + /** 0–1; higher = more competing open PRs with duplicate/collision risk. */ + competitionFactor: number; + /** 0–1; higher = issues in this repo were created or updated more recently. */ + freshnessFactor: number; + }; }; scoreBlockers: string[]; riskBreakdown: { @@ -108,6 +120,16 @@ export type RepoRewardRisk = { summary: string; }; +/** A registered repo where a small number of PR cleanups would unlock or improve scoring. */ +export type EligibilityGapEntry = { + repoFullName: string; + /** Number of open PRs to land or withdraw before the open-PR gate improves. */ + prsToUnlock: number; + /** Estimated merged score after reaching the threshold (from afterCleanupPreview). */ + estimatedScoreAtThreshold: number; + recommendation: string; +}; + export type ContributorRewardRiskStrategy = { login: string; generatedAt: string; @@ -118,6 +140,8 @@ export type ContributorRewardRiskStrategy = { reasoning: string[]; actionImpact: string[]; nextActions: string[]; + /** Repos where 1–5 PR cleanups would flip the open-PR gate toward scoreable. Sorted by fewest prsToUnlock. */ + eligibilityGap: EligibilityGapEntry[]; }; export type MaintainerNoiseReport = { @@ -181,6 +205,8 @@ export function buildRepoRewardRisk(args: { }).recommendation; const labels = bestFitLabels(args.repo); + const competitionFactor = opportunityCompetitionFactor(collisions.summary.highRiskCount, queueHealth.signals.openPullRequests); + const freshnessFactor = opportunityFreshnessFactor(args.issues); const currentOpenPrCount = nonNegative(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. */ @@ -292,6 +318,7 @@ export function buildRepoRewardRisk(args: { issueMultiplier: currentPreview.scoreEstimate.issueMultiplier, estimatedScoreIfClean: afterCleanupPreview.scoreEstimate.estimatedMergedScore, currentEstimatedScore: currentPreview.scoreEstimate.estimatedMergedScore, + opportunityFactors: { competitionFactor, freshnessFactor }, }, scoreBlockers, riskBreakdown: { @@ -372,6 +399,7 @@ export function buildContributorRewardRiskStrategy(args: { .slice(0, 8) .map((analysis) => `${analysis.repoFullName}: ${analysis.actionImpact.explanation} Score preview ${analysis.actionImpact.estimatedScoreDelta}; openPrMultiplier ${analysis.actionImpact.openPrMultiplierDelta}.`); const nextActions = [...new Set(topActions.flatMap((action) => action.nextActions))].slice(0, 10); + const eligibilityGap = buildEligibilityGap(repoAnalyses); return { login: args.login, generatedAt: nowIso(), @@ -382,6 +410,7 @@ export function buildContributorRewardRiskStrategy(args: { reasoning: [...new Set(reasoning)], actionImpact, nextActions: nextActions.length > 0 ? nextActions : ["Refresh official Gittensor and GitHub backfill data, then rerun strategy."], + eligibilityGap, }; } @@ -515,14 +544,15 @@ function buildActions(args: { }): RewardRiskAction[] { const actions: RewardRiskAction[] = []; const openRepoPrs = args.repoOutcome?.openPullRequests ?? 0; + const hasBlockers = args.scoreBlockers.length > 0; if (args.roleContext.maintainerLane) { actions.push( action("maintainer_lane_improve_repo", args, 55 + (100 - args.maintainerFrictionPenalty) * 0.25, [ "Improves the repo's contributor intake, label/config quality, and review flow instead of treating owner work as normal contributor evidence.", - ]), + ], "info"), action("maintainer_cut_readiness", args, 45 + (args.queueHealth.level === "low" ? 20 : 0), [ "Checks whether maintainer-lane economics are configured clearly enough for repo owners without inflating outside-contributor history.", - ]), + ], "info"), ); } if (!args.roleContext.maintainerLane && openRepoPrs > 0) { @@ -531,21 +561,21 @@ function buildActions(args: { args.cleanupNeeded > 0 ? `Reduces open PR pressure; current openPrMultiplier ${args.currentPreview.scoreEstimate.openPrMultiplier} can move toward ${args.afterCleanupPreview.scoreEstimate.openPrMultiplier}.` : "Keeps repo-specific queue pressure lower before adding more work.", - ]), + ], args.cleanupNeeded > 0 ? "warning" : "info"), ); if (args.lane.lane !== "issue_discovery") { actions.push( action("land_existing_prs", args, 25 + args.personalFitScore * 0.28 + args.laneValueScore * 0.18 + args.actionLeverageScore * 0.35 - args.riskPenalty * 0.08, [ "Landing already-open work preserves successful repo-specific evidence and avoids adding new maintainer load.", - ]), + ], "tip"), ); } } - if (!args.roleContext.maintainerLane && openRepoPrs > 0 && (args.scoreBlockers.length > 0 || args.riskPenalty >= 55)) { + if (!args.roleContext.maintainerLane && openRepoPrs > 0 && (hasBlockers || args.riskPenalty >= 55)) { actions.push( action("close_or_withdraw_low_fit_prs", args, 20 + args.actionLeverageScore * 0.35 + args.riskPenalty * 0.08, [ "Withdrawing stale or low-fit work can reduce collateral pressure faster than opening new submissions.", - ]), + ], "warning"), ); } if (!args.roleContext.maintainerLane && (args.lane.lane === "direct_pr" || args.lane.lane === "split")) { @@ -554,9 +584,10 @@ function buildActions(args: { "open_new_direct_pr", args, 18 + args.laneValueScore * 0.22 + args.scoreabilityScore * 0.3 + args.personalFitScore * 0.25 - args.riskPenalty * 0.18 - args.maintainerFrictionPenalty * 0.08, - args.scoreBlockers.length > 0 + hasBlockers ? ["New PR expected value is low until hard scoreability blockers and maintainer-friction signals are cleared."] : ["A tightly scoped, linked, tested direct PR has scoreability and maintainer-fit upside in this lane."], + hasBlockers ? "critical" : "tip", ), ); } @@ -566,7 +597,7 @@ function buildActions(args: { args.lane.lane === "issue_discovery" ? "This repo routes value through issue discovery; direct PR-side work has little or no lane value under current config." : "Issue discovery can be viable only for high-proof reports that someone else can solve.", - ]), + ], "tip"), ); } return actions @@ -583,10 +614,11 @@ function action(kind: RewardRiskActionKind, args: { riskPenalty: number; maintainerFrictionPenalty: number; actionLeverageScore: number; -}, priorityScore: number, whyThisHelps: string[]): RewardRiskAction { +}, priorityScore: number, whyThisHelps: string[], severity: RewardRiskActionSeverity): RewardRiskAction { return { actionKind: kind, repoFullName: args.repoFullName, + severity, priorityScore, laneValueScore: round(args.laneValueScore), scoreabilityScore: round(args.scoreabilityScore), @@ -777,6 +809,39 @@ function maintainerNextStepsFor(action: PullRequestReviewability["action"], nois return ["Watch for tests, checks, linked context, or duplicate-risk changes before prioritizing review."]; } +function buildEligibilityGap(analyses: RepoRewardRisk[]): EligibilityGapEntry[] { + return analyses + .filter((a) => !a.roleContext.maintainerLane && a.actionImpact.cleanupNeeded > 0 && a.actionImpact.cleanupNeeded <= 5) + .sort((left, right) => left.actionImpact.cleanupNeeded - right.actionImpact.cleanupNeeded) + .slice(0, 5) + .map((a) => ({ + repoFullName: a.repoFullName, + prsToUnlock: a.actionImpact.cleanupNeeded, + estimatedScoreAtThreshold: a.afterCleanupPreview.scoreEstimate.estimatedMergedScore, + recommendation: a.actionImpact.explanation, + })); +} + +function opportunityCompetitionFactor(highRiskDuplicateClusters: number, openPullRequests: number): number { + return round(clamp(highRiskDuplicateClusters / Math.max(1, openPullRequests), 0, 1)); +} + +function opportunityFreshnessFactor(issues: IssueRecord[]): number { + const openIssues = issues.filter((issue) => issue.state === "open"); + if (openIssues.length === 0) return 0; + const mostRecentAgeDays = Math.min(...openIssues.map((issue) => issueAgeDays(issue.updatedAt ?? issue.createdAt))); + // Freshness decays exponentially: ~1.0 at 0 days, ~0.6 at 7 days, ~0.2 at 30 days, ~0.05 at 90 days. + return round(clamp(Math.exp(-mostRecentAgeDays / 20), 0.05, 1)); +} + +function issueAgeDays(value: string | null | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + /* v8 ignore next -- Invalid provider timestamps normalize to fresh; stale timestamp handling is covered by signal tests. */ + if (!Number.isFinite(parsed)) return 0; + return Math.floor((Date.now() - parsed) / 86_400_000); +} + function sameRepo(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase(); } diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 11f8cfd0ed..4627f0c4fa 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1583,6 +1583,163 @@ describe("signal coverage edge cases", () => { expect(fitOf(matched)).toBe((fitOf(offLanguage) ?? 0) + 10); expect(fitOf(unknownLanguage)).toBe(fitOf(offLanguage)); }); + it("reward/risk action severity: warning under PR pressure, critical with scoreBlockers, tip for opportunities, info for maintenance", () => { + const directRepo = repo("owner/pressure-repo"); + const profile = buildContributorProfile("dev", { login: "dev", topLanguages: [], source: "github" }, [], []); + + // High pressure: 5 total open PRs, threshold 2 → cleanupNeeded = 3 → cleanup "warning"; open PR count is a blocker → direct PR "critical" + const highHistory = buildContributorOutcomeHistory({ + login: "dev", + profile, + repositories: [directRepo], + pullRequests: [], + issues: [], + repoStats: [{ login: "dev", repoFullName: directRepo.fullName, pullRequests: 5, mergedPullRequests: 1, openPullRequests: 5, issues: 0, stalePullRequests: 0, unlinkedPullRequests: 0, dominantLabels: [] }], + }); + const highPressure = buildRepoRewardRisk({ login: "dev", repo: directRepo, repoFullName: directRepo.fullName, profile, outcomeHistory: highHistory, scoringSnapshot: scoringSnapshot(), issues: [], pullRequests: [] }); + expect(highPressure.actions.find((a) => a.actionKind === "cleanup_existing_prs")?.severity).toBe("warning"); + expect(highPressure.actions.find((a) => a.actionKind === "land_existing_prs")?.severity).toBe("tip"); + expect(highPressure.actions.find((a) => a.actionKind === "open_new_direct_pr")?.severity).toBe("critical"); + expect(highPressure.actions.find((a) => a.actionKind === "close_or_withdraw_low_fit_prs")?.severity).toBe("warning"); + + // Low pressure: 4 total PRs, 2 merged, 2 open → cleanupNeeded = 0 → cleanup "info" + // A scoringProfile is required so credibilityAssumption (0.83 from 2 mergedPRs) fills the + // credibility input; without it totals.credibility=0 (no gittensor data) and 0??0.8 stays 0. + const lowStats = [{ login: "dev", repoFullName: directRepo.fullName, pullRequests: 4, mergedPullRequests: 2, openPullRequests: 2, issues: 0, stalePullRequests: 0, unlinkedPullRequests: 0, dominantLabels: [] }]; + const lowHistory = buildContributorOutcomeHistory({ login: "dev", profile, repositories: [directRepo], pullRequests: [], issues: [], repoStats: lowStats }); + const lowFit = buildContributorFit(profile, [directRepo], [], [], [], lowStats); + const lowScoringProfile = buildContributorScoringProfile({ login: "dev", fit: lowFit, scoringSnapshot: scoringSnapshot() }); + const lowPressure = buildRepoRewardRisk({ login: "dev", repo: directRepo, repoFullName: directRepo.fullName, profile, outcomeHistory: lowHistory, scoringProfile: lowScoringProfile, scoringSnapshot: scoringSnapshot(), issues: [], pullRequests: [] }); + expect(lowPressure.actions.find((a) => a.actionKind === "cleanup_existing_prs")?.severity).toBe("info"); + expect(lowPressure.actions.find((a) => a.actionKind === "open_new_direct_pr")?.severity).toBe("tip"); + + // Issue-discovery lane → file_issue_discovery is always "tip" + const issueRepo = repo("owner/issue-lane", { issueDiscoveryShare: 1 }); + const issueHistory = buildContributorOutcomeHistory({ login: "dev", profile, repositories: [issueRepo], pullRequests: [], issues: [], repoStats: [] }); + const issueResult = buildRepoRewardRisk({ login: "dev", repo: issueRepo, repoFullName: issueRepo.fullName, profile, outcomeHistory: issueHistory, scoringSnapshot: scoringSnapshot(), issues: [], pullRequests: [] }); + expect(issueResult.actions.find((a) => a.actionKind === "file_issue_discovery")?.severity).toBe("tip"); + + // Maintainer lane → every action is "info" + const maintainerRepo = repo("owner/mine"); + const ownerPr = pr(maintainerRepo.fullName, 1, "Owner work", { authorLogin: "owner", authorAssociation: "OWNER" }); + const ownerProfile = buildContributorProfile("owner", { login: "owner", topLanguages: [], source: "github" }, [ownerPr], []); + const ownerHistory = buildContributorOutcomeHistory({ login: "owner", profile: ownerProfile, repositories: [maintainerRepo], pullRequests: [ownerPr], issues: [], repoStats: [] }); + const maintainerResult = buildRepoRewardRisk({ login: "owner", repo: maintainerRepo, repoFullName: maintainerRepo.fullName, profile: ownerProfile, outcomeHistory: ownerHistory, scoringSnapshot: scoringSnapshot(), issues: [], pullRequests: [ownerPr] }); + expect(maintainerResult.actions.every((a) => a.severity === "info")).toBe(true); + }); + + it("opportunityFactors: competitionFactor from collision clusters, freshnessFactor from open issue age", () => { + const collab = repo("owner/collab-repo"); + const profile = buildContributorProfile("dev", { login: "dev", topLanguages: [], source: "github" }, [], []); + const history = buildContributorOutcomeHistory({ login: "dev", profile, repositories: [collab], pullRequests: [], issues: [], repoStats: [] }); + const base = { login: "dev", repo: collab, repoFullName: collab.fullName, profile, outcomeHistory: history, scoringSnapshot: scoringSnapshot() }; + + // No open issues, no collision clusters → both factors zero + const clean = buildRepoRewardRisk({ ...base, issues: [], pullRequests: [] }); + expect(clean.rewardUpside.opportunityFactors.competitionFactor).toBe(0); + expect(clean.rewardUpside.opportunityFactors.freshnessFactor).toBe(0); + + // Two open PRs sharing an issue and title → high-risk collision cluster → competitionFactor > 0 + const sharedIssue = issue(collab.fullName, 10, "Add cursor pagination to the labels endpoint"); + const prA = pr(collab.fullName, 11, "Add cursor pagination to the labels endpoint", { authorLogin: "alice", linkedIssues: [10] }); + const prB = pr(collab.fullName, 12, "Add cursor pagination to the labels endpoint", { authorLogin: "bob", linkedIssues: [10] }); + const withCollision = buildRepoRewardRisk({ ...base, issues: [sharedIssue], pullRequests: [prA, prB] }); + expect(withCollision.rewardUpside.opportunityFactors.competitionFactor).toBeGreaterThan(0); + + // Recently updated open issue (2 days ago) → freshnessFactor > 0.7 + const freshIssue = issue(collab.fullName, 20, "New feature request", { updatedAt: new Date(Date.now() - 2 * 86_400_000).toISOString() }); + const withFresh = buildRepoRewardRisk({ ...base, issues: [freshIssue], pullRequests: [] }); + expect(withFresh.rewardUpside.opportunityFactors.freshnessFactor).toBeGreaterThan(0.7); + + // Years-old open issue → freshnessFactor near minimum (≤ 0.05 clamp) + const staleIssue = issue(collab.fullName, 21, "Old feature request", { updatedAt: "2020-01-01T00:00:00.000Z" }); + const withStale = buildRepoRewardRisk({ ...base, issues: [staleIssue], pullRequests: [] }); + expect(withStale.rewardUpside.opportunityFactors.freshnessFactor).toBeLessThanOrEqual(0.05); + + // Closed issue does not contribute to freshnessFactor + const closedIssue = issue(collab.fullName, 22, "Closed request", { state: "closed", updatedAt: new Date().toISOString() }); + const withClosed = buildRepoRewardRisk({ ...base, issues: [closedIssue], pullRequests: [] }); + expect(withClosed.rewardUpside.opportunityFactors.freshnessFactor).toBe(0); + + // Issue with null dates → treated as fresh (conservative fallback) + const noDateIssue = issue(collab.fullName, 23, "Undated request", { updatedAt: null, createdAt: null }); + const withNoDate = buildRepoRewardRisk({ ...base, issues: [noDateIssue], pullRequests: [] }); + expect(withNoDate.rewardUpside.opportunityFactors.freshnessFactor).toBeGreaterThan(0); + }); + + it("eligibilityGap: surfaces repos within 1–5 PR cleanups of threshold, excludes zero-cleanup and out-of-range repos", () => { + const nearRepo = repo("owner/near-threshold"); + const farRepo = repo("owner/far-threshold"); + const profile = buildContributorProfile("dev", { login: "dev", topLanguages: [], source: "github" }, [], []); + + // 4 open PRs → threshold 2 → cleanupNeeded 2 → in eligibilityGap + const nearHistory = buildContributorOutcomeHistory({ + login: "dev", + profile, + repositories: [nearRepo], + pullRequests: [], + issues: [], + repoStats: [{ login: "dev", repoFullName: nearRepo.fullName, pullRequests: 4, mergedPullRequests: 1, openPullRequests: 4, issues: 0, stalePullRequests: 0, unlinkedPullRequests: 0, dominantLabels: [] }], + }); + const nearFit = buildContributorFit(profile, [nearRepo], [], [], [], [ + { login: "dev", repoFullName: nearRepo.fullName, pullRequests: 4, mergedPullRequests: 1, openPullRequests: 4, issues: 0, stalePullRequests: 0, unlinkedPullRequests: 0, dominantLabels: [] }, + ]); + const nearStrategy = buildContributorRewardRiskStrategy({ + login: "dev", + fit: nearFit, + scoringProfile: buildContributorScoringProfile({ login: "dev", fit: nearFit, scoringSnapshot: scoringSnapshot() }), + scoringSnapshot: scoringSnapshot(), + outcomeHistory: nearHistory, + repositories: [nearRepo], + allIssues: [], + allPullRequests: [], + }); + expect(nearStrategy.eligibilityGap.length).toBeGreaterThan(0); + const nearEntry = nearStrategy.eligibilityGap[0]!; + expect(nearEntry.repoFullName).toBe(nearRepo.fullName); + expect(nearEntry.prsToUnlock).toBeGreaterThan(0); + expect(nearEntry.prsToUnlock).toBeLessThanOrEqual(5); + expect(nearEntry.estimatedScoreAtThreshold).toBeGreaterThan(0); + + // 10 open PRs → cleanupNeeded 8 > 5 → excluded from eligibilityGap + const farHistory = buildContributorOutcomeHistory({ + login: "dev", + profile, + repositories: [farRepo], + pullRequests: [], + issues: [], + repoStats: [{ login: "dev", repoFullName: farRepo.fullName, pullRequests: 10, mergedPullRequests: 1, openPullRequests: 10, issues: 0, stalePullRequests: 0, unlinkedPullRequests: 0, dominantLabels: [] }], + }); + const farFit = buildContributorFit(profile, [farRepo], [], [], [], [ + { login: "dev", repoFullName: farRepo.fullName, pullRequests: 10, mergedPullRequests: 1, openPullRequests: 10, issues: 0, stalePullRequests: 0, unlinkedPullRequests: 0, dominantLabels: [] }, + ]); + const farStrategy = buildContributorRewardRiskStrategy({ + login: "dev", + fit: farFit, + scoringProfile: buildContributorScoringProfile({ login: "dev", fit: farFit, scoringSnapshot: scoringSnapshot() }), + scoringSnapshot: scoringSnapshot(), + outcomeHistory: farHistory, + repositories: [farRepo], + allIssues: [], + allPullRequests: [], + }); + expect(farStrategy.eligibilityGap.length).toBe(0); + + // 0 open PRs → cleanupNeeded 0 → excluded from eligibilityGap + const cleanFit = buildContributorFit(profile, [nearRepo], [], [], [], []); + const cleanStrategy = buildContributorRewardRiskStrategy({ + login: "dev", + fit: cleanFit, + scoringProfile: buildContributorScoringProfile({ login: "dev", fit: cleanFit, scoringSnapshot: scoringSnapshot() }), + scoringSnapshot: scoringSnapshot(), + outcomeHistory: buildContributorOutcomeHistory({ login: "dev", profile, repositories: [nearRepo], pullRequests: [], issues: [], repoStats: [] }), + repositories: [nearRepo], + allIssues: [], + allPullRequests: [], + }); + expect(cleanStrategy.eligibilityGap.length).toBe(0); + }); + it("buildQueueHealth counts draft PRs and fires inactive_draft_prs finding when stale", () => { const directRepo = repo("owner/draft-test"); const collisions = buildCollisionReport(directRepo.fullName, [], []);