From 23d99f21fa125966f9a7b2993b84b5db83c62a26 Mon Sep 17 00:00:00 2001 From: wondercreatemaster Date: Mon, 27 Jul 2026 21:44:58 +0300 Subject: [PATCH] fix(openapi): document validate-linked-issue + check-before-start (#9304) These two pre-work-check routes were live (API + MCP) but absent from the OpenAPI contract, so they never appeared in the generated openapi.json. Mirror the #6611 / #9301 pattern: MCP-aligned request/response schemas, registerPath entries, and a field-parity regression test. --- apps/loopover-ui/public/openapi.json | 245 +++++++++++++++++++++++++++ src/mcp/server.ts | 10 +- src/openapi/schemas.ts | 65 +++++++ src/openapi/spec.ts | 50 ++++++ test/unit/openapi.test.ts | 51 ++++++ 5 files changed, 417 insertions(+), 4 deletions(-) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bd67835359..92448dbcdd 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -16134,6 +16134,129 @@ ] } } + }, + "ValidateLinkedIssueRequest": { + "type": "object", + "properties": { + "issueNumber": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "plannedChange": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 300 + }, + "changedFiles": { + "type": "array", + "items": { + "type": "string", + "maxLength": 300 + }, + "maxItems": 200 + }, + "contributorLogin": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + } + } + }, + "required": [ + "issueNumber" + ] + }, + "ValidateLinkedIssueResponse": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "repoFullName": { + "type": "string" + }, + "issueNumber": { + "type": "number" + }, + "found": { + "type": "boolean" + }, + "multiplierStatus": { + "type": "string" + }, + "multiplierWouldApply": { + "type": "boolean" + }, + "blockingReason": { + "type": "string" + }, + "reasons": { + "nullable": true + }, + "report": { + "nullable": true + } + } + }, + "CheckBeforeStartRequest": { + "type": "object", + "properties": { + "issueNumber": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 300 + }, + "plannedPaths": { + "type": "array", + "items": { + "type": "string", + "maxLength": 300 + }, + "maxItems": 200 + } + } + }, + "CheckBeforeStartResponse": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "repoFullName": { + "type": "string" + }, + "found": { + "type": "boolean" + }, + "claimStatus": { + "type": "string" + }, + "duplicateClusterRisk": { + "type": "string" + }, + "recommendation": { + "type": "string" + }, + "reasons": { + "nullable": true + }, + "blockers": { + "nullable": true + }, + "report": { + "nullable": true + } + } } }, "parameters": {}, @@ -21768,6 +21891,128 @@ } ] } + }, + "/v1/repos/{owner}/{repo}/validate-linked-issue": { + "post": { + "summary": "Validate a linked-issue claim before opening work — REST mirror of loopover_validate_linked_issue (#9304)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateLinkedIssueRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Linked-issue validation report — mirrors the loopover_validate_linked_issue MCP tool. Advisory only; it does not open issues or PRs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateLinkedIssueResponse" + } + } + } + }, + "400": { + "description": "Invalid validate-linked-issue request body" + }, + "401": { + "description": "Missing or invalid authentication" + }, + "403": { + "description": "Authenticated principal cannot access this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/check-before-start": { + "post": { + "summary": "Pre-start claim and duplicate-risk check — REST mirror of loopover_check_before_start (#9304)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckBeforeStartRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Pre-start check with claim status, duplicate-cluster risk, and recommendation — mirrors the loopover_check_before_start MCP tool. Advisory only", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckBeforeStartResponse" + } + } + } + }, + "400": { + "description": "Invalid check-before-start request body" + }, + "401": { + "description": "Missing or invalid authentication" + }, + "403": { + "description": "Authenticated principal cannot access this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index fde2cb0cbd..30a7718948 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -320,7 +320,9 @@ const bountyShape = { id: z.string().min(1), }; -const validateLinkedIssueShape = { +// Exported for OpenAPI contract tests (#9304) — request/response components must stay field-for-field +// in parity with these tool shapes (owner/repo are path params on the REST mirror). +export const validateLinkedIssueShape = { owner: z.string().min(1), repo: z.string().min(1), issueNumber: z.number().int().positive(), @@ -333,7 +335,7 @@ const validateLinkedIssueShape = { .optional(), }; -const checkBeforeStartShape = { +export const checkBeforeStartShape = { owner: z.string().min(1), repo: z.string().min(1), issueNumber: z.number().int().positive().optional(), @@ -1609,7 +1611,7 @@ const localStatusOutputSchema = { supportedTools: z.unknown().optional(), }; -const validateLinkedIssueOutputSchema = { +export const validateLinkedIssueOutputSchema = { status: z.string().optional(), repoFullName: z.string().optional(), issueNumber: z.number().optional(), @@ -1621,7 +1623,7 @@ const validateLinkedIssueOutputSchema = { report: z.unknown().optional(), }; -const checkBeforeStartOutputSchema = { +export const checkBeforeStartOutputSchema = { status: z.string().optional(), repoFullName: z.string().optional(), found: z.boolean().optional(), diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 5ed13dd4b9..bfb91730fe 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -2013,6 +2013,71 @@ export const ScoreBreakdownResponseSchema = z }) .openapi("ScoreBreakdownResponse"); +/** + * Request body for POST /v1/repos/{owner}/{repo}/validate-linked-issue. Field-level parity with + * `validateLinkedIssueShape` in src/mcp/server.ts minus owner/repo (path params) — #9304. + */ +export const ValidateLinkedIssueRequestSchema = z + .object({ + issueNumber: z.number().int().positive(), + plannedChange: z + .object({ + title: z.string().min(1).max(300).optional(), + changedFiles: z.array(z.string().max(300)).max(200).optional(), + contributorLogin: z.string().min(1).max(100).optional(), + }) + .optional(), + }) + .openapi("ValidateLinkedIssueRequest"); + +/** + * Response body for POST /v1/repos/{owner}/{repo}/validate-linked-issue. Field-level parity with + * `validateLinkedIssueOutputSchema` (the `loopover_validate_linked_issue` MCP tool `outputSchema`) — #9304. + */ +export const ValidateLinkedIssueResponseSchema = z + .object({ + status: z.string().optional(), + repoFullName: z.string().optional(), + issueNumber: z.number().optional(), + found: z.boolean().optional(), + multiplierStatus: z.string().optional(), + multiplierWouldApply: z.boolean().optional(), + blockingReason: z.string().optional(), + reasons: z.unknown().optional(), + report: z.unknown().optional(), + }) + .openapi("ValidateLinkedIssueResponse"); + +/** + * Request body for POST /v1/repos/{owner}/{repo}/check-before-start. Field-level parity with + * `checkBeforeStartShape` in src/mcp/server.ts minus owner/repo (path params) — #9304. + */ +export const CheckBeforeStartRequestSchema = z + .object({ + issueNumber: z.number().int().positive().optional(), + title: z.string().min(1).max(300).optional(), + plannedPaths: z.array(z.string().max(300)).max(200).optional(), + }) + .openapi("CheckBeforeStartRequest"); + +/** + * Response body for POST /v1/repos/{owner}/{repo}/check-before-start. Field-level parity with + * `checkBeforeStartOutputSchema` (the `loopover_check_before_start` MCP tool `outputSchema`) — #9304. + */ +export const CheckBeforeStartResponseSchema = z + .object({ + status: z.string().optional(), + repoFullName: z.string().optional(), + found: z.boolean().optional(), + claimStatus: z.string().optional(), + duplicateClusterRisk: z.string().optional(), + recommendation: z.string().optional(), + reasons: z.unknown().optional(), + blockers: z.unknown().optional(), + report: z.unknown().optional(), + }) + .openapi("CheckBeforeStartResponse"); + /** * 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 1b0df5732d..05857053aa 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -39,6 +39,10 @@ import { GateConfigEffectiveResponseSchema, EligibilityPlanResponseSchema, ScoreBreakdownResponseSchema, + ValidateLinkedIssueRequestSchema, + ValidateLinkedIssueResponseSchema, + CheckBeforeStartRequestSchema, + CheckBeforeStartResponseSchema, EvaluateEscalationRequestSchema, EvaluateEscalationResponseSchema, BuildResultsPayloadRequestSchema, @@ -205,6 +209,10 @@ export function buildOpenApiSpec() { registry.register("GateConfigEffectiveResponse", GateConfigEffectiveResponseSchema); registry.register("EligibilityPlanResponse", EligibilityPlanResponseSchema); registry.register("ScoreBreakdownResponse", ScoreBreakdownResponseSchema); + registry.register("ValidateLinkedIssueRequest", ValidateLinkedIssueRequestSchema); + registry.register("ValidateLinkedIssueResponse", ValidateLinkedIssueResponseSchema); + registry.register("CheckBeforeStartRequest", CheckBeforeStartRequestSchema); + registry.register("CheckBeforeStartResponse", CheckBeforeStartResponseSchema); registry.register("EvaluateEscalationRequest", EvaluateEscalationRequestSchema); registry.register("EvaluateEscalationResponse", EvaluateEscalationResponseSchema); registry.register("BuildResultsPayloadRequest", BuildResultsPayloadRequestSchema); @@ -564,6 +572,48 @@ export function buildOpenApiSpec() { 403: { description: "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/repos/{owner}/{repo}/validate-linked-issue", + summary: "Validate a linked-issue claim before opening work — REST mirror of loopover_validate_linked_issue (#9304)", + request: { + params: z.object({ owner: z.string(), repo: z.string() }), + body: { + content: { "application/json": { schema: ValidateLinkedIssueRequestSchema } }, + }, + }, + responses: { + 200: { + description: + "Linked-issue validation report — mirrors the loopover_validate_linked_issue MCP tool. Advisory only; it does not open issues or PRs", + content: { "application/json": { schema: ValidateLinkedIssueResponseSchema } }, + }, + 400: { description: "Invalid validate-linked-issue request body" }, + 401: { description: "Missing or invalid authentication" }, + 403: { description: "Authenticated principal cannot access this repo" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/repos/{owner}/{repo}/check-before-start", + summary: "Pre-start claim and duplicate-risk check — REST mirror of loopover_check_before_start (#9304)", + request: { + params: z.object({ owner: z.string(), repo: z.string() }), + body: { + content: { "application/json": { schema: CheckBeforeStartRequestSchema } }, + }, + }, + responses: { + 200: { + description: + "Pre-start check with claim status, duplicate-cluster risk, and recommendation — mirrors the loopover_check_before_start MCP tool. Advisory only", + content: { "application/json": { schema: CheckBeforeStartResponseSchema } }, + }, + 400: { description: "Invalid check-before-start request body" }, + 401: { description: "Missing or invalid authentication" }, + 403: { description: "Authenticated principal cannot access this repo" }, + }, + }); registry.registerPath({ method: "post", path: "/v1/loop/evaluate-escalation", diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index f3d000bb25..a93411ef40 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -21,6 +21,10 @@ import { gatePrecisionOutputSchema, maintainerMeasurementReportOutputSchema, activationPreviewOutputSchema, + validateLinkedIssueShape, + validateLinkedIssueOutputSchema, + checkBeforeStartShape, + checkBeforeStartOutputSchema, } from "../../src/mcp/server"; describe("OpenAPI contract", () => { @@ -44,6 +48,8 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/repos/{owner}/{repo}/gate-precision"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/outcome-calibration"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/activation-preview"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/validate-linked-issue"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/check-before-start"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/registration-readiness"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/gittensor-config-recommendation"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet"]).toBeDefined(); @@ -455,6 +461,51 @@ describe("OpenAPI contract", () => { } }); + // #9304: validate-linked-issue + check-before-start were live API+MCP routes but missing from the + // OpenAPI contract. Assert both POSTs are documented with request/response components whose keys + // match the MCP tool shapes (owner/repo stay path params, not request-body fields). + it("documents validate-linked-issue and check-before-start with tool-parity schemas (#9304)", () => { + 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/repos/{owner}/{repo}/validate-linked-issue", + request: "ValidateLinkedIssueRequest", + response: "ValidateLinkedIssueResponse", + inputShape: validateLinkedIssueShape, + outputShape: validateLinkedIssueOutputSchema, + }, + { + path: "/v1/repos/{owner}/{repo}/check-before-start", + request: "CheckBeforeStartRequest", + response: "CheckBeforeStartResponse", + inputShape: checkBeforeStartShape, + outputShape: checkBeforeStartOutputSchema, + }, + ]; + + for (const { path, request, response, inputShape, outputShape } of cases) { + const op = spec.paths[path]?.post; + expect(op, `${path} should be a documented POST path`).toBeDefined(); + expect(op?.requestBody, `${path} should document a request body`).toBeDefined(); + expect(op?.responses?.["200"], `${path} should document a 200 response`).toBeDefined(); + + expect(schemas[request], `${request} component should be registered`).toBeDefined(); + expect(schemas[response], `${response} component should be registered`).toBeDefined(); + + expect(propKeys(request)).toEqual( + Object.keys(inputShape) + .filter((k) => k !== "owner" && k !== "repo") + .sort(), + ); + 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 ?? {})) {