Add pull-request auto-merge control API

swadhinbiswas committed 8 months ago
a103fe4
1 parent 2027011
commit a103fe4
4 files changed
+299 -1
▼doc/feature_audit.md
+1-1
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
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
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
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