Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
Expand Down
22 changes: 22 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
58 changes: 58 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
ContributorPrOutcomesSchema,
NotificationFeedSchema,
NotificationsMarkedSchema,
ContributorWatchRequestSchema,
ContributorWatchesResponseSchema,
ContributorRewardRiskStrategySchema,
ContributorProfileSchema,
ContributorScoringProfileSchema,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions test/unit/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
gatePrecisionOutputSchema,
maintainerMeasurementReportOutputSchema,
activationPreviewOutputSchema,
watchIssuesOutputSchema,
} from "../../src/mcp/server";

describe("OpenAPI contract", () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, unknown> }).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 ?? {})) {
Expand Down
Loading