From 14c6e19a9c5f44e014e036b45b4858a39d7ac776 Mon Sep 17 00:00:00 2001 From: phamngocquy Date: Tue, 28 Jul 2026 01:41:09 +0800 Subject: [PATCH] openapi: /v1/scoring/eligibility-plan + /v1/scoring/explain-breakdown missing from spec (MCP tools + schemas already exist) Fixes #9301 --- apps/loopover-ui/public/openapi.json | 112 +++++++++++++++++++++++++++ src/mcp/server.ts | 4 +- src/openapi/schemas.ts | 31 ++++++++ src/openapi/spec.ts | 30 +++++++ test/unit/openapi.test.ts | 37 +++++++++ 5 files changed, 212 insertions(+), 2 deletions(-) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index dbe8ccd68..98bc71379 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -15256,6 +15256,62 @@ "required": [ "ok" ] + }, + "EligibilityPlanResponse": { + "type": "object", + "properties": { + "eligible": { + "type": "boolean" + }, + "linkedIssueStatus": { + "type": "string" + }, + "branchEligibilityStatus": { + "type": "string" + }, + "blockers": { + "type": "array", + "items": { + "type": "string" + } + }, + "cleanupPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "linkedIssueProjection": { + "type": "string", + "nullable": true + }, + "publicSummary": { + "type": "string" + } + } + }, + "ScoreBreakdownResponse": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "scoreabilityStatus": { + "type": "string" + }, + "effectiveEstimatedScore": { + "type": "number" + }, + "components": { + "nullable": true + }, + "gateHighlights": { + "nullable": true + }, + "highestLeverageLever": { + "nullable": true + } + } } }, "parameters": {}, @@ -20022,6 +20078,62 @@ } ] } + }, + "/v1/scoring/eligibility-plan": { + "post": { + "summary": "Derive a contributor eligibility plan from a scoring preview — REST mirror of loopover_get_eligibility_plan (#9301)", + "responses": { + "200": { + "description": "Structured eligibility plan over a server-built score preview — mirrors the loopover_get_eligibility_plan MCP tool. Advisory only; it explains eligibility, it does not open issues or PRs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EligibilityPlanResponse" + } + } + } + }, + "400": { + "description": "Invalid scoring preview input" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/scoring/explain-breakdown": { + "post": { + "summary": "Explain a score breakdown from a scoring preview — REST mirror of loopover_explain_score_breakdown (#9301)", + "responses": { + "200": { + "description": "Score multiplier breakdown and gate highlights over a server-built score preview — mirrors the loopover_explain_score_breakdown MCP tool. Requires contributorLogin in the request body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScoreBreakdownResponse" + } + } + } + }, + "400": { + "description": "Invalid scoring preview input or missing contributorLogin" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 84b39898f..bc8eb9290 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1697,7 +1697,7 @@ const remediationPlanOutputSchema = { items: z.unknown().optional(), }; -const scoreBreakdownOutputSchema = { +export const scoreBreakdownOutputSchema = { repoFullName: z.string().optional(), scoreabilityStatus: z.string().optional(), effectiveEstimatedScore: z.number().optional(), @@ -1706,7 +1706,7 @@ const scoreBreakdownOutputSchema = { highestLeverageLever: z.unknown().optional(), }; -const eligibilityPlanOutputSchema = { +export const eligibilityPlanOutputSchema = { eligible: z.boolean().optional(), linkedIssueStatus: z.string().optional(), branchEligibilityStatus: z.string().optional(), diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 94b40b299..653d9d469 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1926,6 +1926,37 @@ export const GateConfigEffectiveResponseSchema = z }) .openapi("GateConfigEffectiveResponse"); +/** + * Response body for POST /v1/scoring/eligibility-plan. Field-level parity with `eligibilityPlanOutputSchema` + * (the `loopover_get_eligibility_plan` MCP tool `outputSchema`) in src/mcp/server.ts — #9301. + */ +export const EligibilityPlanResponseSchema = z + .object({ + eligible: z.boolean().optional(), + linkedIssueStatus: z.string().optional(), + branchEligibilityStatus: z.string().optional(), + blockers: z.array(z.string()).optional(), + cleanupPaths: z.array(z.string()).optional(), + linkedIssueProjection: z.string().nullable().optional(), + publicSummary: z.string().optional(), + }) + .openapi("EligibilityPlanResponse"); + +/** + * Response body for POST /v1/scoring/explain-breakdown. Field-level parity with `scoreBreakdownOutputSchema` + * (the `loopover_explain_score_breakdown` MCP tool `outputSchema`) in src/mcp/server.ts — #9301. + */ +export const ScoreBreakdownResponseSchema = z + .object({ + repoFullName: z.string().optional(), + scoreabilityStatus: z.string().optional(), + effectiveEstimatedScore: z.number().optional(), + components: z.unknown().optional(), + gateHighlights: z.unknown().optional(), + highestLeverageLever: z.unknown().optional(), + }) + .openapi("ScoreBreakdownResponse"); + /** * Request body for POST /v1/loop/evaluate-escalation. Field-level parity with `evaluateEscalationShape` * (the `loopover_evaluate_escalation` MCP tool `inputSchema`) in src/mcp/server.ts — #9309. diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index a8c3122fe..f1c9f4db8 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -37,6 +37,8 @@ import { IssueQualityReportSchema, IssueQualityResponseSchema, GateConfigEffectiveResponseSchema, + EligibilityPlanResponseSchema, + ScoreBreakdownResponseSchema, EvaluateEscalationRequestSchema, EvaluateEscalationResponseSchema, BuildResultsPayloadRequestSchema, @@ -173,6 +175,8 @@ export function buildOpenApiSpec() { registry.register("IssueQualityReport", IssueQualityReportSchema); registry.register("IssueQualityResponse", IssueQualityResponseSchema); registry.register("GateConfigEffectiveResponse", GateConfigEffectiveResponseSchema); + registry.register("EligibilityPlanResponse", EligibilityPlanResponseSchema); + registry.register("ScoreBreakdownResponse", ScoreBreakdownResponseSchema); registry.register("EvaluateEscalationRequest", EvaluateEscalationRequestSchema); registry.register("EvaluateEscalationResponse", EvaluateEscalationResponseSchema); registry.register("BuildResultsPayloadRequest", BuildResultsPayloadRequestSchema); @@ -331,6 +335,32 @@ export function buildOpenApiSpec() { 400: { description: "Invalid scoring preview input" }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/scoring/eligibility-plan", + summary: "Derive a contributor eligibility plan from a scoring preview — REST mirror of loopover_get_eligibility_plan (#9301)", + responses: { + 200: { + description: + "Structured eligibility plan over a server-built score preview — mirrors the loopover_get_eligibility_plan MCP tool. Advisory only; it explains eligibility, it does not open issues or PRs", + content: { "application/json": { schema: EligibilityPlanResponseSchema } }, + }, + 400: { description: "Invalid scoring preview input" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/scoring/explain-breakdown", + summary: "Explain a score breakdown from a scoring preview — REST mirror of loopover_explain_score_breakdown (#9301)", + responses: { + 200: { + description: + "Score multiplier breakdown and gate highlights over a server-built score preview — mirrors the loopover_explain_score_breakdown MCP tool. Requires contributorLogin in the request body", + content: { "application/json": { schema: ScoreBreakdownResponseSchema } }, + }, + 400: { description: "Invalid scoring preview input or missing contributorLogin" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/sync/status", diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index 627b631ab..49281eb79 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -10,6 +10,8 @@ import { intakeIdeaShape, intakeIdeaOutputSchema, planIdeaClaimsOutputSchema, + eligibilityPlanOutputSchema, + scoreBreakdownOutputSchema, } from "../../src/mcp/server"; describe("OpenAPI contract", () => { @@ -49,6 +51,8 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/agent/explain-blockers"]).toBeDefined(); expect(spec.paths["/v1/scoring/model"]).toBeDefined(); expect(spec.paths["/v1/scoring/preview"]).toBeDefined(); + expect(spec.paths["/v1/scoring/eligibility-plan"]).toBeDefined(); + expect(spec.paths["/v1/scoring/explain-breakdown"]).toBeDefined(); expect(spec.paths["/v1/upstream/status"]).toBeDefined(); expect(spec.paths["/v1/upstream/ruleset"]).toBeDefined(); expect(spec.paths["/v1/upstream/drift"]).toBeDefined(); @@ -226,6 +230,39 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/loop/request-apr-transfer"]).toBeUndefined(); }); + // #9301: the two /v1/scoring/* composer routes backed by loopover_get_eligibility_plan and + // loopover_explain_score_breakdown. Assert each is a documented POST path whose 200 response + // component stays field-for-field in parity with the MCP tool outputSchema. + it("documents the /v1/scoring/eligibility-plan and /v1/scoring/explain-breakdown routes with tool-parity schemas (#9301)", () => { + const spec = buildOpenApiSpec(); + const schemas = spec.components?.schemas ?? {}; + + const propKeys = (name: string) => + Object.keys((schemas[name] as { properties?: Record }).properties ?? {}).sort(); + + const cases = [ + { + path: "/v1/scoring/eligibility-plan", + response: "EligibilityPlanResponse", + outputShape: eligibilityPlanOutputSchema, + }, + { + path: "/v1/scoring/explain-breakdown", + response: "ScoreBreakdownResponse", + outputShape: scoreBreakdownOutputSchema, + }, + ]; + + for (const { path, response, outputShape } of cases) { + const op = spec.paths[path]?.post; + expect(op, `${path} should be a documented POST path`).toBeDefined(); + expect(op?.responses?.["200"], `${path} should document a 200 response`).toBeDefined(); + + expect(schemas[response], `${response} component should be registered`).toBeDefined(); + expect(propKeys(response)).toEqual(Object.keys(outputShape).sort()); + } + }); + it("declares an `in: path` parameter for every {templated} path segment (Cloudflare schema-validation warning 30046)", () => { const spec = buildOpenApiSpec(); for (const [path, methods] of Object.entries(spec.paths ?? {})) {