diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bd67835359..e02de53d98 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -16134,6 +16134,57 @@ ] } } + }, + "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": {}, @@ -21768,6 +21819,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 5ed13dd4b9..f5b5912e0b 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 1b0df5732d..82017d6d72 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -27,6 +27,8 @@ import { ContributorPrOutcomesSchema, NotificationFeedSchema, NotificationsMarkedSchema, + ContributorWatchRequestSchema, + ContributorWatchesResponseSchema, ContributorRewardRiskStrategySchema, ContributorProfileSchema, ContributorScoringProfileSchema, @@ -150,6 +152,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); @@ -1281,6 +1285,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 f3d000bb25..edf924302d 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", () => { @@ -53,6 +54,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(); @@ -455,6 +457,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 ?? {})) {