Tighten PR state transitions and document approval endpoints

swadhinbiswas committed 8 months ago
a14e474
1 parent 34e0385
commit a14e474
3 files changed
+324 -1
▼src/lib/openapi.ts
+116-0
122
122
},
123
123
required: ["reviewId"],
124
124
},
125
+ SuggestionApplyRequest: {
126
+ type: "object",
127
+ properties: {
128
+ commentIds: {
129
+ type: "array",
130
+ minItems: 1,
131
+ maxItems: 50,
132
+ items: { type: "string" },
133
+ },
134
+ },
135
+ required: ["commentIds"],
136
+ },
137
+ StackApprovalRequest: {
138
+ type: "object",
139
+ properties: {
140
+ reviewers: {
141
+ type: "array",
142
+ minItems: 1,
143
+ maxItems: 50,
144
+ items: { type: "string", description: "Reviewer username" },
145
+ },
146
+ },
147
+ required: ["reviewers"],
148
+ },
125
149
},
126
150
},
127
151
paths: {
324
348
{ name: "owner", in: "path", required: true, schema: { type: "string" } },
325
349
{ name: "repo", in: "path", required: true, schema: { type: "string" } },
326
350
{ name: "number", in: "path", required: true, schema: { type: "integer" } },
351
+ {
352
+ name: "x-opencodehub-timestamp",
353
+ in: "header",
354
+ required: false,
355
+ schema: { type: "string" },
356
+ description: "Unix timestamp (seconds or milliseconds) for signed callbacks.",
357
+ },
358
+ {
359
+ name: "x-opencodehub-signature",
360
+ in: "header",
361
+ required: false,
362
+ schema: { type: "string" },
363
+ description: "HMAC signature in format sha256=<hex> over '<timestamp>.<rawBody>'.",
364
+ },
365
+ {
366
+ name: "x-opencodehub-event-id",
367
+ in: "header",
368
+ required: false,
369
+ schema: { type: "string" },
370
+ description: "Unique event id used for replay protection.",
371
+ },
327
372
],
328
373
requestBody: {
329
374
required: true,
360
405
},
361
406
},
362
407
},
408
+ "/repos/{owner}/{repo}/pulls/{number}/suggestions/apply": {
409
+ post: {
410
+ tags: ["Pull Requests"],
411
+ summary: "Apply one or more review suggestions",
412
+ description: "Applies suggestion comments to the PR branch after repository and path-scoped permission checks.",
413
+ security: [{ bearerAuth: [] }],
414
+ parameters: [
415
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
416
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
417
+ { name: "number", in: "path", required: true, schema: { type: "integer" } },
418
+ ],
419
+ requestBody: {
420
+ required: true,
421
+ content: {
422
+ "application/json": {
423
+ schema: { $ref: "#/components/schemas/SuggestionApplyRequest" },
424
+ },
425
+ },
426
+ },
427
+ responses: {
428
+ 200: { description: "Suggestion(s) applied" },
429
+ 400: { description: "Invalid comment ids or payload", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
430
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
431
+ 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
432
+ 404: { description: "Repository or pull request not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
433
+ },
434
+ },
435
+ },
436
+ "/repos/{owner}/{repo}/stacks/{stackId}/approvals": {
437
+ get: {
438
+ tags: ["Stacks"],
439
+ summary: "Get stack approval status",
440
+ security: [{ bearerAuth: [] }],
441
+ parameters: [
442
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
443
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
444
+ { name: "stackId", in: "path", required: true, schema: { type: "string" } },
445
+ ],
446
+ responses: {
447
+ 200: { description: "Stack approval status returned" },
448
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
449
+ 404: { description: "Repository or stack not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
450
+ },
451
+ },
452
+ post: {
453
+ tags: ["Stacks"],
454
+ summary: "Request approvals across all PRs in a stack",
455
+ description: "Requests stack approvals for eligible reviewers. Reviewers without repository access are skipped.",
456
+ security: [{ bearerAuth: [] }],
457
+ parameters: [
458
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
459
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
460
+ { name: "stackId", in: "path", required: true, schema: { type: "string" } },
461
+ ],
462
+ requestBody: {
463
+ required: true,
464
+ content: {
465
+ "application/json": {
466
+ schema: { $ref: "#/components/schemas/StackApprovalRequest" },
467
+ },
468
+ },
469
+ },
470
+ responses: {
471
+ 200: { description: "Approval requests created for eligible reviewers" },
472
+ 400: { description: "Invalid payload or no eligible reviewers", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
473
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
474
+ 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
475
+ 404: { description: "Repository or stack not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
476
+ },
477
+ },
478
+ },
363
479
"/user/ai-config": {
364
480
get: {
365
481
tags: ["AI Review"],
366
482
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/index.ts
+28-1
8
8
import { logger } from "@/lib/logger";
9
9
import { unauthorized, badRequest, notFound, success, forbidden } from "@/lib/api";
10
10
import { autoLinkPR } from "@/lib/pr-issue-linking";
11
+import { canMerge } from "@/lib/merge-queue";
12
+import { checkCodeOwnerApprovalsForPR } from "@/lib/pr-codeowner";
11
13
12
14
// GET: Get PR details
13
15
export const GET: APIRoute = withErrorHandler(async ({ params, locals }) => {
93
95
94
96
if (!pr) return notFound("Pull request not found");
95
97
96
- const body = await request.json();
98
+ const body = await request.json().catch(() => null);
99
+ if (!body || typeof body !== "object") {
100
+ return badRequest("Invalid request body");
101
+ }
97
102
const { title, body: description, state } = body;
98
103
99
104
const updateData: any = { updatedAt: new Date() };
170
175
}
171
176
}
172
177
178
+ if (customState.requireCodeOwner) {
179
+ const codeOwnerResult = await checkCodeOwnerApprovalsForPR(db, pr.id);
180
+ if (!codeOwnerResult.ok) {
181
+ return badRequest(codeOwnerResult.reason || "Code owner approval required");
182
+ }
183
+ }
184
+
185
+ if (customState.allowMerge) {
186
+ const readiness = await canMerge(pr.id);
187
+ if (!readiness.canMerge) {
188
+ return badRequest(readiness.reason || "Merge requirements not met");
189
+ }
190
+ }
191
+
173
192
updateData.stateId = customState.id;
174
193
updateData.customStateChangedAt = new Date();
175
194
// Custom states are generally 'open' in the high-level sense
181
200
} else if (state === "closed") {
182
201
updateData.state = "closed";
183
202
updateData.closedAt = new Date();
203
+ updateData.closedById = user.id;
204
+ updateData.stateId = null;
205
+ updateData.customStateChangedAt = new Date();
184
206
stateChanged = true;
185
207
} else if (state === "open") {
186
208
updateData.state = "open";
187
209
updateData.closedAt = null;
210
+ updateData.closedById = null;
211
+ updateData.stateId = null;
212
+ updateData.customStateChangedAt = new Date();
188
213
if (pr.isMerged) {
189
214
return badRequest("Cannot re-open a merged pull request");
190
215
}
191
216
stateChanged = true;
217
+ } else {
218
+ return badRequest("Invalid pull request state");
192
219
}
193
220
}
194
221
195
222
▼tests/integration/pr-update-state-route.test.ts
+180-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ canWriteRepoMock,
5
+ autoLinkPRMock,
6
+ triggerAutomationMock,
7
+ checkCodeOwnerApprovalsForPRMock,
8
+ canMergeMock,
9
+ fakeSchema,
10
+} = vi.hoisted(() => ({
11
+ canWriteRepoMock: vi.fn(async () => true),
12
+ autoLinkPRMock: vi.fn(async () => {}),
13
+ triggerAutomationMock: vi.fn(async () => {}),
14
+ checkCodeOwnerApprovalsForPRMock: vi.fn(async () => ({ ok: true })),
15
+ canMergeMock: vi.fn(async () => ({ canMerge: true })),
16
+ fakeSchema: {
17
+ users: { username: {}, id: {} },
18
+ repositories: { ownerId: {}, name: {}, id: {} },
19
+ pullRequests: { repositoryId: {}, number: {}, id: {} },
20
+ prStateDefinitions: { repositoryId: {}, name: {} },
21
+ } as any,
22
+}));
23
+
24
+let mockDb: any;
25
+
26
+vi.mock("@/db", () => ({
27
+ getDatabase: () => mockDb,
28
+ schema: fakeSchema,
29
+}));
30
+
31
+vi.mock("@/lib/permissions", () => ({
32
+ canWriteRepo: canWriteRepoMock,
33
+ canReadRepo: vi.fn(async () => true),
34
+}));
35
+
36
+vi.mock("@/lib/pr-issue-linking", () => ({
37
+ autoLinkPR: autoLinkPRMock,
38
+}));
39
+
40
+vi.mock("@/lib/automations", () => ({
41
+ triggerAutomation: triggerAutomationMock,
42
+}));
43
+
44
+vi.mock("@/lib/pr-codeowner", () => ({
45
+ checkCodeOwnerApprovalsForPR: checkCodeOwnerApprovalsForPRMock,
46
+}));
47
+
48
+vi.mock("@/lib/merge-queue", () => ({
49
+ canMerge: canMergeMock,
50
+}));
51
+
52
+import { PATCH as patchPr } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/index";
53
+
54
+function makeDb() {
55
+ const owner = { id: "owner-1", username: "owner-1" };
56
+ const repo = { id: "repo-1", ownerId: "owner-1", name: "demo" };
57
+ const pr = {
58
+ id: "pr-1",
59
+ repositoryId: "repo-1",
60
+ state: "open",
61
+ stateId: "custom-state-1",
62
+ isMerged: false,
63
+ };
64
+
65
+ const updateCalls: unknown[] = [];
66
+
67
+ return {
68
+ query: {
69
+ users: {
70
+ findFirst: vi.fn(async () => owner),
71
+ },
72
+ repositories: {
73
+ findFirst: vi.fn(async () => repo),
74
+ },
75
+ pullRequests: {
76
+ findFirst: vi.fn(async () => pr),
77
+ },
78
+ prStateDefinitions: {
79
+ findFirst: vi.fn(async () => null),
80
+ },
81
+ prStateReviewers: {
82
+ findMany: vi.fn(async () => []),
83
+ },
84
+ pullRequestReviews: {
85
+ findMany: vi.fn(async () => []),
86
+ },
87
+ teams: {
88
+ findFirst: vi.fn(async () => null),
89
+ },
90
+ teamMembers: {
91
+ findMany: vi.fn(async () => []),
92
+ },
93
+ users2: {
94
+ findFirst: vi.fn(async () => null),
95
+ },
96
+ },
97
+ update: vi.fn(() => ({
98
+ set: vi.fn((value: unknown) => ({
99
+ where: vi.fn(async () => {
100
+ updateCalls.push(value);
101
+ }),
102
+ })),
103
+ })),
104
+ __state: {
105
+ updateCalls,
106
+ },
107
+ };
108
+}
109
+
110
+async function readJson(response: Response): Promise<any> {
111
+ return response.json();
112
+}
113
+
114
+describe("PR update state route", () => {
115
+ beforeEach(() => {
116
+ mockDb = makeDb();
117
+ canWriteRepoMock.mockResolvedValue(true);
118
+ triggerAutomationMock.mockResolvedValue(undefined);
119
+ });
120
+
121
+ it("rejects unknown state values", async () => {
122
+ const response = await patchPr({
123
+ params: { owner: "owner-1", repo: "demo", number: "42" },
124
+ locals: { user: { id: "actor-1" } },
125
+ request: new Request("http://localhost/api/repos/owner-1/demo/pulls/42", {
126
+ method: "PATCH",
127
+ headers: { "content-type": "application/json" },
128
+ body: JSON.stringify({ state: "ready_to_ship" }),
129
+ }),
130
+ } as any);
131
+
132
+ const body = await readJson(response);
133
+ expect(response.status).toBe(400);
134
+ expect(body?.error?.code).toBe("BAD_REQUEST");
135
+ });
136
+
137
+ it("clears custom state reference when closing PR", async () => {
138
+ const response = await patchPr({
139
+ params: { owner: "owner-1", repo: "demo", number: "42" },
140
+ locals: { user: { id: "actor-1" } },
141
+ request: new Request("http://localhost/api/repos/owner-1/demo/pulls/42", {
142
+ method: "PATCH",
143
+ headers: { "content-type": "application/json" },
144
+ body: JSON.stringify({ state: "closed" }),
145
+ }),
146
+ } as any);
147
+
148
+ expect(response.status).toBe(200);
149
+ const updateData = mockDb.__state.updateCalls[0] as Record<string, unknown>;
150
+ expect(updateData.state).toBe("closed");
151
+ expect(updateData.stateId).toBeNull();
152
+ expect(updateData.closedById).toBe("actor-1");
153
+ });
154
+
155
+ it("clears custom state reference when reopening PR", async () => {
156
+ mockDb.query.pullRequests.findFirst.mockResolvedValueOnce({
157
+ id: "pr-1",
158
+ repositoryId: "repo-1",
159
+ state: "closed",
160
+ stateId: "custom-state-1",
161
+ isMerged: false,
162
+ });
163
+
164
+ const response = await patchPr({
165
+ params: { owner: "owner-1", repo: "demo", number: "42" },
166
+ locals: { user: { id: "actor-1" } },
167
+ request: new Request("http://localhost/api/repos/owner-1/demo/pulls/42", {
168
+ method: "PATCH",
169
+ headers: { "content-type": "application/json" },
170
+ body: JSON.stringify({ state: "open" }),
171
+ }),
172
+ } as any);
173
+
174
+ expect(response.status).toBe(200);
175
+ const updateData = mockDb.__state.updateCalls[0] as Record<string, unknown>;
176
+ expect(updateData.state).toBe("open");
177
+ expect(updateData.stateId).toBeNull();
178
+ expect(updateData.closedAt).toBeNull();
179
+ });
180
+});
0
181