diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b41ed1c41..d17353a23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ - Add deterministic base-agent orchestrator (#14) -- Add settings preview diagnostics +- Make next-action recommendations repo-specific @@ -77,5 +77,15 @@ - Ignore stale beta api origins +- Restore actionKind guards and add split-lane copy + + + +### Tests + +- Tighten and extend decision-pack regression coverage + +- Cover review-requested branches and tier sanitization + diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 78f9f7ad31..c024edbfca 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -414,7 +414,7 @@ function actionFromDecisionAction(run: AgentRunRecord, action: DecisionAction, d maintainerImpact: maintainerImpactFor(decision), blockedBy: decision.scoreBlockers.map((blocker) => blocker.code), rerunWhen: rerunWhenForDecision(decision), - publicSafeSummary: sanitizePublicSummary(`${decision.repoFullName}: ${action.nextActions[0] ?? "Use Gittensory preflight before posting public PR context."}`), + publicSafeSummary: sanitizePublicSummary(action.publicNextActions?.[0] ?? decision.publicNextActions?.[0] ?? `${decision.repoFullName}: Use Gittensory preflight before posting public PR context.`), payload: { action: action as unknown as JsonValue, decision: decision as unknown as JsonValue, @@ -436,7 +436,7 @@ function actionFromRepoDecision(run: AgentRunRecord, decision: RepoDecision, ind maintainerImpact: maintainerImpactFor(decision), blockedBy: decision.scoreBlockers.map((blocker) => blocker.code), rerunWhen: rerunWhenForDecision(decision), - publicSafeSummary: sanitizePublicSummary(`${decision.repoFullName}: ${decision.nextActions[0] ?? "Use local branch preflight before posting."}`), + publicSafeSummary: sanitizePublicSummary(decision.publicNextActions?.[0] ?? `${decision.repoFullName}: Use local branch preflight before posting.`), payload: { decision: decision as unknown as JsonValue }, }); } @@ -538,11 +538,13 @@ function mapDecisionAction(kind: DecisionAction["actionKind"]): AgentActionType } function recommendationText(action: DecisionAction, decision: RepoDecision): string { - if (action.actionKind === "cleanup_existing_prs") return "Clean up existing PR pressure before opening new work."; - if (action.actionKind === "land_existing_prs") return "Focus on landing or closing already-open PRs."; - if (action.actionKind === "file_issue_discovery") return "Only file an actionable, non-duplicate issue-discovery report."; - if (decision.recommendation === "maintainer_lane") return "Treat this as maintainer-lane repo health work, not outside-contributor work."; - return action.nextActions[0] ?? "Pick narrow work and run branch preflight before opening a PR."; + if (action.actionKind === "cleanup_existing_prs") return `${decision.repoFullName}: clean up existing PR pressure before opening new work.`; + if (action.actionKind === "land_existing_prs") return `${decision.repoFullName}: focus on landing or closing already-open PRs.`; + if (action.actionKind === "file_issue_discovery") return `${decision.repoFullName}: only file an actionable, non-duplicate issue-discovery report.`; + if (action.actionKind === "maintainer_lane_improve_repo" || action.actionKind === "maintainer_cut_readiness") { + return `${decision.repoFullName}: maintainer-lane repo health work, not outside-contributor evidence.`; + } + return action.nextActions[0] ?? `${decision.repoFullName}: pick narrow work and run branch preflight before opening a PR.`; } function maintainerImpactFor(decision: RepoDecision): string { diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index 64e8aa625f..131ec08d23 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -80,6 +80,11 @@ export type DecisionPackRefreshNeeded = { dataQuality?: ContributorDecisionPack["dataQuality"] | undefined; }; +export type LanguageMatch = { + language: string | null; + match: boolean; +}; + export type RepoDecision = { repoFullName: string; recommendation: DecisionRecommendation; @@ -99,10 +104,13 @@ export type RepoDecision = { issueDiscoveryShare: number; maintainerCut: number; }; + languageMatch: LanguageMatch; + labelFit: string[]; scoreBlockers: ScoreBlocker[]; riskReasons: string[]; whyThisHelps: string[]; nextActions: string[]; + publicNextActions: string[]; }; export type DecisionAction = { @@ -112,6 +120,7 @@ export type DecisionAction = { recommendation: DecisionRecommendation; whyThisHelps: string[]; nextActions: string[]; + publicNextActions: string[]; }; export type ScoreBlocker = { @@ -233,6 +242,8 @@ function buildContributorDecisionPack(args: { const syncByRepo = new Map(args.syncStates.map((state) => [state.repoFullName.toLowerCase(), state])); const totalsByRepo = new Map(args.totals.map((total) => [total.repoFullName.toLowerCase(), total])); const outcomeByRepo = new Map(args.outcomeHistory.repoOutcomes.map((outcome) => [outcome.repoFullName.toLowerCase(), outcome])); + const languageSet = new Set((args.profile.github?.topLanguages ?? []).map((language) => language.toLowerCase())); + const labelHistory = new Set(args.profile.registeredRepoActivity?.dominantLabels ?? []); const roleContexts = registeredRepositories.map((repo) => buildRoleContext({ login: args.login, @@ -253,6 +264,8 @@ function buildContributorDecisionPack(args: { outcome: outcomeByRepo.get(key), syncState: syncByRepo.get(key), totals: totalsByRepo.get(key), + languageSet, + labelHistory, }); }) .sort((left, right) => right.priorityScore - left.priorityScore || left.repoFullName.localeCompare(right.repoFullName)); @@ -297,6 +310,8 @@ function buildRepoDecision(args: { outcome?: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; syncState?: RepoSyncStateRecord | undefined; totals?: RepoGithubTotalsSnapshotRecord | undefined; + languageSet?: Set | undefined; + labelHistory?: Set | undefined; }): RepoDecision { const lane = buildLaneAdvice(args.repo, args.repo.fullName); const config = args.repo.registryConfig; @@ -324,6 +339,24 @@ function buildRepoDecision(args: { ]; const recommendation = recommendationFor(lane.lane, args.roleContext, args.outcome, blockers); const priorityScore = priorityFor(recommendation, rewardUpside, args.outcome, queue, blockers); + const syncLanguage = args.syncState?.primaryLanguage ?? null; + const languageMatch: LanguageMatch = { + language: syncLanguage, + match: Boolean(syncLanguage && args.languageSet?.has(syncLanguage.toLowerCase())), + }; + const labelHistory = args.labelHistory; + const labelFit = labelHistory + ? Object.keys(args.repo.registryConfig?.labelMultipliers ?? {}).filter((label) => labelHistory.has(label)) + : []; + const copyContext: RepoCopyContext = { + repoFullName: args.repo.fullName, + lane: lane.lane, + queue, + rewardUpside, + outcome: args.outcome, + languageMatch, + labelFit, + }; return { repoFullName: args.repo.fullName, recommendation, @@ -333,10 +366,13 @@ function buildRepoDecision(args: { outcome: args.outcome, queue, rewardUpside, + languageMatch, + labelFit, scoreBlockers: blockers, riskReasons, - whyThisHelps: whyThisHelpsFor(recommendation, args.repo.fullName, args.outcome, rewardUpside), - nextActions: nextActionsFor(recommendation, lane.lane), + whyThisHelps: whyThisHelpsFor(recommendation, copyContext), + nextActions: nextActionsFor(recommendation, copyContext), + publicNextActions: publicNextActionsFor(recommendation, copyContext), }; } @@ -371,7 +407,10 @@ function priorityFor( const upside = Math.max(rewardUpside.directPrShare, rewardUpside.issueDiscoveryShare, rewardUpside.emissionShare * 0.35) * 1000; const history = (outcome?.mergedPullRequests ?? 0) * 2 + (outcome?.validSolvedIssues ?? 0) * 3 - (outcome?.closedPullRequests ?? 0) * 1.5; const queuePenalty = Math.min(30, queue.openPullRequests * 0.25); - const blockerPenalty = blockers.reduce((sum, blocker) => sum + (blocker.severity === "critical" ? 35 : blocker.severity === "warning" ? 15 : 5), 0); + const penalizedBlockers = recommendation === "cleanup_first" + ? blockers.filter((blocker) => blocker.code !== "open_pr_pressure") + : blockers; + const blockerPenalty = penalizedBlockers.reduce((sum, blocker) => sum + (blocker.severity === "critical" ? 35 : blocker.severity === "warning" ? 15 : 5), 0); const base = recommendation === "cleanup_first" ? 75 : recommendation === "pursue" ? 65 : recommendation === "maintainer_lane" ? 55 : recommendation === "watch" ? 35 : 20; return clamp(round(base + upside + history - queuePenalty - blockerPenalty), 0, 100); } @@ -399,28 +438,100 @@ function action(kind: DecisionActionKind, decision: RepoDecision, priorityScore: recommendation: decision.recommendation, whyThisHelps: decision.whyThisHelps, nextActions: decision.nextActions, + publicNextActions: decision.publicNextActions, }; } -function whyThisHelpsFor( - recommendation: DecisionRecommendation, - repoFullName: string, - outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, - rewardUpside: RepoDecision["rewardUpside"], -): string[] { - if (recommendation === "cleanup_first") return [`${repoFullName}: cleaning up active PR pressure protects scoreability and reduces maintainer friction.`]; - if (recommendation === "maintainer_lane") return [`${repoFullName}: maintainer-owned work should improve repo health, intake quality, labels, and queue clarity.`]; - if (recommendation === "pursue") return [`${repoFullName}: direct PR lane has ${round(rewardUpside.directPrShare)} lane share and no hard personal blocker in current signals.`]; - if (recommendation === "watch") return [`${repoFullName}: issue-discovery context is useful only when the report is actionable, non-duplicate, and likely solvable.`]; +type RepoCopyContext = { + repoFullName: string; + lane: string; + queue: RepoDecision["queue"]; + rewardUpside: RepoDecision["rewardUpside"]; + outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; + languageMatch: LanguageMatch; + labelFit: string[]; +}; + +function whyThisHelpsFor(recommendation: DecisionRecommendation, context: RepoCopyContext): string[] { + const { repoFullName, rewardUpside, outcome, languageMatch, labelFit, lane } = context; + const labelPhrase = labelFit.length > 0 ? ` Label overlap with your history: ${labelFit.slice(0, 3).join(", ")}.` : ""; + const languagePhrase = languageMatch.match && languageMatch.language ? ` Primary language ${languageMatch.language} matches your top languages.` : ""; + if (recommendation === "cleanup_first") { + const openCount = outcome?.openPullRequests ?? 0; + return [`${repoFullName}: ${openCount} of your open PR(s) here block scoreability; clearing them lowers maintainer friction.${labelPhrase}`]; + } + if (recommendation === "maintainer_lane") { + return [`${repoFullName}: maintainer-owned work should improve repo health, intake quality, labels, and queue clarity. Maintainer cut: ${round(rewardUpside.maintainerCut)}.`]; + } + if (recommendation === "pursue") { + const merged = outcome?.mergedPullRequests ?? 0; + const historyPhrase = merged > 0 ? ` You have ${merged} merged PR(s) in this repo already.` : ""; + if (lane === "split") { + return [`${repoFullName}: split lane (direct PR ${round(rewardUpside.directPrShare)}, issue-discovery ${round(rewardUpside.issueDiscoveryShare)}); both lanes are useful here.${languagePhrase}${labelPhrase}${historyPhrase}`]; + } + return [`${repoFullName}: direct PR lane share ${round(rewardUpside.directPrShare)} with no hard personal blocker.${languagePhrase}${labelPhrase}${historyPhrase}`]; + } + if (recommendation === "watch") { + return [`${repoFullName}: ${lane === "issue_discovery" ? "issue-discovery-only" : "low-direct-PR"} lane; only actionable, non-duplicate issue reports add value.${labelPhrase}`]; + } return [`${repoFullName}: risk-adjusted priority is low until blockers improve.`]; } -function nextActionsFor(recommendation: DecisionRecommendation, lane: string): string[] { - if (recommendation === "cleanup_first") return ["Close, update, or land existing open PRs before opening more work.", "Use local branch preflight on each active PR to reduce review friction."]; - if (recommendation === "maintainer_lane") return ["Improve contributor intake health, label clarity, and queue hygiene.", "Review maintainer_cut readiness separately from outside-contributor strategy."]; - if (recommendation === "pursue") return ["Pick one narrow change, link context clearly, run tests, and use local branch analysis before opening the PR."]; - if (lane === "issue_discovery") return ["File only high-confidence, actionable, non-duplicate issue-discovery reports."]; - return ["Choose a different repo or wait for cleaner lane/credibility conditions."]; +function nextActionsFor(recommendation: DecisionRecommendation, context: RepoCopyContext): string[] { + const { repoFullName, queue, outcome, languageMatch, labelFit, lane } = context; + const labelHint = labelFit.length > 0 ? ` (target labels: ${labelFit.slice(0, 3).join(", ")})` : ""; + const languageHint = languageMatch.match && languageMatch.language ? ` in ${languageMatch.language}` : ""; + if (recommendation === "cleanup_first") { + const openCount = outcome?.openPullRequests ?? 0; + return [ + `${repoFullName}: close, update, or land your ${openCount} open PR(s) before opening more work${labelHint}.`, + "Use local branch preflight on each active PR to reduce review friction.", + ]; + } + if (recommendation === "maintainer_lane") { + return [ + `${repoFullName}: improve contributor intake health, label clarity, and queue hygiene as repo owner.`, + "Review maintainer_cut readiness separately from outside-contributor strategy.", + ]; + } + if (recommendation === "pursue") { + if (lane === "split") { + return [ + `${repoFullName}: split lane — choose direct PR${languageHint}${labelHint} OR file an actionable issue-discovery report; queue has ${queue.openPullRequests} open PR(s) and ${queue.openIssues} open issue(s).`, + ]; + } + return [ + `${repoFullName}: pick one narrow change${languageHint}${labelHint}; run tests + branch preflight before opening the PR. Queue has ${queue.openPullRequests} open PR(s).`, + ]; + } + if (recommendation === "watch" || lane === "issue_discovery") { + return [ + `${repoFullName}: file only high-confidence, actionable, non-duplicate issue-discovery reports${labelHint}. Open issues in queue: ${queue.openIssues}.`, + ]; + } + return [`${repoFullName}: choose a different repo or wait for cleaner lane/credibility conditions.`]; +} + +function publicNextActionsFor(recommendation: DecisionRecommendation, context: RepoCopyContext): string[] { + const { repoFullName, languageMatch, labelFit, lane } = context; + const languageHint = languageMatch.match && languageMatch.language ? ` in ${languageMatch.language}` : ""; + const labelHint = labelFit.length > 0 ? ` (consider labels: ${labelFit.slice(0, 3).join(", ")})` : ""; + if (recommendation === "cleanup_first") { + return [`${repoFullName}: resolve open PR pressure before opening additional review load.`]; + } + if (recommendation === "maintainer_lane") { + return [`${repoFullName}: as repo owner, improve intake health, label clarity, and queue hygiene.`]; + } + if (recommendation === "pursue") { + if (lane === "split") { + return [`${repoFullName}: split lane — direct PR or actionable issue report${languageHint}${labelHint}; use Gittensory preflight before posting public PR context.`]; + } + return [`${repoFullName}: pick a narrow change${languageHint}${labelHint}; use Gittensory preflight before posting public PR context.`]; + } + if (recommendation === "watch" || lane === "issue_discovery") { + return [`${repoFullName}: file only actionable, non-duplicate issue-discovery reports${labelHint}.`]; + } + return [`${repoFullName}: consider a different repo until lane/credibility signals improve.`]; } function sanitizeOfficialStats(profile: ContributorProfile): ContributorDecisionPack["profile"]["officialStats"] { @@ -479,6 +590,7 @@ export const __decisionPackInternals = { actionsForDecision, whyThisHelpsFor, nextActionsFor, + publicNextActionsFor, sanitizeOfficialStats, withSnapshotMetadata, snapshotAgeMs, diff --git a/test/unit/agent-orchestrator.test.ts b/test/unit/agent-orchestrator.test.ts index 48444c712f..b256249840 100644 --- a/test/unit/agent-orchestrator.test.ts +++ b/test/unit/agent-orchestrator.test.ts @@ -199,7 +199,7 @@ describe("agent orchestrator", () => { expect(__agentOrchestratorInternals.mapDecisionAction("maintainer_cut_readiness")).toBe("explain_repo_fit"); expect(__agentOrchestratorInternals.recommendationText(action("file_issue_discovery", "owner/issues", "watch", 30), readyDecision)).toMatch(/actionable/); expect(__agentOrchestratorInternals.recommendationText(action("maintainer_lane_improve_repo", "owner/maintainer", "maintainer_lane", 44), maintainerDecision)).toMatch(/maintainer-lane/); - expect(__agentOrchestratorInternals.recommendationText({ ...action("open_new_direct_pr", "owner/ready", "pursue", 80), nextActions: [] }, readyDecision)).toMatch(/Pick narrow work/); + expect(__agentOrchestratorInternals.recommendationText({ ...action("open_new_direct_pr", "owner/ready", "pursue", 80), nextActions: [] }, readyDecision)).toMatch(/pick narrow work/i); expect(__agentOrchestratorInternals.maintainerImpactFor(maintainerDecision)).toMatch(/Repo-owner/); expect(__agentOrchestratorInternals.rerunWhenForDecision(criticalDecision)).toMatch(/blockers/); expect(__agentOrchestratorInternals.sameRepo("Owner/Repo", "owner/repo")).toBe(true); @@ -337,12 +337,11 @@ describe("agent orchestrator", () => { const noRepoBlockers = await explainBlockersWithAgent(env, { login: "oktofeesh1" }); const missingRepoPlan = await planNextWork(env, { login: "oktofeesh1", repoFullName: "missing/repo" }); - expect(plan.actions.map((entry) => entry.recommendation)).toEqual( - expect.arrayContaining([ - "Use Gittensory preflight before posting public PR context.", - "Only file an actionable, non-duplicate issue-discovery report.", - ]), - ); + const planRepos = plan.actions.map((entry) => entry.targetRepoFullName); + expect(planRepos).toEqual(expect.arrayContaining(["touchpilot/touchpilot", "entrius/allways"])); + const joined = plan.actions.map((entry) => entry.recommendation).join(" | "); + expect(joined).toMatch(/touchpilot\/touchpilot:.*narrow change/); + expect(joined).toMatch(/entrius\/allways:.*non-duplicate/); expect(plan.contextSnapshots[0]?.freshnessWarnings).toEqual(expect.arrayContaining(["entrius/allways: capped signal coverage", "entrius/allways: rate limited signal coverage"])); expect(noBlockers.actions[0]).toMatchObject({ status: "ready", @@ -612,8 +611,11 @@ function repoDecision(overrides: Partial { expect(__decisionPackInternals.actionsForDecision(baseDecision("watch", "issue_discovery")).map((action) => action.actionKind)).toEqual(["file_issue_discovery"]); expect(__decisionPackInternals.actionsForDecision(baseDecision("avoid_for_now"))).toEqual([]); - expect(__decisionPackInternals.whyThisHelpsFor("cleanup_first", "owner/repo", undefined, { directPrShare: 0.01 } as any)[0]).toMatch(/cleaning up/); - expect(__decisionPackInternals.whyThisHelpsFor("maintainer_lane", "owner/repo", undefined, { directPrShare: 0.01 } as any)[0]).toMatch(/maintainer-owned/); - expect(__decisionPackInternals.whyThisHelpsFor("pursue", "owner/repo", undefined, { directPrShare: 0.01234 } as any)[0]).toMatch(/0.0123/); - expect(__decisionPackInternals.whyThisHelpsFor("watch", "owner/repo", undefined, { directPrShare: 0.01 } as any)[0]).toMatch(/issue-discovery/); - expect(__decisionPackInternals.whyThisHelpsFor("avoid_for_now", "owner/repo", undefined, { directPrShare: 0.01 } as any)[0]).toMatch(/low/); - - expect(__decisionPackInternals.nextActionsFor("cleanup_first", "direct_pr")[0]).toMatch(/Close/); - expect(__decisionPackInternals.nextActionsFor("maintainer_lane", "direct_pr")[0]).toMatch(/intake/); - expect(__decisionPackInternals.nextActionsFor("pursue", "direct_pr")[0]).toMatch(/narrow/); - expect(__decisionPackInternals.nextActionsFor("watch", "issue_discovery")[0]).toMatch(/high-confidence/); - expect(__decisionPackInternals.nextActionsFor("avoid_for_now", "inactive")[0]).toMatch(/different repo/); + const ctxFor = (lane: string, overrides: Record = {}) => + ({ + repoFullName: "owner/repo", + lane, + queue: { openPullRequests: 0, openIssues: 0, mergedPullRequests: 0, closedUnmergedPullRequests: 0 }, + rewardUpside: { directPrShare: 0.0123, issueDiscoveryShare: 0, emissionShare: 0.01, maintainerCut: 0 }, + outcome: undefined, + languageMatch: { language: null, match: false }, + labelFit: [], + roleContext: outsideRole, + ...overrides, + }) as any; + expect(__decisionPackInternals.whyThisHelpsFor("cleanup_first", ctxFor("direct_pr", { outcome: pressureOutcome }))[0]).toMatch(/block scoreability/); + expect(__decisionPackInternals.whyThisHelpsFor("maintainer_lane", ctxFor("direct_pr"))[0]).toMatch(/maintainer-owned/); + expect(__decisionPackInternals.whyThisHelpsFor("pursue", ctxFor("direct_pr"))[0]).toMatch(/0.0123/); + expect(__decisionPackInternals.whyThisHelpsFor("watch", ctxFor("issue_discovery"))[0]).toMatch(/issue-discovery-only/); + expect(__decisionPackInternals.whyThisHelpsFor("avoid_for_now", ctxFor("inactive"))[0]).toMatch(/low/); + + expect(__decisionPackInternals.nextActionsFor("cleanup_first", ctxFor("direct_pr", { outcome: pressureOutcome }))[0]).toMatch(/close, update, or land/); + expect(__decisionPackInternals.nextActionsFor("maintainer_lane", ctxFor("direct_pr"))[0]).toMatch(/intake health/); + expect(__decisionPackInternals.nextActionsFor("pursue", ctxFor("direct_pr"))[0]).toMatch(/narrow change/); + expect(__decisionPackInternals.nextActionsFor("watch", ctxFor("issue_discovery"))[0]).toMatch(/non-duplicate/); + expect(__decisionPackInternals.nextActionsFor("avoid_for_now", ctxFor("inactive"))[0]).toMatch(/different repo/); + + expect(__decisionPackInternals.publicNextActionsFor("cleanup_first", ctxFor("direct_pr", { outcome: pressureOutcome }))[0]).not.toMatch(/\d/); + expect(__decisionPackInternals.publicNextActionsFor("pursue", ctxFor("direct_pr"))[0]).toMatch(/preflight/); expect(__decisionPackInternals.priorityFor("pursue", { directPrShare: 0.02, issueDiscoveryShare: 0, emissionShare: 0.02 } as any, moderateOutcome, { openPullRequests: 2 } as any, [])).toBeGreaterThan(0); expect(__decisionPackInternals.priorityFor("avoid_for_now", { directPrShare: 0, issueDiscoveryShare: 0, emissionShare: 0 } as any, pressureOutcome, { openPullRequests: 500 } as any, [{ severity: "critical" } as any])).toBe(0); @@ -222,8 +237,344 @@ describe("decision-pack service", () => { expect(pack.roleContexts.map((role) => role.repoFullName)).not.toContain("owner/unconfigured"); expect(pack.nextActions.length).toBeGreaterThan(0); }); + + it("issues repo-specific direct-PR reasoning that names language and label fit", () => { + const decision = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/direct", 0.04, 0, { bug: 1.2, "good-first-issue": 1.1, perf: 1 }), + roleContext: { maintainerLane: false } as any, + outcome: { mergedPullRequests: 3, openPullRequests: 0, closedPullRequestRate: 0, credibility: 1 } as any, + syncState: { primaryLanguage: "TypeScript" } as any, + languageSet: new Set(["typescript", "python"]), + labelHistory: new Set(["bug", "good-first-issue"]), + }); + expect(decision.recommendation).toBe("pursue"); + expect(decision.lane.lane).toBe("direct_pr"); + expect(decision.languageMatch).toEqual({ language: "TypeScript", match: true }); + expect(decision.labelFit).toEqual(expect.arrayContaining(["bug", "good-first-issue"])); + expect(decision.nextActions[0]).toMatch(/in TypeScript/); + expect(decision.nextActions[0]).toMatch(/bug, good-first-issue/); + expect(decision.whyThisHelps[0]).toMatch(/3 merged PR/); + expect(noStructuralCountLeak(decision.publicNextActions)).toBe(true); + }); + + it("issues split-lane reasoning distinct from direct-PR copy", () => { + const split = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/split", 0.04, 0.5, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: { mergedPullRequests: 1, openPullRequests: 0, closedPullRequestRate: 0, credibility: 1 } as any, + syncState: { primaryLanguage: "TypeScript" } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + }); + const directOnly = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/direct-only", 0.04, 0, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: { mergedPullRequests: 1, openPullRequests: 0, closedPullRequestRate: 0, credibility: 1 } as any, + syncState: { primaryLanguage: "TypeScript" } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + }); + expect(split.recommendation).toBe("pursue"); + expect(split.lane.lane).toBe("split"); + expect(split.nextActions[0]).toMatch(/split lane/); + expect(split.whyThisHelps[0]).toMatch(/split lane/); + expect(split.nextActions[0]).not.toEqual(directOnly.nextActions[0]); + expect(split.whyThisHelps[0]).not.toEqual(directOnly.whyThisHelps[0]); + expect(noStructuralCountLeak(split.publicNextActions)).toBe(true); + }); + + it("issues issue-discovery-only reasoning that discourages direct PRs", () => { + const decision = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/issues", 0.02, 1, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: undefined, + syncState: { primaryLanguage: "TypeScript", openIssuesCount: 42 } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + }); + expect(decision.recommendation).toBe("watch"); + expect(decision.lane.lane).toBe("issue_discovery"); + expect(decision.nextActions[0]).toMatch(/non-duplicate/); + expect(decision.nextActions[0]).toMatch(/Open issues in queue: 42/); + expect(decision.whyThisHelps[0]).toMatch(/issue-discovery-only/); + expect(noStructuralCountLeak(decision.publicNextActions)).toBe(true); + }); + + it("issues avoid_for_now reasoning with sanitized public copy", () => { + const decision = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/inactive", 0, 0, {}), + roleContext: { maintainerLane: false } as any, + outcome: undefined, + syncState: undefined, + languageSet: new Set(), + labelHistory: new Set(), + }); + expect(decision.recommendation).toBe("avoid_for_now"); + expect(decision.whyThisHelps[0]).toMatch(/risk-adjusted priority is low/); + expect(noStructuralCountLeak(decision.publicNextActions)).toBe(true); + }); + + it("does not leak outside-contributor open-PR counts into maintainer-lane copy", () => { + const outsideCleanup = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/outside", 0.005, 0, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: { openPullRequests: 8, mergedPullRequests: 0, closedPullRequestRate: 0, credibility: 1, maintainerLane: false } as any, + syncState: { primaryLanguage: "TypeScript" } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + }); + const maintainerOwned = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("jsonbored/owned", 0.005, 0, { bug: 1.1 }, 0.2), + roleContext: { maintainerLane: true } as any, + outcome: { openPullRequests: 8, mergedPullRequests: 0, closedPullRequestRate: 0, credibility: 1, maintainerLane: true } as any, + syncState: { primaryLanguage: "TypeScript" } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + }); + expect(outsideCleanup.recommendation).toBe("cleanup_first"); + expect(maintainerOwned.recommendation).toBe("maintainer_lane"); + expect(outsideCleanup.nextActions[0]).toMatch(/8 open PR/); + expect(maintainerOwned.nextActions.join(" | ")).not.toMatch(/8 open PR/); + expect(maintainerOwned.whyThisHelps.join(" | ")).not.toMatch(/8 open PR/); + expect(maintainerOwned.nextActions[0]).toMatch(/repo owner/); + expect(maintainerOwned.whyThisHelps[0]).toMatch(/Maintainer cut: 0.2/); + expect(noStructuralCountLeak(maintainerOwned.publicNextActions)).toBe(true); + }); + + it("ranks cleanup-first above pursue when open-PR pressure is the trigger", () => { + const pressure = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/pressure", 0.005, 0, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: { openPullRequests: 7, mergedPullRequests: 2, closedPullRequestRate: 0.1, credibility: 1 } as any, + syncState: { primaryLanguage: "TypeScript" } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + }); + const pursueRepo = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/clean", 0.005, 0, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: { openPullRequests: 0, mergedPullRequests: 2, closedPullRequestRate: 0.1, credibility: 1 } as any, + syncState: { primaryLanguage: "TypeScript" } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + }); + expect(pressure.recommendation).toBe("cleanup_first"); + expect(pursueRepo.recommendation).toBe("pursue"); + expect(pressure.priorityScore).toBeGreaterThan(pursueRepo.priorityScore); + expect(pressure.nextActions[0]).toMatch(/7 open PR/); + expect(noStructuralCountLeak(pressure.publicNextActions)).toBe(true); + }); + + it("surfaces queue-pressure caveats when repo open-PR and open-issue queues are large", () => { + const decision = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/busy", 0.02, 0.5, { bug: 1 }), + roleContext: { maintainerLane: false } as any, + outcome: { openPullRequests: 0, mergedPullRequests: 0, closedPullRequestRate: 0, credibility: 1 } as any, + syncState: { primaryLanguage: "TypeScript", openPullRequestsCount: 30, openIssuesCount: 120 } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + }); + expect(decision.riskReasons).toEqual(expect.arrayContaining([expect.stringContaining("busy"), expect.stringContaining("large")])); + }); + + it("threads languageSet end-to-end via buildContributorDecisionPack", () => { + const profile = { + login: "jsonbored", + github: { topLanguages: ["TypeScript"] }, + source: {}, + gittensor: null, + registeredRepoActivity: { reposTouched: ["owner/ts"], dominantLabels: ["bug"] }, + trustSignals: {}, + } as any; + const pack = __decisionPackInternals.buildContributorDecisionPack({ + login: "jsonbored", + profile, + outcomeHistory: { login: "jsonbored", totals: {}, repoOutcomes: [], successPatterns: [], failurePatterns: [], summary: "" } as any, + repositories: [repoWithLabels("owner/ts", 0.04, 0, { bug: 1.1 })], + syncStates: [{ repoFullName: "owner/ts", primaryLanguage: "TypeScript" }] as any, + syncSegments: [], + totals: [], + scoringModelSnapshotId: "scoring-1", + contributorPullRequests: [], + contributorIssues: [], + }); + const tsDecision = pack.repoDecisions.find((d) => d.repoFullName === "owner/ts")!; + expect(tsDecision.languageMatch).toEqual({ language: "TypeScript", match: true }); + expect(tsDecision.labelFit).toContain("bug"); + expect(tsDecision.nextActions[0]).toMatch(/in TypeScript/); + }); + + it("emits sanitized publicNextActions across every recommendation tier without lane shares or counts", () => { + const tiers = [ + { name: "cleanup_first", outcome: { openPullRequests: 6, mergedPullRequests: 0, closedPullRequestRate: 0, credibility: 1 } as any, lane: "direct_pr", emission: 0.005, idShare: 0, maintainerLane: false }, + { name: "maintainer_lane", outcome: { openPullRequests: 0, mergedPullRequests: 0, closedPullRequestRate: 0, credibility: 1, maintainerLane: true } as any, lane: "direct_pr", emission: 0.005, idShare: 0, maintainerLane: true, maintainerCut: 0.25 }, + { name: "pursue", outcome: { openPullRequests: 0, mergedPullRequests: 0, closedPullRequestRate: 0, credibility: 1 } as any, lane: "direct_pr", emission: 0.04, idShare: 0, maintainerLane: false }, + { name: "pursue-split", outcome: { openPullRequests: 0, mergedPullRequests: 0, closedPullRequestRate: 0, credibility: 1 } as any, lane: "split", emission: 0.04, idShare: 0.5, maintainerLane: false }, + { name: "watch", outcome: undefined, lane: "issue_discovery", emission: 0.01, idShare: 1, maintainerLane: false }, + { name: "avoid_for_now", outcome: undefined, lane: "inactive", emission: 0, idShare: 0, maintainerLane: false }, + ]; + for (const tier of tiers) { + const decision = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels(`owner/${tier.name}`, tier.emission, tier.idShare, { bug: 1.1 }, tier.maintainerCut ?? 0), + roleContext: { maintainerLane: tier.maintainerLane } as any, + outcome: tier.outcome, + syncState: { primaryLanguage: "TypeScript" } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + }); + const joined = decision.publicNextActions.join(" | "); + expect(decision.publicNextActions.length).toBeGreaterThan(0); + expect(joined).not.toMatch(/\b\d+(\.\d+)?\b/); + expect(joined).not.toMatch(/share|emission|priority/i); + expect(noStructuralCountLeak(decision.publicNextActions)).toBe(true); + } + }); + + it("covers languageMatch true/false and labelFit empty/non-empty paths", () => { + const ctx = (overrides: Record = {}) => + __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/lang", 0.005, 0, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: { openPullRequests: 0, mergedPullRequests: 1, closedPullRequestRate: 0, credibility: 1 } as any, + syncState: { primaryLanguage: "TypeScript" } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + ...overrides, + }); + const matched = ctx(); + expect(matched.languageMatch).toEqual({ language: "TypeScript", match: true }); + expect(matched.labelFit).toEqual(["bug"]); + expect(matched.nextActions[0]).toMatch(/in TypeScript/); + expect(matched.nextActions[0]).toMatch(/target labels: bug/); + + const noLangMatch = ctx({ languageSet: new Set(["go"]) }); + expect(noLangMatch.languageMatch).toEqual({ language: "TypeScript", match: false }); + expect(noLangMatch.nextActions[0]).not.toMatch(/in TypeScript/); + + const noSyncLang = ctx({ syncState: undefined }); + expect(noSyncLang.languageMatch).toEqual({ language: null, match: false }); + expect(noSyncLang.nextActions[0]).not.toMatch(/ in [A-Z]/); + + const emptyLabels = ctx({ labelHistory: new Set() }); + expect(emptyLabels.labelFit).toEqual([]); + expect(emptyLabels.nextActions[0]).not.toMatch(/target labels/); + + const missingHistory = ctx({ labelHistory: undefined }); + expect(missingHistory.labelFit).toEqual([]); + }); + + it("preserves cleanup_first priority when triggered without an open_pr_pressure blocker", () => { + const moderateCleanup = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/moderate", 0.005, 0, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: { openPullRequests: 3, mergedPullRequests: 1, closedPullRequestRate: 0.1, credibility: 1 } as any, + syncState: { primaryLanguage: "TypeScript" } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + }); + expect(moderateCleanup.recommendation).toBe("cleanup_first"); + expect(moderateCleanup.scoreBlockers.map((b) => b.code)).not.toContain("open_pr_pressure"); + const pursueBaseline = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/baseline", 0.005, 0, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: { openPullRequests: 0, mergedPullRequests: 1, closedPullRequestRate: 0.1, credibility: 1 } as any, + syncState: { primaryLanguage: "TypeScript" } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + }); + expect(pursueBaseline.recommendation).toBe("pursue"); + expect(moderateCleanup.priorityScore).toBeGreaterThan(pursueBaseline.priorityScore); + }); + + it("handles undefined outcome paths in cleanup and watch copy", () => { + const cleanup = __decisionPackInternals.whyThisHelpsFor("cleanup_first", { + repoFullName: "owner/x", + lane: "direct_pr", + queue: { openPullRequests: 0, openIssues: 0, mergedPullRequests: 0, closedUnmergedPullRequests: 0 }, + rewardUpside: { directPrShare: 0.01, issueDiscoveryShare: 0, emissionShare: 0.01, maintainerCut: 0 }, + outcome: undefined, + languageMatch: { language: null, match: false }, + labelFit: [], + } as any); + expect(cleanup[0]).toMatch(/0 of your open PR/); + + const cleanupNext = __decisionPackInternals.nextActionsFor("cleanup_first", { + repoFullName: "owner/x", + lane: "direct_pr", + queue: { openPullRequests: 0, openIssues: 0, mergedPullRequests: 0, closedUnmergedPullRequests: 0 }, + rewardUpside: { directPrShare: 0.01, issueDiscoveryShare: 0, emissionShare: 0.01, maintainerCut: 0 }, + outcome: undefined, + languageMatch: { language: null, match: false }, + labelFit: [], + } as any); + expect(cleanupNext[0]).toMatch(/your 0 open PR/); + + const watchSplit = __decisionPackInternals.whyThisHelpsFor("watch", { + repoFullName: "owner/y", + lane: "split", + queue: { openPullRequests: 0, openIssues: 0, mergedPullRequests: 0, closedUnmergedPullRequests: 0 }, + rewardUpside: { directPrShare: 0.01, issueDiscoveryShare: 0.01, emissionShare: 0.01, maintainerCut: 0 }, + outcome: undefined, + languageMatch: { language: null, match: false }, + labelFit: [], + } as any); + expect(watchSplit[0]).toMatch(/low-direct-PR/); + }); + + it("covers scoreBlockersFor branches when outcome is undefined", () => { + const noOutcome = __decisionPackInternals.scoreBlockersFor("owner/x", "direct_pr", { maintainerLane: false } as any, undefined); + expect(noOutcome.map((b) => b.code)).not.toContain("open_pr_pressure"); + expect(noOutcome.map((b) => b.code)).not.toContain("closed_pr_credibility"); + expect(noOutcome.map((b) => b.code)).not.toContain("low_credibility"); + }); + + it("falls back to nowIso in withSnapshotMetadata when both generatedAt fields are missing", () => { + const wrapped = __decisionPackInternals.withSnapshotMetadata({ + id: "snap", + signalType: "contributor-decision-pack", + targetKey: "user", + generatedAt: null, + payload: { status: "ready", source: "computed", login: "user", repoDecisions: [], topActions: [] } as any, + }); + expect(typeof wrapped.generatedAt).toBe("string"); + expect(wrapped.generatedAt.length).toBeGreaterThan(0); + }); + + it("produces fully deterministic repoDecisions, priorityScores, and nextActions across builds", () => { + const fixedArgs = () => ({ + login: "jsonbored", + profile: { + login: "jsonbored", + github: { topLanguages: ["TypeScript"] }, + source: {}, + gittensor: null, + registeredRepoActivity: { reposTouched: ["owner/a", "owner/b", "owner/c"], dominantLabels: ["bug"] }, + trustSignals: {}, + } as any, + outcomeHistory: { login: "jsonbored", totals: {}, repoOutcomes: [], successPatterns: [], failurePatterns: [], summary: "" } as any, + repositories: [repoWithLabels("owner/c", 0.02, 0, { bug: 1 }), repoWithLabels("owner/a", 0.02, 0, { bug: 1 }), repoWithLabels("owner/b", 0.02, 0, { bug: 1 })], + syncStates: [{ repoFullName: "owner/a", primaryLanguage: "TypeScript" }, { repoFullName: "owner/b", primaryLanguage: "TypeScript" }, { repoFullName: "owner/c", primaryLanguage: "TypeScript" }] as any, + syncSegments: [], + totals: [], + scoringModelSnapshotId: "scoring-1", + contributorPullRequests: [], + contributorIssues: [], + }); + const packA = __decisionPackInternals.buildContributorDecisionPack(fixedArgs()); + const packB = __decisionPackInternals.buildContributorDecisionPack(fixedArgs()); + expect(packA.repoDecisions.map((d) => d.repoFullName)).toEqual(packB.repoDecisions.map((d) => d.repoFullName)); + expect(packA.repoDecisions.map((d) => d.priorityScore)).toEqual(packB.repoDecisions.map((d) => d.priorityScore)); + expect(packA.repoDecisions.map((d) => d.nextActions)).toEqual(packB.repoDecisions.map((d) => d.nextActions)); + expect(packA.topActions.map((a) => `${a.actionKind}:${a.repoFullName}`)).toEqual(packB.topActions.map((a) => `${a.actionKind}:${a.repoFullName}`)); + }); }); +function noStructuralCountLeak(lines: string[]): boolean { + const joined = lines.join(" | "); + if (/\b(openPullRequests?|openIssues?|mergedPullRequests?|closedPullRequests?|priorityScore)\b/.test(joined)) return false; + return !/\b\d+\s*open\s*PR/i.test(joined); +} + function repo(fullName: string, emissionShare: number, issueDiscoveryShare: number) { const [owner, name] = fullName.split("/"); return { @@ -243,3 +594,8 @@ function repo(fullName: string, emissionShare: number, issueDiscoveryShare: numb }, } as any; } + +function repoWithLabels(fullName: string, emissionShare: number, issueDiscoveryShare: number, labelMultipliers: Record, maintainerCut = 0) { + const base = repo(fullName, emissionShare, issueDiscoveryShare); + return { ...base, registryConfig: { ...base.registryConfig, labelMultipliers, maintainerCut } } as any; +}