Add pull-request auto-merge control API
swadhinbiswas committed 8 months ago
a103fe4
1 parent 2027011
commit a103fe4
4 files changed
+299 -1
Showing 4 changed files+299-1
▼doc/feature_audit.md
+1-1
| @@ -53,7 +53,7 @@ | ||
53 | 53 | | Required reviewers per state | ⚠️ | Basic reviewer requirements exist; full state-specific enforcement remains incomplete | |
54 | 54 | | PR merge queues | ✅ | `merge-queue.ts` (590+ lines) | |
55 | 55 | | Conflict detection before merge | ✅ | `mergeable` field in PR schema | |
56 | -| Auto-merge rules | ⚠️ | Basic via automations, incomplete | | |
56 | +| Auto-merge rules | ⚠️ | Auto-merge rules engine plus explicit PR auto-merge control/status APIs now implemented (`pulls/{number}/auto-merge`); richer policy UX and rule introspection remain partial | | |
57 | 57 | | Draft PRs | ✅ | Schema supports `isDraft` field | |
58 | 58 | | PR labels | ✅ | Full implementation with schema | |
59 | 59 | | PR assignees | ✅ | Full implementation with schema | |
60 | 60 | |
▼src/lib/openapi.ts
+64-0
| @@ -1124,6 +1124,70 @@ export const openApiSpec = { | ||
1124 | 1124 | }, |
1125 | 1125 | }, |
1126 | 1126 | }, |
1127 | + "/repos/{owner}/{repo}/pulls/{number}/auto-merge": { | |
1128 | + get: { | |
1129 | + tags: ["Pull Requests"], | |
1130 | + summary: "Get pull request auto-merge status", | |
1131 | + security: [{ bearerAuth: [] }], | |
1132 | + parameters: [ | |
1133 | + { name: "owner", in: "path", required: true, schema: { type: "string" } }, | |
1134 | + { name: "repo", in: "path", required: true, schema: { type: "string" } }, | |
1135 | + { name: "number", in: "path", required: true, schema: { type: "integer" } }, | |
1136 | + ], | |
1137 | + responses: { | |
1138 | + 200: { description: "Auto-merge status returned" }, | |
1139 | + 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1140 | + 404: { description: "Repository or pull request not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1141 | + }, | |
1142 | + }, | |
1143 | + post: { | |
1144 | + tags: ["Pull Requests"], | |
1145 | + summary: "Enable pull request auto-merge", | |
1146 | + security: [{ bearerAuth: [] }], | |
1147 | + parameters: [ | |
1148 | + { name: "owner", in: "path", required: true, schema: { type: "string" } }, | |
1149 | + { name: "repo", in: "path", required: true, schema: { type: "string" } }, | |
1150 | + { name: "number", in: "path", required: true, schema: { type: "integer" } }, | |
1151 | + ], | |
1152 | + requestBody: { | |
1153 | + required: false, | |
1154 | + content: { | |
1155 | + "application/json": { | |
1156 | + schema: { | |
1157 | + type: "object", | |
1158 | + properties: { | |
1159 | + mergeMethod: { type: "string", enum: ["merge", "squash", "rebase"], default: "merge" }, | |
1160 | + }, | |
1161 | + }, | |
1162 | + }, | |
1163 | + }, | |
1164 | + }, | |
1165 | + responses: { | |
1166 | + 200: { description: "Auto-merge enabled" }, | |
1167 | + 400: { description: "Invalid payload or PR not eligible", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1168 | + 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1169 | + 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1170 | + 404: { description: "Repository or pull request not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1171 | + }, | |
1172 | + }, | |
1173 | + delete: { | |
1174 | + tags: ["Pull Requests"], | |
1175 | + summary: "Disable pull request auto-merge", | |
1176 | + security: [{ bearerAuth: [] }], | |
1177 | + parameters: [ | |
1178 | + { name: "owner", in: "path", required: true, schema: { type: "string" } }, | |
1179 | + { name: "repo", in: "path", required: true, schema: { type: "string" } }, | |
1180 | + { name: "number", in: "path", required: true, schema: { type: "integer" } }, | |
1181 | + ], | |
1182 | + responses: { | |
1183 | + 200: { description: "Auto-merge disabled" }, | |
1184 | + 400: { description: "Disable failed", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1185 | + 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1186 | + 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1187 | + 404: { description: "Repository or pull request not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1188 | + }, | |
1189 | + }, | |
1190 | + }, | |
1127 | 1191 | "/repos/{owner}/{repo}/workflow/states": { |
1128 | 1192 | get: { |
1129 | 1193 | tags: ["Workflow"], |
1130 | 1194 | |
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/auto-merge.ts
+111-0
| @@ -0,0 +1,111 @@ | ||
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 { badRequest, forbidden, notFound, success, unauthorized } from "@/lib/api"; | |
7 | +import { withErrorHandler } from "@/lib/errors"; | |
8 | +import { canReadRepo, canWriteRepo } from "@/lib/permissions"; | |
9 | +import { disableAutoMerge, enableAutoMerge, getAutoMergeStatus } from "@/lib/auto-merge"; | |
10 | + | |
11 | +const enableSchema = z.object({ | |
12 | + mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(), | |
13 | +}); | |
14 | + | |
15 | +async function resolveRepoAndPr(params: { | |
16 | + owner?: string; | |
17 | + repo?: string; | |
18 | + number?: string; | |
19 | +}) { | |
20 | + const ownerName = params.owner; | |
21 | + const repoName = params.repo; | |
22 | + const number = Number.parseInt(params.number || "", 10); | |
23 | + if (!ownerName || !repoName || Number.isNaN(number)) { | |
24 | + return { response: badRequest("Missing or invalid parameters"), repo: null, pr: null } as const; | |
25 | + } | |
26 | + | |
27 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
28 | + const owner = await db.query.users.findFirst({ | |
29 | + where: eq(schema.users.username, ownerName), | |
30 | + }); | |
31 | + if (!owner) return { response: notFound("Repository not found"), repo: null, pr: null } as const; | |
32 | + | |
33 | + const repo = await db.query.repositories.findFirst({ | |
34 | + where: and( | |
35 | + eq(schema.repositories.ownerId, owner.id), | |
36 | + eq(schema.repositories.name, repoName) | |
37 | + ), | |
38 | + }); | |
39 | + if (!repo) return { response: notFound("Repository not found"), repo: null, pr: null } as const; | |
40 | + | |
41 | + const pr = await db.query.pullRequests.findFirst({ | |
42 | + where: and( | |
43 | + eq(schema.pullRequests.repositoryId, repo.id), | |
44 | + eq(schema.pullRequests.number, number) | |
45 | + ), | |
46 | + }); | |
47 | + if (!pr) return { response: notFound("Pull request not found"), repo: null, pr: null } as const; | |
48 | + | |
49 | + return { response: null, repo, pr } as const; | |
50 | +} | |
51 | + | |
52 | +export const GET: APIRoute = withErrorHandler(async ({ params, locals }) => { | |
53 | + const user = locals.user; | |
54 | + if (!user) return unauthorized(); | |
55 | + | |
56 | + const resolved = await resolveRepoAndPr(params); | |
57 | + if (resolved.response || !resolved.repo || !resolved.pr) return resolved.response || badRequest("Invalid request"); | |
58 | + | |
59 | + if (!(await canReadRepo(user.id, resolved.repo, { isAdmin: user.isAdmin }))) { | |
60 | + return notFound("Repository not found"); | |
61 | + } | |
62 | + | |
63 | + const status = await getAutoMergeStatus(resolved.pr.id); | |
64 | + return success(status); | |
65 | +}); | |
66 | + | |
67 | +export const POST: APIRoute = withErrorHandler(async ({ params, locals, request }) => { | |
68 | + const user = locals.user; | |
69 | + if (!user) return unauthorized(); | |
70 | + | |
71 | + const resolved = await resolveRepoAndPr(params); | |
72 | + if (resolved.response || !resolved.repo || !resolved.pr) return resolved.response || badRequest("Invalid request"); | |
73 | + | |
74 | + if (!(await canWriteRepo(user.id, resolved.repo, { isAdmin: user.isAdmin }))) { | |
75 | + return forbidden(); | |
76 | + } | |
77 | + | |
78 | + const body = await request.json().catch(() => null); | |
79 | + const parsed = enableSchema.safeParse(body || {}); | |
80 | + if (!parsed.success) { | |
81 | + return badRequest(parsed.error.issues[0]?.message || "Invalid auto-merge payload"); | |
82 | + } | |
83 | + | |
84 | + const result = await enableAutoMerge(resolved.pr.id, user.id, { | |
85 | + mergeMethod: parsed.data.mergeMethod || "merge", | |
86 | + }); | |
87 | + if (!result.success) { | |
88 | + return badRequest(result.error || "Failed to enable auto-merge"); | |
89 | + } | |
90 | + | |
91 | + const status = await getAutoMergeStatus(resolved.pr.id); | |
92 | + return success(status); | |
93 | +}); | |
94 | + | |
95 | +export const DELETE: APIRoute = withErrorHandler(async ({ params, locals }) => { | |
96 | + const user = locals.user; | |
97 | + if (!user) return unauthorized(); | |
98 | + | |
99 | + const resolved = await resolveRepoAndPr(params); | |
100 | + if (resolved.response || !resolved.repo || !resolved.pr) return resolved.response || badRequest("Invalid request"); | |
101 | + | |
102 | + if (!(await canWriteRepo(user.id, resolved.repo, { isAdmin: user.isAdmin }))) { | |
103 | + return forbidden(); | |
104 | + } | |
105 | + | |
106 | + const ok = await disableAutoMerge(resolved.pr.id); | |
107 | + if (!ok) return badRequest("Failed to disable auto-merge"); | |
108 | + | |
109 | + const status = await getAutoMergeStatus(resolved.pr.id); | |
110 | + return success(status); | |
111 | +}); | |
0 | 112 | |
▼tests/integration/pr-auto-merge-route.test.ts
+123-0
| @@ -0,0 +1,123 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { | |
4 | + canReadRepoMock, | |
5 | + canWriteRepoMock, | |
6 | + getAutoMergeStatusMock, | |
7 | + enableAutoMergeMock, | |
8 | + disableAutoMergeMock, | |
9 | + fakeSchema, | |
10 | +} = vi.hoisted(() => ({ | |
11 | + canReadRepoMock: vi.fn(async () => true), | |
12 | + canWriteRepoMock: vi.fn(async () => true), | |
13 | + getAutoMergeStatusMock: vi.fn(async () => ({ | |
14 | + enabled: true, | |
15 | + eligibleToMerge: false, | |
16 | + blockers: ["1 pending check(s)"], | |
17 | + })), | |
18 | + enableAutoMergeMock: vi.fn(async () => ({ success: true })), | |
19 | + disableAutoMergeMock: vi.fn(async () => true), | |
20 | + fakeSchema: { | |
21 | + users: { username: {} }, | |
22 | + repositories: { ownerId: {}, name: {}, id: {} }, | |
23 | + pullRequests: { repositoryId: {}, number: {}, id: {} }, | |
24 | + } as any, | |
25 | +})); | |
26 | + | |
27 | +let mockDb: any; | |
28 | + | |
29 | +vi.mock("@/db", () => ({ | |
30 | + getDatabase: () => mockDb, | |
31 | + schema: fakeSchema, | |
32 | +})); | |
33 | + | |
34 | +vi.mock("@/lib/permissions", () => ({ | |
35 | + canReadRepo: canReadRepoMock, | |
36 | + canWriteRepo: canWriteRepoMock, | |
37 | +})); | |
38 | + | |
39 | +vi.mock("@/lib/auto-merge", () => ({ | |
40 | + getAutoMergeStatus: getAutoMergeStatusMock, | |
41 | + enableAutoMerge: enableAutoMergeMock, | |
42 | + disableAutoMerge: disableAutoMergeMock, | |
43 | +})); | |
44 | + | |
45 | +import { | |
46 | + GET as getAutoMergeGet, | |
47 | + POST as autoMergePost, | |
48 | + DELETE as autoMergeDelete, | |
49 | +} from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/auto-merge"; | |
50 | + | |
51 | +function makeDb() { | |
52 | + return { | |
53 | + query: { | |
54 | + users: { | |
55 | + findFirst: vi.fn(async () => ({ id: "owner-1" })), | |
56 | + }, | |
57 | + repositories: { | |
58 | + findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })), | |
59 | + }, | |
60 | + pullRequests: { | |
61 | + findFirst: vi.fn(async () => ({ id: "pr-1", repositoryId: "repo-1", number: 12 })), | |
62 | + }, | |
63 | + }, | |
64 | + }; | |
65 | +} | |
66 | + | |
67 | +async function readJson(response: Response): Promise<any> { | |
68 | + return response.json(); | |
69 | +} | |
70 | + | |
71 | +describe("pull request auto-merge route", () => { | |
72 | + beforeEach(() => { | |
73 | + mockDb = makeDb(); | |
74 | + canReadRepoMock.mockResolvedValue(true); | |
75 | + canWriteRepoMock.mockResolvedValue(true); | |
76 | + getAutoMergeStatusMock.mockResolvedValue({ | |
77 | + enabled: true, | |
78 | + eligibleToMerge: false, | |
79 | + blockers: ["1 pending check(s)"], | |
80 | + }); | |
81 | + enableAutoMergeMock.mockResolvedValue({ success: true }); | |
82 | + disableAutoMergeMock.mockResolvedValue(true); | |
83 | + }); | |
84 | + | |
85 | + it("returns status for authorized readers", async () => { | |
86 | + const response = await getAutoMergeGet({ | |
87 | + params: { owner: "owner-1", repo: "demo", number: "12" }, | |
88 | + locals: { user: { id: "user-1", isAdmin: false } }, | |
89 | + } as any); | |
90 | + | |
91 | + const body = await readJson(response); | |
92 | + expect(response.status).toBe(200); | |
93 | + expect(body?.data?.enabled).toBe(true); | |
94 | + expect(getAutoMergeStatusMock).toHaveBeenCalledWith("pr-1"); | |
95 | + }); | |
96 | + | |
97 | + it("enables auto-merge for writers", async () => { | |
98 | + const response = await autoMergePost({ | |
99 | + params: { owner: "owner-1", repo: "demo", number: "12" }, | |
100 | + locals: { user: { id: "user-1", isAdmin: false } }, | |
101 | + request: new Request("http://localhost/api/repos/owner-1/demo/pulls/12/auto-merge", { | |
102 | + method: "POST", | |
103 | + headers: { "content-type": "application/json" }, | |
104 | + body: JSON.stringify({ mergeMethod: "squash" }), | |
105 | + }), | |
106 | + } as any); | |
107 | + | |
108 | + expect(response.status).toBe(200); | |
109 | + expect(enableAutoMergeMock).toHaveBeenCalledWith("pr-1", "user-1", { | |
110 | + mergeMethod: "squash", | |
111 | + }); | |
112 | + }); | |
113 | + | |
114 | + it("disables auto-merge for writers", async () => { | |
115 | + const response = await autoMergeDelete({ | |
116 | + params: { owner: "owner-1", repo: "demo", number: "12" }, | |
117 | + locals: { user: { id: "user-1", isAdmin: false } }, | |
118 | + } as any); | |
119 | + | |
120 | + expect(response.status).toBe(200); | |
121 | + expect(disableAutoMergeMock).toHaveBeenCalledWith("pr-1"); | |
122 | + }); | |
123 | +}); | |
0 | 124 | |