Expose stack bulk-merge API with OpenAPI coverage
swadhinbiswas committed 8 months ago
cb06ef7
1 parent d6cd63a
commit cb06ef7
3 files changed
+201 -0
Showing 3 changed files+201-0
▼src/lib/openapi.ts
+35-0
| @@ -146,6 +146,13 @@ export const openApiSpec = { | ||
146 | 146 | }, |
147 | 147 | required: ["reviewers"], |
148 | 148 | }, |
149 | + StackMergeRequest: { | |
150 | + type: "object", | |
151 | + properties: { | |
152 | + mergeMethod: { type: "string", enum: ["merge", "squash", "rebase"] }, | |
153 | + skipApprovalCheck: { type: "boolean" }, | |
154 | + }, | |
155 | + }, | |
149 | 156 | PullRequestCommentCreateRequest: { |
150 | 157 | type: "object", |
151 | 158 | properties: { |
| @@ -523,6 +530,34 @@ export const openApiSpec = { | ||
523 | 530 | }, |
524 | 531 | }, |
525 | 532 | }, |
533 | + "/repos/{owner}/{repo}/stacks/{stackId}/merge": { | |
534 | + post: { | |
535 | + tags: ["Stacks"], | |
536 | + summary: "Queue merge for all PRs in a stack", | |
537 | + description: "Attempts to merge an entire stack in order by enqueuing each eligible PR into the merge queue.", | |
538 | + security: [{ bearerAuth: [] }], | |
539 | + parameters: [ | |
540 | + { name: "owner", in: "path", required: true, schema: { type: "string" } }, | |
541 | + { name: "repo", in: "path", required: true, schema: { type: "string" } }, | |
542 | + { name: "stackId", in: "path", required: true, schema: { type: "string" } }, | |
543 | + ], | |
544 | + requestBody: { | |
545 | + required: false, | |
546 | + content: { | |
547 | + "application/json": { | |
548 | + schema: { $ref: "#/components/schemas/StackMergeRequest" }, | |
549 | + }, | |
550 | + }, | |
551 | + }, | |
552 | + responses: { | |
553 | + 200: { description: "Bulk merge result returned" }, | |
554 | + 400: { description: "Invalid merge payload", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
555 | + 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
556 | + 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
557 | + 404: { description: "Repository or stack not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
558 | + }, | |
559 | + }, | |
560 | + }, | |
526 | 561 | "/repos/{owner}/{repo}/pulls/{number}/comments": { |
527 | 562 | get: { |
528 | 563 | tags: ["Pull Requests"], |
529 | 564 | |
▼src/pages/api/repos/[owner]/[repo]/stacks/[stackId]/merge.ts
+59-0
| @@ -0,0 +1,59 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, eq } from "drizzle-orm"; | |
4 | +import { z } from "zod"; | |
5 | +import { getDatabase, schema } from "@/db"; | |
6 | +import { canWriteRepo } from "@/lib/permissions"; | |
7 | +import { withErrorHandler } from "@/lib/errors"; | |
8 | +import { badRequest, notFound, success, unauthorized, forbidden } from "@/lib/api"; | |
9 | +import { bulkMergeStack } from "@/lib/bulk-merge"; | |
10 | + | |
11 | +const mergeStackSchema = z.object({ | |
12 | + mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), | |
13 | + skipApprovalCheck: z.boolean().optional(), | |
14 | +}); | |
15 | + | |
16 | +export const POST: APIRoute = withErrorHandler(async ({ params, locals, request }) => { | |
17 | + const { owner: ownerName, repo: repoName, stackId } = params; | |
18 | + const user = locals.user; | |
19 | + | |
20 | + if (!user) return unauthorized(); | |
21 | + if (!ownerName || !repoName || !stackId) return badRequest("Missing parameters"); | |
22 | + | |
23 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
24 | + const owner = await db.query.users.findFirst({ | |
25 | + where: eq(schema.users.username, ownerName), | |
26 | + }); | |
27 | + if (!owner) return notFound("Repository not found"); | |
28 | + | |
29 | + const repo = await db.query.repositories.findFirst({ | |
30 | + where: and( | |
31 | + eq(schema.repositories.ownerId, owner.id), | |
32 | + eq(schema.repositories.name, repoName) | |
33 | + ), | |
34 | + }); | |
35 | + if (!repo) return notFound("Repository not found"); | |
36 | + if (!(await canWriteRepo(user.id, repo))) return forbidden(); | |
37 | + | |
38 | + const stack = await db.query.prStacks.findFirst({ | |
39 | + where: and( | |
40 | + eq(schema.prStacks.id, stackId), | |
41 | + eq(schema.prStacks.repositoryId, repo.id) | |
42 | + ), | |
43 | + }); | |
44 | + if (!stack) return notFound("Stack not found"); | |
45 | + | |
46 | + const body = await request.json().catch(() => null); | |
47 | + const parsed = mergeStackSchema.safeParse(body || {}); | |
48 | + if (!parsed.success) { | |
49 | + return badRequest(parsed.error.issues[0]?.message || "Invalid merge payload"); | |
50 | + } | |
51 | + | |
52 | + const result = await bulkMergeStack(stackId, user.id, { | |
53 | + mergeMethod: parsed.data.mergeMethod, | |
54 | + skipApprovalCheck: parsed.data.skipApprovalCheck, | |
55 | + }); | |
56 | + | |
57 | + return success(result); | |
58 | +}); | |
59 | + | |
0 | 60 | |
▼tests/integration/stack-merge-route.test.ts
+107-0
| @@ -0,0 +1,107 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { | |
4 | + canWriteRepoMock, | |
5 | + bulkMergeStackMock, | |
6 | + fakeSchema, | |
7 | +} = vi.hoisted(() => ({ | |
8 | + canWriteRepoMock: vi.fn(async () => true), | |
9 | + bulkMergeStackMock: vi.fn(async () => ({ | |
10 | + success: true, | |
11 | + merged: [{ prId: "pr-1", prNumber: 1 }], | |
12 | + failed: [], | |
13 | + skipped: [], | |
14 | + })), | |
15 | + fakeSchema: { | |
16 | + users: { username: {} }, | |
17 | + repositories: { ownerId: {}, name: {}, id: {} }, | |
18 | + prStacks: { id: {}, repositoryId: {} }, | |
19 | + } as any, | |
20 | +})); | |
21 | + | |
22 | +let mockDb: any; | |
23 | + | |
24 | +vi.mock("@/db", () => ({ | |
25 | + getDatabase: () => mockDb, | |
26 | + schema: fakeSchema, | |
27 | +})); | |
28 | + | |
29 | +vi.mock("@/lib/permissions", () => ({ | |
30 | + canWriteRepo: canWriteRepoMock, | |
31 | +})); | |
32 | + | |
33 | +vi.mock("@/lib/bulk-merge", () => ({ | |
34 | + bulkMergeStack: bulkMergeStackMock, | |
35 | +})); | |
36 | + | |
37 | +import { POST as mergeStackPost } from "@/pages/api/repos/[owner]/[repo]/stacks/[stackId]/merge"; | |
38 | + | |
39 | +function makeDb() { | |
40 | + return { | |
41 | + query: { | |
42 | + users: { | |
43 | + findFirst: vi.fn(async () => ({ id: "owner-1" })), | |
44 | + }, | |
45 | + repositories: { | |
46 | + findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })), | |
47 | + }, | |
48 | + prStacks: { | |
49 | + findFirst: vi.fn(async () => ({ id: "stack-1", repositoryId: "repo-1" })), | |
50 | + }, | |
51 | + }, | |
52 | + }; | |
53 | +} | |
54 | + | |
55 | +async function readJson(response: Response): Promise<any> { | |
56 | + return response.json(); | |
57 | +} | |
58 | + | |
59 | +describe("stack merge route", () => { | |
60 | + beforeEach(() => { | |
61 | + mockDb = makeDb(); | |
62 | + canWriteRepoMock.mockResolvedValue(true); | |
63 | + bulkMergeStackMock.mockResolvedValue({ | |
64 | + success: true, | |
65 | + merged: [{ prId: "pr-1", prNumber: 1 }], | |
66 | + failed: [], | |
67 | + skipped: [], | |
68 | + }); | |
69 | + }); | |
70 | + | |
71 | + it("returns forbidden when user lacks repo write permission", async () => { | |
72 | + canWriteRepoMock.mockResolvedValue(false); | |
73 | + | |
74 | + const response = await mergeStackPost({ | |
75 | + params: { owner: "owner-1", repo: "demo", stackId: "stack-1" }, | |
76 | + locals: { user: { id: "user-1" } }, | |
77 | + request: new Request("http://localhost/api/repos/owner-1/demo/stacks/stack-1/merge", { | |
78 | + method: "POST", | |
79 | + headers: { "content-type": "application/json" }, | |
80 | + body: JSON.stringify({ mergeMethod: "merge" }), | |
81 | + }), | |
82 | + } as any); | |
83 | + | |
84 | + expect(response.status).toBe(403); | |
85 | + }); | |
86 | + | |
87 | + it("calls bulk merge with merge options", async () => { | |
88 | + const response = await mergeStackPost({ | |
89 | + params: { owner: "owner-1", repo: "demo", stackId: "stack-1" }, | |
90 | + locals: { user: { id: "user-1" } }, | |
91 | + request: new Request("http://localhost/api/repos/owner-1/demo/stacks/stack-1/merge", { | |
92 | + method: "POST", | |
93 | + headers: { "content-type": "application/json" }, | |
94 | + body: JSON.stringify({ mergeMethod: "squash", skipApprovalCheck: true }), | |
95 | + }), | |
96 | + } as any); | |
97 | + | |
98 | + const body = await readJson(response); | |
99 | + expect(response.status).toBe(200); | |
100 | + expect(body?.data?.success).toBe(true); | |
101 | + expect(bulkMergeStackMock).toHaveBeenCalledWith("stack-1", "user-1", { | |
102 | + mergeMethod: "squash", | |
103 | + skipApprovalCheck: true, | |
104 | + }); | |
105 | + }); | |
106 | +}); | |
107 | + | |
0 | 108 | |