Expose stack bulk-merge API with OpenAPI coverage

swadhinbiswas committed 8 months ago
cb06ef7
1 parent d6cd63a
commit cb06ef7
3 files changed
+201 -0
▼src/lib/openapi.ts
+35-0
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
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
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
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