diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index eb25b146c..052b0b758 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -14856,6 +14856,78 @@ "recommendation", "summary" ] + }, + "WatchSubscriptionList": { + "type": "object", + "properties": { + "watching": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WatchSubscription" + } + } + }, + "required": [ + "watching" + ] + }, + "WatchSubscription": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "repoFullName", + "labels" + ] + }, + "WatchSubscriptionChange": { + "type": "object", + "properties": { + "watching": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WatchSubscription" + } + }, + "changed": { + "type": "string" + } + }, + "required": [ + "watching", + "changed" + ] + }, + "WatchSubscriptionRequest": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string", + "minLength": 3, + "maxLength": 200 + }, + "labels": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "maxItems": 50 + } + }, + "required": [ + "repoFullName" + ] } }, "parameters": {}, @@ -19370,6 +19442,137 @@ } ] } + }, + "/v1/contributors/{login}/watches": { + "get": { + "summary": "List contributor issue-watch subscriptions", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The contributor's own issue-watch subscriptions (self-scoped), mirroring loopover_watch_issues action=list.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchSubscriptionList" + } + } + } + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + }, + "post": { + "summary": "Watch a repo for new grabbable issues", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchSubscriptionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Subscribes the contributor to repoFullName (optional label filter) and returns the updated watch list, mirroring loopover_watch_issues action=watch.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchSubscriptionChange" + } + } + } + }, + "400": { + "description": "Invalid watch request body" + }, + "403": { + "description": "Forbidden when the repo cannot be watched by this login" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + }, + "delete": { + "summary": "Unwatch a repo", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchSubscriptionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Removes the contributor's subscription to repoFullName and returns the updated watch list, mirroring loopover_watch_issues action=unwatch.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WatchSubscriptionChange" + } + } + } + }, + "400": { + "description": "Invalid watch request body" + }, + "403": { + "description": "Forbidden when the repo cannot be watched by this login" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 79085ab1e..1f2df45f7 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -539,6 +539,34 @@ export const NotificationsMarkedSchema = z }) .openapi("NotificationsMarked"); +// #9306: mirrors watchIssuesOutputSchema's `watching` shape (src/mcp/server.ts) for the REST surface. +export const WatchSubscriptionSchema = z + .object({ + repoFullName: z.string(), + labels: z.array(z.string()), + }) + .openapi("WatchSubscription"); + +export const WatchSubscriptionListSchema = z + .object({ + watching: z.array(WatchSubscriptionSchema), + }) + .openapi("WatchSubscriptionList"); + +export const WatchSubscriptionChangeSchema = z + .object({ + watching: z.array(WatchSubscriptionSchema), + changed: z.string(), + }) + .openapi("WatchSubscriptionChange"); + +export const WatchSubscriptionRequestSchema = z + .object({ + repoFullName: z.string().min(3).max(200), + labels: z.array(z.string().min(1).max(100)).max(50).optional(), + }) + .openapi("WatchSubscriptionRequest"); + export const ContributorOpportunitySchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index b8bfea05b..b98bf4c7a 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -87,6 +87,9 @@ import { UpstreamDriftReportSchema, UpstreamRulesetSnapshotSchema, UpstreamStatusSchema, + WatchSubscriptionChangeSchema, + WatchSubscriptionListSchema, + WatchSubscriptionRequestSchema, WorkboardItemSchema, } from "./schemas"; @@ -841,6 +844,52 @@ export function buildOpenApiSpec() { 400: { description: "Invalid mark-read body" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/contributors/{login}/watches", + summary: "List contributor issue-watch subscriptions", + request: { params: z.object({ login: z.string() }) }, + responses: { + 200: { + description: "The contributor's own issue-watch subscriptions (self-scoped), mirroring loopover_watch_issues action=list.", + content: { "application/json": { schema: WatchSubscriptionListSchema } }, + }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/contributors/{login}/watches", + summary: "Watch a repo for new grabbable issues", + request: { + params: z.object({ login: z.string() }), + body: { content: { "application/json": { schema: WatchSubscriptionRequestSchema } } }, + }, + responses: { + 200: { + description: "Subscribes the contributor to repoFullName (optional label filter) and returns the updated watch list, mirroring loopover_watch_issues action=watch.", + content: { "application/json": { schema: WatchSubscriptionChangeSchema } }, + }, + 400: { description: "Invalid watch request body" }, + 403: { description: "Forbidden when the repo cannot be watched by this login" }, + }, + }); + registry.registerPath({ + method: "delete", + path: "/v1/contributors/{login}/watches", + summary: "Unwatch a repo", + request: { + params: z.object({ login: z.string() }), + body: { content: { "application/json": { schema: WatchSubscriptionRequestSchema } } }, + }, + responses: { + 200: { + description: "Removes the contributor's subscription to repoFullName and returns the updated watch list, mirroring loopover_watch_issues action=unwatch.", + content: { "application/json": { schema: WatchSubscriptionChangeSchema } }, + }, + 400: { description: "Invalid watch request body" }, + 403: { description: "Forbidden when the repo cannot be watched by this login" }, + }, + }); 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 f4afc434c..c30346aec 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -25,6 +25,9 @@ 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"]?.get).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/watches"]?.post).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/watches"]?.delete).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(); @@ -101,6 +104,12 @@ describe("OpenAPI contract", () => { expect(spec.components?.schemas?.PullRequestMaintainerPacket).toBeDefined(); expect(spec.components?.schemas?.PullRequestReviewability).toBeDefined(); expect(spec.components?.schemas?.LocalBranchAnalysis).toBeDefined(); + expect(spec.components?.schemas?.WatchSubscriptionList).toBeDefined(); + expect(spec.components?.schemas?.WatchSubscriptionChange).toBeDefined(); + expect(spec.components?.schemas?.WatchSubscriptionRequest).toBeDefined(); + expect(JSON.stringify(spec.components?.schemas?.WatchSubscriptionList)).toContain("watching"); + expect(JSON.stringify(spec.components?.schemas?.WatchSubscriptionChange)).toContain("changed"); + expect(JSON.stringify(spec.components?.schemas?.WatchSubscriptionRequest)).toContain("repoFullName"); expect(spec.components?.schemas?.RepoSettingsPreview).toBeDefined(); expect(spec.components?.schemas?.InstallationRepair).toBeDefined(); expect(spec.components?.schemas?.CommandPreviewResponse).toBeDefined(); @@ -162,4 +171,26 @@ describe("OpenAPI contract", () => { } } }); + + // #9306: /v1/contributors/{login}/watches (GET/POST/DELETE) mirrors loopover_watch_issues (watchIssuesOutputSchema + // in src/mcp/server.ts) but had no OpenAPI documentation. Pin all three verbs plus their response schema refs. + it("documents all three verbs of /v1/contributors/{login}/watches with matching response schemas", () => { + const spec = buildOpenApiSpec(); + const refName = (schema: unknown) => (schema as { $ref?: string }).$ref?.split("/").pop(); + const watches = spec.paths["/v1/contributors/{login}/watches"] as { + get?: { responses: Record }> }; + post?: { responses: Record }>; requestBody?: { content: Record } }; + delete?: { responses: Record }>; requestBody?: { content: Record } }; + }; + + expect(refName(watches.get?.responses["200"]?.content?.["application/json"]?.schema)).toBe("WatchSubscriptionList"); + expect(refName(watches.post?.responses["200"]?.content?.["application/json"]?.schema)).toBe("WatchSubscriptionChange"); + expect(refName(watches.post?.requestBody?.content["application/json"]?.schema)).toBe("WatchSubscriptionRequest"); + expect(watches.post?.responses["400"]).toBeDefined(); + expect(watches.post?.responses["403"]).toBeDefined(); + expect(refName(watches.delete?.responses["200"]?.content?.["application/json"]?.schema)).toBe("WatchSubscriptionChange"); + expect(refName(watches.delete?.requestBody?.content["application/json"]?.schema)).toBe("WatchSubscriptionRequest"); + expect(watches.delete?.responses["400"]).toBeDefined(); + expect(watches.delete?.responses["403"]).toBeDefined(); + }); });