diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index da0c768e9..0dc4a98de 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -15386,6 +15386,210 @@ "nullable": true } } + }, + "ListPendingActionsResponse": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "pendingActions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "actionClass": { + "type": "string" + }, + "pullNumber": { + "type": "number" + }, + "status": { + "type": "string" + }, + "autonomyLevel": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "decidedBy": { + "type": "string", + "nullable": true + }, + "decidedAt": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "actionClass", + "pullNumber", + "status", + "autonomyLevel", + "reason", + "decidedBy", + "decidedAt", + "createdAt" + ] + } + } + } + }, + "ProposeActionRequest": { + "type": "object", + "properties": { + "pullNumber": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "actionClass": { + "type": "string", + "enum": [ + "review", + "request_changes", + "approve", + "merge", + "close", + "label", + "review_state_label" + ] + }, + "reason": { + "type": "string", + "maxLength": 500 + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "reviewBody": { + "type": "string", + "maxLength": 60000 + }, + "mergeMethod": { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + }, + "closeComment": { + "type": "string", + "maxLength": 60000 + } + }, + "required": [ + "pullNumber", + "actionClass" + ] + }, + "ProposeActionResponse": { + "type": "object", + "properties": { + "created": { + "type": "boolean" + }, + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "actionClass": { + "type": "string" + }, + "pullNumber": { + "type": "number" + }, + "status": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "actionClass", + "pullNumber", + "status", + "reason" + ] + } + } + }, + "DecidePendingActionResponse": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "executionOutcome": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "actionClass": { + "type": "string" + }, + "pullNumber": { + "type": "number" + }, + "status": { + "type": "string" + }, + "autonomyLevel": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "decidedBy": { + "type": "string", + "nullable": true + }, + "decidedAt": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "actionClass", + "pullNumber", + "status", + "autonomyLevel", + "reason", + "decidedBy", + "decidedAt", + "createdAt" + ] + } + } } }, "parameters": {}, @@ -20279,6 +20483,186 @@ } ] } + }, + "/v1/repos/{owner}/{repo}/agent/pending-actions": { + "get": { + "summary": "Maintainer-scoped agent approval queue of pending staged actions", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Pending agent actions staged for maintainer approval (#784), mirroring the loopover_list_pending_actions MCP tool.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListPendingActionsResponse" + } + } + } + }, + "403": { + "description": "Insufficient role" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + }, + "post": { + "summary": "Stage an agent action into the approval queue for maintainer review", + "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/ProposeActionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The staged (or already-present) pending action (#6744), mirroring the loopover_propose_action MCP tool VERBATIM.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProposeActionResponse" + } + } + } + }, + "400": { + "description": "Malformed propose-action request body" + }, + "403": { + "description": "Insufficient role" + }, + "409": { + "description": "The LoopOver App is not installed on this repository" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/agent/pending-actions/{id}/{decision}": { + "post": { + "summary": "Accept (execute) or reject a staged agent action in the approval queue", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": [ + "accept", + "reject" + ] + }, + "required": true, + "name": "decision", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The decided action's outcome (#779): accept executes it live, reject cancels it. Mirrors the loopover_decide_pending_action MCP tool.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecidePendingActionResponse" + } + } + } + }, + "400": { + "description": "Decision is not 'accept' or 'reject'" + }, + "403": { + "description": "Insufficient role" + }, + "404": { + "description": "Pending action not found for this repository" + }, + "409": { + "description": "Pending action was already decided" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index bc8eb9290..73dbe7ff2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -594,7 +594,7 @@ const planViewOutputSchema = { }; // #784 (MCP slice) — propose-action: a maintainer stages an action into the approval queue (#779). -const proposeActionShape = { +export const proposeActionShape = { owner: z.string().min(1), repo: z.string().min(1), pullNumber: z.number().int().positive(), @@ -610,7 +610,7 @@ const proposeActionShape = { // MEMBER/COLLABORATOR for users without push permission, so write-capable MCP surfaces must verify live. const REPO_WRITE_PERMISSIONS = new Set(["admin", "maintain", "write"]); -const proposeActionOutputSchema = { +export const proposeActionOutputSchema = { created: z.boolean().optional(), action: z .object({ id: z.string(), actionClass: z.string(), pullNumber: z.number(), status: z.string(), reason: z.string().nullable() }) @@ -687,7 +687,7 @@ const pendingActionEntrySchema = z.object({ createdAt: z.string(), }); -const listPendingActionsOutputSchema = { +export const listPendingActionsOutputSchema = { repoFullName: z.string().optional(), status: z.string().optional(), pendingActions: z.array(pendingActionEntrySchema).optional(), @@ -700,7 +700,7 @@ const decidePendingActionShape = { decision: z.enum(["accept", "reject"]), }; -const decidePendingActionOutputSchema = { +export const decidePendingActionOutputSchema = { status: z.string().optional(), executionOutcome: z.string().optional(), action: pendingActionEntrySchema.optional(), diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 3a9ca7b38..3816a9437 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1019,6 +1019,62 @@ export const RepoDocRefreshResultSchema = z ]) .openapi("RepoDocRefreshResult"); +// #9307 — the agent approval-queue routes under /v1/repos/:owner/:repo/agent/pending-actions, documented to +// match the loopover_propose_action / loopover_list_pending_actions / loopover_decide_pending_action MCP tools +// they mirror (src/mcp/server.ts). The audit-feed neighbor (same agent/* prefix) is already documented; these +// three were the gap. Field-level source of truth is each MCP tool's raw Zod shape, hand-mirrored here so the +// codegen path never pulls the heavy MCP server module into the OpenAPI build. +const PendingActionEntrySchema = z.object({ + id: z.string(), + actionClass: z.string(), + pullNumber: z.number(), + status: z.string(), + autonomyLevel: z.string(), + reason: z.string().nullable(), + decidedBy: z.string().nullable(), + decidedAt: z.string().nullable(), + createdAt: z.string(), +}); + +export const ListPendingActionsResponseSchema = z + .object({ + repoFullName: z.string().optional(), + status: z.string().optional(), + pendingActions: z.array(PendingActionEntrySchema).optional(), + }) + .openapi("ListPendingActionsResponse"); + +// Mirrors proposeActionShape minus owner/repo (both are path params on the REST route), matching the request +// body proposePendingActionSchema already validates in src/api/routes.ts. +export const ProposeActionRequestSchema = z + .object({ + pullNumber: z.number().int().positive(), + actionClass: z.enum(["review", "request_changes", "approve", "merge", "close", "label", "review_state_label"]), + reason: z.string().max(500).optional(), + label: z.string().min(1).max(100).optional(), + reviewBody: z.string().max(60000).optional(), + mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), + closeComment: z.string().max(60000).optional(), + }) + .openapi("ProposeActionRequest"); + +export const ProposeActionResponseSchema = z + .object({ + created: z.boolean().optional(), + action: z + .object({ id: z.string(), actionClass: z.string(), pullNumber: z.number(), status: z.string(), reason: z.string().nullable() }) + .optional(), + }) + .openapi("ProposeActionResponse"); + +export const DecidePendingActionResponseSchema = z + .object({ + status: z.string().optional(), + executionOutcome: z.string().optional(), + action: PendingActionEntrySchema.optional(), + }) + .openapi("DecidePendingActionResponse"); + export const RepoSettingsPreviewSchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 23a30ef9b..cf1df39aa 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -89,6 +89,10 @@ import { AutomationStateSchema, RepositorySettingsSchema, RepoDocRefreshResultSchema, + ListPendingActionsResponseSchema, + ProposeActionRequestSchema, + ProposeActionResponseSchema, + DecidePendingActionResponseSchema, RoleContextSchema, ReviewRiskExplanationSchema, RewardRiskActionSchema, @@ -151,6 +155,10 @@ export function buildOpenApiSpec() { registry.register("RepositorySettings", RepositorySettingsSchema); registry.register("AutomationState", AutomationStateSchema); registry.register("RepoDocRefreshResult", RepoDocRefreshResultSchema); + registry.register("ListPendingActionsResponse", ListPendingActionsResponseSchema); + registry.register("ProposeActionRequest", ProposeActionRequestSchema); + registry.register("ProposeActionResponse", ProposeActionResponseSchema); + registry.register("DecidePendingActionResponse", DecidePendingActionResponseSchema); registry.register("InstallationRepair", InstallationRepairSchema); registry.register("RepoSettingsPreview", RepoSettingsPreviewSchema); registry.register("SkippedPrAuditExport", SkippedPrAuditExportSchema); @@ -681,6 +689,53 @@ export function buildOpenApiSpec() { 403: { description: "Insufficient role" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/agent/pending-actions", + summary: "Maintainer-scoped agent approval queue of pending staged actions", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, + responses: { + 200: { + description: "Pending agent actions staged for maintainer approval (#784), mirroring the loopover_list_pending_actions MCP tool.", + content: { "application/json": { schema: ListPendingActionsResponseSchema } }, + }, + 403: { description: "Insufficient role" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/repos/{owner}/{repo}/agent/pending-actions", + summary: "Stage an agent action into the approval queue for maintainer review", + request: { + params: z.object({ owner: z.string(), repo: z.string() }), + body: { content: { "application/json": { schema: ProposeActionRequestSchema } } }, + }, + responses: { + 200: { + description: "The staged (or already-present) pending action (#6744), mirroring the loopover_propose_action MCP tool VERBATIM.", + content: { "application/json": { schema: ProposeActionResponseSchema } }, + }, + 400: { description: "Malformed propose-action request body" }, + 403: { description: "Insufficient role" }, + 409: { description: "The LoopOver App is not installed on this repository" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/repos/{owner}/{repo}/agent/pending-actions/{id}/{decision}", + summary: "Accept (execute) or reject a staged agent action in the approval queue", + request: { params: z.object({ owner: z.string(), repo: z.string(), id: z.string(), decision: z.enum(["accept", "reject"]) }) }, + responses: { + 200: { + description: "The decided action's outcome (#779): accept executes it live, reject cancels it. Mirrors the loopover_decide_pending_action MCP tool.", + content: { "application/json": { schema: DecidePendingActionResponseSchema } }, + }, + 400: { description: "Decision is not 'accept' or 'reject'" }, + 403: { description: "Insufficient role" }, + 404: { description: "Pending action not found for this repository" }, + 409: { description: "Pending action was already decided" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/agent/audit-feed", diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index 57bfcf03a..c5eb0da30 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -12,6 +12,10 @@ import { planIdeaClaimsOutputSchema, eligibilityPlanOutputSchema, scoreBreakdownOutputSchema, + listPendingActionsOutputSchema, + proposeActionOutputSchema, + proposeActionShape, + decidePendingActionOutputSchema, } from "../../src/mcp/server"; describe("OpenAPI contract", () => { @@ -153,6 +157,46 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/auth/github/token"]?.post?.security).toEqual([{ LoopOverBearer: [] }, { LoopOverSessionCookie: [] }]); }); + // #9307: the three agent/pending-actions approval-queue routes were undocumented while their agent/audit-feed + // neighbor was. Assert each is registered with a response schema whose keys match the MCP tool output shape it + // mirrors, so the OpenAPI contract can never drift from what loopover_{list,propose,decide}_pending_action validate. + it("documents the agent/pending-actions routes with schemas matching their MCP tool shapes (#9307)", () => { + const spec = buildOpenApiSpec(); + const schemaProps = (name: string) => + Object.keys((spec.components?.schemas?.[name] as { properties?: Record })?.properties ?? {}).sort(); + + const getOp = spec.paths["/v1/repos/{owner}/{repo}/agent/pending-actions"]?.get as { + responses?: Record }>; + }; + expect(getOp?.responses?.["200"]?.content?.["application/json"]?.schema?.$ref).toBe( + "#/components/schemas/ListPendingActionsResponse", + ); + expect(schemaProps("ListPendingActionsResponse")).toEqual(Object.keys(listPendingActionsOutputSchema).sort()); + + const proposeOp = spec.paths["/v1/repos/{owner}/{repo}/agent/pending-actions"]?.post as { + requestBody?: { content?: Record }; + responses?: Record }>; + }; + expect(proposeOp?.requestBody?.content?.["application/json"]?.schema?.$ref).toBe("#/components/schemas/ProposeActionRequest"); + expect(schemaProps("ProposeActionRequest")).toEqual( + Object.keys(proposeActionShape) + .filter((k) => k !== "owner" && k !== "repo") + .sort(), + ); + expect(proposeOp?.responses?.["200"]?.content?.["application/json"]?.schema?.$ref).toBe( + "#/components/schemas/ProposeActionResponse", + ); + expect(schemaProps("ProposeActionResponse")).toEqual(Object.keys(proposeActionOutputSchema).sort()); + + const decideOp = spec.paths["/v1/repos/{owner}/{repo}/agent/pending-actions/{id}/{decision}"]?.post as { + responses?: Record }>; + }; + expect(decideOp?.responses?.["200"]?.content?.["application/json"]?.schema?.$ref).toBe( + "#/components/schemas/DecidePendingActionResponse", + ); + expect(schemaProps("DecidePendingActionResponse")).toEqual(Object.keys(decidePendingActionOutputSchema).sort()); + }); + // #5810: every operation needs a title in the generated spec and the rendered API browser. Iterating the built // document (rather than counting `summary:` lines in the source) also covers the paths registered from a loop, // and fails loudly when a future route is added without one.