diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index e9fb73690b..ca0396a431 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -302,3 +302,23 @@ export { type VisualRoutesConfig, type VisualTheme, } from "./focus-manifest.js"; +// Reward/risk reasoning signals (#2281). The four builders depend on the still-in-`src` maintainer signal +// stack, so they take an injected `RewardRiskEngineDeps` (the `src/signals/reward-risk.ts` shim binds it). +export { + buildRepoRewardRisk, + buildContributorRewardRiskStrategy, + buildMaintainerNoiseReport, + buildPullRequestReviewability, + rewardRiskFreshnessInternals, + type RewardRiskEngineDeps, + type PullRequestReviewabilityInput, + type PullRequestReviewIntelligenceView, + type RewardRiskAction, + type RewardRiskActionKind, + type RewardRiskActionSeverity, + type RepoRewardRisk, + type EligibilityGapEntry, + type ContributorRewardRiskStrategy, + type MaintainerNoiseReport, + type PullRequestReviewability, +} from "./reward-risk.js"; diff --git a/packages/gittensory-engine/src/reward-risk.ts b/packages/gittensory-engine/src/reward-risk.ts new file mode 100644 index 0000000000..a4e981b3d9 --- /dev/null +++ b/packages/gittensory-engine/src/reward-risk.ts @@ -0,0 +1,991 @@ +// Deterministic reward/risk reasoning signals, extracted to `@jsonbored/gittensory-engine` (#2281) so the +// gittensory-miner can rank candidate work locally with the same logic the maintainer-side gate computes. +// +// Unlike the earlier self-contained extractions, reward-risk sits on top of the maintainer signal stack in +// `src/signals/engine.ts` (`buildRoleContext`, `buildLaneAdvice`, `buildCollisionReport`, `buildQueueHealth`, +// `buildRepoFitRecommendation`, `buildContributorIntakeHealth`, `buildPullRequestReviewIntelligence`) and on +// `isFailingCheckSummary` from `src/signals/local-branch.ts`. Those builders are not yet extracted (and are +// far too large to port under the per-issue size cap), so — rather than reach back into `src/`, which this +// package must never do — they are DEPENDENCY-INJECTED via `RewardRiskEngineDeps`. The +// `src/signals/reward-risk.ts` shim binds the real `src` builders; a follow-up issue can drop the injection +// once they have engine homes. +import type { ScorePreviewResult } from "./scoring/preview.js"; +import { buildScorePreview } from "./scoring/preview.js"; +import { nowIso } from "./utils/json.js"; +import type { + CheckSummaryRecord, + CollisionReport, + ContributorFit, + ContributorOutcomeHistory, + ContributorProfile, + ContributorScoringProfile, + IssueRecord, + LaneAdvice, + PullRequestFileRecord, + PullRequestRecord, + PullRequestReviewRecord, + QueueHealth, + RecentMergedPullRequestRecord, + RepoFitRecommendation, + RepositoryRecord, + RoleContext, + ScoringModelSnapshotRecord, +} from "./types/reward-risk-types.js"; + +/** + * Minimal covariant view of `buildPullRequestReviewIntelligence`'s return — only the fields this module + * reads. The full `src` type (`PullRequestMaintainerPacket & …`) is covariantly assignable to it. + */ +export type PullRequestReviewIntelligenceView = { + roleContext: { maintainerLane: boolean }; + outcomeContext?: { closedPullRequestRate: number } | undefined; + reviewSignals: { linkedIssues: readonly unknown[]; collisionClusters: number; approvalCount: number }; + changeSummary: { fileCount: number; additions: number; deletions: number; codeFileCount: number; testFileCount: number }; +}; + +/** Input shared by {@link buildPullRequestReviewability} and the injected `buildPullRequestReviewIntelligence`. */ +export type PullRequestReviewabilityInput = { + repo: RepositoryRecord | null; + pullRequest: PullRequestRecord | null; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; + files: PullRequestFileRecord[]; + reviews: PullRequestReviewRecord[]; + checks: CheckSummaryRecord[]; + recentMergedPullRequests: RecentMergedPullRequestRecord[]; + repoFullName: string; + pullNumber: number; + profile?: ContributorProfile | null | undefined; + outcomeHistory?: ContributorOutcomeHistory | null | undefined; +}; + +/** + * The `src/signals/engine.ts` + `src/signals/local-branch.ts` builders reward-risk depends on, injected so + * this package stays free of any `src/` import. The real `src`-typed builders bind cleanly: their argument + * records are wider than (assignable from) these engine mirrors, and their richer return types are + * covariantly assignable to the narrowed views above. + */ +export type RewardRiskEngineDeps = { + buildRoleContext: (args: { + login: string; + repo: RepositoryRecord | null; + repoFullName: string; + pullRequests: PullRequestRecord[]; + issues: IssueRecord[]; + profile: ContributorProfile; + }) => RoleContext; + buildLaneAdvice: (repo: RepositoryRecord | null, fullName: string) => LaneAdvice; + buildCollisionReport: ( + fullName: string, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + recentMergedPullRequests: RecentMergedPullRequestRecord[], + ) => CollisionReport; + buildQueueHealth: ( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + collisions: CollisionReport, + ) => QueueHealth; + buildRepoFitRecommendation: (args: { + login: string; + repo: RepositoryRecord | null; + repoFullName: string; + profile: ContributorProfile; + outcomeHistory: ContributorOutcomeHistory; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; + }) => RepoFitRecommendation; + buildContributorIntakeHealth: ( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + fullName: string, + collisions: CollisionReport, + ) => { level: "healthy" | "watch" | "strained" | "blocked" }; + buildPullRequestReviewIntelligence: (args: PullRequestReviewabilityInput) => PullRequestReviewIntelligenceView; + isFailingCheckSummary: (check: CheckSummaryRecord) => boolean; +}; + +export type RewardRiskActionKind = + | "cleanup_existing_prs" + | "land_existing_prs" + | "close_or_withdraw_low_fit_prs" + | "open_new_direct_pr" + | "file_issue_discovery" + | "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, + close_or_withdraw_low_fit_prs: 2, + open_new_direct_pr: 3, + file_issue_discovery: 4, + maintainer_lane_improve_repo: 5, + maintainer_cut_readiness: 6, +}; + +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; + personalFitScore: number; + riskPenalty: number; + maintainerFrictionPenalty: number; + actionLeverageScore: number; + whyThisHelps: string[]; + nextActions: string[]; +}; + +export type RepoRewardRisk = { + login: string; + repoFullName: string; + generatedAt: string; + roleContext: RoleContext; + lane: LaneAdvice; + recommendation: RepoFitRecommendation["recommendation"]; + rewardUpside: { + relevantLane: "direct_pr" | "issue_discovery" | "maintainer_lane" | "none"; + repoSlice: number; + directPrSlice: number; + issueDiscoverySlice: number; + maintainerCutSlice: number; + labelMultiplier: number; + 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: { + queueBurden: QueueHealth["level"]; + queueBurdenScore: number; + duplicateClusters: number; + highRiskDuplicateClusters: number; + closedPullRequestRate: number; + openPullRequests: number; + credibility: number; + reviewChurnRisk: "low" | "medium" | "high"; + }; + actionImpact: { + currentOpenPrCount: number; + openPrThreshold: number; + openPrMultiplierDelta: string; + estimatedScoreDelta: string; + cleanupNeeded: number; + explanation: string; + }; + currentPreview: ScorePreviewResult; + afterCleanupPreview: ScorePreviewResult; + actions: RewardRiskAction[]; + whyThisHelps: string[]; + nextActions: string[]; + 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; + scoringModelSnapshotId: string; + summary: string; + topActions: RewardRiskAction[]; + repoAnalyses: RepoRewardRisk[]; + 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 = { + repoFullName: string; + generatedAt: string; + score: number; + level: "low" | "medium" | "high" | "critical"; + noiseSources: string[]; + maintainerActions: Array<"review_now" | "needs_author" | "likely_duplicate" | "close_or_redirect" | "watch" | "maintainer_lane">; + queueHealth: QueueHealth; + summary: string; +}; + +export type PullRequestReviewability = { + repoFullName: string; + pullNumber: number; + generatedAt: string; + score: number; + action: "review_now" | "needs_author" | "likely_duplicate" | "close_or_redirect" | "watch" | "maintainer_lane"; + noiseSources: string[]; + whyThisHelps: string[]; + maintainerNextSteps: string[]; + privateSummary: string; +}; + +export function buildRepoRewardRisk(args: { + login: string; + repo: RepositoryRecord | null; + repoFullName: string; + profile: ContributorProfile; + outcomeHistory: ContributorOutcomeHistory; + scoringSnapshot: ScoringModelSnapshotRecord; + scoringProfile?: ContributorScoringProfile | null | undefined; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; + recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined; + /** Repo primary language (from sync metadata / ContributorFit.languageFit), + * used for the personalFit language-match bonus. */ + repoLanguage?: string | null | undefined; +}, deps: RewardRiskEngineDeps): RepoRewardRisk { + const roleContext = deps.buildRoleContext({ + login: args.login, + repo: args.repo, + repoFullName: args.repoFullName, + pullRequests: args.pullRequests, + issues: args.issues, + profile: args.profile, + }); + const lane = deps.buildLaneAdvice(args.repo, args.repoFullName); + const repoOutcome = args.outcomeHistory.repoOutcomes.find((outcome) => sameRepo(outcome.repoFullName, args.repoFullName)); + const collisions = deps.buildCollisionReport(args.repoFullName, args.issues, args.pullRequests, args.recentMergedPullRequests ?? []); + const queueHealth = deps.buildQueueHealth(args.repo, args.issues, args.pullRequests, collisions); + const recommendation = deps.buildRepoFitRecommendation({ + login: args.login, + repo: args.repo, + repoFullName: args.repoFullName, + profile: args.profile, + outcomeHistory: args.outcomeHistory, + issues: args.issues, + pullRequests: args.pullRequests, + }).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. */ + const credibility = repoOutcome?.credibility && repoOutcome.credibility > 0 ? repoOutcome.credibility : args.scoringProfile?.evidence.credibilityAssumption ?? args.outcomeHistory.totals.credibility ?? 0.8; + const commonPreviewInput = { + repoFullName: args.repoFullName, + targetType: "planned_pr" as const, + targetKey: `${args.login}:${args.repoFullName}:reward-risk`, + contributorLogin: args.login, + labels, + linkedIssueMode: lane.lane === "issue_discovery" ? ("none" as const) : ("standard" as const), + sourceTokenScore: estimatedSourceTokenScore(repoOutcome), + totalTokenScore: estimatedTotalTokenScore(repoOutcome), + sourceLines: estimatedSourceLines(repoOutcome), + existingContributorTokenScore: 0, + credibility, + metadataOnly: true, + duplicateRiskCount: collisions.summary.highRiskCount, + openIssueCount: currentOpenIssueCount, + }; + const currentPreview = buildScorePreview({ + input: { ...commonPreviewInput, openPrCount: currentOpenPrCount }, + repo: args.repo, + snapshot: args.scoringSnapshot, + }); + const cleanupOpenPrCount = Math.min(currentOpenPrCount, currentPreview.gates.openPrThreshold); + const afterCleanupPreview = buildScorePreview({ + input: { ...commonPreviewInput, openPrCount: cleanupOpenPrCount }, + repo: args.repo, + snapshot: args.scoringSnapshot, + }); + + const relevantLane = relevantLaneFor(lane, roleContext); + const laneValueScore = laneValue(lane, currentPreview, relevantLane); + const personalFitScore = personalFit(repoOutcome, args.scoringProfile, roleContext, args.profile, args.repoLanguage ?? null); + const riskPenalty = riskScore(repoOutcome, queueHealth, collisions.summary.clusterCount, collisions.summary.highRiskCount, currentOpenPrCount, currentPreview.gates.openPrThreshold); + const maintainerFrictionPenalty = maintainerFriction(queueHealth, collisions.summary.clusterCount, args.pullRequests); + const scoreBlockers = scoreBlockersFor({ + lane, + roleContext, + currentPreview, + repo: args.repo, + repoOutcome, + currentOpenPrCount, + }); + const scoreabilityScore = scoreBlockers.length > 0 ? 0 : clamp((currentPreview.scoreEstimate.estimatedMergedScore / 50) * 100, 0, 100); + const actionLeverageScore = cleanupOpenPrCount < currentOpenPrCount ? clamp((currentOpenPrCount - cleanupOpenPrCount) * 18, 30, 100) : 0; + const baseActionInput = { + repoFullName: args.repoFullName, + laneValueScore, + scoreabilityScore, + personalFitScore, + riskPenalty, + maintainerFrictionPenalty, + actionLeverageScore, + }; + const cleanupNeeded = Math.max(0, currentOpenPrCount - currentPreview.gates.openPrThreshold); + const actions = buildActions({ + ...baseActionInput, + lane, + roleContext, + repoOutcome, + currentPreview, + afterCleanupPreview, + cleanupNeeded, + scoreBlockers, + queueHealth, + collisionsHighRiskCount: collisions.summary.highRiskCount, + }); + const actionImpact = { + currentOpenPrCount, + openPrThreshold: currentPreview.gates.openPrThreshold, + openPrMultiplierDelta: `${currentPreview.scoreEstimate.openPrMultiplier} -> ${afterCleanupPreview.scoreEstimate.openPrMultiplier}`, + estimatedScoreDelta: `${currentPreview.scoreEstimate.estimatedMergedScore} -> ${afterCleanupPreview.scoreEstimate.estimatedMergedScore}`, + cleanupNeeded, + explanation: + cleanupNeeded > 0 + ? `Landing, closing, or withdrawing ${cleanupNeeded} open PR(s) moves the current open-PR gate from blocked toward scoreable future work.` + : "Open PR pressure is not the primary scoreability blocker for this repo right now.", + }; + const whyThisHelps = whyThisHelpsFor({ + repoFullName: args.repoFullName, + lane, + roleContext, + repoOutcome, + currentPreview, + afterCleanupPreview, + cleanupNeeded, + scoreBlockers, + queueHealth, + collisionsHighRiskCount: collisions.summary.highRiskCount, + }); + const nextActions = [...new Set(actions.flatMap((action) => action.nextActions))].slice(0, 8); + + return { + login: args.login, + repoFullName: args.repoFullName, + generatedAt: nowIso(), + roleContext, + lane, + recommendation, + rewardUpside: { + relevantLane, + repoSlice: currentPreview.laneMath.repoSlice, + directPrSlice: currentPreview.laneMath.directPrSlice, + issueDiscoverySlice: currentPreview.laneMath.issueDiscoverySlice, + maintainerCutSlice: round((args.repo?.registryConfig?.maintainerCut ?? 0) * currentPreview.laneMath.repoSlice), + labelMultiplier: currentPreview.scoreEstimate.labelMultiplier, + issueMultiplier: currentPreview.scoreEstimate.issueMultiplier, + estimatedScoreIfClean: afterCleanupPreview.scoreEstimate.estimatedMergedScore, + currentEstimatedScore: currentPreview.scoreEstimate.estimatedMergedScore, + opportunityFactors: { competitionFactor, freshnessFactor }, + }, + scoreBlockers, + riskBreakdown: { + queueBurden: queueHealth.level, + queueBurdenScore: queueHealth.burdenScore, + duplicateClusters: collisions.summary.clusterCount, + highRiskDuplicateClusters: collisions.summary.highRiskCount, + closedPullRequestRate: repoOutcome?.closedPullRequestRate ?? args.outcomeHistory.totals.closedPullRequestRate, + openPullRequests: currentOpenPrCount, + credibility, + reviewChurnRisk: reviewChurnRisk(repoOutcome, queueHealth, collisions.summary.highRiskCount), + }, + actionImpact, + currentPreview, + afterCleanupPreview, + actions, + whyThisHelps, + nextActions: nextActions.length > 0 ? nextActions : ["Gather fresher repo and contributor evidence before acting."], + summary: `${args.repoFullName}: ${scoreBlockers.length > 0 ? "blocked or cautionary" : "scoreable"} private reward/risk context; top action ${actions[0]?.actionKind ?? "none"}.`, + }; +} + +export function buildContributorRewardRiskStrategy(args: { + login: string; + fit: ContributorFit; + scoringProfile: ContributorScoringProfile; + scoringSnapshot: ScoringModelSnapshotRecord; + outcomeHistory: ContributorOutcomeHistory; + repositories: RepositoryRecord[]; + allIssues: IssueRecord[]; + allPullRequests: PullRequestRecord[]; + recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined; +}, deps: RewardRiskEngineDeps): ContributorRewardRiskStrategy { + const registeredRepoNames = new Map(args.repositories.filter((repo) => repo.isRegistered).map((repo) => [repo.fullName.toLowerCase(), repo.fullName])); + const candidateRepoNames = uniqueRegisteredRepoNames( + [ + ...args.fit.opportunities.map((opportunity) => opportunity.repoFullName), + ...args.outcomeHistory.repoOutcomes.filter((outcome) => registeredRepoNames.has(outcome.repoFullName.toLowerCase())).map((outcome) => outcome.repoFullName), + ...args.repositories.filter((repo) => repo.isRegistered).map((repo) => repo.fullName), + ], + registeredRepoNames, + ); + const issuesByRepo = groupByRepo(args.allIssues); + const pullRequestsByRepo = groupByRepo(args.allPullRequests); + const recentMergedPullRequestsByRepo = groupByRepo(args.recentMergedPullRequests ?? []); + const repoAnalyses = candidateRepoNames + .map((repoFullName) => { + /* v8 ignore next -- Strategy inputs usually originate from repository records; null protects stale fit snapshots. */ + const repo = args.repositories.find((candidate) => sameRepo(candidate.fullName, repoFullName)) ?? null; + const repoKey = repoFullName.toLowerCase(); + return buildRepoRewardRisk({ + login: args.login, + repo, + repoFullName, + profile: args.fit.profile, + outcomeHistory: args.outcomeHistory, + scoringSnapshot: args.scoringSnapshot, + scoringProfile: args.scoringProfile, + issues: issuesByRepo.get(repoKey) ?? [], + pullRequests: pullRequestsByRepo.get(repoKey) ?? [], + recentMergedPullRequests: recentMergedPullRequestsByRepo.get(repoKey) ?? [], + repoLanguage: args.fit.languageFit.find((entry) => sameRepo(entry.repoFullName, repoFullName))?.language ?? null, + }, deps); + }) + /* v8 ignore next -- Locale tie ordering is deterministic presentation fallback after ranked analysis scores. */ + .sort((left, right) => analysisRank(right) - analysisRank(left) || left.repoFullName.localeCompare(right.repoFullName)) + .slice(0, 20); + const topActions = repoAnalyses + .flatMap((analysis) => analysis.actions) + /* v8 ignore next -- Secondary sort keys make ties deterministic; priority ordering is covered by strategy tests. */ + .sort((left, right) => right.priorityScore - left.priorityScore || ACTION_RANK[left.actionKind] - ACTION_RANK[right.actionKind] || left.repoFullName.localeCompare(right.repoFullName)) + .slice(0, 12); + const reasoning = [ + ...topActions.slice(0, 5).flatMap((action) => action.whyThisHelps.map((reason) => `${action.repoFullName}: ${reason}`)), + ...repoAnalyses + .filter((analysis) => analysis.roleContext.maintainerLane) + .slice(0, 4) + .map((analysis) => `${analysis.repoFullName}: maintainer-lane economics are separate from normal contributor rewards.`), + ]; + const actionImpact = repoAnalyses + .filter((analysis) => analysis.actionImpact.cleanupNeeded > 0 || analysis.currentPreview.scoreEstimate.estimatedMergedScore !== analysis.afterCleanupPreview.scoreEstimate.estimatedMergedScore) + .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(), + scoringModelSnapshotId: args.scoringSnapshot.id, + summary: `${args.login} has ${topActions.length} ranked reward/risk action(s) from ${repoAnalyses.length} repo analysis record(s).`, + topActions, + repoAnalyses, + reasoning: [...new Set(reasoning)], + actionImpact, + nextActions: nextActions.length > 0 ? nextActions : ["Refresh official Gittensor and GitHub backfill data, then rerun strategy."], + eligibilityGap, + }; +} + +export function buildMaintainerNoiseReport( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + recentMergedPullRequests: RecentMergedPullRequestRecord[], + fullName: string, + deps: RewardRiskEngineDeps, +): MaintainerNoiseReport { + const collisions = deps.buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests); + const queueHealth = deps.buildQueueHealth(repo, issues, pullRequests, collisions); + const intake = deps.buildContributorIntakeHealth(repo, issues, pullRequests, fullName, collisions); + const unlinked = pullRequests.filter((pr) => pr.state === "open" && pr.linkedIssues.length === 0).length; + // Only OPEN PRs are live maintainer-queue noise. Without the state guard (which the sibling `unlinked` + // count above already applies), already-merged/closed PRs with common churn titles ("refactor", "cleanup", + // "various", …) are miscounted as active noise, inflating noiseSources and depressing the score. + const broadDiffSignals = pullRequests.filter((pr) => pr.state === "open" && (pr.title.length > 120 || /refactor|cleanup|misc|various/i.test(pr.title))).length; + const noiseSources = [ + ...(unlinked > 0 ? [`${unlinked} open PR(s) lack linked issue context.`] : []), + ...(collisions.summary.highRiskCount > 0 ? [`${collisions.summary.highRiskCount} high-risk duplicate/WIP cluster(s).`] : []), + ...(queueHealth.signals.stalePullRequests > 0 ? [`${queueHealth.signals.stalePullRequests} stale PR(s) add queue drag.`] : []), + ...(broadDiffSignals > 0 ? [`${broadDiffSignals} PR(s) look broad or hard to triage from title metadata.`] : []), + ...(intake.level === "strained" || intake.level === "blocked" ? [`Contributor intake is ${intake.level}.`] : []), + ]; + const score = clamp(100 - queueHealth.burdenScore * 0.55 - collisions.summary.highRiskCount * 12 - unlinked * 6 - broadDiffSignals * 4, 0, 100); + const level: MaintainerNoiseReport["level"] = score < 25 ? "critical" : score < 50 ? "high" : score < 75 ? "medium" : "low"; + const maintainerActions: MaintainerNoiseReport["maintainerActions"] = [ + ...(collisions.summary.highRiskCount > 0 ? ["likely_duplicate" as const] : []), + ...(unlinked > 0 || queueHealth.signals.stalePullRequests > 0 ? ["needs_author" as const] : []), + ...(queueHealth.signals.likelyReviewablePullRequests > 0 ? ["review_now" as const] : []), + ...(noiseSources.length === 0 ? ["watch" as const] : []), + ]; + return { + repoFullName: fullName, + generatedAt: nowIso(), + score: round(score), + level, + noiseSources: noiseSources.length > 0 ? noiseSources : ["No major maintainer-noise source detected in cached metadata."], + maintainerActions: [...new Set(maintainerActions)], + queueHealth, + summary: `${fullName} maintainer noise is ${level}; queue ${queueHealth.level}, ${collisions.summary.highRiskCount} high-risk collision cluster(s), ${unlinked} unlinked open PR(s).`, + }; +} + +export function buildPullRequestReviewability(args: PullRequestReviewabilityInput, deps: RewardRiskEngineDeps): PullRequestReviewability { + const intelligence = deps.buildPullRequestReviewIntelligence(args); + const pr = args.pullRequest; + const failingChecks = args.checks.filter(deps.isFailingCheckSummary).length; + const broadDiff = intelligence.changeSummary.fileCount >= 12 || intelligence.changeSummary.additions + intelligence.changeSummary.deletions >= 800; + const noiseSources = [ + ...(pr?.state && pr.state !== "open" ? [`PR is ${pr.state}.`] : []), + ...(intelligence.reviewSignals.linkedIssues.length === 0 ? ["Missing linked issue or no-issue rationale."] : []), + ...(intelligence.reviewSignals.collisionClusters > 0 ? [`${intelligence.reviewSignals.collisionClusters} duplicate/WIP collision cluster(s).`] : []), + ...(intelligence.changeSummary.codeFileCount > 0 && intelligence.changeSummary.testFileCount === 0 ? ["Code changes do not include cached test files."] : []), + ...(failingChecks > 0 ? [`${failingChecks} failing or cancelled check(s).`] : []), + ...(broadDiff ? ["Diff is broad enough to create avoidable review friction."] : []), + ...(intelligence.outcomeContext && !intelligence.roleContext.maintainerLane && intelligence.outcomeContext.closedPullRequestRate >= 0.35 + ? [`Contributor repo-specific closed PR rate is ${percent(intelligence.outcomeContext.closedPullRequestRate)}.`] + : []), + ]; + const score = clamp( + 100 - + noiseSources.length * 14 - + intelligence.reviewSignals.collisionClusters * 12 - + failingChecks * 18 - + (broadDiff ? 18 : 0) + + (intelligence.reviewSignals.approvalCount > 0 ? 12 : 0), + 0, + 100, + ); + const action: PullRequestReviewability["action"] = intelligence.roleContext.maintainerLane + ? "maintainer_lane" + : pr?.state && pr.state !== "open" + ? "close_or_redirect" + : intelligence.reviewSignals.collisionClusters > 0 + ? "likely_duplicate" + : score >= 75 + ? "review_now" + : score >= 45 + ? "needs_author" + : "watch"; + const whyThisHelps = [ + ...(action === "review_now" ? ["Reviewing now is efficient because cached signals show linked context and manageable friction."] : []), + ...(action === "needs_author" ? ["Asking for author cleanup first reduces maintainer review time before deep technical review."] : []), + ...(action === "likely_duplicate" ? ["Checking overlap first prevents maintainers from reviewing duplicate or soon-obsolete work."] : []), + ...(action === "maintainer_lane" ? ["Maintainer-authored work should be reviewed as repo stewardship, not outside-contributor triage."] : []), + ...(action === "close_or_redirect" ? ["Closed or non-open PRs should be redirected before consuming review time."] : []), + ...(action === "watch" ? ["Watching is lower-cost until checks, tests, issue links, or overlap signals improve."] : []), + ]; + return { + repoFullName: args.repoFullName, + pullNumber: args.pullNumber, + generatedAt: nowIso(), + score: round(score), + action, + noiseSources: noiseSources.length > 0 ? noiseSources : ["No major reviewability blocker detected in cached metadata."], + whyThisHelps, + maintainerNextSteps: maintainerNextStepsFor(action, noiseSources), + privateSummary: `Reviewability ${round(score)}/100; action ${action}; ${noiseSources.length} noise source(s) from cached metadata.`, + }; +} + +function buildActions(args: { + repoFullName: string; + lane: LaneAdvice; + roleContext: RoleContext; + repoOutcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; + currentPreview: ScorePreviewResult; + afterCleanupPreview: ScorePreviewResult; + cleanupNeeded: number; + scoreBlockers: string[]; + queueHealth: QueueHealth; + collisionsHighRiskCount: number; + laneValueScore: number; + scoreabilityScore: number; + personalFitScore: number; + riskPenalty: number; + maintainerFrictionPenalty: number; + actionLeverageScore: number; +}): 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) { + actions.push( + action("cleanup_existing_prs", args, 30 + args.actionLeverageScore * 0.55 + args.personalFitScore * 0.22 + args.laneValueScore * 0.12 - args.maintainerFrictionPenalty * 0.04, [ + 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 && (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")) { + actions.push( + action( + "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, + 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", + ), + ); + } + if (!args.roleContext.maintainerLane && (args.lane.lane === "issue_discovery" || args.lane.lane === "split")) { + actions.push( + action("file_issue_discovery", args, 18 + args.laneValueScore * 0.28 + (args.lane.lane === "issue_discovery" ? 20 : 0) - args.riskPenalty * 0.16, [ + 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"), + ); + } + const ranked = actions.map((candidate) => ({ ...candidate, priorityScore: round(clamp(candidate.priorityScore, 0, 100)) })); + /* v8 ignore start -- secondary action rank is a deterministic presentation tie-break */ + return ranked.sort((left, right) => right.priorityScore - left.priorityScore || ACTION_RANK[left.actionKind] - ACTION_RANK[right.actionKind]); + /* v8 ignore stop */ +} + +function action(kind: RewardRiskActionKind, args: { + repoFullName: string; + laneValueScore: number; + scoreabilityScore: number; + personalFitScore: number; + riskPenalty: number; + maintainerFrictionPenalty: number; + actionLeverageScore: number; +}, priorityScore: number, whyThisHelps: string[], severity: RewardRiskActionSeverity): RewardRiskAction { + return { + actionKind: kind, + repoFullName: args.repoFullName, + severity, + priorityScore, + laneValueScore: round(args.laneValueScore), + scoreabilityScore: round(args.scoreabilityScore), + personalFitScore: round(args.personalFitScore), + riskPenalty: round(args.riskPenalty), + maintainerFrictionPenalty: round(args.maintainerFrictionPenalty), + actionLeverageScore: round(args.actionLeverageScore), + whyThisHelps, + nextActions: nextActionsFor(kind), + }; +} + +function scoreBlockersFor(args: { + lane: LaneAdvice; + roleContext: RoleContext; + currentPreview: ScorePreviewResult; + repo: RepositoryRecord | null; + repoOutcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; + currentOpenPrCount: number; +}): string[] { + return [ + ...(!args.repo?.isRegistered ? ["Repository is not registered in the local snapshot."] : []), + ...(args.lane.lane === "inactive" ? ["Repository allocation is inactive."] : []), + ...(args.lane.lane === "unknown" ? ["Repository lane is unknown."] : []), + ...(args.roleContext.maintainerLane ? ["Maintainer-lane work is not normal outside-contributor reward evidence."] : []), + ...(args.currentPreview.laneMath.directPrSlice <= 0 && args.lane.lane === "issue_discovery" ? ["Direct PR-side lane value is disabled for this repo."] : []), + ...(args.currentOpenPrCount > args.currentPreview.gates.openPrThreshold ? ["Open PR count exceeds the current threshold assumption."] : []), + ...(args.currentPreview.gates.credibilityObserved < args.currentPreview.gates.credibilityFloor ? ["Credibility assumption is below the current floor."] : []), + ...((args.repoOutcome?.closedPullRequestRate ?? 0) >= 0.35 ? ["Repo-specific closed PR rate is high enough to create credibility risk."] : []), + ]; +} + +function whyThisHelpsFor(args: { + repoFullName: string; + lane: LaneAdvice; + roleContext: RoleContext; + repoOutcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; + currentPreview: ScorePreviewResult; + afterCleanupPreview: ScorePreviewResult; + cleanupNeeded: number; + scoreBlockers: string[]; + queueHealth: QueueHealth; + collisionsHighRiskCount: number; +}): string[] { + return [ + ...(args.cleanupNeeded > 0 + ? [`Cleanup is high leverage because it changes openPrMultiplier ${args.currentPreview.scoreEstimate.openPrMultiplier} -> ${args.afterCleanupPreview.scoreEstimate.openPrMultiplier} and estimated score ${args.currentPreview.scoreEstimate.estimatedMergedScore} -> ${args.afterCleanupPreview.scoreEstimate.estimatedMergedScore}.`] + : []), + ...(args.repoOutcome && args.repoOutcome.mergedPullRequests > 0 + ? [`Protects repo-specific credibility where ${args.repoOutcome.mergedPullRequests} merged PR(s) already show fit.`] + : []), + ...(args.roleContext.maintainerLane + ? [`${args.repoFullName} is maintainer lane for this user, so repo-health and maintainer_cut readiness matter more than normal contributor submissions.`] + : []), + ...(args.lane.lane === "issue_discovery" ? ["Direct PRs have no PR-side lane value here; issue-discovery quality and closure risk dominate."] : []), + ...(args.scoreBlockers.length > 0 ? [`Hard blockers: ${args.scoreBlockers.join(" ")}`] : []), + ...(args.queueHealth.level === "high" || args.queueHealth.level === "critical" ? [`Maintainer queue is ${args.queueHealth.level}; review friction lowers risk-adjusted priority.`] : []), + ...(args.collisionsHighRiskCount > 0 ? [`${args.collisionsHighRiskCount} high-risk collision cluster(s) must be cleared before new work has good expected value.`] : []), + ]; +} + +function relevantLaneFor(lane: LaneAdvice, roleContext: RoleContext): RepoRewardRisk["rewardUpside"]["relevantLane"] { + if (roleContext.maintainerLane) return "maintainer_lane"; + if (lane.lane === "direct_pr") return "direct_pr"; + if (lane.lane === "issue_discovery") return "issue_discovery"; + if (lane.lane === "split") return "direct_pr"; + return "none"; +} + +function laneValue(lane: LaneAdvice, preview: ScorePreviewResult, relevantLane: RepoRewardRisk["rewardUpside"]["relevantLane"]): number { + if (lane.lane === "inactive" || lane.lane === "unknown" || relevantLane === "none") return 0; + if (relevantLane === "issue_discovery") return clamp(preview.laneMath.issueDiscoverySlice * 1000, 0, 100); + if (relevantLane === "maintainer_lane") return clamp(preview.laneMath.repoSlice * 800, 0, 100); + return clamp(preview.laneMath.directPrSlice * 1000, 0, 100); +} + +function personalFit( + outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, + scoringProfile: ContributorScoringProfile | null | undefined, + roleContext: RoleContext, + profile: ContributorProfile, + repoLanguage: string | null | undefined, +): number { + if (roleContext.maintainerLane) return 80; + // Award the language-fit bonus only when the repo's primary language (sourced + // from ContributorFit.languageFit, as decision-pack.ts does) is one the + // contributor actually works in. Previously this granted +10 to any repo + // whenever the contributor had *any* top language, never comparing the two — + // so an off-language repo (e.g. a Rust repo for a Python-only contributor) was + // scored as a language match, inflating personalFit and the action + // priorityScores derived from it. + const contributorLanguages = new Set(profile.github.topLanguages.map((language) => language.toLowerCase())); + const languageMatch = repoLanguage && contributorLanguages.has(repoLanguage.toLowerCase()) ? 10 : 0; + return clamp( + (outcome?.mergedPullRequests ?? 0) * 2.2 + + /* v8 ignore next -- Credibility fallback order protects sparse private snapshots; scoring behavior is covered at public entry points. */ + (outcome?.credibility ?? scoringProfile?.evidence.credibilityAssumption ?? 0.8) * 35 + + (outcome?.validSolvedIssues ?? 0) * 3 + + languageMatch - + (outcome?.closedPullRequestRate ?? 0) * 45 - + Math.max(0, (outcome?.openPullRequests ?? 0) - 2) * 4, + 0, + 100, + ); +} + +function riskScore( + outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, + queueHealth: QueueHealth, + duplicateClusters: number, + highRiskDuplicateClusters: number, + openPrCount: number, + openPrThreshold: number, +): number { + const queuePenalty = queueHealth.level === "critical" ? 35 : queueHealth.level === "high" ? 24 : queueHealth.level === "medium" ? 12 : 0; + return clamp( + queuePenalty + + duplicateClusters * 4 + + highRiskDuplicateClusters * 14 + + Math.max(0, openPrCount - openPrThreshold) * 12 + + (outcome?.closedPullRequestRate ?? 0) * 55 + + Math.max(0, (outcome?.openPullRequests ?? 0) - 2) * 5, + 0, + 100, + ); +} + +function maintainerFriction(queueHealth: QueueHealth, duplicateClusters: number, pullRequests: PullRequestRecord[]): number { + const unlinked = pullRequests.filter((pr) => pr.state === "open" && pr.linkedIssues.length === 0).length; + return clamp(queueHealth.burdenScore * 0.55 + duplicateClusters * 8 + unlinked * 5, 0, 100); +} + +function reviewChurnRisk(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, queueHealth: QueueHealth, highRiskDuplicateClusters: number): "low" | "medium" | "high" { + const risk = (outcome?.closedPullRequestRate ?? 0) * 100 + highRiskDuplicateClusters * 18 + (queueHealth.level === "critical" ? 25 : queueHealth.level === "high" ? 15 : 0); + return risk >= 45 ? "high" : risk >= 20 ? "medium" : "low"; +} + +function analysisRank(analysis: RepoRewardRisk): number { + return (analysis.actions[0]?.priorityScore ?? 0) + analysis.rewardUpside.directPrSlice * 100 + analysis.rewardUpside.issueDiscoverySlice * 100; +} + +function bestFitLabels(repo: RepositoryRecord | null): string[] { + const multipliers = repo?.registryConfig?.labelMultipliers ?? {}; + const labels = Object.entries(multipliers) + // Exclude meta labels only at a keyword boundary (a real separator or end-of-string after the keyword), + // not mid-word — mirroring the anchored `suspiciousConfiguredLabels` matcher in engine.ts. The old + // unanchored regex over-matched substrings (e.g. "opensource" via "source", "risky-refactor" via "risk"), + // wrongly dropping a legitimate high-multiplier label from the best-fit suggestion. + .filter(([label]) => !/^(status|source|contributor|verified|risk|codex)([:/-]|$)/i.test(label)) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .map(([label]) => label); + return labels.slice(0, 1); +} + +function estimatedSourceTokenScore(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): number { + return clamp(42 + (outcome?.mergedPullRequests ?? 0) * 2, 30, 120); +} + +function estimatedTotalTokenScore(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): number { + return clamp(70 + (outcome?.mergedPullRequests ?? 0) * 4, 60, 220); +} + +function estimatedSourceLines(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): number { + return Math.max(12, estimatedSourceTokenScore(outcome)); +} + +function nextActionsFor(kind: RewardRiskActionKind): string[] { + switch (kind) { + case "cleanup_existing_prs": + return ["Land, close, or withdraw stale open PRs before opening additional direct-PR work.", "Prioritize the repo where existing successful evidence is strongest."]; + case "land_existing_prs": + return ["Tighten validation, update PR bodies, and resolve review/check blockers on already-open work."]; + case "close_or_withdraw_low_fit_prs": + return ["Withdraw stale or low-fit PRs that are unlikely to merge cleanly and are adding open PR pressure."]; + case "open_new_direct_pr": + return ["Only open a new PR after duplicate checks, local score preview, tests, and linked/no-issue rationale are clean."]; + case "file_issue_discovery": + return ["File only high-proof issues that someone else can solve and that are unlikely to be closed as duplicate or unclear."]; + case "maintainer_lane_improve_repo": + return ["Improve labels, contribution docs, queue hygiene, and contributor intake for the maintained repo."]; + case "maintainer_cut_readiness": + return ["Check config quality and maintainer_cut readiness before expecting maintainer-lane economics to work cleanly."]; + } +} + +function maintainerNextStepsFor(action: PullRequestReviewability["action"], noiseSources: string[]): string[] { + if (action === "review_now") return ["Review the technical diff now; cached hygiene signals look clean enough."]; + if (action === "maintainer_lane") return ["Treat as maintainer stewardship and verify repo-health impact separately."]; + if (action === "likely_duplicate") return ["Compare against linked issues, active PRs, and recent merges before detailed review."]; + if (action === "close_or_redirect") return ["Redirect or close non-open/stale context before spending review time."]; + if (action === "needs_author") return ["Ask the author to address the concrete missing context before deep review.", ...noiseSources.slice(0, 3)]; + 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; + let mostRecentAgeDays = Number.POSITIVE_INFINITY; + for (const issue of openIssues) { + const ageDays = issueAgeDays(pickIssueTimestamp(issue)); + if (ageDays < mostRecentAgeDays) mostRecentAgeDays = ageDays; + } + // 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 isParseableIssueTimestamp(value: string): boolean { + return Number.isFinite(Date.parse(value)); +} + +function pickIssueTimestamp(issue: IssueRecord): string | null { + const updated = typeof issue.updatedAt === "string" ? issue.updatedAt.trim() : ""; + if (updated && isParseableIssueTimestamp(updated)) return updated; + + const created = typeof issue.createdAt === "string" ? issue.createdAt.trim() : ""; + if (created && isParseableIssueTimestamp(created)) return created; + + return null; +} + +/** Unknown/unparseable timestamps floor freshness (parity with gittensory-engine opportunity-freshness.ts). */ +function issueAgeDays(value: string | null): number { + if (!value) return Number.POSITIVE_INFINITY; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY; + return Math.floor((Date.now() - parsed) / 86_400_000); +} + +function sameRepo(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} + +/** Bucket records by their `repoFullName` (case-insensitive) for O(1) per-repo lookups (#2112). */ +function groupByRepo(records: readonly T[]): Map { + const buckets = new Map(); + for (const record of records) { + const key = record.repoFullName.toLowerCase(); + const bucket = buckets.get(key); + if (bucket) bucket.push(record); + else buckets.set(key, [record]); + } + return buckets; +} + +function uniqueRegisteredRepoNames(repoFullNames: string[], registeredRepoNames: Map): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const repoFullName of repoFullNames) { + const key = repoFullName.toLowerCase(); + const canonical = registeredRepoNames.get(key); + if (!canonical || seen.has(key)) continue; + seen.add(key); + unique.push(canonical); + } + return unique; +} + +function nonNegative(value: number | undefined): number { + /* v8 ignore next -- Sparse contributor totals normalize to zero before scoring; aggregate scoring tests cover the behavior. */ + return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0; +} + +function percent(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function round(value: number): number { + return Math.round(value * 10000) / 10000; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/* v8 ignore start -- Test-only export surface for branch coverage. */ +export const rewardRiskFreshnessInternals = { + pickIssueTimestamp, + issueAgeDays, + bestFitLabels, +}; +/* v8 ignore stop */ diff --git a/packages/gittensory-engine/src/types/reward-risk-types.ts b/packages/gittensory-engine/src/types/reward-risk-types.ts new file mode 100644 index 0000000000..8b93364698 --- /dev/null +++ b/packages/gittensory-engine/src/types/reward-risk-types.ts @@ -0,0 +1,359 @@ +// Local type mirrors for the reward-risk engine module (#2281). +// +// Mirrored by hand from `src/types.ts` and `src/signals/engine.ts` — the engine package cannot import +// across into `src/`, so (as with `predicted-gate-types.ts`) these are kept in sync manually. Types that +// only ever reach the injected `src`-side builders (see `RewardRiskEngineDeps` in `../reward-risk.ts`) or +// `buildScorePreview` are subset mirrors carrying just the fields those consumers require: because the +// omitted `src` fields are all optional, a subset stays mutually assignable to the full `src` type, and the +// real runtime objects still flow through the injected builders untouched. Types that appear in this +// module's PUBLIC return surface (`RoleContext`, `LaneAdvice`, `QueueHealth`) are full verbatim copies so +// existing consumers can read every field. + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export type ParticipationLane = "direct_pr" | "issue_discovery" | "split" | "inactive" | "unknown"; +export type ContributorRole = "outside_contributor" | "repo_maintainer" | "org_member" | "collaborator" | "owner" | "unknown"; + +export type AdvisorySeverity = "info" | "warning" | "critical"; + +export type AdvisoryFinding = { + code: string; + title: string; + severity: AdvisorySeverity; + detail: string; + action?: string; + publicText?: string; + confidence?: number; +}; +export type SignalFinding = AdvisoryFinding; + +// --- src/types.ts record mirrors (full verbatim: these records are constructed as inline object literals +// by the reward-risk tests/callers, so a subset would trip TypeScript's excess-property check) --- + +export type RepoTimeDecayOverrides = { + gracePeriodHours?: number | null | undefined; + sigmoidMidpointDays?: number | null | undefined; + sigmoidSteepness?: number | null | undefined; + minMultiplier?: number | null | undefined; +}; + +export type RegistryRepoConfig = { + repo: string; + emissionShare: number; + issueDiscoveryShare: number; + labelMultipliers: Record; + trustedLabelPipeline?: boolean | null; + maintainerCut: number; + defaultLabelMultiplier?: number | null; + fixedBaseScore?: number | null; + eligibilityMode?: string | null; + timeDecay?: RepoTimeDecayOverrides | null; + raw: Record; +}; + +export type RepositoryRecord = { + fullName: string; + owner: string; + name: string; + installationId?: number | null | undefined; + isInstalled: boolean; + isRegistered: boolean; + isPrivate: boolean; + htmlUrl?: string | null | undefined; + defaultBranch?: string | null | undefined; + registryConfig?: RegistryRepoConfig | null | undefined; +}; + +export type PullRequestRecord = { + repoFullName: string; + number: number; + title: string; + state: string; + authorLogin?: string | null | undefined; + authorAssociation?: string | null | undefined; + headSha?: string | null | undefined; + headRef?: string | null | undefined; + baseRef?: string | null | undefined; + htmlUrl?: string | null | undefined; + mergedAt?: string | null | undefined; + isDraft?: boolean | null | undefined; + mergeableState?: string | null | undefined; + reviewDecision?: string | null | undefined; + body?: string | null | undefined; + createdAt?: string | null | undefined; + updatedAt?: string | null | undefined; + closedAt?: string | null | undefined; + linkedIssueClaimedAt?: string | null | undefined; + labels: string[]; + linkedIssues: number[]; + slopRisk?: number | null | undefined; + slopBand?: string | null | undefined; + mergeAttemptCount?: number | null | undefined; + mergeBlockedSha?: string | null | undefined; + mergeBlockedReason?: string | null | undefined; + approvedHeadSha?: string | null | undefined; + lastRegatedAt?: string | null | undefined; + lastPublishedSurfaceSha?: string | null | undefined; + changedFiles?: string[] | undefined; +}; + +export type IssueRecord = { + repoFullName: string; + number: number; + title: string; + state: string; + authorLogin?: string | null | undefined; + authorAssociation?: string | null | undefined; + htmlUrl?: string | null | undefined; + body?: string | null | undefined; + createdAt?: string | null | undefined; + updatedAt?: string | null | undefined; + closedAt?: string | null | undefined; + labels: string[]; + linkedPrs: number[]; +}; + +export type RecentMergedPullRequestRecord = { + repoFullName: string; + number: number; + title: string; + authorLogin?: string | null | undefined; + htmlUrl?: string | null | undefined; + mergedAt?: string | null | undefined; + labels: string[]; + linkedIssues: number[]; + changedFiles: string[]; + payload: Record; +}; + +export type PullRequestFileRecord = { + repoFullName: string; + pullNumber: number; + path: string; + status?: string | null | undefined; + additions: number; + deletions: number; + changes: number; + previousFilename?: string | null | undefined; + payload: Record; +}; + +export type PullRequestReviewRecord = { + id: string; + repoFullName: string; + pullNumber: number; + reviewerLogin?: string | null | undefined; + state: string; + authorAssociation?: string | null | undefined; + submittedAt?: string | null | undefined; + payload: Record; +}; + +export type CheckSummaryRecord = { + id: string; + repoFullName: string; + pullNumber?: number | null | undefined; + headSha?: string | null | undefined; + name: string; + status: string; + conclusion?: string | null | undefined; + startedAt?: string | null | undefined; + completedAt?: string | null | undefined; + detailsUrl?: string | null | undefined; + payload: Record; +}; + +export type ScoringModelSnapshotRecord = { + id: string; + sourceKind: "raw-github" | "api" | "fallback" | "test"; + sourceUrl: string; + fetchedAt: string; + activeModel: "current_density_model" | "pending_saturation_model" | "exponential_saturation_model" | "unknown"; + constants: Record; + programmingLanguages: Record; + registrySnapshotId?: string | null | undefined; + warnings: string[]; + payload: Record; +}; + +// --- src/github/public.ts + src/signals/engine.ts mirrors --- + +export type PublicContributorProfile = { + login: string; + topLanguages: string[]; + source: "github" | "unavailable"; +}; + +export type ContributorProfile = { + login: string; + generatedAt: string; + github: PublicContributorProfile; + source: "gittensor_api" | "github_cache"; + registeredRepoActivity: { + pullRequests: number; + mergedPullRequests: number; + issues: number; + reposTouched: string[]; + dominantLabels: string[]; + }; + trustSignals: { + evidenceScore: number; + level: "new" | "emerging" | "established"; + unlinkedOpenPullRequests: number; + maintainerAssociatedPullRequests: number; + }; +}; + +export type ContributorScoringProfile = { + evidence: { + credibilityAssumption: number; + }; +}; + +export type ContributorFit = { + profile: ContributorProfile; + languageFit: Array<{ repoFullName: string; language?: string | null | undefined; match: boolean }>; + opportunities: Array<{ repoFullName: string }>; +}; + +export type OutcomePattern = { + repoFullName?: string | undefined; + title: string; + detail: string; + confidence: "high" | "medium" | "low"; +}; + +export type ContributorOutcomeHistory = { + login: string; + generatedAt: string; + source: ContributorProfile["source"]; + totals: { + pullRequests: number; + mergedPullRequests: number; + openPullRequests: number; + closedPullRequests: number; + closedPullRequestRate: number; + issues: number; + openIssues: number; + closedIssues: number; + solvedIssues: number; + validSolvedIssues: number; + credibility: number; + issueCredibility: number; + }; + repoOutcomes: Array<{ + repoFullName: string; + role: ContributorRole; + lane: ParticipationLane; + maintainerLane: boolean; + pullRequests: number; + mergedPullRequests: number; + openPullRequests: number; + closedPullRequests: number; + closedPullRequestRate: number; + issues: number; + openIssues: number; + closedIssues: number; + solvedIssues: number; + validSolvedIssues: number; + credibility: number; + issueCredibility: number; + isEligible: boolean; + successLevel: "strong" | "emerging" | "weak" | "maintainer_context"; + strengths: string[]; + risks: string[]; + }>; + successPatterns: OutcomePattern[]; + failurePatterns: OutcomePattern[]; + summary: string; +}; + +// --- collision report (flows only between injected builders) --- + +export type CollisionItem = { + type: "issue" | "pull_request" | "recent_merged_pull_request"; + number: number; + title: string; +}; + +export type CollisionCluster = { + id: string; + risk: "low" | "medium" | "high"; + reason: string; + items: CollisionItem[]; +}; + +export type CollisionReport = { + repoFullName: string; + generatedAt: string; + summary: { + clusterCount: number; + highRiskCount: number; + itemsReviewed: number; + }; + clusters: CollisionCluster[]; +}; + +// --- full verbatim public-surface types (src/signals/engine.ts) --- + +export type RoleContext = { + login: string; + repoFullName: string; + generatedAt: string; + role: ContributorRole; + maintainerLane: boolean; + normalContributorEvidenceAllowed: boolean; + source: "github_association" | "repo_owner_match" | "gittensor_api" | "cache" | "unknown"; + association?: string | null | undefined; + reasons: string[]; + guidance: string; +}; + +export type LaneAdvice = { + lane: ParticipationLane; + repoFullName: string; + issueDiscoveryShare?: number | undefined; + directPrShare?: number | undefined; + summary: string; + contributorGuidance: string; + maintainerGuidance: string; +}; + +export type QueueHealth = { + repoFullName: string; + generatedAt: string; + burdenScore: number; + level: "low" | "medium" | "high" | "critical"; + summary: string; + signals: { + openIssues: number; + openPullRequests: number; + unlinkedPullRequests: number; + stalePullRequests: number; + draftPullRequests: number; + maintainerAuthoredPullRequests: number; + collisionClusters: number; + ageBuckets: { + under7Days: number; + days7To30: number; + over30Days: number; + }; + likelyReviewablePullRequests: number; + cachedOpenPullRequests?: number | undefined; + likelyReviewablePullRequestsSource?: "cache" | "sampled_cache" | "authoritative" | undefined; + }; + findings: SignalFinding[]; + rankedPullRequests?: { + number: number; + title: string; + authorLogin: string; + recommendation: string; + }[]; +}; + +// Only `.recommendation` is read from the injected `buildRepoFitRecommendation`; the full src type carries +// many more fields, all covariantly assignable to this narrowed mirror. +export type RepoFitRecommendation = { + recommendation: "pursue" | "cleanup_first" | "maintainer_lane" | "avoid_for_now" | "unknown"; +}; diff --git a/src/signals/reward-risk.ts b/src/signals/reward-risk.ts index 9372c06abe..50db994227 100644 --- a/src/signals/reward-risk.ts +++ b/src/signals/reward-risk.ts @@ -1,16 +1,24 @@ -import type { ScorePreviewResult } from "../scoring/preview"; -import { buildScorePreview } from "../scoring/preview"; -import type { - CheckSummaryRecord, - IssueRecord, - PullRequestFileRecord, - PullRequestRecord, - PullRequestReviewRecord, - RecentMergedPullRequestRecord, - RepositoryRecord, - ScoringModelSnapshotRecord, -} from "../types"; -import { nowIso } from "../utils/json"; +// Reward/risk reasoning signals, extracted to `@jsonbored/gittensory-engine` (#2281) so the gittensory-miner +// can rank candidate work locally with the same logic the maintainer-side gate computes. The implementation +// lives at `packages/gittensory-engine/src/reward-risk.ts`, imported via its RELATIVE SOURCE PATH (matching +// the merged #2276/#2278/#2282 shims) — not the published `@jsonbored/gittensory-engine` specifier, so no +// tsconfig path / vitest alias / root dependency is introduced. +// +// This is a WRAPPING shim rather than the usual pure `export *` re-export. reward-risk depends on the +// maintainer signal stack in `src/signals/engine.ts` (`buildRoleContext`, `buildLaneAdvice`, +// `buildCollisionReport`, `buildQueueHealth`, `buildRepoFitRecommendation`, `buildContributorIntakeHealth`, +// `buildPullRequestReviewIntelligence`) plus `isFailingCheckSummary` from `./local-branch` — none of which +// are extracted yet (and are far too large to port under the size cap). The engine module takes them as an +// injected `RewardRiskEngineDeps`; this shim binds the real `src` builders and threads them in, so every +// existing importer keeps calling the four builders with their original signatures. Once those builders gain +// engine homes, a follow-up can drop the injection and collapse this back to a plain re-export. +import { + buildContributorRewardRiskStrategy as engineBuildContributorRewardRiskStrategy, + buildMaintainerNoiseReport as engineBuildMaintainerNoiseReport, + buildPullRequestReviewability as engineBuildPullRequestReviewability, + buildRepoRewardRisk as engineBuildRepoRewardRisk, + type RewardRiskEngineDeps, +} from "../../packages/gittensory-engine/src/reward-risk.js"; import { buildCollisionReport, buildContributorIntakeHealth, @@ -19,909 +27,60 @@ import { buildQueueHealth, buildRepoFitRecommendation, buildRoleContext, - type ContributorFit, - type ContributorOutcomeHistory, - type ContributorProfile, - type ContributorScoringProfile, - type LaneAdvice, - type ParticipationLane, - type QueueHealth, - type RepoFitRecommendation, - type RoleContext, } from "./engine"; import { isFailingCheckSummary } from "./local-branch"; -export type RewardRiskActionKind = - | "cleanup_existing_prs" - | "land_existing_prs" - | "close_or_withdraw_low_fit_prs" - | "open_new_direct_pr" - | "file_issue_discovery" - | "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, - close_or_withdraw_low_fit_prs: 2, - open_new_direct_pr: 3, - file_issue_discovery: 4, - maintainer_lane_improve_repo: 5, - maintainer_cut_readiness: 6, -}; - -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; - personalFitScore: number; - riskPenalty: number; - maintainerFrictionPenalty: number; - actionLeverageScore: number; - whyThisHelps: string[]; - nextActions: string[]; -}; - -export type RepoRewardRisk = { - login: string; - repoFullName: string; - generatedAt: string; - roleContext: RoleContext; - lane: LaneAdvice; - recommendation: RepoFitRecommendation["recommendation"]; - rewardUpside: { - relevantLane: "direct_pr" | "issue_discovery" | "maintainer_lane" | "none"; - repoSlice: number; - directPrSlice: number; - issueDiscoverySlice: number; - maintainerCutSlice: number; - labelMultiplier: number; - 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: { - queueBurden: QueueHealth["level"]; - queueBurdenScore: number; - duplicateClusters: number; - highRiskDuplicateClusters: number; - closedPullRequestRate: number; - openPullRequests: number; - credibility: number; - reviewChurnRisk: "low" | "medium" | "high"; - }; - actionImpact: { - currentOpenPrCount: number; - openPrThreshold: number; - openPrMultiplierDelta: string; - estimatedScoreDelta: string; - cleanupNeeded: number; - explanation: string; - }; - currentPreview: ScorePreviewResult; - afterCleanupPreview: ScorePreviewResult; - actions: RewardRiskAction[]; - whyThisHelps: string[]; - nextActions: string[]; - 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; - scoringModelSnapshotId: string; - summary: string; - topActions: RewardRiskAction[]; - repoAnalyses: RepoRewardRisk[]; - 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 = { - repoFullName: string; - generatedAt: string; - score: number; - level: "low" | "medium" | "high" | "critical"; - noiseSources: string[]; - maintainerActions: Array<"review_now" | "needs_author" | "likely_duplicate" | "close_or_redirect" | "watch" | "maintainer_lane">; - queueHealth: QueueHealth; - summary: string; -}; - -export type PullRequestReviewability = { - repoFullName: string; - pullNumber: number; - generatedAt: string; - score: number; - action: "review_now" | "needs_author" | "likely_duplicate" | "close_or_redirect" | "watch" | "maintainer_lane"; - noiseSources: string[]; - whyThisHelps: string[]; - maintainerNextSteps: string[]; - privateSummary: string; +export type { + ContributorRewardRiskStrategy, + EligibilityGapEntry, + MaintainerNoiseReport, + PullRequestReviewability, + RepoRewardRisk, + RewardRiskAction, + RewardRiskActionKind, + RewardRiskActionSeverity, +} from "../../packages/gittensory-engine/src/reward-risk.js"; +export { rewardRiskFreshnessInternals } from "../../packages/gittensory-engine/src/reward-risk.js"; + +// The real `src`-side builders, bound once and injected into the engine implementations. Their argument +// records are wider than (assignable to) the engine's subset mirrors and their return types are covariantly +// assignable to the engine's narrowed views, so the whole object type-checks with no casts. The runtime +// objects the builders receive are the caller's originals, so behavior is identical to the pre-extraction file. +const deps: RewardRiskEngineDeps = { + buildRoleContext, + buildLaneAdvice, + buildCollisionReport, + buildQueueHealth, + buildRepoFitRecommendation, + buildContributorIntakeHealth, + buildPullRequestReviewIntelligence, + isFailingCheckSummary, }; -export function buildRepoRewardRisk(args: { - login: string; - repo: RepositoryRecord | null; - repoFullName: string; - profile: ContributorProfile; - outcomeHistory: ContributorOutcomeHistory; - scoringSnapshot: ScoringModelSnapshotRecord; - scoringProfile?: ContributorScoringProfile | null | undefined; - issues: IssueRecord[]; - pullRequests: PullRequestRecord[]; - recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined; - /** Repo primary language (from sync metadata / ContributorFit.languageFit), - * used for the personalFit language-match bonus. */ - repoLanguage?: string | null | undefined; -}): RepoRewardRisk { - const roleContext = buildRoleContext({ - login: args.login, - repo: args.repo, - repoFullName: args.repoFullName, - pullRequests: args.pullRequests, - issues: args.issues, - profile: args.profile, - }); - const lane = buildLaneAdvice(args.repo, args.repoFullName); - const repoOutcome = args.outcomeHistory.repoOutcomes.find((outcome) => sameRepo(outcome.repoFullName, args.repoFullName)); - const collisions = buildCollisionReport(args.repoFullName, args.issues, args.pullRequests, args.recentMergedPullRequests ?? []); - const queueHealth = buildQueueHealth(args.repo, args.issues, args.pullRequests, collisions); - const recommendation = buildRepoFitRecommendation({ - login: args.login, - repo: args.repo, - repoFullName: args.repoFullName, - profile: args.profile, - outcomeHistory: args.outcomeHistory, - issues: args.issues, - pullRequests: args.pullRequests, - }).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. */ - const credibility = repoOutcome?.credibility && repoOutcome.credibility > 0 ? repoOutcome.credibility : args.scoringProfile?.evidence.credibilityAssumption ?? args.outcomeHistory.totals.credibility ?? 0.8; - const commonPreviewInput = { - repoFullName: args.repoFullName, - targetType: "planned_pr" as const, - targetKey: `${args.login}:${args.repoFullName}:reward-risk`, - contributorLogin: args.login, - labels, - linkedIssueMode: lane.lane === "issue_discovery" ? ("none" as const) : ("standard" as const), - sourceTokenScore: estimatedSourceTokenScore(repoOutcome), - totalTokenScore: estimatedTotalTokenScore(repoOutcome), - sourceLines: estimatedSourceLines(repoOutcome), - existingContributorTokenScore: 0, - credibility, - metadataOnly: true, - duplicateRiskCount: collisions.summary.highRiskCount, - openIssueCount: currentOpenIssueCount, - }; - const currentPreview = buildScorePreview({ - input: { ...commonPreviewInput, openPrCount: currentOpenPrCount }, - repo: args.repo, - snapshot: args.scoringSnapshot, - }); - const cleanupOpenPrCount = Math.min(currentOpenPrCount, currentPreview.gates.openPrThreshold); - const afterCleanupPreview = buildScorePreview({ - input: { ...commonPreviewInput, openPrCount: cleanupOpenPrCount }, - repo: args.repo, - snapshot: args.scoringSnapshot, - }); - - const relevantLane = relevantLaneFor(lane, roleContext); - const laneValueScore = laneValue(lane, currentPreview, relevantLane); - const personalFitScore = personalFit(repoOutcome, args.scoringProfile, roleContext, args.profile, args.repoLanguage ?? null); - const riskPenalty = riskScore(repoOutcome, queueHealth, collisions.summary.clusterCount, collisions.summary.highRiskCount, currentOpenPrCount, currentPreview.gates.openPrThreshold); - const maintainerFrictionPenalty = maintainerFriction(queueHealth, collisions.summary.clusterCount, args.pullRequests); - const scoreBlockers = scoreBlockersFor({ - lane, - roleContext, - currentPreview, - repo: args.repo, - repoOutcome, - currentOpenPrCount, - }); - const scoreabilityScore = scoreBlockers.length > 0 ? 0 : clamp((currentPreview.scoreEstimate.estimatedMergedScore / 50) * 100, 0, 100); - const actionLeverageScore = cleanupOpenPrCount < currentOpenPrCount ? clamp((currentOpenPrCount - cleanupOpenPrCount) * 18, 30, 100) : 0; - const baseActionInput = { - repoFullName: args.repoFullName, - laneValueScore, - scoreabilityScore, - personalFitScore, - riskPenalty, - maintainerFrictionPenalty, - actionLeverageScore, - }; - const cleanupNeeded = Math.max(0, currentOpenPrCount - currentPreview.gates.openPrThreshold); - const actions = buildActions({ - ...baseActionInput, - lane, - roleContext, - repoOutcome, - currentPreview, - afterCleanupPreview, - cleanupNeeded, - scoreBlockers, - queueHealth, - collisionsHighRiskCount: collisions.summary.highRiskCount, - }); - const actionImpact = { - currentOpenPrCount, - openPrThreshold: currentPreview.gates.openPrThreshold, - openPrMultiplierDelta: `${currentPreview.scoreEstimate.openPrMultiplier} -> ${afterCleanupPreview.scoreEstimate.openPrMultiplier}`, - estimatedScoreDelta: `${currentPreview.scoreEstimate.estimatedMergedScore} -> ${afterCleanupPreview.scoreEstimate.estimatedMergedScore}`, - cleanupNeeded, - explanation: - cleanupNeeded > 0 - ? `Landing, closing, or withdrawing ${cleanupNeeded} open PR(s) moves the current open-PR gate from blocked toward scoreable future work.` - : "Open PR pressure is not the primary scoreability blocker for this repo right now.", - }; - const whyThisHelps = whyThisHelpsFor({ - repoFullName: args.repoFullName, - lane, - roleContext, - repoOutcome, - currentPreview, - afterCleanupPreview, - cleanupNeeded, - scoreBlockers, - queueHealth, - collisionsHighRiskCount: collisions.summary.highRiskCount, - }); - const nextActions = [...new Set(actions.flatMap((action) => action.nextActions))].slice(0, 8); - - return { - login: args.login, - repoFullName: args.repoFullName, - generatedAt: nowIso(), - roleContext, - lane, - recommendation, - rewardUpside: { - relevantLane, - repoSlice: currentPreview.laneMath.repoSlice, - directPrSlice: currentPreview.laneMath.directPrSlice, - issueDiscoverySlice: currentPreview.laneMath.issueDiscoverySlice, - maintainerCutSlice: round((args.repo?.registryConfig?.maintainerCut ?? 0) * currentPreview.laneMath.repoSlice), - labelMultiplier: currentPreview.scoreEstimate.labelMultiplier, - issueMultiplier: currentPreview.scoreEstimate.issueMultiplier, - estimatedScoreIfClean: afterCleanupPreview.scoreEstimate.estimatedMergedScore, - currentEstimatedScore: currentPreview.scoreEstimate.estimatedMergedScore, - opportunityFactors: { competitionFactor, freshnessFactor }, - }, - scoreBlockers, - riskBreakdown: { - queueBurden: queueHealth.level, - queueBurdenScore: queueHealth.burdenScore, - duplicateClusters: collisions.summary.clusterCount, - highRiskDuplicateClusters: collisions.summary.highRiskCount, - closedPullRequestRate: repoOutcome?.closedPullRequestRate ?? args.outcomeHistory.totals.closedPullRequestRate, - openPullRequests: currentOpenPrCount, - credibility, - reviewChurnRisk: reviewChurnRisk(repoOutcome, queueHealth, collisions.summary.highRiskCount), - }, - actionImpact, - currentPreview, - afterCleanupPreview, - actions, - whyThisHelps, - nextActions: nextActions.length > 0 ? nextActions : ["Gather fresher repo and contributor evidence before acting."], - summary: `${args.repoFullName}: ${scoreBlockers.length > 0 ? "blocked or cautionary" : "scoreable"} private reward/risk context; top action ${actions[0]?.actionKind ?? "none"}.`, - }; +export function buildRepoRewardRisk( + args: Parameters[0], +): ReturnType { + return engineBuildRepoRewardRisk(args, deps); } -export function buildContributorRewardRiskStrategy(args: { - login: string; - fit: ContributorFit; - scoringProfile: ContributorScoringProfile; - scoringSnapshot: ScoringModelSnapshotRecord; - outcomeHistory: ContributorOutcomeHistory; - repositories: RepositoryRecord[]; - allIssues: IssueRecord[]; - allPullRequests: PullRequestRecord[]; - recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined; -}): ContributorRewardRiskStrategy { - const registeredRepoNames = new Map(args.repositories.filter((repo) => repo.isRegistered).map((repo) => [repo.fullName.toLowerCase(), repo.fullName])); - const candidateRepoNames = uniqueRegisteredRepoNames( - [ - ...args.fit.opportunities.map((opportunity) => opportunity.repoFullName), - ...args.outcomeHistory.repoOutcomes.filter((outcome) => registeredRepoNames.has(outcome.repoFullName.toLowerCase())).map((outcome) => outcome.repoFullName), - ...args.repositories.filter((repo) => repo.isRegistered).map((repo) => repo.fullName), - ], - registeredRepoNames, - ); - const issuesByRepo = groupByRepo(args.allIssues); - const pullRequestsByRepo = groupByRepo(args.allPullRequests); - const recentMergedPullRequestsByRepo = groupByRepo(args.recentMergedPullRequests ?? []); - const repoAnalyses = candidateRepoNames - .map((repoFullName) => { - /* v8 ignore next -- Strategy inputs usually originate from repository records; null protects stale fit snapshots. */ - const repo = args.repositories.find((candidate) => sameRepo(candidate.fullName, repoFullName)) ?? null; - const repoKey = repoFullName.toLowerCase(); - return buildRepoRewardRisk({ - login: args.login, - repo, - repoFullName, - profile: args.fit.profile, - outcomeHistory: args.outcomeHistory, - scoringSnapshot: args.scoringSnapshot, - scoringProfile: args.scoringProfile, - issues: issuesByRepo.get(repoKey) ?? [], - pullRequests: pullRequestsByRepo.get(repoKey) ?? [], - recentMergedPullRequests: recentMergedPullRequestsByRepo.get(repoKey) ?? [], - repoLanguage: args.fit.languageFit.find((entry) => sameRepo(entry.repoFullName, repoFullName))?.language ?? null, - }); - }) - /* v8 ignore next -- Locale tie ordering is deterministic presentation fallback after ranked analysis scores. */ - .sort((left, right) => analysisRank(right) - analysisRank(left) || left.repoFullName.localeCompare(right.repoFullName)) - .slice(0, 20); - const topActions = repoAnalyses - .flatMap((analysis) => analysis.actions) - /* v8 ignore next -- Secondary sort keys make ties deterministic; priority ordering is covered by strategy tests. */ - .sort((left, right) => right.priorityScore - left.priorityScore || ACTION_RANK[left.actionKind] - ACTION_RANK[right.actionKind] || left.repoFullName.localeCompare(right.repoFullName)) - .slice(0, 12); - const reasoning = [ - ...topActions.slice(0, 5).flatMap((action) => action.whyThisHelps.map((reason) => `${action.repoFullName}: ${reason}`)), - ...repoAnalyses - .filter((analysis) => analysis.roleContext.maintainerLane) - .slice(0, 4) - .map((analysis) => `${analysis.repoFullName}: maintainer-lane economics are separate from normal contributor rewards.`), - ]; - const actionImpact = repoAnalyses - .filter((analysis) => analysis.actionImpact.cleanupNeeded > 0 || analysis.currentPreview.scoreEstimate.estimatedMergedScore !== analysis.afterCleanupPreview.scoreEstimate.estimatedMergedScore) - .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(), - scoringModelSnapshotId: args.scoringSnapshot.id, - summary: `${args.login} has ${topActions.length} ranked reward/risk action(s) from ${repoAnalyses.length} repo analysis record(s).`, - topActions, - repoAnalyses, - reasoning: [...new Set(reasoning)], - actionImpact, - nextActions: nextActions.length > 0 ? nextActions : ["Refresh official Gittensor and GitHub backfill data, then rerun strategy."], - eligibilityGap, - }; +export function buildContributorRewardRiskStrategy( + args: Parameters[0], +): ReturnType { + return engineBuildContributorRewardRiskStrategy(args, deps); } export function buildMaintainerNoiseReport( - repo: RepositoryRecord | null, - issues: IssueRecord[], - pullRequests: PullRequestRecord[], - recentMergedPullRequests: RecentMergedPullRequestRecord[], - fullName: string, -): MaintainerNoiseReport { - const collisions = buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests); - const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); - const intake = buildContributorIntakeHealth(repo, issues, pullRequests, fullName, collisions); - const unlinked = pullRequests.filter((pr) => pr.state === "open" && pr.linkedIssues.length === 0).length; - // Only OPEN PRs are live maintainer-queue noise. Without the state guard (which the sibling `unlinked` - // count above already applies), already-merged/closed PRs with common churn titles ("refactor", "cleanup", - // "various", …) are miscounted as active noise, inflating noiseSources and depressing the score. - const broadDiffSignals = pullRequests.filter((pr) => pr.state === "open" && (pr.title.length > 120 || /refactor|cleanup|misc|various/i.test(pr.title))).length; - const noiseSources = [ - ...(unlinked > 0 ? [`${unlinked} open PR(s) lack linked issue context.`] : []), - ...(collisions.summary.highRiskCount > 0 ? [`${collisions.summary.highRiskCount} high-risk duplicate/WIP cluster(s).`] : []), - ...(queueHealth.signals.stalePullRequests > 0 ? [`${queueHealth.signals.stalePullRequests} stale PR(s) add queue drag.`] : []), - ...(broadDiffSignals > 0 ? [`${broadDiffSignals} PR(s) look broad or hard to triage from title metadata.`] : []), - ...(intake.level === "strained" || intake.level === "blocked" ? [`Contributor intake is ${intake.level}.`] : []), - ]; - const score = clamp(100 - queueHealth.burdenScore * 0.55 - collisions.summary.highRiskCount * 12 - unlinked * 6 - broadDiffSignals * 4, 0, 100); - const level: MaintainerNoiseReport["level"] = score < 25 ? "critical" : score < 50 ? "high" : score < 75 ? "medium" : "low"; - const maintainerActions: MaintainerNoiseReport["maintainerActions"] = [ - ...(collisions.summary.highRiskCount > 0 ? ["likely_duplicate" as const] : []), - ...(unlinked > 0 || queueHealth.signals.stalePullRequests > 0 ? ["needs_author" as const] : []), - ...(queueHealth.signals.likelyReviewablePullRequests > 0 ? ["review_now" as const] : []), - ...(noiseSources.length === 0 ? ["watch" as const] : []), - ]; - return { - repoFullName: fullName, - generatedAt: nowIso(), - score: round(score), - level, - noiseSources: noiseSources.length > 0 ? noiseSources : ["No major maintainer-noise source detected in cached metadata."], - maintainerActions: [...new Set(maintainerActions)], - queueHealth, - summary: `${fullName} maintainer noise is ${level}; queue ${queueHealth.level}, ${collisions.summary.highRiskCount} high-risk collision cluster(s), ${unlinked} unlinked open PR(s).`, - }; -} - -export function buildPullRequestReviewability(args: { - repo: RepositoryRecord | null; - pullRequest: PullRequestRecord | null; - issues: IssueRecord[]; - pullRequests: PullRequestRecord[]; - files: PullRequestFileRecord[]; - reviews: PullRequestReviewRecord[]; - checks: CheckSummaryRecord[]; - recentMergedPullRequests: RecentMergedPullRequestRecord[]; - repoFullName: string; - pullNumber: number; - profile?: ContributorProfile | null | undefined; - outcomeHistory?: ContributorOutcomeHistory | null | undefined; -}): PullRequestReviewability { - const intelligence = buildPullRequestReviewIntelligence(args); - const pr = args.pullRequest; - const failingChecks = args.checks.filter(isFailingCheckSummary).length; - const broadDiff = intelligence.changeSummary.fileCount >= 12 || intelligence.changeSummary.additions + intelligence.changeSummary.deletions >= 800; - const noiseSources = [ - ...(pr?.state && pr.state !== "open" ? [`PR is ${pr.state}.`] : []), - ...(intelligence.reviewSignals.linkedIssues.length === 0 ? ["Missing linked issue or no-issue rationale."] : []), - ...(intelligence.reviewSignals.collisionClusters > 0 ? [`${intelligence.reviewSignals.collisionClusters} duplicate/WIP collision cluster(s).`] : []), - ...(intelligence.changeSummary.codeFileCount > 0 && intelligence.changeSummary.testFileCount === 0 ? ["Code changes do not include cached test files."] : []), - ...(failingChecks > 0 ? [`${failingChecks} failing or cancelled check(s).`] : []), - ...(broadDiff ? ["Diff is broad enough to create avoidable review friction."] : []), - ...(intelligence.outcomeContext && !intelligence.roleContext.maintainerLane && intelligence.outcomeContext.closedPullRequestRate >= 0.35 - ? [`Contributor repo-specific closed PR rate is ${percent(intelligence.outcomeContext.closedPullRequestRate)}.`] - : []), - ]; - const score = clamp( - 100 - - noiseSources.length * 14 - - intelligence.reviewSignals.collisionClusters * 12 - - failingChecks * 18 - - (broadDiff ? 18 : 0) + - (intelligence.reviewSignals.approvalCount > 0 ? 12 : 0), - 0, - 100, - ); - const action: PullRequestReviewability["action"] = intelligence.roleContext.maintainerLane - ? "maintainer_lane" - : pr?.state && pr.state !== "open" - ? "close_or_redirect" - : intelligence.reviewSignals.collisionClusters > 0 - ? "likely_duplicate" - : score >= 75 - ? "review_now" - : score >= 45 - ? "needs_author" - : "watch"; - const whyThisHelps = [ - ...(action === "review_now" ? ["Reviewing now is efficient because cached signals show linked context and manageable friction."] : []), - ...(action === "needs_author" ? ["Asking for author cleanup first reduces maintainer review time before deep technical review."] : []), - ...(action === "likely_duplicate" ? ["Checking overlap first prevents maintainers from reviewing duplicate or soon-obsolete work."] : []), - ...(action === "maintainer_lane" ? ["Maintainer-authored work should be reviewed as repo stewardship, not outside-contributor triage."] : []), - ...(action === "close_or_redirect" ? ["Closed or non-open PRs should be redirected before consuming review time."] : []), - ...(action === "watch" ? ["Watching is lower-cost until checks, tests, issue links, or overlap signals improve."] : []), - ]; - return { - repoFullName: args.repoFullName, - pullNumber: args.pullNumber, - generatedAt: nowIso(), - score: round(score), - action, - noiseSources: noiseSources.length > 0 ? noiseSources : ["No major reviewability blocker detected in cached metadata."], - whyThisHelps, - maintainerNextSteps: maintainerNextStepsFor(action, noiseSources), - privateSummary: `Reviewability ${round(score)}/100; action ${action}; ${noiseSources.length} noise source(s) from cached metadata.`, - }; -} - -function buildActions(args: { - repoFullName: string; - lane: LaneAdvice; - roleContext: RoleContext; - repoOutcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; - currentPreview: ScorePreviewResult; - afterCleanupPreview: ScorePreviewResult; - cleanupNeeded: number; - scoreBlockers: string[]; - queueHealth: QueueHealth; - collisionsHighRiskCount: number; - laneValueScore: number; - scoreabilityScore: number; - personalFitScore: number; - riskPenalty: number; - maintainerFrictionPenalty: number; - actionLeverageScore: number; -}): 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) { - actions.push( - action("cleanup_existing_prs", args, 30 + args.actionLeverageScore * 0.55 + args.personalFitScore * 0.22 + args.laneValueScore * 0.12 - args.maintainerFrictionPenalty * 0.04, [ - 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 && (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")) { - actions.push( - action( - "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, - 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", - ), - ); - } - if (!args.roleContext.maintainerLane && (args.lane.lane === "issue_discovery" || args.lane.lane === "split")) { - actions.push( - action("file_issue_discovery", args, 18 + args.laneValueScore * 0.28 + (args.lane.lane === "issue_discovery" ? 20 : 0) - args.riskPenalty * 0.16, [ - 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 - .map((candidate) => ({ ...candidate, priorityScore: round(clamp(candidate.priorityScore, 0, 100)) })) - /* v8 ignore next -- Secondary action rank is deterministic presentation fallback after priority scoring. */ - .sort((left, right) => right.priorityScore - left.priorityScore || ACTION_RANK[left.actionKind] - ACTION_RANK[right.actionKind]); -} - -function action(kind: RewardRiskActionKind, args: { - repoFullName: string; - laneValueScore: number; - scoreabilityScore: number; - personalFitScore: number; - riskPenalty: number; - maintainerFrictionPenalty: number; - actionLeverageScore: number; -}, priorityScore: number, whyThisHelps: string[], severity: RewardRiskActionSeverity): RewardRiskAction { - return { - actionKind: kind, - repoFullName: args.repoFullName, - severity, - priorityScore, - laneValueScore: round(args.laneValueScore), - scoreabilityScore: round(args.scoreabilityScore), - personalFitScore: round(args.personalFitScore), - riskPenalty: round(args.riskPenalty), - maintainerFrictionPenalty: round(args.maintainerFrictionPenalty), - actionLeverageScore: round(args.actionLeverageScore), - whyThisHelps, - nextActions: nextActionsFor(kind), - }; -} - -function scoreBlockersFor(args: { - lane: LaneAdvice; - roleContext: RoleContext; - currentPreview: ScorePreviewResult; - repo: RepositoryRecord | null; - repoOutcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; - currentOpenPrCount: number; -}): string[] { - return [ - ...(!args.repo?.isRegistered ? ["Repository is not registered in the local snapshot."] : []), - ...(args.lane.lane === "inactive" ? ["Repository allocation is inactive."] : []), - ...(args.lane.lane === "unknown" ? ["Repository lane is unknown."] : []), - ...(args.roleContext.maintainerLane ? ["Maintainer-lane work is not normal outside-contributor reward evidence."] : []), - ...(args.currentPreview.laneMath.directPrSlice <= 0 && args.lane.lane === "issue_discovery" ? ["Direct PR-side lane value is disabled for this repo."] : []), - ...(args.currentOpenPrCount > args.currentPreview.gates.openPrThreshold ? ["Open PR count exceeds the current threshold assumption."] : []), - ...(args.currentPreview.gates.credibilityObserved < args.currentPreview.gates.credibilityFloor ? ["Credibility assumption is below the current floor."] : []), - ...((args.repoOutcome?.closedPullRequestRate ?? 0) >= 0.35 ? ["Repo-specific closed PR rate is high enough to create credibility risk."] : []), - ]; -} - -function whyThisHelpsFor(args: { - repoFullName: string; - lane: LaneAdvice; - roleContext: RoleContext; - repoOutcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; - currentPreview: ScorePreviewResult; - afterCleanupPreview: ScorePreviewResult; - cleanupNeeded: number; - scoreBlockers: string[]; - queueHealth: QueueHealth; - collisionsHighRiskCount: number; -}): string[] { - return [ - ...(args.cleanupNeeded > 0 - ? [`Cleanup is high leverage because it changes openPrMultiplier ${args.currentPreview.scoreEstimate.openPrMultiplier} -> ${args.afterCleanupPreview.scoreEstimate.openPrMultiplier} and estimated score ${args.currentPreview.scoreEstimate.estimatedMergedScore} -> ${args.afterCleanupPreview.scoreEstimate.estimatedMergedScore}.`] - : []), - ...(args.repoOutcome && args.repoOutcome.mergedPullRequests > 0 - ? [`Protects repo-specific credibility where ${args.repoOutcome.mergedPullRequests} merged PR(s) already show fit.`] - : []), - ...(args.roleContext.maintainerLane - ? [`${args.repoFullName} is maintainer lane for this user, so repo-health and maintainer_cut readiness matter more than normal contributor submissions.`] - : []), - ...(args.lane.lane === "issue_discovery" ? ["Direct PRs have no PR-side lane value here; issue-discovery quality and closure risk dominate."] : []), - ...(args.scoreBlockers.length > 0 ? [`Hard blockers: ${args.scoreBlockers.join(" ")}`] : []), - ...(args.queueHealth.level === "high" || args.queueHealth.level === "critical" ? [`Maintainer queue is ${args.queueHealth.level}; review friction lowers risk-adjusted priority.`] : []), - ...(args.collisionsHighRiskCount > 0 ? [`${args.collisionsHighRiskCount} high-risk collision cluster(s) must be cleared before new work has good expected value.`] : []), - ]; -} - -function relevantLaneFor(lane: LaneAdvice, roleContext: RoleContext): RepoRewardRisk["rewardUpside"]["relevantLane"] { - if (roleContext.maintainerLane) return "maintainer_lane"; - if (lane.lane === "direct_pr") return "direct_pr"; - if (lane.lane === "issue_discovery") return "issue_discovery"; - if (lane.lane === "split") return "direct_pr"; - return "none"; -} - -function laneValue(lane: LaneAdvice, preview: ScorePreviewResult, relevantLane: RepoRewardRisk["rewardUpside"]["relevantLane"]): number { - if (lane.lane === "inactive" || lane.lane === "unknown" || relevantLane === "none") return 0; - if (relevantLane === "issue_discovery") return clamp(preview.laneMath.issueDiscoverySlice * 1000, 0, 100); - if (relevantLane === "maintainer_lane") return clamp(preview.laneMath.repoSlice * 800, 0, 100); - return clamp(preview.laneMath.directPrSlice * 1000, 0, 100); -} - -function personalFit( - outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, - scoringProfile: ContributorScoringProfile | null | undefined, - roleContext: RoleContext, - profile: ContributorProfile, - repoLanguage: string | null | undefined, -): number { - if (roleContext.maintainerLane) return 80; - // Award the language-fit bonus only when the repo's primary language (sourced - // from ContributorFit.languageFit, as decision-pack.ts does) is one the - // contributor actually works in. Previously this granted +10 to any repo - // whenever the contributor had *any* top language, never comparing the two — - // so an off-language repo (e.g. a Rust repo for a Python-only contributor) was - // scored as a language match, inflating personalFit and the action - // priorityScores derived from it. - const contributorLanguages = new Set(profile.github.topLanguages.map((language) => language.toLowerCase())); - const languageMatch = repoLanguage && contributorLanguages.has(repoLanguage.toLowerCase()) ? 10 : 0; - return clamp( - (outcome?.mergedPullRequests ?? 0) * 2.2 + - /* v8 ignore next -- Credibility fallback order protects sparse private snapshots; scoring behavior is covered at public entry points. */ - (outcome?.credibility ?? scoringProfile?.evidence.credibilityAssumption ?? 0.8) * 35 + - (outcome?.validSolvedIssues ?? 0) * 3 + - languageMatch - - (outcome?.closedPullRequestRate ?? 0) * 45 - - Math.max(0, (outcome?.openPullRequests ?? 0) - 2) * 4, - 0, - 100, - ); -} - -function riskScore( - outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, - queueHealth: QueueHealth, - duplicateClusters: number, - highRiskDuplicateClusters: number, - openPrCount: number, - openPrThreshold: number, -): number { - const queuePenalty = queueHealth.level === "critical" ? 35 : queueHealth.level === "high" ? 24 : queueHealth.level === "medium" ? 12 : 0; - return clamp( - queuePenalty + - duplicateClusters * 4 + - highRiskDuplicateClusters * 14 + - Math.max(0, openPrCount - openPrThreshold) * 12 + - (outcome?.closedPullRequestRate ?? 0) * 55 + - Math.max(0, (outcome?.openPullRequests ?? 0) - 2) * 5, - 0, - 100, - ); -} - -function maintainerFriction(queueHealth: QueueHealth, duplicateClusters: number, pullRequests: PullRequestRecord[]): number { - const unlinked = pullRequests.filter((pr) => pr.state === "open" && pr.linkedIssues.length === 0).length; - return clamp(queueHealth.burdenScore * 0.55 + duplicateClusters * 8 + unlinked * 5, 0, 100); + repo: Parameters[0], + issues: Parameters[1], + pullRequests: Parameters[2], + recentMergedPullRequests: Parameters[3], + fullName: Parameters[4], +): ReturnType { + return engineBuildMaintainerNoiseReport(repo, issues, pullRequests, recentMergedPullRequests, fullName, deps); } -function reviewChurnRisk(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, queueHealth: QueueHealth, highRiskDuplicateClusters: number): "low" | "medium" | "high" { - const risk = (outcome?.closedPullRequestRate ?? 0) * 100 + highRiskDuplicateClusters * 18 + (queueHealth.level === "critical" ? 25 : queueHealth.level === "high" ? 15 : 0); - return risk >= 45 ? "high" : risk >= 20 ? "medium" : "low"; +export function buildPullRequestReviewability( + args: Parameters[0], +): ReturnType { + return engineBuildPullRequestReviewability(args, deps); } - -function analysisRank(analysis: RepoRewardRisk): number { - return (analysis.actions[0]?.priorityScore ?? 0) + analysis.rewardUpside.directPrSlice * 100 + analysis.rewardUpside.issueDiscoverySlice * 100; -} - -function bestFitLabels(repo: RepositoryRecord | null): string[] { - const multipliers = repo?.registryConfig?.labelMultipliers ?? {}; - const labels = Object.entries(multipliers) - // Exclude meta labels only at a keyword boundary (a real separator or end-of-string after the keyword), - // not mid-word — mirroring the anchored `suspiciousConfiguredLabels` matcher in engine.ts. The old - // unanchored regex over-matched substrings (e.g. "opensource" via "source", "risky-refactor" via "risk"), - // wrongly dropping a legitimate high-multiplier label from the best-fit suggestion. - .filter(([label]) => !/^(status|source|contributor|verified|risk|codex)([:/-]|$)/i.test(label)) - .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) - .map(([label]) => label); - return labels.slice(0, 1); -} - -function estimatedSourceTokenScore(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): number { - return clamp(42 + (outcome?.mergedPullRequests ?? 0) * 2, 30, 120); -} - -function estimatedTotalTokenScore(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): number { - return clamp(70 + (outcome?.mergedPullRequests ?? 0) * 4, 60, 220); -} - -function estimatedSourceLines(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): number { - return Math.max(12, estimatedSourceTokenScore(outcome)); -} - -function nextActionsFor(kind: RewardRiskActionKind): string[] { - switch (kind) { - case "cleanup_existing_prs": - return ["Land, close, or withdraw stale open PRs before opening additional direct-PR work.", "Prioritize the repo where existing successful evidence is strongest."]; - case "land_existing_prs": - return ["Tighten validation, update PR bodies, and resolve review/check blockers on already-open work."]; - case "close_or_withdraw_low_fit_prs": - return ["Withdraw stale or low-fit PRs that are unlikely to merge cleanly and are adding open PR pressure."]; - case "open_new_direct_pr": - return ["Only open a new PR after duplicate checks, local score preview, tests, and linked/no-issue rationale are clean."]; - case "file_issue_discovery": - return ["File only high-proof issues that someone else can solve and that are unlikely to be closed as duplicate or unclear."]; - case "maintainer_lane_improve_repo": - return ["Improve labels, contribution docs, queue hygiene, and contributor intake for the maintained repo."]; - case "maintainer_cut_readiness": - return ["Check config quality and maintainer_cut readiness before expecting maintainer-lane economics to work cleanly."]; - } -} - -function maintainerNextStepsFor(action: PullRequestReviewability["action"], noiseSources: string[]): string[] { - if (action === "review_now") return ["Review the technical diff now; cached hygiene signals look clean enough."]; - if (action === "maintainer_lane") return ["Treat as maintainer stewardship and verify repo-health impact separately."]; - if (action === "likely_duplicate") return ["Compare against linked issues, active PRs, and recent merges before detailed review."]; - if (action === "close_or_redirect") return ["Redirect or close non-open/stale context before spending review time."]; - if (action === "needs_author") return ["Ask the author to address the concrete missing context before deep review.", ...noiseSources.slice(0, 3)]; - 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; - let mostRecentAgeDays = Number.POSITIVE_INFINITY; - for (const issue of openIssues) { - const ageDays = issueAgeDays(pickIssueTimestamp(issue)); - if (ageDays < mostRecentAgeDays) mostRecentAgeDays = ageDays; - } - // 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 isParseableIssueTimestamp(value: string): boolean { - return Number.isFinite(Date.parse(value)); -} - -function pickIssueTimestamp(issue: IssueRecord): string | null { - const updated = typeof issue.updatedAt === "string" ? issue.updatedAt.trim() : ""; - if (updated && isParseableIssueTimestamp(updated)) return updated; - - const created = typeof issue.createdAt === "string" ? issue.createdAt.trim() : ""; - if (created && isParseableIssueTimestamp(created)) return created; - - return null; -} - -/** Unknown/unparseable timestamps floor freshness (parity with gittensory-engine opportunity-freshness.ts). */ -function issueAgeDays(value: string | null): number { - if (!value) return Number.POSITIVE_INFINITY; - const parsed = Date.parse(value); - if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY; - return Math.floor((Date.now() - parsed) / 86_400_000); -} - -function sameRepo(left: string, right: string): boolean { - return left.toLowerCase() === right.toLowerCase(); -} - -/** Bucket records by their `repoFullName` (case-insensitive) for O(1) per-repo lookups (#2112). */ -function groupByRepo(records: readonly T[]): Map { - const buckets = new Map(); - for (const record of records) { - const key = record.repoFullName.toLowerCase(); - const bucket = buckets.get(key); - if (bucket) bucket.push(record); - else buckets.set(key, [record]); - } - return buckets; -} - -function uniqueRegisteredRepoNames(repoFullNames: string[], registeredRepoNames: Map): string[] { - const seen = new Set(); - const unique: string[] = []; - for (const repoFullName of repoFullNames) { - const key = repoFullName.toLowerCase(); - const canonical = registeredRepoNames.get(key); - if (!canonical || seen.has(key)) continue; - seen.add(key); - unique.push(canonical); - } - return unique; -} - -function nonNegative(value: number | undefined): number { - /* v8 ignore next -- Sparse contributor totals normalize to zero before scoring; aggregate scoring tests cover the behavior. */ - return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0; -} - -function percent(value: number): string { - return `${Math.round(value * 100)}%`; -} - -function round(value: number): number { - return Math.round(value * 10000) / 10000; -} - -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - -/* v8 ignore start -- Test-only export surface for branch coverage. */ -export const rewardRiskFreshnessInternals = { - pickIssueTimestamp, - issueAgeDays, - bestFitLabels, -}; -/* v8 ignore stop */ diff --git a/test/unit/reward-risk-engine-branch-coverage.test.ts b/test/unit/reward-risk-engine-branch-coverage.test.ts new file mode 100644 index 0000000000..74053b86f7 --- /dev/null +++ b/test/unit/reward-risk-engine-branch-coverage.test.ts @@ -0,0 +1,171 @@ +// Branch-coverage tests for the reward-risk engine module (#2281). The verbatim lift preserved a handful of +// deterministic tie-break / defensive branches the pre-existing suite never exercised; because the module is +// brand-new to the engine package, codecov/patch measures every one of them. These cases drive each remaining +// branch directly (no behavior change to the module itself). +import { describe, expect, it } from "vitest"; +import { + buildContributorFit, + buildContributorOutcomeHistory, + buildContributorProfile, + buildContributorScoringProfile, +} from "../../src/signals/engine"; +import { buildContributorRewardRiskStrategy, buildRepoRewardRisk, rewardRiskFreshnessInternals } from "../../src/signals/reward-risk"; +import type { + ContributorRepoStatRecord, + IssueRecord, + PullRequestRecord, + RegistryRepoConfig, + RepositoryRecord, + ScoringModelSnapshotRecord, +} from "../../src/types"; + +function repo(fullName: string, overrides: Partial = {}): RepositoryRecord { + const [owner, name] = fullName.split("/") as [string, string]; + return { + fullName, + owner, + name, + isInstalled: true, + isRegistered: true, + isPrivate: false, + defaultBranch: "main", + registryConfig: { repo: fullName, emissionShare: 0.02, issueDiscoveryShare: 0, labelMultipliers: {}, trustedLabelPipeline: false, maintainerCut: 0, raw: {}, ...overrides }, + }; +} + +function pr(repoFullName: string, number: number, title: string, overrides: Partial = {}): PullRequestRecord { + return { repoFullName, number, title, state: "open", authorLogin: "dev", authorAssociation: "NONE", labels: [], linkedIssues: [], body: "", updatedAt: new Date().toISOString(), ...overrides }; +} + +function scoringSnapshot(): ScoringModelSnapshotRecord { + return { id: "branch-cov", sourceKind: "test", sourceUrl: "fixture://branch-cov", fetchedAt: "2026-05-25T00:00:00.000Z", activeModel: "current_density_model", constants: {}, programmingLanguages: {}, warnings: [], payload: {} }; +} + +const github = { login: "dev", topLanguages: ["TypeScript"], source: "github" as const }; + +describe("reward-risk engine branch coverage (#2281)", () => { + it("bestFitLabels breaks an equal-multiplier tie by label name", () => { + // Two labels with the SAME multiplier force the sort comparator's `|| localeCompare` fallback. + const labels = rewardRiskFreshnessInternals.bestFitLabels(repo("owner/tie", { labelMultipliers: { zebra: 1.5, alpha: 1.5 } })); + expect(labels).toEqual(["alpha"]); + }); + + it("reviewChurnRisk reports high risk when the repo-specific closed-PR rate is high", () => { + const profile = buildContributorProfile("dev", github, [], []); + const churnRepo = repo("owner/churn"); + // Two closed + one merged PR => closedPullRequestRate ~0.67 => reviewChurnRisk risk >= 45 => "high". + const outcomeHistory = buildContributorOutcomeHistory({ + login: "dev", + profile, + repositories: [churnRepo], + pullRequests: [ + pr(churnRepo.fullName, 30, "Closed one", { state: "closed" }), + pr(churnRepo.fullName, 31, "Closed two", { state: "closed" }), + pr(churnRepo.fullName, 32, "Merged", { state: "merged", mergedAt: "2026-05-20T00:00:00.000Z" }), + ], + issues: [], + repoStats: [], + }); + const fit = buildContributorFit(profile, [churnRepo], [], [], [], []); + const scoringProfile = buildContributorScoringProfile({ login: "dev", fit, scoringSnapshot: scoringSnapshot() }); + const analysis = buildRepoRewardRisk({ + login: "dev", + repo: churnRepo, + repoFullName: churnRepo.fullName, + profile, + outcomeHistory, + scoringSnapshot: scoringSnapshot(), + scoringProfile, + issues: [], + pullRequests: [], + }); + expect(analysis.riskBreakdown.reviewChurnRisk).toBe("high"); + }); + + it("reviewChurnRisk reports medium risk for a moderate closed-PR rate", () => { + const profile = buildContributorProfile("dev", github, [], []); + const churnRepo = repo("owner/churn-mid"); + // One closed + two merged => closedPullRequestRate ~0.33 => risk in [20, 45) => "medium". + const outcomeHistory = buildContributorOutcomeHistory({ + login: "dev", + profile, + repositories: [churnRepo], + pullRequests: [ + pr(churnRepo.fullName, 40, "Closed one", { state: "closed" }), + pr(churnRepo.fullName, 41, "Merged one", { state: "merged", mergedAt: "2026-05-20T00:00:00.000Z" }), + pr(churnRepo.fullName, 42, "Merged two", { state: "merged", mergedAt: "2026-05-21T00:00:00.000Z" }), + ], + issues: [], + repoStats: [], + }); + const fit = buildContributorFit(profile, [churnRepo], [], [], [], []); + const scoringProfile = buildContributorScoringProfile({ login: "dev", fit, scoringSnapshot: scoringSnapshot() }); + const analysis = buildRepoRewardRisk({ + login: "dev", + repo: churnRepo, + repoFullName: churnRepo.fullName, + profile, + outcomeHistory, + scoringSnapshot: scoringSnapshot(), + scoringProfile, + issues: [], + pullRequests: [], + }); + expect(analysis.riskBreakdown.reviewChurnRisk).toBe("medium"); + }); + + it("maintainer-cut readiness scores without the low-queue bonus when the owned repo's queue is not low", () => { + // Owner === login => maintainer lane; a heavily loaded queue keeps queueHealth.level above "low", + // exercising the `level === "low" ? 20 : 0` false branch. + const ownedRepo = repo("dev/owned"); + const busyPrs = Array.from({ length: 14 }, (_, i) => pr(ownedRepo.fullName, i + 1, `Open work ${i}`, { authorLogin: `other${i}` })); + const profile = buildContributorProfile("dev", github, [], []); + const fit = buildContributorFit(profile, [ownedRepo], [], [], [], []); + const scoringProfile = buildContributorScoringProfile({ login: "dev", fit, scoringSnapshot: scoringSnapshot() }); + const analysis = buildRepoRewardRisk({ + login: "dev", + repo: ownedRepo, + repoFullName: ownedRepo.fullName, + profile, + outcomeHistory: buildContributorOutcomeHistory({ login: "dev", profile, repositories: [ownedRepo], pullRequests: busyPrs, issues: [], repoStats: [] }), + scoringSnapshot: scoringSnapshot(), + scoringProfile, + issues: [], + pullRequests: busyPrs, + }); + expect(analysis.roleContext.maintainerLane).toBe(true); + expect(analysis.actions.some((a) => a.actionKind === "maintainer_cut_readiness")).toBe(true); + }); + + it("contributor strategy breaks analysis and action ties across two identical repos", () => { + // Two byte-identical registered repos (differing only by name) produce equal analysisRank and equal + // top-action (priorityScore, actionKind) pairs, exercising the localeCompare/ACTION_RANK tie-breaks in + // both the repoAnalyses and topActions sorts, plus the fit.opportunities map callback. + const repoA = repo("twin/aaa"); + const repoB = repo("twin/bbb"); + const profile = buildContributorProfile("dev", github, [], []); + const stat = (repoFullName: string): ContributorRepoStatRecord => ({ login: "dev", repoFullName, pullRequests: 4, mergedPullRequests: 2, openPullRequests: 4, issues: 0, stalePullRequests: 0, unlinkedPullRequests: 0, dominantLabels: ["feature"] }); + const outcomeHistory = buildContributorOutcomeHistory({ login: "dev", profile, repositories: [repoA, repoB], pullRequests: [], issues: [], repoStats: [stat(repoA.fullName), stat(repoB.fullName)] }); + const fit = buildContributorFit(profile, [repoA, repoB], [], [], [], [stat(repoA.fullName), stat(repoB.fullName)]); + const scoringProfile = buildContributorScoringProfile({ login: "dev", fit, scoringSnapshot: scoringSnapshot() }); + const fitWithOpportunities = { + ...fit, + opportunities: [ + { repoFullName: repoA.fullName, title: "Grabbable", fit: "good" as const, score: 40, lane: "direct_pr" as const, multiplierTier: "community" as const, availability: "ready" as const, reasons: [], warnings: [] }, + ], + }; + const strategy = buildContributorRewardRiskStrategy({ + login: "dev", + fit: fitWithOpportunities, + scoringProfile, + scoringSnapshot: scoringSnapshot(), + outcomeHistory, + repositories: [repoA, repoB], + allIssues: [] as IssueRecord[], + allPullRequests: [] as PullRequestRecord[], + }); + expect(strategy.repoAnalyses).toHaveLength(2); + // Deterministic tie-break => the two identical analyses come back in lexicographic repo order. + expect(strategy.repoAnalyses.map((a) => a.repoFullName)).toEqual([repoA.fullName, repoB.fullName]); + }); +});