From 3df6a7bd700d6f00b6e37dada52787e0a895937f Mon Sep 17 00:00:00 2001 From: philluiz2323 Date: Mon, 27 Jul 2026 09:17:20 -0700 Subject: [PATCH] feat(openapi): document validate-linked-issue + check-before-start (#9304) Both pre-work-check POSTs were live with MCP tools and Zod output shapes but missing from the OpenAPI contract. Add request/response schemas from the MCP shapes, register both paths, assert them in openapi.test, and regenerate apps/loopover-ui/public/openapi.json. Closes #9304 --- apps/loopover-ui/public/openapi.json | 245 +++++++++++++++++++++++++++ src/openapi/schemas.ts | 52 ++++++ src/openapi/spec.ts | 56 ++++++ test/unit/openapi.test.ts | 14 ++ 4 files changed, 367 insertions(+) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index eb25b146c..b0f9b5867 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -14856,6 +14856,129 @@ "recommendation", "summary" ] + }, + "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": {}, @@ -19370,6 +19493,128 @@ } ] } + }, + "/v1/repos/{owner}/{repo}/validate-linked-issue": { + "post": { + "summary": "Validate a planned linked-issue claim before opening a PR", + "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 loopover_validate_linked_issue)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateLinkedIssueResponse" + } + } + } + }, + "400": { + "description": "Invalid validate-linked-issue request body" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Session lacks access to this repository" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/check-before-start": { + "post": { + "summary": "Pre-start claim and duplicate-cluster check for planned work", + "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 report (mirrors loopover_check_before_start)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckBeforeStartResponse" + } + } + } + }, + "400": { + "description": "Invalid check-before-start request body" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Session lacks access to this repository" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 79085ab1e..866c95f80 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { MAX_REVIEW_NAG_COOLDOWN_DAYS } from "../settings/agent-actions"; import { MAX_CONTRIBUTOR_OPEN_ITEM_CAP } from "../types"; +import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; extendZodWithOpenApi(z); @@ -1926,6 +1927,57 @@ export const GateConfigEffectiveResponseSchema = z }) .openapi("GateConfigEffectiveResponse"); +// Request/response parity with validateLinkedIssueShape + validateLinkedIssueOutputSchema (#9304). +export const ValidateLinkedIssueRequestSchema = z + .object({ + issueNumber: z.number().int().positive(), + plannedChange: z + .object({ + title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars).optional(), + changedFiles: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), + contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), + }) + .optional(), + }) + .openapi("ValidateLinkedIssueRequest"); + +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/response parity with checkBeforeStartShape + checkBeforeStartOutputSchema (#9304). +export const CheckBeforeStartRequestSchema = z + .object({ + issueNumber: z.number().int().positive().optional(), + title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars).optional(), + plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(), + }) + .openapi("CheckBeforeStartRequest"); + +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"); + export const BurdenForecastSchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index b8bfea05b..6f9fa4de5 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -37,6 +37,10 @@ import { IssueQualityReportSchema, IssueQualityResponseSchema, GateConfigEffectiveResponseSchema, + ValidateLinkedIssueRequestSchema, + ValidateLinkedIssueResponseSchema, + CheckBeforeStartRequestSchema, + CheckBeforeStartResponseSchema, LabelAuditSchema, LaneAdviceSchema, LiveGateThresholdsResponseSchema, @@ -163,6 +167,10 @@ export function buildOpenApiSpec() { registry.register("IssueQualityReport", IssueQualityReportSchema); registry.register("IssueQualityResponse", IssueQualityResponseSchema); registry.register("GateConfigEffectiveResponse", GateConfigEffectiveResponseSchema); + registry.register("ValidateLinkedIssueRequest", ValidateLinkedIssueRequestSchema); + registry.register("ValidateLinkedIssueResponse", ValidateLinkedIssueResponseSchema); + registry.register("CheckBeforeStartRequest", CheckBeforeStartRequestSchema); + registry.register("CheckBeforeStartResponse", CheckBeforeStartResponseSchema); registry.register("LiveGateThresholdsResponse", LiveGateThresholdsResponseSchema); registry.register("BurdenForecast", BurdenForecastSchema); registry.register("ContributorScoringProfile", ContributorScoringProfileSchema); @@ -466,6 +474,54 @@ 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 planned linked-issue claim before opening a PR", + request: { + params: z.object({ owner: z.string(), repo: z.string() }), + body: { + content: { + "application/json": { + schema: ValidateLinkedIssueRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Linked-issue validation report (mirrors loopover_validate_linked_issue)", + content: { "application/json": { schema: ValidateLinkedIssueResponseSchema } }, + }, + 400: { description: "Invalid validate-linked-issue request body" }, + 401: { description: "Unauthorized" }, + 403: { description: "Session lacks access to this repository" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/repos/{owner}/{repo}/check-before-start", + summary: "Pre-start claim and duplicate-cluster check for planned work", + request: { + params: z.object({ owner: z.string(), repo: z.string() }), + body: { + content: { + "application/json": { + schema: CheckBeforeStartRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Pre-start check report (mirrors loopover_check_before_start)", + content: { "application/json": { schema: CheckBeforeStartResponseSchema } }, + }, + 400: { description: "Invalid check-before-start request body" }, + 401: { description: "Unauthorized" }, + 403: { description: "Session lacks access to this repository" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/live-gate-thresholds", diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index f4afc434c..0a619e349 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -17,6 +17,10 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/repos/{owner}/{repo}/issue-quality"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/outcome-patterns"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/gate-config/effective"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/validate-linked-issue"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/validate-linked-issue"]?.post).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/check-before-start"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/check-before-start"]?.post).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(); @@ -97,6 +101,16 @@ describe("OpenAPI contract", () => { expect(spec.components?.schemas?.RepoIntelligence).toBeDefined(); expect(spec.components?.schemas?.RepoOutcomePatterns).toBeDefined(); expect(spec.components?.schemas?.RegistrationReadiness).toBeDefined(); + expect(spec.components?.schemas?.GateConfigEffectiveResponse).toBeDefined(); + expect(spec.components?.schemas?.ValidateLinkedIssueResponse).toBeDefined(); + expect(spec.components?.schemas?.CheckBeforeStartResponse).toBeDefined(); + // #9304: response keys must match MCP tool output shapes in src/mcp/server.ts + expect(JSON.stringify(spec.components?.schemas?.ValidateLinkedIssueResponse)).toContain("multiplierWouldApply"); + expect(JSON.stringify(spec.components?.schemas?.ValidateLinkedIssueResponse)).toContain("blockingReason"); + expect(JSON.stringify(spec.components?.schemas?.CheckBeforeStartResponse)).toContain("duplicateClusterRisk"); + expect(JSON.stringify(spec.components?.schemas?.CheckBeforeStartResponse)).toContain("claimStatus"); + expect(JSON.stringify(spec.components?.schemas?.ValidateLinkedIssueRequest)).toContain("plannedChange"); + expect(JSON.stringify(spec.components?.schemas?.CheckBeforeStartRequest)).toContain("plannedPaths"); expect(spec.components?.schemas?.GittensorConfigRecommendation).toBeDefined(); expect(spec.components?.schemas?.PullRequestMaintainerPacket).toBeDefined(); expect(spec.components?.schemas?.PullRequestReviewability).toBeDefined();