diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 6007993495..88cca14089 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -421,6 +421,231 @@ "detail" ] }, + "ActionPortfolio": { + "type": "object", + "properties": { + "generatedAt": { + "type": "string" + }, + "bucketOrder": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionPortfolioBucketName" + } + }, + "buckets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bucket": { + "$ref": "#/components/schemas/ActionPortfolioBucketName" + }, + "label": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionPortfolioItem" + } + } + }, + "required": [ + "bucket", + "label", + "summary", + "actions" + ] + } + }, + "topActions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActionPortfolioItem" + } + }, + "counts": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "summary": { + "type": "string" + } + }, + "required": [ + "generatedAt", + "bucketOrder", + "buckets", + "topActions", + "counts", + "summary" + ] + }, + "ActionPortfolioBucketName": { + "type": "string", + "enum": [ + "cleanup", + "wait", + "direct_pr", + "issue_discovery", + "avoid", + "maintainer_lane" + ] + }, + "ActionPortfolioItem": { + "type": "object", + "properties": { + "bucket": { + "$ref": "#/components/schemas/ActionPortfolioBucketName" + }, + "repoFullName": { + "type": "string" + }, + "actionKind": { + "$ref": "#/components/schemas/DecisionActionKind" + }, + "priorityScore": { + "type": "number" + }, + "recommendation": { + "$ref": "#/components/schemas/DecisionRecommendation" + }, + "status": { + "type": "string", + "enum": [ + "recommended", + "blocked", + "watch" + ] + }, + "whyNow": { + "type": "array", + "items": { + "type": "string" + } + }, + "scoreabilityImpact": { + "type": "string" + }, + "riskImpact": { + "type": "string" + }, + "maintainerImpact": { + "type": "string" + }, + "blockedBy": { + "type": "array", + "items": { + "type": "string" + } + }, + "rerunWhen": { + "type": "string" + }, + "publicSafeSummary": { + "type": "string" + }, + "nextActions": { + "type": "array", + "items": { + "type": "string" + } + }, + "publicNextActions": { + "type": "array", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "enum": [ + "decision_pack" + ] + }, + "scenarioProjection": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": [ + "github_observed", + "user_supplied" + ] + }, + "pendingMergedPrCount": { + "type": "number" + }, + "pendingClosedPrCount": { + "type": "number" + }, + "approvedPrCount": { + "type": "number" + }, + "expectedOpenPrCountAfterMerge": { + "type": "number" + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "source", + "pendingMergedPrCount", + "pendingClosedPrCount", + "approvedPrCount", + "notes" + ] + } + }, + "required": [ + "bucket", + "repoFullName", + "priorityScore", + "recommendation", + "status", + "whyNow", + "scoreabilityImpact", + "riskImpact", + "maintainerImpact", + "blockedBy", + "rerunWhen", + "publicSafeSummary", + "nextActions", + "publicNextActions", + "source" + ] + }, + "DecisionActionKind": { + "type": "string", + "enum": [ + "cleanup_existing_prs", + "land_existing_prs", + "open_new_direct_pr", + "file_issue_discovery", + "maintainer_lane_improve_repo", + "maintainer_cut_readiness" + ] + }, + "DecisionRecommendation": { + "type": "string", + "enum": [ + "pursue", + "cleanup_first", + "maintainer_lane", + "avoid_for_now", + "watch" + ] + }, "WorkboardItem": { "type": "object", "properties": { @@ -1977,6 +2202,9 @@ } } }, + "actionPortfolio": { + "$ref": "#/components/schemas/ActionPortfolio" + }, "cleanupFirst": { "type": "array", "items": { @@ -2062,6 +2290,7 @@ "opportunities", "repoDecisions", "topActions", + "actionPortfolio", "cleanupFirst", "pursueRepos", "avoidRepos", diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 27b66c2c43..f75195a95c 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1425,6 +1425,63 @@ export const ContributorStrategySchema = z export const DecisionPackFreshnessSchema = z.enum(["fresh", "stale", "rebuilding", "missing"]).openapi("DecisionPackFreshness"); +export const DecisionRecommendationSchema = z.enum(["pursue", "cleanup_first", "maintainer_lane", "avoid_for_now", "watch"]).openapi("DecisionRecommendation"); + +export const DecisionActionKindSchema = z + .enum(["cleanup_existing_prs", "land_existing_prs", "open_new_direct_pr", "file_issue_discovery", "maintainer_lane_improve_repo", "maintainer_cut_readiness"]) + .openapi("DecisionActionKind"); + +export const ActionPortfolioBucketNameSchema = z.enum(["cleanup", "wait", "direct_pr", "issue_discovery", "avoid", "maintainer_lane"]).openapi("ActionPortfolioBucketName"); + +export const ActionPortfolioItemSchema = z + .object({ + bucket: ActionPortfolioBucketNameSchema, + repoFullName: z.string(), + actionKind: DecisionActionKindSchema.optional(), + priorityScore: z.number(), + recommendation: DecisionRecommendationSchema, + status: z.enum(["recommended", "blocked", "watch"]), + whyNow: z.array(z.string()), + scoreabilityImpact: z.string(), + riskImpact: z.string(), + maintainerImpact: z.string(), + blockedBy: z.array(z.string()), + rerunWhen: z.string(), + publicSafeSummary: z.string(), + nextActions: z.array(z.string()), + publicNextActions: z.array(z.string()), + source: z.enum(["decision_pack"]), + scenarioProjection: z + .object({ + source: z.enum(["github_observed", "user_supplied"]), + pendingMergedPrCount: z.number(), + pendingClosedPrCount: z.number(), + approvedPrCount: z.number(), + expectedOpenPrCountAfterMerge: z.number().optional(), + notes: z.array(z.string()), + }) + .optional(), + }) + .openapi("ActionPortfolioItem"); + +export const ActionPortfolioSchema = z + .object({ + generatedAt: z.string(), + bucketOrder: z.array(ActionPortfolioBucketNameSchema), + buckets: z.array( + z.object({ + bucket: ActionPortfolioBucketNameSchema, + label: z.string(), + summary: z.string(), + actions: z.array(ActionPortfolioItemSchema), + }), + ), + topActions: z.array(ActionPortfolioItemSchema), + counts: z.record(z.string(), z.number()), + summary: z.string(), + }) + .openapi("ActionPortfolio"); + export const ContributorDecisionPackSchema = z .object({ status: z.enum(["ready"]), @@ -1442,6 +1499,7 @@ export const ContributorDecisionPackSchema = z opportunities: z.array(ContributorOpportunitySchema), repoDecisions: z.array(z.record(z.string(), z.unknown())), topActions: z.array(z.record(z.string(), z.unknown())), + actionPortfolio: ActionPortfolioSchema, cleanupFirst: z.array(z.record(z.string(), z.unknown())), pursueRepos: z.array(z.record(z.string(), z.unknown())), avoidRepos: z.array(z.record(z.string(), z.unknown())), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 8badc687c5..95145d5389 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -2,6 +2,7 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-open import { z } from "zod"; import { AdvisorySchema, + ActionPortfolioSchema, AgentActionSchema, AgentContextSnapshotSchema, AgentRunBundleSchema, @@ -80,6 +81,7 @@ export function buildOpenApiSpec() { registry.register("RegistrySnapshot", RegistrySnapshotSchema); registry.register("Repository", RepositorySchema); registry.register("Advisory", AdvisorySchema); + registry.register("ActionPortfolio", ActionPortfolioSchema); registry.register("WorkboardItem", WorkboardItemSchema); registry.register("QueueHealth", QueueHealthSchema); registry.register("CollisionReport", CollisionReportSchema); diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 20a0cc04ee..4731eaea8b 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -22,7 +22,7 @@ import { import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { fetchPublicContributorProfile } from "../github/public"; import { getOrCreateScoringModelSnapshot } from "../scoring/model"; -import { loadContributorDecisionPackForServing, repoDecisionFromPack, type ContributorDecisionPack, type DecisionAction, type RepoDecision } from "./decision-pack"; +import { loadContributorDecisionPackForServing, repoDecisionFromPack, type ActionPortfolio, type ActionPortfolioBucketName, type ContributorDecisionPack, type DecisionAction, type RepoDecision } from "./decision-pack"; import { loadOrComputeIssueQualityResponse } from "./issue-quality"; import { summarizeAgentBundleWithAi } from "./ai-summaries"; import { buildContributorFit, buildContributorOutcomeHistory, buildContributorProfile, buildContributorScoringProfile } from "../signals/engine"; @@ -262,6 +262,7 @@ async function executeDecisionPackRun(env: Env, run: AgentRunRecord, kind: strin ? buildBlockerActions(run, pack, decisions, { allowFallback: allowCrossRepoFallback }) : buildDecisionActions(run, pack, scopedDecisionActions); const contexts = [contextSnapshotFromPack(run.id, pack, decisions)]; + const selectedActionPortfolio = contexts[0]?.payload.actionPortfolio ?? null; await replaceAgentActions(env, run.id, actions); await persistAgentContextSnapshot(env, contexts[0]!); const dataQualityStatus = isStale ? "degraded" : pack.dataQuality.signalFidelity.status; @@ -274,6 +275,7 @@ async function executeDecisionPackRun(env: Env, run: AgentRunRecord, kind: strin actionCount: actions.length, freshness: pack.freshness, rebuildEnqueued: pack.rebuildEnqueued, + actionPortfolio: selectedActionPortfolio, ...(isStale ? { refreshReason: pack.rebuildEnqueued ? "stale_decision_pack" : "stale_decision_pack_queue_unavailable" } : {}), @@ -847,6 +849,7 @@ function contextSnapshotFromPack(runId: string, pack: ContributorDecisionPack, d login: pack.login, source: pack.source, selectedRepos: decisions.map((decision) => decision.repoFullName), + actionPortfolio: scopedActionPortfolio(pack.actionPortfolio, decisions) as unknown as JsonValue, evidenceGraph: (pack.evidenceGraph ? { version: pack.evidenceGraph.version, @@ -862,6 +865,29 @@ function contextSnapshotFromPack(runId: string, pack: ContributorDecisionPack, d }; } +function scopedActionPortfolio(portfolio: ActionPortfolio | undefined, decisions: RepoDecision[]): ActionPortfolio | null { + if (!portfolio) return null; + const repoKeys = new Set(decisions.map((decision) => decision.repoFullName.toLowerCase())); + if (repoKeys.size === 0) return null; + const buckets = portfolio.buckets.map((bucket) => ({ + ...bucket, + actions: bucket.actions.filter((action) => repoKeys.has(action.repoFullName.toLowerCase())), + })); + const topActions = portfolio.topActions.filter((action) => repoKeys.has(action.repoFullName.toLowerCase())); + const counts = Object.fromEntries(buckets.map((bucket) => [bucket.bucket, bucket.actions.length])) as Record; + const activeBuckets = buckets.filter((bucket) => bucket.actions.length > 0); + return { + ...portfolio, + buckets, + topActions, + counts, + summary: + activeBuckets.length === 0 + ? "No portfolio actions are currently available for the selected repo scope." + : `Scoped portfolio has ${topActions.length} action(s) across ${activeBuckets.length} active bucket(s): ${activeBuckets.map((bucket) => `${bucket.bucket} ${bucket.actions.length}`).join(", ")}.`, + }; +} + function buildRunRecord(args: { objective: string; actorLogin: string; @@ -945,6 +971,7 @@ export const __agentOrchestratorInternals = { actionFromRepoDecision, actionRecord, contextSnapshotFromPack, + scopedActionPortfolio, buildRunRecord, mapDecisionAction, recommendationText, diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index a1e2680df1..9bc0a80ec5 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -72,6 +72,7 @@ const pendingDecisionPackRebuilds = new Map>(); export type DecisionRecommendation = "pursue" | "cleanup_first" | "maintainer_lane" | "avoid_for_now" | "watch"; export type DecisionActionKind = "cleanup_existing_prs" | "land_existing_prs" | "open_new_direct_pr" | "file_issue_discovery" | "maintainer_lane_improve_repo" | "maintainer_cut_readiness"; export type DecisionPackFreshness = "fresh" | "stale" | "rebuilding" | "missing"; +export type ActionPortfolioBucketName = "cleanup" | "wait" | "direct_pr" | "issue_discovery" | "avoid" | "maintainer_lane"; export type ContributorDecisionPack = { status: "ready"; @@ -96,6 +97,7 @@ export type ContributorDecisionPack = { opportunities: ContributorOpportunity[]; repoDecisions: RepoDecision[]; topActions: DecisionAction[]; + actionPortfolio: ActionPortfolio; cleanupFirst: RepoDecision[]; pursueRepos: RepoDecision[]; avoidRepos: RepoDecision[]; @@ -188,6 +190,51 @@ export type DecisionAction = { publicNextActions: string[]; }; +export type ActionPortfolioScenarioProjection = { + source: ContributorOpenPrMonitor["pendingScenarios"][number]["detection"]["source"]; + pendingMergedPrCount: number; + pendingClosedPrCount: number; + approvedPrCount: number; + expectedOpenPrCountAfterMerge?: number | undefined; + notes: string[]; +}; + +export type ActionPortfolioItem = { + bucket: ActionPortfolioBucketName; + repoFullName: string; + actionKind?: DecisionActionKind | undefined; + priorityScore: number; + recommendation: DecisionRecommendation; + status: "recommended" | "blocked" | "watch"; + whyNow: string[]; + scoreabilityImpact: string; + riskImpact: string; + maintainerImpact: string; + blockedBy: string[]; + rerunWhen: string; + publicSafeSummary: string; + nextActions: string[]; + publicNextActions: string[]; + source: "decision_pack"; + scenarioProjection?: ActionPortfolioScenarioProjection | undefined; +}; + +export type ActionPortfolioBucket = { + bucket: ActionPortfolioBucketName; + label: string; + summary: string; + actions: ActionPortfolioItem[]; +}; + +export type ActionPortfolio = { + generatedAt: string; + bucketOrder: ActionPortfolioBucketName[]; + buckets: ActionPortfolioBucket[]; + topActions: ActionPortfolioItem[]; + counts: Record; + summary: string; +}; + export type ScoreBlocker = { code: "open_pr_pressure" | "maintainer_lane" | "inactive_or_unknown_lane" | "closed_pr_credibility" | "issue_discovery_only" | "low_credibility"; repoFullName?: string | undefined; @@ -502,11 +549,18 @@ function buildContributorDecisionPack(args: { const monitorNextSteps = monitor.guidance.slice(0, 6); const packNextActions = [...new Set([...monitorNextSteps, ...topActions.flatMap((action) => action.nextActions)])].slice(0, 12); const monitorSummary = monitor.openPrCount > 0 ? ` ${monitor.summary}` : ""; + const generatedAt = nowIso(); + const actionPortfolio = buildActionPortfolio({ + generatedAt, + repoDecisions, + topActions, + openPrMonitor: monitor, + }); return { status: "ready", source: "computed", login: args.login, - generatedAt: nowIso(), + generatedAt, stale: false, freshness: "fresh", rebuildEnqueued: false, @@ -524,6 +578,7 @@ function buildContributorDecisionPack(args: { opportunities: args.opportunities ?? [], repoDecisions, topActions, + actionPortfolio, cleanupFirst: repoDecisions.filter((decision) => decision.recommendation === "cleanup_first").slice(0, 8), pursueRepos: repoDecisions.filter((decision) => decision.recommendation === "pursue").slice(0, 8), avoidRepos: repoDecisions.filter((decision) => decision.recommendation === "avoid_for_now").slice(0, 8), @@ -761,6 +816,235 @@ function action(kind: DecisionActionKind, decision: RepoDecision, priorityScore: }; } +const ACTION_PORTFOLIO_BUCKET_ORDER: ActionPortfolioBucketName[] = ["cleanup", "wait", "direct_pr", "issue_discovery", "avoid", "maintainer_lane"]; + +function buildActionPortfolio(args: { + generatedAt: string; + repoDecisions: RepoDecision[]; + topActions: DecisionAction[]; + openPrMonitor?: ContributorOpenPrMonitor | undefined; +}): ActionPortfolio { + const repoDecisions = args.repoDecisions.filter(isPortfolioDecision); + const decisionByRepo = new Map(repoDecisions.map((decision) => [decision.repoFullName.toLowerCase(), decision])); + const scenarioByRepo = new Map((args.openPrMonitor?.pendingScenarios ?? []).map((scenario) => [scenario.repoFullName.toLowerCase(), scenario.detection])); + const items = [ + ...args.topActions + .filter((entry) => typeof entry.repoFullName === "string") + .map((entry) => { + const decision = decisionByRepo.get(entry.repoFullName.toLowerCase()); + return decision ? portfolioItemFromAction(entry, decision, scenarioByRepo.get(entry.repoFullName.toLowerCase())) : null; + }) + .filter((entry): entry is ActionPortfolioItem => Boolean(entry)), + ...repoDecisions + .filter((decision) => decision.recommendation === "avoid_for_now") + .map((decision) => portfolioItemFromDecision(decision, "avoid", scenarioByRepo.get(decision.repoFullName.toLowerCase()))), + ]; + const uniqueItems = dedupePortfolioItems(items).sort(comparePortfolioItems); + const buckets = ACTION_PORTFOLIO_BUCKET_ORDER.map((bucket) => { + const actions = uniqueItems.filter((entry) => entry.bucket === bucket); + return { + bucket, + label: portfolioBucketLabel(bucket), + summary: portfolioBucketSummary(bucket, actions), + actions, + } satisfies ActionPortfolioBucket; + }); + const counts = Object.fromEntries(buckets.map((bucket) => [bucket.bucket, bucket.actions.length])) as Record; + const activeBuckets = buckets.filter((bucket) => bucket.actions.length > 0); + return { + generatedAt: args.generatedAt, + bucketOrder: ACTION_PORTFOLIO_BUCKET_ORDER, + buckets, + topActions: uniqueItems.slice(0, 12), + counts, + summary: + activeBuckets.length === 0 + ? "No portfolio actions are currently available from the decision pack." + : `Portfolio has ${uniqueItems.length} action(s) across ${activeBuckets.length} active bucket(s): ${activeBuckets.map((bucket) => `${bucket.bucket} ${bucket.actions.length}`).join(", ")}.`, + }; +} + +function portfolioItemFromAction( + actionEntry: DecisionAction, + decision: RepoDecision, + scenario: ContributorOpenPrMonitor["pendingScenarios"][number]["detection"] | undefined, +): ActionPortfolioItem { + const bucket = bucketForAction(actionEntry); + return portfolioItem({ + bucket, + actionKind: actionEntry.actionKind, + decision, + priorityScore: Number.isFinite(actionEntry.priorityScore) ? actionEntry.priorityScore : decision.priorityScore, + whyNow: [...safeStringArray(actionEntry.whyThisHelps), ...safeStringArray(decision.riskReasons), ...scenarioWhyNow(scenario)].slice(0, 8), + nextActions: safeStringArray(actionEntry.nextActions).length > 0 ? safeStringArray(actionEntry.nextActions) : safeStringArray(decision.nextActions), + publicNextActions: safeStringArray(actionEntry.publicNextActions).length > 0 ? safeStringArray(actionEntry.publicNextActions) : safeStringArray(decision.publicNextActions), + scenario, + }); +} + +function portfolioItemFromDecision( + decision: RepoDecision, + bucket: ActionPortfolioBucketName, + scenario: ContributorOpenPrMonitor["pendingScenarios"][number]["detection"] | undefined, +): ActionPortfolioItem { + return portfolioItem({ + bucket, + decision, + priorityScore: decision.priorityScore, + whyNow: [...safeStringArray(decision.whyThisHelps), ...safeStringArray(decision.riskReasons), ...scenarioWhyNow(scenario)].slice(0, 8), + nextActions: safeStringArray(decision.nextActions), + publicNextActions: safeStringArray(decision.publicNextActions), + scenario, + }); +} + +function portfolioItem(args: { + bucket: ActionPortfolioBucketName; + actionKind?: DecisionActionKind | undefined; + decision: RepoDecision; + priorityScore: number; + whyNow: string[]; + nextActions: string[]; + publicNextActions: string[]; + scenario?: ContributorOpenPrMonitor["pendingScenarios"][number]["detection"] | undefined; +}): ActionPortfolioItem { + return { + bucket: args.bucket, + repoFullName: args.decision.repoFullName, + actionKind: args.actionKind, + priorityScore: args.priorityScore, + recommendation: args.decision.recommendation, + status: portfolioStatusFor(args.decision, args.bucket), + whyNow: args.whyNow.length > 0 ? args.whyNow : [`${args.decision.repoFullName}: current decision-pack signals place this repo in ${args.bucket}.`], + scoreabilityImpact: portfolioScoreabilityImpact(args.decision, args.bucket), + riskImpact: safeStringArray(args.decision.riskReasons)[0] ?? "No major repo-specific risk is visible in the current decision pack.", + maintainerImpact: portfolioMaintainerImpact(args.decision, args.bucket), + blockedBy: safeScoreBlockers(args.decision).map((blocker) => blocker.code), + rerunWhen: portfolioRerunWhen(args.decision, args.bucket), + publicSafeSummary: sanitizePortfolioPublicSummary(args.publicNextActions[0] ?? `${args.decision.repoFullName}: Use Gittensory preflight before posting public PR context.`), + nextActions: args.nextActions, + publicNextActions: args.publicNextActions.map(sanitizePortfolioPublicSummary), + source: "decision_pack", + scenarioProjection: args.scenario ? portfolioScenarioProjection(args.scenario) : undefined, + }; +} + +function bucketForAction(actionEntry: DecisionAction): ActionPortfolioBucketName { + if (actionEntry.actionKind === "cleanup_existing_prs") return "cleanup"; + if (actionEntry.actionKind === "land_existing_prs") return "wait"; + if (actionEntry.actionKind === "file_issue_discovery") return "issue_discovery"; + if (actionEntry.actionKind === "maintainer_lane_improve_repo" || actionEntry.actionKind === "maintainer_cut_readiness") return "maintainer_lane"; + return "direct_pr"; +} + +function portfolioStatusFor(decision: RepoDecision, bucket: ActionPortfolioBucketName): ActionPortfolioItem["status"] { + if (bucket === "avoid" || bucket === "wait") return "watch"; + if (safeScoreBlockers(decision).some((blocker) => blocker.severity === "critical")) return "blocked"; + return "recommended"; +} + +function portfolioScoreabilityImpact(decision: RepoDecision, bucket: ActionPortfolioBucketName): string { + if (bucket === "cleanup") return "Resolving open PR pressure can unblock scoreability before opening new work."; + if (bucket === "wait") return "Wait for current PR outcomes or close stale work before adding more queue pressure."; + if (bucket === "issue_discovery") return "Direct PR scoreability is not the target; issue-discovery evidence is the useful lane."; + if (bucket === "maintainer_lane") return "Maintainer-lane work is separated from outside-contributor scoreability evidence."; + const blockers = safeScoreBlockers(decision); + if (blockers.length > 0) return `Blocked by ${blockers.map((blocker) => blocker.code).join(", ")}.`; + return `Lane fit: ${decision.lane?.lane ?? "unknown"}; direct PR share ${decision.rewardUpside?.directPrShare ?? 0}.`; +} + +function portfolioMaintainerImpact(decision: RepoDecision, bucket: ActionPortfolioBucketName): string { + if (bucket === "cleanup") return "Cleanup lowers active-review pressure before adding more queue load."; + if (bucket === "wait") return "Waiting on merge-ready or stale PR outcomes avoids noisy parallel work."; + if (bucket === "maintainer_lane") return "Repo-owner work should improve intake quality and contributor routing."; + if (bucket === "avoid") return "Avoiding this repo keeps maintainer attention away from low-fit or blocked submissions."; + return "Narrow, validated work with clear lane fit is easier to review."; +} + +function portfolioRerunWhen(decision: RepoDecision, bucket: ActionPortfolioBucketName): string { + if (bucket === "cleanup" || bucket === "wait") return "Rerun after open PRs merge, close, or are withdrawn."; + if (safeScoreBlockers(decision).length > 0) return "Rerun after the listed scoreability blockers change."; + return "Rerun before opening a PR or when repo queue/registry signals change."; +} + +function scenarioWhyNow(scenario: ContributorOpenPrMonitor["pendingScenarios"][number]["detection"] | undefined): string[] { + if (!scenario) return []; + return scenario.scenarioNotes.slice(0, 2).map((note) => `Scenario projection: ${note}`); +} + +function portfolioScenarioProjection( + scenario: ContributorOpenPrMonitor["pendingScenarios"][number]["detection"], +): ActionPortfolioScenarioProjection { + return { + source: scenario.source, + pendingMergedPrCount: scenario.pendingMergedPrCount, + pendingClosedPrCount: scenario.pendingClosedPrCount, + approvedPrCount: scenario.approvedPrCount, + ...(scenario.expectedOpenPrCountAfterMerge !== undefined ? { expectedOpenPrCountAfterMerge: scenario.expectedOpenPrCountAfterMerge } : {}), + notes: scenario.scenarioNotes.slice(0, 4), + }; +} + +function portfolioBucketLabel(bucket: ActionPortfolioBucketName): string { + if (bucket === "cleanup") return "Cleanup first"; + if (bucket === "wait") return "Wait or land existing work"; + if (bucket === "direct_pr") return "Direct PR opportunities"; + if (bucket === "issue_discovery") return "Issue discovery"; + if (bucket === "avoid") return "Avoid for now"; + return "Maintainer lane"; +} + +function portfolioBucketSummary(bucket: ActionPortfolioBucketName, actions: ActionPortfolioItem[]): string { + if (actions.length === 0) return `No ${portfolioBucketLabel(bucket).toLowerCase()} actions are currently recommended.`; + const topRepo = actions[0]?.repoFullName ?? "repo"; + if (bucket === "cleanup") return `${actions.length} cleanup action(s), led by ${topRepo}.`; + if (bucket === "wait") return `${actions.length} wait/land action(s), led by ${topRepo}.`; + if (bucket === "direct_pr") return `${actions.length} direct-PR action(s), led by ${topRepo}.`; + if (bucket === "issue_discovery") return `${actions.length} issue-discovery action(s), led by ${topRepo}.`; + if (bucket === "avoid") return `${actions.length} repo(s) should be avoided for now, led by ${topRepo}.`; + return `${actions.length} maintainer-lane action(s), led by ${topRepo}.`; +} + +function dedupePortfolioItems(items: ActionPortfolioItem[]): ActionPortfolioItem[] { + const seen = new Set(); + const deduped: ActionPortfolioItem[] = []; + for (const item of items) { + const key = `${item.bucket}:${item.repoFullName.toLowerCase()}:${item.actionKind ?? item.recommendation}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(item); + } + return deduped; +} + +function comparePortfolioItems(left: ActionPortfolioItem, right: ActionPortfolioItem): number { + return ( + ACTION_PORTFOLIO_BUCKET_ORDER.indexOf(left.bucket) - ACTION_PORTFOLIO_BUCKET_ORDER.indexOf(right.bucket) || + right.priorityScore - left.priorityScore || + left.repoFullName.localeCompare(right.repoFullName) || + (left.actionKind ?? "").localeCompare(right.actionKind ?? "") + ); +} + +function isPortfolioDecision(value: RepoDecision): boolean { + return typeof value.repoFullName === "string" && typeof value.recommendation === "string"; +} + +function safeStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} + +function safeScoreBlockers(decision: RepoDecision): ScoreBlocker[] { + return Array.isArray(decision.scoreBlockers) ? decision.scoreBlockers : []; +} + +function sanitizePortfolioPublicSummary(value: string): string { + return value + .replace(/\b(reward|payout|farming|estimated score|public score estimate|raw trust score|trust score|scoreability|wallet|hotkey|coldkey|private reviewability)\b/gi, "private signal") + .replace(/\s+/g, " ") + .trim(); +} + type RepoCopyContext = { repoFullName: string; lane: string; @@ -897,6 +1181,14 @@ function withSnapshotMetadata(snapshot: SignalSnapshotRecord): ContributorDecisi const generatedAt = snapshot.generatedAt ?? payload.generatedAt ?? nowIso(); const ageSeconds = Math.max(0, Math.floor(snapshotAgeMs(generatedAt) / 1000)); const stale = snapshotAgeMs(generatedAt) > DECISION_PACK_MAX_AGE_MS; + const actionPortfolio = + (payload as Partial).actionPortfolio ?? + buildActionPortfolio({ + generatedAt, + repoDecisions: payload.repoDecisions ?? [], + topActions: payload.topActions ?? [], + openPrMonitor: payload.openPrMonitor, + }); return { ...payload, status: "ready", @@ -907,6 +1199,7 @@ function withSnapshotMetadata(snapshot: SignalSnapshotRecord): ContributorDecisi freshness: stale ? "stale" : "fresh", rebuildEnqueued: false, opportunities: payload.opportunities ?? [], + actionPortfolio, }; } @@ -944,6 +1237,7 @@ export const __decisionPackInternals = { recommendationFor, priorityFor, actionsForDecision, + buildActionPortfolio, whyThisHelpsFor, nextActionsFor, publicNextActionsFor, diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index b833fde21f..0d787cef5c 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -596,11 +596,17 @@ describe("api routes", () => { profile: { github: { topLanguages: string[] }; officialStats?: Record | null }; outcomeHistory: { totals: Record }; topActions: unknown[]; + actionPortfolio: { bucketOrder: string[]; buckets: unknown[]; topActions: unknown[] }; }; expect(builtDecisionPayload.profile.github.topLanguages).toEqual(["TypeScript", "Python"]); expect(builtDecisionPayload.profile.officialStats).not.toHaveProperty("hotkey"); expect(builtDecisionPayload.outcomeHistory.totals).toMatchObject({ pullRequests: 2, mergedPullRequests: 1, openPullRequests: 1 }); expect(builtDecisionPayload.topActions.length).toBeGreaterThan(0); + expect(builtDecisionPayload.actionPortfolio).toMatchObject({ + bucketOrder: ["cleanup", "wait", "direct_pr", "issue_discovery", "avoid", "maintainer_lane"], + buckets: expect.any(Array), + topActions: expect.any(Array), + }); const decisionPack = await app.request("/v1/contributors/oktofeesh1/decision-pack", { headers: apiHeaders(env) }, env); expect(decisionPack.status).toBe(200); @@ -626,10 +632,18 @@ describe("api routes", () => { ); expect(agentPlan.status).toBe(200); const agentPlanPayload = (await agentPlan.json()) as { - run: { id: string; status: string; mode: string; surface: string }; + run: { id: string; status: string; mode: string; surface: string; payload: Record }; actions: Array<{ actionType: string; publicSafeSummary: string; payload: Record }>; + contextSnapshots: Array<{ payload: Record }>; }; expect(agentPlanPayload.run).toMatchObject({ status: "completed", mode: "copilot", surface: "api" }); + expect(agentPlanPayload.run.payload.actionPortfolio).toMatchObject({ + bucketOrder: ["cleanup", "wait", "direct_pr", "issue_discovery", "avoid", "maintainer_lane"], + buckets: expect.any(Array), + }); + expect(agentPlanPayload.contextSnapshots[0]?.payload.actionPortfolio).toMatchObject({ + buckets: expect.arrayContaining([expect.objectContaining({ bucket: expect.any(String), actions: expect.any(Array) })]), + }); expect(agentPlanPayload.actions.length).toBeGreaterThan(0); expect(agentPlanPayload.actions[0]?.publicSafeSummary).not.toMatch(/wallet|hotkey|reward estimate|payout|farming|raw trust score/i); expect(agentPlanPayload.actions[0]?.payload).toHaveProperty("decision"); diff --git a/test/unit/agent-orchestrator.test.ts b/test/unit/agent-orchestrator.test.ts index 46f4be873c..06fadfca20 100644 --- a/test/unit/agent-orchestrator.test.ts +++ b/test/unit/agent-orchestrator.test.ts @@ -767,6 +767,14 @@ describe("agent orchestrator", () => { 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(plan.run.payload.actionPortfolio).toMatchObject({ + bucketOrder: ["cleanup", "wait", "direct_pr", "issue_discovery", "avoid", "maintainer_lane"], + buckets: expect.arrayContaining([ + expect.objectContaining({ bucket: "direct_pr", actions: expect.arrayContaining([expect.objectContaining({ repoFullName: "touchpilot/touchpilot" })]) }), + expect.objectContaining({ bucket: "issue_discovery", actions: expect.arrayContaining([expect.objectContaining({ repoFullName: "entrius/allways" })]) }), + ]), + }); + expect(plan.contextSnapshots[0]?.payload.actionPortfolio).toMatchObject({ summary: expect.stringContaining("Scoped portfolio") }); expect(noBlockers.actions[0]).toMatchObject({ status: "ready", scoreabilityImpact: "Current signals do not show a hard scoreability gate.", diff --git a/test/unit/decision-pack.test.ts b/test/unit/decision-pack.test.ts index f9d3825f18..129c9cef18 100644 --- a/test/unit/decision-pack.test.ts +++ b/test/unit/decision-pack.test.ts @@ -616,6 +616,19 @@ describe("decision-pack service", () => { expect(pack.pursueRepos.map((decision) => decision.repoFullName)).toContain("owner/pursue"); expect(pack.avoidRepos.map((decision) => decision.repoFullName)).toEqual(expect.arrayContaining(["owner/inactive", "owner/unconfigured"])); expect(pack.topActions.map((action) => action.actionKind)).toEqual(expect.arrayContaining(["maintainer_lane_improve_repo", "cleanup_existing_prs", "open_new_direct_pr", "file_issue_discovery"])); + expect(pack.actionPortfolio.bucketOrder).toEqual(["cleanup", "wait", "direct_pr", "issue_discovery", "avoid", "maintainer_lane"]); + const portfolioBuckets = new Map(pack.actionPortfolio.buckets.map((bucket) => [bucket.bucket, bucket.actions])); + expect(portfolioBuckets.get("cleanup")).toEqual([expect.objectContaining({ repoFullName: "owner/cleanup", actionKind: "cleanup_existing_prs" })]); + expect(portfolioBuckets.get("wait")).toEqual([expect.objectContaining({ repoFullName: "owner/cleanup", actionKind: "land_existing_prs", status: "watch" })]); + expect(portfolioBuckets.get("direct_pr")).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "owner/pursue", actionKind: "open_new_direct_pr" })])); + expect(portfolioBuckets.get("issue_discovery")).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "owner/issues", actionKind: "file_issue_discovery" })])); + expect(portfolioBuckets.get("avoid")).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "owner/inactive", recommendation: "avoid_for_now" })])); + expect(portfolioBuckets.get("maintainer_lane")).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "jsonbored/owned", actionKind: "maintainer_lane_improve_repo" })])); + expect(pack.actionPortfolio.topActions[0]).toMatchObject({ bucket: "cleanup", repoFullName: "owner/cleanup" }); + expect(portfolioBuckets.get("issue_discovery")?.[0]?.scoreabilityImpact).toMatch(/Direct PR scoreability is not the target/); + expect(JSON.stringify(pack.actionPortfolio.buckets.map((bucket) => bucket.actions.map((entry) => entry.publicSafeSummary)))).not.toMatch( + /wallet|hotkey|raw trust score|payout|reward estimate|farming|private reviewability|public score estimate|scoreability/i, + ); expect(pack.roleContexts.map((role) => role.repoFullName)).not.toContain("owner/unconfigured"); expect(pack.opportunities).toEqual([expect.objectContaining({ repoFullName: "owner/pursue", issueNumber: 7, fit: "good" })]); expect(pack.nextActions.length).toBeGreaterThan(0); @@ -631,7 +644,20 @@ describe("decision-pack service", () => { cleanupFirst: true, summary: "One open PR needs attention on owner/cleanup.", guidance: ["Close or land owner/cleanup#42 before opening new direct PR work."], - pendingScenarios: [], + pendingScenarios: [ + { + repoFullName: "owner/cleanup", + detection: { + source: "github_observed" as const, + pendingMergedPrCount: 1, + pendingClosedPrCount: 0, + approvedPrCount: 1, + expectedOpenPrCountAfterMerge: 5, + scenarioNotes: ["1 open PR looks merge-ready after review.", "Expected open PR pressure drops after merge."], + classified: [], + }, + }, + ], pullRequests: [ { repoFullName: "owner/cleanup", @@ -681,6 +707,12 @@ describe("decision-pack service", () => { expect(pack.summary).toContain("One open PR needs attention"); expect(pack.nextActions[0]).toMatch(/owner\/cleanup#42/); expect(pack.openPrMonitor).toEqual(monitor); + const cleanupPortfolioItem = pack.actionPortfolio.buckets.find((bucket) => bucket.bucket === "cleanup")?.actions[0]; + expect(cleanupPortfolioItem).toMatchObject({ + repoFullName: "owner/cleanup", + scenarioProjection: { source: "github_observed", pendingMergedPrCount: 1, expectedOpenPrCountAfterMerge: 5 }, + }); + expect(cleanupPortfolioItem?.whyNow.join(" ")).toMatch(/Scenario projection/); }); it("issues repo-specific direct-PR reasoning that names language and label fit", () => { @@ -1055,10 +1087,127 @@ describe("decision-pack service", () => { signalType: "contributor-decision-pack", targetKey: "user", generatedAt: null, - payload: { status: "ready", source: "computed", login: "user", repoDecisions: [], topActions: [] } as any, + payload: { status: "ready", source: "computed", login: "user", repoDecisions: undefined, topActions: undefined } as any, }); expect(typeof wrapped.generatedAt).toBe("string"); expect(wrapped.generatedAt.length).toBeGreaterThan(0); + expect(wrapped.actionPortfolio.topActions).toEqual([]); + }); + + it("builds action portfolios from sparse legacy actions with stable tie-breaks", () => { + const directDecision = (repoFullName: string): RepoDecision => + ({ + repoFullName, + recommendation: "pursue", + priorityScore: 30, + lane: { lane: "direct_pr" }, + whyThisHelps: [], + riskReasons: [], + nextActions: ["Open a narrow PR."], + publicNextActions: ["Run public preflight before posting."], + scoreBlockers: [], + rewardUpside: { directPrShare: 0.03, issueDiscoveryShare: 0, emissionShare: 0.02 }, + }) as any; + const maintainerDecision = { + ...directDecision("owner/maintainer"), + recommendation: "maintainer_lane", + priorityScore: 20, + scoreBlockers: [{ code: "maintainer_lane", severity: "info" }], + } as RepoDecision; + const portfolio = __decisionPackInternals.buildActionPortfolio({ + generatedAt: "2026-05-25T00:00:00.000Z", + repoDecisions: [ + directDecision("owner/beta"), + directDecision("owner/alpha"), + maintainerDecision, + { recommendation: "pursue", priorityScore: 5 } as RepoDecision, + ], + topActions: [ + { actionKind: "open_new_direct_pr", repoFullName: "owner/beta", priorityScore: 30, recommendation: "pursue", whyThisHelps: [], nextActions: [], publicNextActions: [] }, + { actionKind: "open_new_direct_pr", repoFullName: "owner/alpha", priorityScore: 30, recommendation: "pursue", whyThisHelps: [], nextActions: [], publicNextActions: [] }, + { actionKind: "maintainer_lane_improve_repo", repoFullName: "owner/maintainer", priorityScore: 20, recommendation: "maintainer_lane", whyThisHelps: [], nextActions: [], publicNextActions: ["Avoid reward payout language."] }, + { actionKind: "maintainer_cut_readiness", repoFullName: "owner/maintainer", priorityScore: 20, recommendation: "maintainer_lane", whyThisHelps: [], nextActions: [], publicNextActions: ["Prepare public intake notes."] }, + { actionKind: "maintainer_cut_readiness", repoFullName: "owner/maintainer", priorityScore: 20, recommendation: "maintainer_lane", whyThisHelps: [], nextActions: [], publicNextActions: ["Prepare public intake notes."] }, + { actionKind: "open_new_direct_pr", repoFullName: 42, priorityScore: 99, recommendation: "pursue", whyThisHelps: [], nextActions: [], publicNextActions: [] }, + ] as any, + openPrMonitor: { + pendingScenarios: [ + { + repoFullName: "owner/maintainer", + detection: { + source: "user_supplied", + pendingMergedPrCount: 0, + pendingClosedPrCount: 1, + approvedPrCount: 0, + scenarioNotes: ["Manual projection expects one PR to close."], + classified: [], + }, + }, + ], + } as any, + }); + const buckets = new Map(portfolio.buckets.map((bucket) => [bucket.bucket, bucket.actions])); + expect(buckets.get("direct_pr")?.map((action) => action.repoFullName)).toEqual(["owner/alpha", "owner/beta"]); + expect(buckets.get("maintainer_lane")?.map((action) => action.actionKind)).toEqual(["maintainer_cut_readiness", "maintainer_lane_improve_repo"]); + expect(buckets.get("maintainer_lane")).toHaveLength(2); + expect(buckets.get("maintainer_lane")?.[0]?.scenarioProjection).toMatchObject({ source: "user_supplied", pendingClosedPrCount: 1 }); + expect(buckets.get("maintainer_lane")?.[1]?.maintainerImpact).toMatch(/Repo-owner work/); + expect(JSON.stringify(buckets.get("maintainer_lane"))).not.toMatch(/reward payout/i); + }); + + it("builds safe portfolio fallbacks for empty and sparse action inputs", () => { + const emptyPortfolio = __decisionPackInternals.buildActionPortfolio({ + generatedAt: "2026-05-25T00:00:00.000Z", + repoDecisions: [{ priorityScore: 99 } as RepoDecision], + topActions: [{ actionKind: "open_new_direct_pr", repoFullName: "owner/missing", priorityScore: 99, recommendation: "pursue" } as any], + }); + expect(emptyPortfolio.summary).toBe("No portfolio actions are currently available from the decision pack."); + expect(emptyPortfolio.topActions).toEqual([]); + expect(emptyPortfolio.buckets.every((bucket) => bucket.actions.length === 0)).toBe(true); + expect(emptyPortfolio.buckets.find((bucket) => bucket.bucket === "direct_pr")?.summary).toBe("No direct pr opportunities actions are currently recommended."); + + const sparseDecision = { + repoFullName: "owner/sparse", + recommendation: "pursue", + priorityScore: 17, + lane: { lane: "direct_pr" }, + whyThisHelps: ["Decision-level reason."], + riskReasons: ["Queue is busy."], + nextActions: ["Use decision next action."], + publicNextActions: ["Use public preflight."], + scoreBlockers: [{ code: "open_pr_pressure", severity: "warning" }], + rewardUpside: { directPrShare: 0.02, issueDiscoveryShare: 0, emissionShare: 0.02 }, + } as RepoDecision; + + const portfolio = __decisionPackInternals.buildActionPortfolio({ + generatedAt: "2026-05-25T00:00:00.000Z", + repoDecisions: [sparseDecision], + topActions: [ + { + actionKind: "open_new_direct_pr", + repoFullName: "owner/sparse", + priorityScore: Number.NaN, + recommendation: "pursue", + whyThisHelps: undefined, + nextActions: undefined, + publicNextActions: undefined, + } as any, + ], + }); + + const action = portfolio.buckets.find((bucket) => bucket.bucket === "direct_pr")?.actions[0]; + expect(action).toMatchObject({ + repoFullName: "owner/sparse", + priorityScore: 17, + whyNow: ["Queue is busy."], + riskImpact: "Queue is busy.", + blockedBy: ["open_pr_pressure"], + rerunWhen: "Rerun after the listed scoreability blockers change.", + nextActions: ["Use decision next action."], + publicNextActions: ["Use public preflight."], + }); + expect(action?.scoreabilityImpact).toMatch(/Blocked by open_pr_pressure/); + expect(action?.publicSafeSummary).toBe("Use public preflight."); }); it("produces fully deterministic repoDecisions, priorityScores, and nextActions across builds", () => { @@ -1088,6 +1237,9 @@ describe("decision-pack service", () => { 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}`)); + expect(packA.actionPortfolio.buckets.map((bucket) => `${bucket.bucket}:${bucket.actions.map((action) => `${action.actionKind ?? action.recommendation}:${action.repoFullName}`).join(",")}`)).toEqual( + packB.actionPortfolio.buckets.map((bucket) => `${bucket.bucket}:${bucket.actions.map((action) => `${action.actionKind ?? action.recommendation}:${action.repoFullName}`).join(",")}`), + ); expect(packA.evidenceGraph?.repos.map((repo) => repo.repoFullName)).toEqual(packB.evidenceGraph?.repos.map((repo) => repo.repoFullName)); });