From fb951c40d19c5fc153e43ea9e0e1becd364a7569 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Mon, 27 Jul 2026 19:32:50 +0000 Subject: [PATCH] fix(openapi): document contributor watches GET/POST/DELETE (#9306) GET/POST/DELETE /v1/contributors/{login}/watches were live (REST mirror of loopover_watch_issues) but absent from OpenAPI. Add MCP-aligned request and response schemas, register all three verbs, regenerate openapi.json, and add a field-parity regression test. Replaces conflict-closed PR 9446 on current main. Co-authored-by: Cursor --- apps/loopover-ui/public/openapi.json | 194 +++++++++++++++++++++++++++ src/mcp/server.ts | 2 +- src/openapi/schemas.ts | 22 +++ src/openapi/spec.ts | 58 ++++++++ test/unit/openapi.test.ts | 26 ++++ 5 files changed, 301 insertions(+), 1 deletion(-) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 6043b11b3a..8a670d147a 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -16290,6 +16290,57 @@ "nullable": true } } + }, + "ContributorWatchRequest": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string", + "minLength": 3, + "maxLength": 200 + }, + "labels": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "maxItems": 50 + } + }, + "required": [ + "repoFullName" + ] + }, + "ContributorWatchesResponse": { + "type": "object", + "properties": { + "watching": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "repoFullName", + "labels" + ] + } + }, + "changed": { + "type": "string" + } + } } }, "parameters": {}, @@ -22172,6 +22223,149 @@ } ] } + }, + "/v1/contributors/{login}/watches": { + "get": { + "summary": "List contributor issue-watch subscriptions — REST mirror of loopover_watch_issues action=list (#9306)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The contributor's own issue-watch subscriptions (self-scoped) — mirrors loopover_watch_issues action=list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContributorWatchesResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication" + }, + "403": { + "description": "Authenticated principal cannot access this contributor's watches" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + }, + "post": { + "summary": "Subscribe to a repo's new issues — REST mirror of loopover_watch_issues action=watch (#9306)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContributorWatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Updated watch list after subscribing — mirrors loopover_watch_issues action=watch", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContributorWatchesResponse" + } + } + } + }, + "400": { + "description": "Invalid watch request body" + }, + "401": { + "description": "Missing or invalid authentication" + }, + "403": { + "description": "Authenticated principal cannot watch this repo or contributor" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + }, + "delete": { + "summary": "Unsubscribe from a repo's issue watches — REST mirror of loopover_watch_issues action=unwatch (#9306)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContributorWatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Updated watch list after unsubscribing — mirrors loopover_watch_issues action=unwatch", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContributorWatchesResponse" + } + } + } + }, + "400": { + "description": "Invalid unwatch request body" + }, + "401": { + "description": "Missing or invalid authentication" + }, + "403": { + "description": "Authenticated principal cannot unwatch this repo or contributor" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index fde2cb0cbd..a46341b2a0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1536,7 +1536,7 @@ const watchIssuesShape = { labels: z.array(z.string().min(1).max(100)).max(50).optional(), }; -const watchIssuesOutputSchema = { +export const watchIssuesOutputSchema = { watching: z.array(z.object({ repoFullName: z.string(), labels: z.array(z.string()) })).optional(), changed: z.string().optional(), }; diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 5c11c616b8..0aafeaf727 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -539,6 +539,28 @@ export const NotificationsMarkedSchema = z }) .openapi("NotificationsMarked"); +/** + * Request body for POST/DELETE /v1/contributors/{login}/watches. Mirrors `watchSubscriptionBodySchema` + * in src/api/routes.ts (repoFullName + optional labels) — #9306. + */ +export const ContributorWatchRequestSchema = z + .object({ + repoFullName: z.string().min(3).max(200), + labels: z.array(z.string().min(1).max(100)).max(50).optional(), + }) + .openapi("ContributorWatchRequest"); + +/** + * Response body for GET/POST/DELETE /v1/contributors/{login}/watches. Field-level parity with + * `watchIssuesOutputSchema` (the `loopover_watch_issues` MCP tool `outputSchema`) — #9306. + */ +export const ContributorWatchesResponseSchema = z + .object({ + watching: z.array(z.object({ repoFullName: z.string(), labels: z.array(z.string()) })).optional(), + changed: z.string().optional(), + }) + .openapi("ContributorWatchesResponse"); + export const ContributorOpportunitySchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 7e2ce4d3d0..784936b4b1 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -27,6 +27,8 @@ import { ContributorPrOutcomesSchema, NotificationFeedSchema, NotificationsMarkedSchema, + ContributorWatchRequestSchema, + ContributorWatchesResponseSchema, ContributorRewardRiskStrategySchema, ContributorProfileSchema, ContributorScoringProfileSchema, @@ -156,6 +158,8 @@ export function buildOpenApiSpec() { registry.register("ContributorOutcomeHistory", ContributorOutcomeHistorySchema); registry.register("ContributorPatternReport", ContributorPatternReportSchema); registry.register("ContributorDecisionPack", ContributorDecisionPackSchema); + registry.register("ContributorWatchRequest", ContributorWatchRequestSchema); + registry.register("ContributorWatchesResponse", ContributorWatchesResponseSchema); registry.register("DecisionPackRefreshNeeded", DecisionPackRefreshNeededSchema); registry.register("RepoDecisionResponse", RepoDecisionResponseSchema); registry.register("RepoIntelligence", RepoIntelligenceSchema); @@ -1379,6 +1383,60 @@ export function buildOpenApiSpec() { 400: { description: "Invalid mark-read body" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/contributors/{login}/watches", + summary: "List contributor issue-watch subscriptions — REST mirror of loopover_watch_issues action=list (#9306)", + request: { params: z.object({ login: z.string() }) }, + responses: { + 200: { + description: "The contributor's own issue-watch subscriptions (self-scoped) — mirrors loopover_watch_issues action=list", + content: { "application/json": { schema: ContributorWatchesResponseSchema } }, + }, + 401: { description: "Missing or invalid authentication" }, + 403: { description: "Authenticated principal cannot access this contributor's watches" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/contributors/{login}/watches", + summary: "Subscribe to a repo's new issues — REST mirror of loopover_watch_issues action=watch (#9306)", + request: { + params: z.object({ login: z.string() }), + body: { + content: { "application/json": { schema: ContributorWatchRequestSchema } }, + }, + }, + responses: { + 200: { + description: "Updated watch list after subscribing — mirrors loopover_watch_issues action=watch", + content: { "application/json": { schema: ContributorWatchesResponseSchema } }, + }, + 400: { description: "Invalid watch request body" }, + 401: { description: "Missing or invalid authentication" }, + 403: { description: "Authenticated principal cannot watch this repo or contributor" }, + }, + }); + registry.registerPath({ + method: "delete", + path: "/v1/contributors/{login}/watches", + summary: "Unsubscribe from a repo's issue watches — REST mirror of loopover_watch_issues action=unwatch (#9306)", + request: { + params: z.object({ login: z.string() }), + body: { + content: { "application/json": { schema: ContributorWatchRequestSchema } }, + }, + }, + responses: { + 200: { + description: "Updated watch list after unsubscribing — mirrors loopover_watch_issues action=unwatch", + content: { "application/json": { schema: ContributorWatchesResponseSchema } }, + }, + 400: { description: "Invalid unwatch request body" }, + 401: { description: "Missing or invalid authentication" }, + 403: { description: "Authenticated principal cannot unwatch this repo or contributor" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision", diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index 3f8c1d5970..cc2365e941 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -21,6 +21,7 @@ import { gatePrecisionOutputSchema, maintainerMeasurementReportOutputSchema, activationPreviewOutputSchema, + watchIssuesOutputSchema, } from "../../src/mcp/server"; describe("OpenAPI contract", () => { @@ -55,6 +56,7 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/contributors/{login}/decision-pack"]).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/open-pr-monitor"]).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/pr-outcomes"]).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/watches"]).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/repos/{owner}/{repo}/decision"]).toBeDefined(); expect(spec.paths["/v1/preflight/pr"]).toBeDefined(); expect(spec.paths["/v1/preflight/review-risk"]).toBeDefined(); @@ -486,6 +488,30 @@ describe("OpenAPI contract", () => { } }); + // #9306: GET/POST/DELETE /v1/contributors/{login}/watches are the REST mirror of loopover_watch_issues + // but were missing from the OpenAPI contract. Assert all three verbs are documented with a response + // component whose keys match watchIssuesOutputSchema, and POST/DELETE carry the watch request body. + it("documents contributor watches GET/POST/DELETE with tool-parity schemas (#9306)", () => { + const spec = buildOpenApiSpec(); + const schemas = spec.components?.schemas ?? {}; + const path = "/v1/contributors/{login}/watches"; + + const propKeys = (name: string) => + Object.keys((schemas[name] as { properties?: Record }).properties ?? {}).sort(); + + expect(spec.paths[path]?.get).toBeDefined(); + expect(spec.paths[path]?.post).toBeDefined(); + expect(spec.paths[path]?.delete).toBeDefined(); + + expect(schemas.ContributorWatchesResponse).toBeDefined(); + expect(schemas.ContributorWatchRequest).toBeDefined(); + expect(propKeys("ContributorWatchesResponse")).toEqual(Object.keys(watchIssuesOutputSchema).sort()); + expect(propKeys("ContributorWatchRequest")).toEqual(["labels", "repoFullName"].sort()); + + expect(spec.paths[path]?.post?.requestBody).toBeDefined(); + expect(spec.paths[path]?.delete?.requestBody).toBeDefined(); + }); + 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 ?? {})) {