Skip to content

Commit 8fe39ad

Browse files
committed
feat(api): document /v1/contributors/:login/watches in OpenAPI spec
Adds WatchSubscription* schemas and registers GET/POST/DELETE paths for the watch-subscription REST endpoints, mirroring watchIssuesOutputSchema from the MCP tool, and regenerates apps/loopover-ui/public/openapi.json. Closes #9306
1 parent b4064b6 commit 8fe39ad

4 files changed

Lines changed: 311 additions & 0 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14856,6 +14856,78 @@
1485614856
"recommendation",
1485714857
"summary"
1485814858
]
14859+
},
14860+
"WatchSubscriptionList": {
14861+
"type": "object",
14862+
"properties": {
14863+
"watching": {
14864+
"type": "array",
14865+
"items": {
14866+
"$ref": "#/components/schemas/WatchSubscription"
14867+
}
14868+
}
14869+
},
14870+
"required": [
14871+
"watching"
14872+
]
14873+
},
14874+
"WatchSubscription": {
14875+
"type": "object",
14876+
"properties": {
14877+
"repoFullName": {
14878+
"type": "string"
14879+
},
14880+
"labels": {
14881+
"type": "array",
14882+
"items": {
14883+
"type": "string"
14884+
}
14885+
}
14886+
},
14887+
"required": [
14888+
"repoFullName",
14889+
"labels"
14890+
]
14891+
},
14892+
"WatchSubscriptionChange": {
14893+
"type": "object",
14894+
"properties": {
14895+
"watching": {
14896+
"type": "array",
14897+
"items": {
14898+
"$ref": "#/components/schemas/WatchSubscription"
14899+
}
14900+
},
14901+
"changed": {
14902+
"type": "string"
14903+
}
14904+
},
14905+
"required": [
14906+
"watching",
14907+
"changed"
14908+
]
14909+
},
14910+
"WatchSubscriptionRequest": {
14911+
"type": "object",
14912+
"properties": {
14913+
"repoFullName": {
14914+
"type": "string",
14915+
"minLength": 3,
14916+
"maxLength": 200
14917+
},
14918+
"labels": {
14919+
"type": "array",
14920+
"items": {
14921+
"type": "string",
14922+
"minLength": 1,
14923+
"maxLength": 100
14924+
},
14925+
"maxItems": 50
14926+
}
14927+
},
14928+
"required": [
14929+
"repoFullName"
14930+
]
1485914931
}
1486014932
},
1486114933
"parameters": {},
@@ -19370,6 +19442,137 @@
1937019442
}
1937119443
]
1937219444
}
19445+
},
19446+
"/v1/contributors/{login}/watches": {
19447+
"get": {
19448+
"summary": "List contributor issue-watch subscriptions",
19449+
"parameters": [
19450+
{
19451+
"schema": {
19452+
"type": "string"
19453+
},
19454+
"required": true,
19455+
"name": "login",
19456+
"in": "path"
19457+
}
19458+
],
19459+
"responses": {
19460+
"200": {
19461+
"description": "The contributor's own issue-watch subscriptions (self-scoped), mirroring loopover_watch_issues action=list.",
19462+
"content": {
19463+
"application/json": {
19464+
"schema": {
19465+
"$ref": "#/components/schemas/WatchSubscriptionList"
19466+
}
19467+
}
19468+
}
19469+
}
19470+
},
19471+
"security": [
19472+
{
19473+
"LoopOverBearer": []
19474+
},
19475+
{
19476+
"LoopOverSessionCookie": []
19477+
}
19478+
]
19479+
},
19480+
"post": {
19481+
"summary": "Watch a repo for new grabbable issues",
19482+
"parameters": [
19483+
{
19484+
"schema": {
19485+
"type": "string"
19486+
},
19487+
"required": true,
19488+
"name": "login",
19489+
"in": "path"
19490+
}
19491+
],
19492+
"requestBody": {
19493+
"content": {
19494+
"application/json": {
19495+
"schema": {
19496+
"$ref": "#/components/schemas/WatchSubscriptionRequest"
19497+
}
19498+
}
19499+
}
19500+
},
19501+
"responses": {
19502+
"200": {
19503+
"description": "Subscribes the contributor to repoFullName (optional label filter) and returns the updated watch list, mirroring loopover_watch_issues action=watch.",
19504+
"content": {
19505+
"application/json": {
19506+
"schema": {
19507+
"$ref": "#/components/schemas/WatchSubscriptionChange"
19508+
}
19509+
}
19510+
}
19511+
},
19512+
"400": {
19513+
"description": "Invalid watch request body"
19514+
},
19515+
"403": {
19516+
"description": "Forbidden when the repo cannot be watched by this login"
19517+
}
19518+
},
19519+
"security": [
19520+
{
19521+
"LoopOverBearer": []
19522+
},
19523+
{
19524+
"LoopOverSessionCookie": []
19525+
}
19526+
]
19527+
},
19528+
"delete": {
19529+
"summary": "Unwatch a repo",
19530+
"parameters": [
19531+
{
19532+
"schema": {
19533+
"type": "string"
19534+
},
19535+
"required": true,
19536+
"name": "login",
19537+
"in": "path"
19538+
}
19539+
],
19540+
"requestBody": {
19541+
"content": {
19542+
"application/json": {
19543+
"schema": {
19544+
"$ref": "#/components/schemas/WatchSubscriptionRequest"
19545+
}
19546+
}
19547+
}
19548+
},
19549+
"responses": {
19550+
"200": {
19551+
"description": "Removes the contributor's subscription to repoFullName and returns the updated watch list, mirroring loopover_watch_issues action=unwatch.",
19552+
"content": {
19553+
"application/json": {
19554+
"schema": {
19555+
"$ref": "#/components/schemas/WatchSubscriptionChange"
19556+
}
19557+
}
19558+
}
19559+
},
19560+
"400": {
19561+
"description": "Invalid watch request body"
19562+
},
19563+
"403": {
19564+
"description": "Forbidden when the repo cannot be watched by this login"
19565+
}
19566+
},
19567+
"security": [
19568+
{
19569+
"LoopOverBearer": []
19570+
},
19571+
{
19572+
"LoopOverSessionCookie": []
19573+
}
19574+
]
19575+
}
1937319576
}
1937419577
},
1937519578
"servers": [

src/openapi/schemas.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,34 @@ export const NotificationsMarkedSchema = z
539539
})
540540
.openapi("NotificationsMarked");
541541

542+
// #9306: mirrors watchIssuesOutputSchema's `watching` shape (src/mcp/server.ts) for the REST surface.
543+
export const WatchSubscriptionSchema = z
544+
.object({
545+
repoFullName: z.string(),
546+
labels: z.array(z.string()),
547+
})
548+
.openapi("WatchSubscription");
549+
550+
export const WatchSubscriptionListSchema = z
551+
.object({
552+
watching: z.array(WatchSubscriptionSchema),
553+
})
554+
.openapi("WatchSubscriptionList");
555+
556+
export const WatchSubscriptionChangeSchema = z
557+
.object({
558+
watching: z.array(WatchSubscriptionSchema),
559+
changed: z.string(),
560+
})
561+
.openapi("WatchSubscriptionChange");
562+
563+
export const WatchSubscriptionRequestSchema = z
564+
.object({
565+
repoFullName: z.string().min(3).max(200),
566+
labels: z.array(z.string().min(1).max(100)).max(50).optional(),
567+
})
568+
.openapi("WatchSubscriptionRequest");
569+
542570
export const ContributorOpportunitySchema = z
543571
.object({
544572
repoFullName: z.string(),

src/openapi/spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ import {
8787
UpstreamDriftReportSchema,
8888
UpstreamRulesetSnapshotSchema,
8989
UpstreamStatusSchema,
90+
WatchSubscriptionChangeSchema,
91+
WatchSubscriptionListSchema,
92+
WatchSubscriptionRequestSchema,
9093
WorkboardItemSchema,
9194
} from "./schemas";
9295

@@ -841,6 +844,52 @@ export function buildOpenApiSpec() {
841844
400: { description: "Invalid mark-read body" },
842845
},
843846
});
847+
registry.registerPath({
848+
method: "get",
849+
path: "/v1/contributors/{login}/watches",
850+
summary: "List contributor issue-watch subscriptions",
851+
request: { params: z.object({ login: z.string() }) },
852+
responses: {
853+
200: {
854+
description: "The contributor's own issue-watch subscriptions (self-scoped), mirroring loopover_watch_issues action=list.",
855+
content: { "application/json": { schema: WatchSubscriptionListSchema } },
856+
},
857+
},
858+
});
859+
registry.registerPath({
860+
method: "post",
861+
path: "/v1/contributors/{login}/watches",
862+
summary: "Watch a repo for new grabbable issues",
863+
request: {
864+
params: z.object({ login: z.string() }),
865+
body: { content: { "application/json": { schema: WatchSubscriptionRequestSchema } } },
866+
},
867+
responses: {
868+
200: {
869+
description: "Subscribes the contributor to repoFullName (optional label filter) and returns the updated watch list, mirroring loopover_watch_issues action=watch.",
870+
content: { "application/json": { schema: WatchSubscriptionChangeSchema } },
871+
},
872+
400: { description: "Invalid watch request body" },
873+
403: { description: "Forbidden when the repo cannot be watched by this login" },
874+
},
875+
});
876+
registry.registerPath({
877+
method: "delete",
878+
path: "/v1/contributors/{login}/watches",
879+
summary: "Unwatch a repo",
880+
request: {
881+
params: z.object({ login: z.string() }),
882+
body: { content: { "application/json": { schema: WatchSubscriptionRequestSchema } } },
883+
},
884+
responses: {
885+
200: {
886+
description: "Removes the contributor's subscription to repoFullName and returns the updated watch list, mirroring loopover_watch_issues action=unwatch.",
887+
content: { "application/json": { schema: WatchSubscriptionChangeSchema } },
888+
},
889+
400: { description: "Invalid watch request body" },
890+
403: { description: "Forbidden when the repo cannot be watched by this login" },
891+
},
892+
});
844893
registry.registerPath({
845894
method: "get",
846895
path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision",

test/unit/openapi.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ describe("OpenAPI contract", () => {
2525
expect(spec.paths["/v1/contributors/{login}/decision-pack"]).toBeDefined();
2626
expect(spec.paths["/v1/contributors/{login}/open-pr-monitor"]).toBeDefined();
2727
expect(spec.paths["/v1/contributors/{login}/pr-outcomes"]).toBeDefined();
28+
expect(spec.paths["/v1/contributors/{login}/watches"]?.get).toBeDefined();
29+
expect(spec.paths["/v1/contributors/{login}/watches"]?.post).toBeDefined();
30+
expect(spec.paths["/v1/contributors/{login}/watches"]?.delete).toBeDefined();
2831
expect(spec.paths["/v1/contributors/{login}/repos/{owner}/{repo}/decision"]).toBeDefined();
2932
expect(spec.paths["/v1/preflight/pr"]).toBeDefined();
3033
expect(spec.paths["/v1/preflight/review-risk"]).toBeDefined();
@@ -101,6 +104,12 @@ describe("OpenAPI contract", () => {
101104
expect(spec.components?.schemas?.PullRequestMaintainerPacket).toBeDefined();
102105
expect(spec.components?.schemas?.PullRequestReviewability).toBeDefined();
103106
expect(spec.components?.schemas?.LocalBranchAnalysis).toBeDefined();
107+
expect(spec.components?.schemas?.WatchSubscriptionList).toBeDefined();
108+
expect(spec.components?.schemas?.WatchSubscriptionChange).toBeDefined();
109+
expect(spec.components?.schemas?.WatchSubscriptionRequest).toBeDefined();
110+
expect(JSON.stringify(spec.components?.schemas?.WatchSubscriptionList)).toContain("watching");
111+
expect(JSON.stringify(spec.components?.schemas?.WatchSubscriptionChange)).toContain("changed");
112+
expect(JSON.stringify(spec.components?.schemas?.WatchSubscriptionRequest)).toContain("repoFullName");
104113
expect(spec.components?.schemas?.RepoSettingsPreview).toBeDefined();
105114
expect(spec.components?.schemas?.InstallationRepair).toBeDefined();
106115
expect(spec.components?.schemas?.CommandPreviewResponse).toBeDefined();
@@ -162,4 +171,26 @@ describe("OpenAPI contract", () => {
162171
}
163172
}
164173
});
174+
175+
// #9306: /v1/contributors/{login}/watches (GET/POST/DELETE) mirrors loopover_watch_issues (watchIssuesOutputSchema
176+
// in src/mcp/server.ts) but had no OpenAPI documentation. Pin all three verbs plus their response schema refs.
177+
it("documents all three verbs of /v1/contributors/{login}/watches with matching response schemas", () => {
178+
const spec = buildOpenApiSpec();
179+
const refName = (schema: unknown) => (schema as { $ref?: string }).$ref?.split("/").pop();
180+
const watches = spec.paths["/v1/contributors/{login}/watches"] as {
181+
get?: { responses: Record<string, { content?: Record<string, { schema: unknown }> }> };
182+
post?: { responses: Record<string, { content?: Record<string, { schema: unknown }> }>; requestBody?: { content: Record<string, { schema: unknown }> } };
183+
delete?: { responses: Record<string, { content?: Record<string, { schema: unknown }> }>; requestBody?: { content: Record<string, { schema: unknown }> } };
184+
};
185+
186+
expect(refName(watches.get?.responses["200"]?.content?.["application/json"]?.schema)).toBe("WatchSubscriptionList");
187+
expect(refName(watches.post?.responses["200"]?.content?.["application/json"]?.schema)).toBe("WatchSubscriptionChange");
188+
expect(refName(watches.post?.requestBody?.content["application/json"]?.schema)).toBe("WatchSubscriptionRequest");
189+
expect(watches.post?.responses["400"]).toBeDefined();
190+
expect(watches.post?.responses["403"]).toBeDefined();
191+
expect(refName(watches.delete?.responses["200"]?.content?.["application/json"]?.schema)).toBe("WatchSubscriptionChange");
192+
expect(refName(watches.delete?.requestBody?.content["application/json"]?.schema)).toBe("WatchSubscriptionRequest");
193+
expect(watches.delete?.responses["400"]).toBeDefined();
194+
expect(watches.delete?.responses["403"]).toBeDefined();
195+
});
165196
});

0 commit comments

Comments
 (0)