diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index fe665240b..09f603ab4 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -12777,6 +12777,164 @@ "weekly", "byProject" ] + }, + "ScoreScenarioExplanation": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "scoreabilityStatus": { + "type": "string", + "enum": [ + "blocked", + "conditionally_scoreable", + "scoreable", + "hold" + ] + }, + "effectiveEstimatedScore": { + "type": "number" + }, + "headline": { + "type": "string" + }, + "scenarios": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "current", + "cleanGates", + "afterPendingMerges", + "afterApprovedPrsMerge", + "afterStalePrsClose", + "linkedIssueFixed", + "bestReasonableCase" + ] + }, + "source": { + "type": "string", + "enum": [ + "current_data", + "user_supplied", + "github_observed", + "gittensory_projection" + ] + }, + "band": { + "type": "string", + "enum": [ + "blocked", + "conditionally_scoreable", + "scoreable", + "hold" + ] + }, + "rank": { + "type": "integer" + }, + "summary": { + "type": "string" + }, + "lever": { + "type": "string" + }, + "assumptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "unlockDelta": { + "type": "string" + }, + "leverageScore": { + "type": "number" + } + }, + "required": [ + "name", + "source", + "band", + "rank", + "summary", + "lever", + "assumptions", + "unlockDelta", + "leverageScore" + ] + } + }, + "gateDeltaNarratives": { + "type": "array", + "items": { + "type": "object", + "properties": { + "gate": { + "type": "string", + "enum": [ + "open_pr_threshold", + "open_issue_threshold", + "merged_pr_history_floor", + "issue_discovery_validity_floor", + "credibility_floor", + "linked_issue_multiplier" + ] + }, + "narrative": { + "type": "string" + }, + "lever": { + "type": "string" + } + }, + "required": [ + "gate", + "narrative", + "lever" + ] + } + }, + "recommendedPath": { + "type": "object", + "properties": { + "scenario": { + "type": "string" + }, + "lever": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "orderedLevers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "scenario", + "lever", + "reason", + "orderedLevers" + ] + } + }, + "required": [ + "repoFullName", + "scoreabilityStatus", + "effectiveEstimatedScore", + "headline", + "scenarios", + "gateDeltaNarratives", + "recommendedPath" + ] } }, "parameters": {}, @@ -15648,6 +15806,33 @@ } ] } + }, + "/v1/scoring/explain-scenarios": { + "post": { + "responses": { + "200": { + "description": "Private score scenario explanation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScoreScenarioExplanation" + } + } + } + }, + "400": { + "description": "Invalid scoring preview input" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 29d929090..77703be06 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -501,6 +501,50 @@ server.registerTool( }, ); +server.registerTool( + "gittensory_explain_score_scenarios", + { + description: "Explain private score-preview what-if scenarios and gate deltas with ranked cleanup paths.", + inputSchema: localScoreShape, + }, + async (input) => { + const workspaceInput = await withClientWorkspaceRoots(input); + const contributorLogin = workspaceInput.contributorLogin ?? activeProfile.session?.login; + if (!contributorLogin) throw new Error("contributorLogin is required for score scenario explanation."); + const workspace = resolveWorkspaceCwd(workspaceInput); + const diff = collectLocalDiff(workspace.cwd, workspaceInput.baseRef, workspaceInput.workspaceRoots); + const branchPayload = buildBranchAnalysisPayload({ + ...workspaceInput, + login: contributorLogin, + cwd: workspace.cwd, + repoFullName: workspaceInput.repoFullName, + baseRef: workspaceInput.baseRef, + }); + const upstreamPreview = branchPayload.localScorerStatus; + const estimatedSourceLines = workspaceInput.sourceLines ?? Math.max(1, diff.changedLineCount - diff.testFiles.length); + const body = { + repoFullName: workspaceInput.repoFullName, + contributorLogin, + sourceTokenScore: workspaceInput.sourceTokenScore ?? estimatedSourceLines, + sourceLines: estimatedSourceLines, + totalTokenScore: workspaceInput.totalTokenScore ?? diff.changedLineCount, + testTokenScore: diff.testFiles.length, + openPrCount: workspaceInput.openPrCount, + credibility: workspaceInput.credibility, + changesRequestedCount: workspaceInput.changesRequestedCount, + pendingMergedPrCount: workspaceInput.pendingMergedPrCount, + pendingClosedPrCount: workspaceInput.pendingClosedPrCount, + approvedPrCount: workspaceInput.approvedPrCount, + expectedOpenPrCountAfterMerge: workspaceInput.expectedOpenPrCountAfterMerge, + projectedCredibility: workspaceInput.projectedCredibility, + scenarioNotes: workspaceInput.scenarioNotes, + branchEligibility: workspaceInput.branchEligibility, + metadataOnly: !upstreamPreview.ok, + }; + return toolResult("Gittensory private score scenarios.", await apiPost("/v1/scoring/explain-scenarios", body)); + }, +); + server.registerTool( "gittensory_get_decision_pack", { diff --git a/src/api/routes.ts b/src/api/routes.ts index 7a73a07cb..333432200 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -149,6 +149,7 @@ import { buildRemediationPlan } from "../services/remediation-plan"; import { handleDraftCreate, handleDraftOAuthCallback, handleDraftStatus } from "../services/draft"; import { decidePendingAgentAction } from "../services/agent-approval-queue"; import { explainScoreBreakdown } from "../services/score-breakdown"; +import { explainScoreScenarios } from "../services/score-scenario-explain"; import { buildMcpClientTelemetry } from "../services/client-telemetry"; import { buildAndPersistContributorDecisionPack, @@ -1704,6 +1705,25 @@ export function createApp() { return c.json(explainScoreBreakdown(preview)); }); + app.post("/v1/scoring/explain-scenarios", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = scorePreviewSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_scoring_preview_request", issues: parsed.error.issues }, 400); + if (!parsed.data.contributorLogin) return c.json({ error: "contributor_login_required" }, 400); + const unauthorized = await requireContributorAccess(c, parsed.data.contributorLogin); + if (unauthorized) return unauthorized; + const [repo, snapshot, evidence, contributorIssues] = await Promise.all([ + getRepository(c.env, parsed.data.repoFullName), + getOrCreateScoringModelSnapshot(c.env), + getContributorEvidence(c.env, parsed.data.contributorLogin), + listContributorIssues(c.env, parsed.data.contributorLogin), + ]); + const openIssueCount = contributorOpenIssueCount(contributorIssues, parsed.data.repoFullName); + const input = { ...parsed.data, openIssueCount, applyTimeDecay: isTimeDecayEnabled(c.env) }; + const preview = buildScorePreview({ input, repo, snapshot, contributorEvidence: evidence }); + return c.json(explainScoreScenarios(preview)); + }); + app.get("/v1/sync/status", async (c) => { const [snapshot, scoringSnapshot, repositories, segments, totals, detailStates, installations, rateLimits, signalSnapshots, bounties, upstreamDrift] = await Promise.all([ getLatestRegistrySnapshot(c.env), diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2330a13d1..ee25d2d61 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -65,6 +65,7 @@ import { loadContributorDecisionPackForServing, repoDecisionFromPack } from "../ import { buildPublicPrBodyDraft } from "../services/pr-body-draft"; import { buildRemediationPlan } from "../services/remediation-plan"; import { explainScoreBreakdown } from "../services/score-breakdown"; +import { explainScoreScenarios } from "../services/score-scenario-explain"; import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; import { buildMcpClientTelemetry } from "../services/client-telemetry"; @@ -876,6 +877,16 @@ const scoreBreakdownOutputSchema = { highestLeverageLever: z.unknown().optional(), }; +const scoreScenarioExplanationOutputSchema = { + repoFullName: z.string().optional(), + scoreabilityStatus: z.string().optional(), + effectiveEstimatedScore: z.number().optional(), + headline: z.string().optional(), + scenarios: z.unknown().optional(), + gateDeltaNarratives: z.unknown().optional(), + recommendedPath: z.unknown().optional(), +}; + const lintPrTextOutputSchema = { verdict: z.string().optional(), score: z.number().optional(), @@ -1501,6 +1512,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.explainScoreBreakdown(input)), ); + server.registerTool( + "gittensory_explain_score_scenarios", + { + description: + "Explain private score-preview what-if scenarios and gate deltas with ranked cleanup paths. Login and repo scoped; no new computation beyond the preview projection.", + inputSchema: scorePreviewShape, + outputSchema: scoreScenarioExplanationOutputSchema, + }, + async (input) => this.toolResult(await this.explainScoreScenarios(input)), + ); + server.registerTool( "gittensory_explain_review_risk", { @@ -2569,6 +2591,26 @@ export class GittensoryMcp { }; } + private async explainScoreScenarios(input: z.infer>): Promise { + if (!input.contributorLogin) throw new Error("contributorLogin is required for score scenario explanation."); + this.requireContributorAccess(input.contributorLogin); + await this.requireRepoAccess(input.repoFullName); + const [repo, snapshot, evidence, contributorIssues] = await Promise.all([ + getRepository(this.env, input.repoFullName), + getOrCreateScoringModelSnapshot(this.env), + getContributorEvidence(this.env, input.contributorLogin), + listContributorIssues(this.env, input.contributorLogin), + ]); + const openIssueCount = contributorOpenIssueCount(contributorIssues, input.repoFullName); + const scoreInput = { ...input, openIssueCount, applyTimeDecay: isTimeDecayEnabled(this.env) }; + const preview = buildScorePreview({ input: scoreInput, repo, snapshot, contributorEvidence: evidence }); + const explanation = explainScoreScenarios(preview); + return { + summary: `Private Gittensory score scenarios for ${input.contributorLogin} in ${input.repoFullName}. Recommended path: ${explanation.recommendedPath.scenario}.`, + data: explanation as unknown as Record, + }; + } + private async explainReviewRisk(input: z.infer>): Promise { if (input.contributorLogin) this.requireContributorAccess(input.contributorLogin); await this.requireRepoAccess(input.repoFullName); diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 356566f9a..a62fc68c0 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1407,6 +1407,48 @@ export const ScorePreviewSchema = z }) .openapi("ScorePreview"); +export const ScoreScenarioExplanationSchema = z + .object({ + repoFullName: z.string(), + scoreabilityStatus: z.enum(["blocked", "conditionally_scoreable", "scoreable", "hold"]), + effectiveEstimatedScore: z.number(), + headline: z.string(), + scenarios: z.array( + z.object({ + name: z.enum(["current", "cleanGates", "afterPendingMerges", "afterApprovedPrsMerge", "afterStalePrsClose", "linkedIssueFixed", "bestReasonableCase"]), + source: z.enum(["current_data", "user_supplied", "github_observed", "gittensory_projection"]), + band: z.enum(["blocked", "conditionally_scoreable", "scoreable", "hold"]), + rank: z.number().int(), + summary: z.string(), + lever: z.string(), + assumptions: z.array(z.string()), + unlockDelta: z.string(), + leverageScore: z.number(), + }), + ), + gateDeltaNarratives: z.array( + z.object({ + gate: z.enum([ + "open_pr_threshold", + "open_issue_threshold", + "merged_pr_history_floor", + "issue_discovery_validity_floor", + "credibility_floor", + "linked_issue_multiplier", + ]), + narrative: z.string(), + lever: z.string(), + }), + ), + recommendedPath: z.object({ + scenario: z.string(), + lever: z.string(), + reason: z.string(), + orderedLevers: z.array(z.string()), + }), + }) + .openapi("ScoreScenarioExplanation"); + export const IssueQualityReportSchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 80e2dc6a2..4e0f4804b 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -68,6 +68,7 @@ import { RoleContextSchema, RewardRiskActionSchema, ScorePreviewSchema, + ScoreScenarioExplanationSchema, ScoringModelSnapshotSchema, SignalFidelitySchema, SkippedPrAuditExportSchema, @@ -144,6 +145,7 @@ export function buildOpenApiSpec() { registry.register("LaneAdvice", LaneAdviceSchema); registry.register("ScoringModelSnapshot", ScoringModelSnapshotSchema); registry.register("ScorePreview", ScorePreviewSchema); + registry.register("ScoreScenarioExplanation", ScoreScenarioExplanationSchema); registry.register("IssueQualityReport", IssueQualityReportSchema); registry.register("IssueQualityResponse", IssueQualityResponseSchema); registry.register("BurdenForecast", BurdenForecastSchema); @@ -250,6 +252,14 @@ export function buildOpenApiSpec() { 400: { description: "Invalid scoring preview input" }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/scoring/explain-scenarios", + responses: { + 200: { description: "Private score scenario explanation", content: { "application/json": { schema: ScoreScenarioExplanationSchema } } }, + 400: { description: "Invalid scoring preview input" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/sync/status", diff --git a/src/services/score-scenario-explain.ts b/src/services/score-scenario-explain.ts new file mode 100644 index 000000000..90f8bb015 --- /dev/null +++ b/src/services/score-scenario-explain.ts @@ -0,0 +1,213 @@ +import { sanitizePublicComment } from "../github/commands"; +import type { ScoreGateDelta, ScorePreviewResult, ScoreScenarioPreview } from "../scoring/preview"; + +export type ScenarioScoreabilityBand = "blocked" | "conditionally_scoreable" | "scoreable" | "hold"; + +export type ScenarioCard = { + name: ScoreScenarioPreview["name"]; + source: ScoreScenarioPreview["source"]; + band: ScenarioScoreabilityBand; + rank: number; + summary: string; + lever: string; + assumptions: string[]; + unlockDelta: string; + leverageScore: number; +}; + +export type GateDeltaNarrative = { + gate: ScoreGateDelta["gate"]; + narrative: string; + lever: string; +}; + +export type ScoreScenarioExplanation = { + repoFullName: string; + scoreabilityStatus: ScorePreviewResult["scoreabilityStatus"]; + effectiveEstimatedScore: number; + headline: string; + scenarios: ScenarioCard[]; + gateDeltaNarratives: GateDeltaNarrative[]; + recommendedPath: { + scenario: string; + lever: string; + reason: string; + orderedLevers: string[]; + }; +}; + +const SCENARIO_LABELS: Record = { + current: "Current state", + cleanGates: "Gates cleared", + afterPendingMerges: "After pending merges land", + afterApprovedPrsMerge: "After approved PRs merge", + afterStalePrsClose: "After stale PRs close", + linkedIssueFixed: "Linked issue validated", + bestReasonableCase: "Best reasonable case", +}; + +const SCENARIO_LEVERS: Record = { + current: "Review current blockers before opening more work.", + cleanGates: "Clear open-PR, open-issue, credibility, and history gates before relying on full-strength previews.", + afterPendingMerges: "Land or close pending merged/closed PRs to relieve open-PR pressure.", + afterApprovedPrsMerge: "Merge approved open PRs already observed in cached GitHub state.", + afterStalePrsClose: "Close stale open PRs before opening more concurrent contributions.", + linkedIssueFixed: "Validate linked issue context with solved-by-PR evidence or refresh mirror metadata.", + bestReasonableCase: "Combine the most plausible gate cleanups that remain achievable without speculative assumptions.", +}; + +const GATE_DELTA_LEVERS: Record = { + open_pr_threshold: "Land, merge, or close excess open PRs to move this gate.", + open_issue_threshold: "Close excess open issues to drop back within the spam threshold.", + merged_pr_history_floor: "Build more merged PR history in this repo.", + issue_discovery_validity_floor: "Improve valid solved-issue history or issue credibility.", + credibility_floor: "Build cleaner merged history before relying on full-strength previews.", + linked_issue_multiplier: "Validate linked issue eligibility or remove invalid assumptions.", +}; + +function bandForScenario(scenario: ScoreScenarioPreview, currentScore: number): ScenarioScoreabilityBand { + if (scenario.blockedBy.some((blocker) => blocker.code === "inactive_allocation")) return "hold"; + const hasBlocker = scenario.blockedBy.some((blocker) => blocker.severity === "blocker"); + if (scenario.effectiveEstimatedScore > 0 && !hasBlocker) return "scoreable"; + if (scenario.name !== "current" && scenario.effectiveEstimatedScore > currentScore) return "conditionally_scoreable"; + return "blocked"; +} + +function leverageScoreForScenario(scenario: ScoreScenarioPreview, currentScore: number): number { + if (scenario.name === "current") return 0; + const delta = scenario.effectiveEstimatedScore - currentScore; + if (delta <= 0) return 0; + const band = bandForScenario(scenario, currentScore); + let score = Math.round(delta * 10); + if (band === "scoreable") score += 100; + else if (band === "conditionally_scoreable") score += 50; + if (scenario.source === "github_observed") score += 25; + else if (scenario.source === "user_supplied") score += 15; + return score; +} + +function unlockDeltaText(scenario: ScoreScenarioPreview, currentScore: number): string { + const delta = scenario.effectiveEstimatedScore - currentScore; + if (scenario.name === "current" || delta === 0) { + return "No projected change versus the current preview."; + } + if (delta > 0) { + return "Projected preview strength improves versus current (higher effective estimate)."; + } + return "Projected preview strength does not improve versus current."; +} + +function scenarioSummary(scenario: ScoreScenarioPreview, band: ScenarioScoreabilityBand): string { + const label = SCENARIO_LABELS[scenario.name]; + if (band === "scoreable") { + return `${label} clears blocking gates and reaches a scoreable preview under stated assumptions.`; + } + if (band === "conditionally_scoreable") { + return `${label} improves the preview versus current state but may still leave reducers or context blockers.`; + } + if (band === "hold") { + return `${label} cannot be evaluated while repo allocation or registration is inactive.`; + } + const blockerCodes = scenario.blockedBy.filter((blocker) => blocker.severity === "blocker").map((blocker) => blocker.code); + return blockerCodes.length > 0 + ? `${label} remains blocked by ${blockerCodes.join(", ")} under stated assumptions.` + : `${label} remains blocked or reduced under stated assumptions.`; +} + +function buildScenarioCards(preview: ScorePreviewResult): ScenarioCard[] { + const currentScore = preview.effectiveEstimatedScore; + const ranked = preview.scenarioPreviews + .filter((scenario) => scenario.name !== "current") + .map((scenario) => { + const band = bandForScenario(scenario, currentScore); + const leverageScore = leverageScoreForScenario(scenario, currentScore); + return { + name: scenario.name, + source: scenario.source, + band, + rank: 0, + summary: scenarioSummary(scenario, band), + lever: SCENARIO_LEVERS[scenario.name], + assumptions: scenario.assumptions, + unlockDelta: unlockDeltaText(scenario, currentScore), + leverageScore, + }; + }) + .sort((left, right) => right.leverageScore - left.leverageScore || left.name.localeCompare(right.name)) + .map((card, index) => ({ ...card, rank: index + 1 })); + + return ranked.map((card) => ({ + ...card, + summary: sanitizePublicComment(card.summary), + lever: sanitizePublicComment(card.lever), + assumptions: card.assumptions.map((assumption) => sanitizePublicComment(assumption)), + unlockDelta: sanitizePublicComment(card.unlockDelta), + })); +} + +function gateDeltaNarrativesFor(preview: ScorePreviewResult): GateDeltaNarrative[] { + return preview.gateDeltas.map((delta) => ({ + gate: delta.gate, + narrative: sanitizePublicComment(`${delta.explanation} Current: ${delta.current}. Projected: ${delta.projected}.`), + lever: sanitizePublicComment(GATE_DELTA_LEVERS[delta.gate] ?? "Review this gate before proceeding."), + })); +} + +function headlineFor(preview: ScorePreviewResult, scenarios: ScenarioCard[]): string { + if (preview.scoreabilityStatus === "scoreable") { + return sanitizePublicComment("Current preview is scoreable; scenario cards show optional cleanup paths if concurrent pressure rises."); + } + if (preview.scoreabilityStatus === "hold") { + return sanitizePublicComment("Preview is on hold until repo registration or allocation is active."); + } + const hasUnlock = scenarios.some((card) => card.leverageScore > 0); + if (!hasUnlock) { + return sanitizePublicComment("No scenario path improves the current preview; address current blockers directly."); + } + if (preview.scoreabilityStatus === "conditionally_scoreable") { + return sanitizePublicComment("Current preview is conditionally scoreable; ranked scenarios show which cleanup path unlocks the most headroom."); + } + return sanitizePublicComment("Current preview is blocked; ranked scenarios show the highest-leverage cleanup sequence."); +} + +function recommendedPathFor(preview: ScorePreviewResult, scenarios: ScenarioCard[]): ScoreScenarioExplanation["recommendedPath"] { + const top = scenarios.find((card) => card.leverageScore > 0); + if (!top) { + return { + scenario: "current", + lever: sanitizePublicComment(SCENARIO_LEVERS.current), + reason: sanitizePublicComment("No non-current scenario improves the current preview under supplied assumptions."), + orderedLevers: [sanitizePublicComment(SCENARIO_LEVERS.current)], + }; + } + const orderedLevers = scenarios.filter((card) => card.leverageScore > 0).map((card) => card.lever); + const reason = + top.band === "scoreable" + ? `${top.name} is the strongest unlock path because it clears blocking gates under ${top.source.replace(/_/g, " ")} assumptions.` + : top.band === "conditionally_scoreable" + ? `${top.name} is the largest improvement lever even though reducers or context blockers may remain.` + : `${top.name} is the best ranked scenario, but blocking gates may still apply after this cleanup.`; + return { + scenario: top.name, + lever: top.lever, + reason: sanitizePublicComment(reason), + orderedLevers, + }; +} + +/** + * Pure projection over a {@link ScorePreviewResult} that explains what-if scenario previews + * and gate deltas in plain language and ranks the highest-leverage cleanup path. + */ +export function explainScoreScenarios(preview: ScorePreviewResult): ScoreScenarioExplanation { + const scenarios = buildScenarioCards(preview); + return { + repoFullName: preview.repoFullName, + scoreabilityStatus: preview.scoreabilityStatus, + effectiveEstimatedScore: preview.effectiveEstimatedScore, + headline: headlineFor(preview, scenarios), + scenarios, + gateDeltaNarratives: gateDeltaNarrativesFor(preview), + recommendedPath: recommendedPathFor(preview, scenarios), + }; +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 7ee663644..5b261ccf8 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1698,6 +1698,27 @@ describe("api routes", () => { expect(missingContributorBreakdown.status).toBe(400); await expect(missingContributorBreakdown.json()).resolves.toMatchObject({ error: "contributor_login_required" }); + const scoreScenarios = await app.request( + "/v1/scoring/explain-scenarios", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify(agedScoreInput), + }, + env, + ); + expect(scoreScenarios.status).toBe(200); + const scoreScenariosBody = (await scoreScenarios.json()) as { + repoFullName: string; + scenarios: Array<{ name: string; lever: string }>; + recommendedPath: { scenario: string; lever: string }; + }; + expect(scoreScenariosBody).toMatchObject({ + repoFullName: "entrius/allways-ui", + scenarios: expect.arrayContaining([expect.objectContaining({ name: expect.any(String), lever: expect.any(String) })]), + recommendedPath: expect.objectContaining({ scenario: expect.any(String), lever: expect.any(String) }), + }); + for (const [signalType, payload] of [ ["queue-health", { repoFullName: "entrius/allways-ui", signals: { openPullRequests: 2 } }], ["config-quality", { repoFullName: "entrius/allways-ui", notObservedConfiguredLabels: ["refactor"] }], @@ -4911,6 +4932,7 @@ describe("api routes", () => { expect(toolNames).toContain("gittensory_preflight_local_diff"); expect(toolNames).toContain("gittensory_preview_local_pr_score"); expect(toolNames).toContain("gittensory_explain_score_breakdown"); + expect(toolNames).toContain("gittensory_explain_score_scenarios"); expect(toolNames).toContain("gittensory_get_outcome_calibration"); expect(toolNames).toContain("gittensory_get_registry_changes"); expect(toolNames).toContain("gittensory_get_upstream_drift"); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 51be3e964..9f7300768 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -31,6 +31,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "gittensory_local_status", "gittensory_remediation_plan", "gittensory_explain_score_breakdown", + "gittensory_explain_score_scenarios", ]; async function connectTestClient(env: Env = createTestEnv(), identity?: AuthIdentity) { @@ -414,6 +415,30 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(result.isError).toBe(true); }); + it("gittensory_explain_score_scenarios returns validated structured content", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" }); + const { client } = await connectTestClient(env); + const result = await client.callTool({ + name: "gittensory_explain_score_scenarios", + arguments: { + repoFullName: "octo/demo", + contributorLogin: "octo", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 4, + credibility: 0.5, + }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.repoFullName).toBe("octo/demo"); + expect(Array.isArray(data.scenarios)).toBe(true); + expect(data.recommendedPath).toBeTruthy(); + expect(typeof data.headline).toBe("string"); + }); + it("gittensory_lint_pr_text returns a deterministic verdict and fixes", async () => { const { client } = await connectTestClient(); const weak = await client.callTool({ name: "gittensory_lint_pr_text", arguments: { commitMessages: ["wip"], prBody: "" } }); diff --git a/test/unit/score-scenario-explain.test.ts b/test/unit/score-scenario-explain.test.ts new file mode 100644 index 000000000..0dc7b2892 --- /dev/null +++ b/test/unit/score-scenario-explain.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it } from "vitest"; +import { buildScorePreview, type ScorePreviewResult, type ScoreScenarioPreview } from "../../src/scoring/preview"; +import { explainScoreScenarios } from "../../src/services/score-scenario-explain"; +import type { RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types"; + +const FORBIDDEN = /\b(wallet|hotkey|coldkey|mnemonic|farming|payout|raw[-_\s]?trust)\b/i; + +const snapshot: ScoringModelSnapshotRecord = { + id: "score-model-fixture", + sourceKind: "test", + sourceUrl: "fixture://constants.py", + fetchedAt: "2026-05-23T00:00:00.000Z", + activeModel: "current_density_model", + constants: { + OSS_EMISSION_SHARE: 0.9, + MERGED_PR_BASE_SCORE: 25, + MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5, + MAX_CODE_DENSITY_MULTIPLIER: 1.15, + MAX_CONTRIBUTION_BONUS: 25, + CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, + STANDARD_ISSUE_MULTIPLIER: 1.33, + MAINTAINER_ISSUE_MULTIPLIER: 1.66, + MIN_CREDIBILITY: 0.8, + REVIEW_PENALTY_RATE: 0.15, + EXCESSIVE_PR_PENALTY_BASE_THRESHOLD: 2, + OPEN_PR_THRESHOLD_TOKEN_SCORE: 300, + MAX_OPEN_PR_THRESHOLD: 30, + OPEN_PR_COLLATERAL_PERCENT: 0.2, + SRC_TOK_SATURATION_SCALE: 58, + TOTAL_TOK_SATURATION_SCALE: 58, + }, + payload: {}, + programmingLanguages: {}, + warnings: [], +}; + +const repo: RepositoryRecord = { + fullName: "octo/demo", + owner: "octo", + name: "demo", + isInstalled: false, + isRegistered: true, + isPrivate: false, + registryConfig: { + repo: "octo/demo", + emissionShare: 0.02, + issueDiscoveryShare: 0.25, + labelMultipliers: { bug: 1.2 }, + maintainerCut: 0, + raw: {}, + }, +}; + +function scenarioFromBase( + base: ScorePreviewResult, + name: ScoreScenarioPreview["name"], + overrides: Partial, +): ScoreScenarioPreview { + const template = base.scenarioPreviews.find((scenario) => scenario.name === name) ?? base.scenarioPreviews[0]!; + return { ...template, ...overrides, name }; +} + +function withScenarios( + base: ScorePreviewResult, + args: { + effectiveEstimatedScore: number; + scoreabilityStatus: ScorePreviewResult["scoreabilityStatus"]; + scenarios: ScoreScenarioPreview[]; + }, +): ScorePreviewResult { + return { + ...base, + effectiveEstimatedScore: args.effectiveEstimatedScore, + scoreabilityStatus: args.scoreabilityStatus, + scenarioPreviews: args.scenarios, + }; +} + +function basePreview(): ScorePreviewResult { + return buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 0, + credibility: 1, + linkedIssueMode: "none", + }, + }); +} + +describe("explainScoreScenarios", () => { + it("ranks bestReasonableCase when open-PR pressure fully blocks the current preview", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 50, + credibility: 0.95, + linkedIssueMode: "none", + }, + }); + + const explanation = explainScoreScenarios(preview); + expect(preview.scoreabilityStatus).toMatch(/blocked|conditionally_scoreable/); + expect(explanation.recommendedPath.scenario).toBe("bestReasonableCase"); + expect(explanation.recommendedPath.lever).toMatch(/gate cleanups|plausible/i); + expect(explanation.scenarios.some((card) => card.name === "bestReasonableCase" && card.leverageScore > 0)).toBe(true); + expect(explanation.scenarios.every((card) => card.name !== "current")).toBe(true); + expect(JSON.stringify(explanation)).not.toMatch(FORBIDDEN); + }); + + it("prefers afterPendingMerges when pending merge pressure is caller-supplied", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 4, + pendingMergedPrCount: 2, + expectedOpenPrCountAfterMerge: 2, + projectedCredibility: 0.95, + credibility: 0.5, + linkedIssueMode: "none", + }, + }); + + const explanation = explainScoreScenarios(preview); + const pending = explanation.scenarios.find((card) => card.name === "afterPendingMerges"); + const clean = explanation.scenarios.find((card) => card.name === "cleanGates"); + expect(pending?.leverageScore ?? 0).toBeGreaterThan(0); + expect((pending?.leverageScore ?? 0) >= (clean?.leverageScore ?? 0)).toBe(true); + expect(explanation.headline).toMatch(/conditionally scoreable|cleanup path/i); + }); + + it("surfaces linkedIssueFixed when linked issue context is invalid", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 1, + credibility: 0.95, + linkedIssueMode: "standard", + linkedIssueContext: { status: "invalid", source: "github_cache", issueNumbers: [12], reason: "Issue closed" }, + }, + }); + + const explanation = explainScoreScenarios(preview); + const linked = explanation.scenarios.find((card) => card.name === "linkedIssueFixed"); + expect(linked?.lever).toMatch(/linked issue|solved-by-PR|mirror metadata/i); + expect(linked?.summary).toMatch(/linked issue validated|Linked issue validated/i); + }); + + it("uses a neutral headline when no scenario improves the current preview", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 0, + credibility: 1, + linkedIssueMode: "none", + }, + }); + + const explanation = explainScoreScenarios(preview); + expect(explanation.scoreabilityStatus).toBe("scoreable"); + expect(explanation.headline).toMatch(/scoreable|optional cleanup/i); + expect(explanation.recommendedPath.scenario).toBe("current"); + expect(explanation.scenarios.every((card) => card.leverageScore === 0)).toBe(true); + }); + + it("expands gate deltas into actionable narratives", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 50, + openIssueCount: 50, + credibility: 0.5, + mergedPullRequests: 1, + linkedIssueMode: "standard", + linkedIssueContext: { status: "raw", source: "github_cache", issueNumbers: [12] }, + }, + }); + + const explanation = explainScoreScenarios(preview); + expect(explanation.gateDeltaNarratives.length).toBeGreaterThan(0); + for (const narrative of explanation.gateDeltaNarratives) { + expect(narrative.narrative.length).toBeGreaterThan(0); + expect(narrative.lever.length).toBeGreaterThan(0); + } + expect(JSON.stringify(explanation)).not.toMatch(FORBIDDEN); + }); + + it("assigns hold band when repo allocation is inactive", () => { + const inactiveRepo: RepositoryRecord = { + ...repo, + registryConfig: { ...repo.registryConfig!, emissionShare: 0 }, + }; + const preview = buildScorePreview({ + repo: inactiveRepo, + snapshot, + input: { + repoFullName: inactiveRepo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 0, + credibility: 1, + linkedIssueMode: "none", + }, + }); + + const explanation = explainScoreScenarios(preview); + expect(preview.scoreabilityStatus).toBe("hold"); + expect(explanation.headline).toMatch(/hold|registration|allocation/i); + expect(explanation.scenarios.some((card) => card.band === "hold")).toBe(true); + }); + + it("uses the blocked headline when cleanup paths exist but the preview stays blocked", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 50, + credibility: 0.95, + linkedIssueMode: "none", + }, + }); + + const explanation = explainScoreScenarios(preview); + expect(explanation.scenarios.some((card) => card.leverageScore > 0)).toBe(true); + if (preview.scoreabilityStatus === "blocked") { + expect(explanation.headline).toMatch(/blocked|cleanup sequence/i); + } + }); + + it("labels github_observed scenario cards and blocked summaries with gate codes", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + contributorLogin: "miner", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 80, + openPrCount: 4, + approvedPrCount: 2, + pendingScenarioObserved: true, + credibility: 0.5, + linkedIssueMode: "none", + }, + }); + + const explanation = explainScoreScenarios(preview); + const approved = explanation.scenarios.find((card) => card.name === "afterApprovedPrsMerge"); + expect(approved?.source).toBe("github_observed"); + expect(approved?.summary).toMatch(/remains blocked by open_pr_threshold|improves the preview versus current state|clears blocking gates/i); + expect(explanation.recommendedPath.reason.length).toBeGreaterThan(0); + }); + + it("uses the neutral headline when blocked and no scenario improves the preview", () => { + const base = basePreview(); + const openPrBlocker = [{ code: "open_pr_threshold" as const, severity: "blocker" as const, detail: "Too many open PRs." }]; + const preview = withScenarios(base, { + effectiveEstimatedScore: 0, + scoreabilityStatus: "blocked", + scenarios: [ + scenarioFromBase(base, "current", { source: "current_data", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + scenarioFromBase(base, "cleanGates", { source: "user_supplied", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + scenarioFromBase(base, "afterPendingMerges", { source: "user_supplied", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + scenarioFromBase(base, "afterApprovedPrsMerge", { source: "github_observed", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + scenarioFromBase(base, "afterStalePrsClose", { source: "user_supplied", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + scenarioFromBase(base, "linkedIssueFixed", { source: "gittensory_projection", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + scenarioFromBase(base, "bestReasonableCase", { source: "gittensory_projection", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + ], + }); + + const explanation = explainScoreScenarios(preview); + expect(explanation.headline).toMatch(/No scenario path improves the current preview/i); + expect(explanation.recommendedPath.scenario).toBe("current"); + }); + + it("uses the blocked headline when cleanup paths exist for a blocked preview", () => { + const base = basePreview(); + const openPrBlocker = [{ code: "open_pr_threshold" as const, severity: "blocker" as const, detail: "Too many open PRs." }]; + const preview = withScenarios(base, { + effectiveEstimatedScore: 0, + scoreabilityStatus: "blocked", + scenarios: [ + scenarioFromBase(base, "current", { source: "current_data", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + scenarioFromBase(base, "cleanGates", { source: "user_supplied", effectiveEstimatedScore: 8, blockedBy: openPrBlocker }), + scenarioFromBase(base, "afterPendingMerges", { source: "user_supplied", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + scenarioFromBase(base, "afterApprovedPrsMerge", { source: "github_observed", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + scenarioFromBase(base, "afterStalePrsClose", { source: "user_supplied", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + scenarioFromBase(base, "linkedIssueFixed", { source: "gittensory_projection", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + scenarioFromBase(base, "bestReasonableCase", { source: "gittensory_projection", effectiveEstimatedScore: 0, blockedBy: openPrBlocker }), + ], + }); + + const explanation = explainScoreScenarios(preview); + expect(explanation.headline).toMatch(/blocked; ranked scenarios show the highest-leverage cleanup sequence/i); + const clean = explanation.scenarios.find((card) => card.name === "cleanGates"); + expect(clean?.band).toBe("conditionally_scoreable"); + expect(clean?.summary).toMatch(/improves the preview versus current state/i); + expect(clean?.unlockDelta).toMatch(/improves versus current/i); + }); + + it("describes negative scenario deltas and scoreable unlock reasons", () => { + const base = basePreview(); + const preview = withScenarios(base, { + effectiveEstimatedScore: 10, + scoreabilityStatus: "conditionally_scoreable", + scenarios: [ + scenarioFromBase(base, "current", { source: "current_data", effectiveEstimatedScore: 10, blockedBy: [] }), + scenarioFromBase(base, "cleanGates", { source: "user_supplied", effectiveEstimatedScore: 20, blockedBy: [] }), + scenarioFromBase(base, "afterStalePrsClose", { source: "user_supplied", effectiveEstimatedScore: 5, blockedBy: [{ code: "open_pr_threshold", severity: "blocker", detail: "Still blocked." }] }), + scenarioFromBase(base, "afterPendingMerges", { source: "user_supplied", effectiveEstimatedScore: 10, blockedBy: [] }), + scenarioFromBase(base, "afterApprovedPrsMerge", { source: "github_observed", effectiveEstimatedScore: 10, blockedBy: [] }), + scenarioFromBase(base, "linkedIssueFixed", { source: "gittensory_projection", effectiveEstimatedScore: 10, blockedBy: [] }), + scenarioFromBase(base, "bestReasonableCase", { source: "gittensory_projection", effectiveEstimatedScore: 10, blockedBy: [] }), + ], + }); + + const explanation = explainScoreScenarios(preview); + const stale = explanation.scenarios.find((card) => card.name === "afterStalePrsClose"); + expect(stale?.unlockDelta).toMatch(/does not improve versus current/i); + expect(explanation.recommendedPath.scenario).toBe("cleanGates"); + expect(explanation.recommendedPath.reason).toMatch(/clears blocking gates under user supplied assumptions/i); + }); + + it("summarizes blocked scenarios without blocker codes when only reducers remain", () => { + const base = basePreview(); + const reducerOnly = [{ code: "review_penalty" as const, severity: "reducer" as const, detail: "Review churn." }]; + const preview = withScenarios(base, { + effectiveEstimatedScore: 0, + scoreabilityStatus: "blocked", + scenarios: [ + scenarioFromBase(base, "current", { source: "current_data", effectiveEstimatedScore: 0, blockedBy: reducerOnly }), + scenarioFromBase(base, "cleanGates", { source: "user_supplied", effectiveEstimatedScore: 0, blockedBy: reducerOnly }), + scenarioFromBase(base, "afterPendingMerges", { source: "user_supplied", effectiveEstimatedScore: 0, blockedBy: reducerOnly }), + scenarioFromBase(base, "afterApprovedPrsMerge", { source: "github_observed", effectiveEstimatedScore: 0, blockedBy: reducerOnly }), + scenarioFromBase(base, "afterStalePrsClose", { source: "user_supplied", effectiveEstimatedScore: 0, blockedBy: reducerOnly }), + scenarioFromBase(base, "linkedIssueFixed", { source: "gittensory_projection", effectiveEstimatedScore: 0, blockedBy: reducerOnly }), + scenarioFromBase(base, "bestReasonableCase", { source: "gittensory_projection", effectiveEstimatedScore: 0, blockedBy: reducerOnly }), + ], + }); + + const explanation = explainScoreScenarios(preview); + expect(explanation.scenarios.every((card) => card.band === "blocked")).toBe(true); + expect(explanation.scenarios[0]?.summary).toMatch(/remains blocked or reduced under stated assumptions/i); + }); +});