Add repository merge gates management API

swadhinbiswas committed 8 months ago
8879d41
1 parent 2c88b03
commit 8879d41
4 files changed
+361 -1
▼doc/feature_audit.md
+1-1
94
94
| External CI integration | ⚠️ | Provider-aware integration APIs and normalized status ingestion are implemented, with repo-level integration health/status + token rotate/disable APIs (`external-ci`, `external-ci/checks`); provider-specific setup UX still maturing |
95
95
| Self-hosted runners | ✅ | `runner/` with Docker executor |
96
96
| Secrets management | ✅ | Schema + UI for repo/org secrets |
97
-| Merge checks & gates | ⚠️ | Required status checks + external CI gate readiness are enforced, with explicit PR gate introspection via `pulls/{number}/merge-readiness`; broader policy UX remains |
97
+| Merge checks & gates | ⚠️ | Required status checks + external CI gate readiness are enforced, with explicit PR gate introspection via `pulls/{number}/merge-readiness` and repository-level merge gate management via `/repos/{owner}/{repo}/merge-gates`; broader policy UX remains |
98
98
| Automation rules engine | ⚠️ | Conditions/actions active with retry + dead-letter audit logging; advanced orchestration still maturing |
99
99
| Webhooks | ✅ | Full implementation with UI |
100
100
| Workflow templates | ⚠️ | Template library plus repo-level listing/apply APIs are now implemented (`workflow/templates`); broader UX/workflow adoption still maturing |
101
101
▼src/lib/openapi.ts
+67-0
1000
1000
},
1001
1001
},
1002
1002
},
1003
+ "/repos/{owner}/{repo}/merge-gates": {
1004
+ get: {
1005
+ tags: ["CI/CD"],
1006
+ summary: "List repository merge gates and required checks",
1007
+ description: "Returns configured required status checks and merge gate definitions used by merge-readiness evaluation.",
1008
+ security: [{ bearerAuth: [] }],
1009
+ parameters: [
1010
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
1011
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
1012
+ ],
1013
+ responses: {
1014
+ 200: { description: "Merge gate configuration returned" },
1015
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1016
+ 404: { description: "Repository not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1017
+ },
1018
+ },
1019
+ post: {
1020
+ tags: ["CI/CD"],
1021
+ summary: "Create required check or merge gate rule",
1022
+ description: "Allows repository admins to create `required_check` rules or higher-level `merge_gate` policies.",
1023
+ security: [{ bearerAuth: [] }],
1024
+ parameters: [
1025
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
1026
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
1027
+ ],
1028
+ requestBody: {
1029
+ required: true,
1030
+ content: {
1031
+ "application/json": {
1032
+ schema: {
1033
+ oneOf: [
1034
+ {
1035
+ type: "object",
1036
+ properties: {
1037
+ kind: { type: "string", enum: ["required_check"] },
1038
+ branch: { type: "string" },
1039
+ checkName: { type: "string" },
1040
+ strictMode: { type: "boolean" },
1041
+ },
1042
+ required: ["kind", "branch", "checkName"],
1043
+ },
1044
+ {
1045
+ type: "object",
1046
+ properties: {
1047
+ kind: { type: "string", enum: ["merge_gate"] },
1048
+ name: { type: "string" },
1049
+ description: { type: "string" },
1050
+ gateType: { type: "string", enum: ["status_check", "review", "label", "custom"] },
1051
+ config: { type: "object", additionalProperties: true },
1052
+ conditionScript: { type: "string" },
1053
+ },
1054
+ required: ["kind", "name", "gateType"],
1055
+ },
1056
+ ],
1057
+ },
1058
+ },
1059
+ },
1060
+ },
1061
+ responses: {
1062
+ 200: { description: "Merge gate configuration created" },
1063
+ 400: { description: "Invalid payload", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1064
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1065
+ 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1066
+ 404: { description: "Repository not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1067
+ },
1068
+ },
1069
+ },
1003
1070
"/user/notification-digests/test": {
1004
1071
post: {
1005
1072
tags: ["Notifications"],
1006
1073
▼src/pages/api/repos/[owner]/[repo]/merge-gates.ts
+109-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 { canAdminRepo, canReadRepo } from "@/lib/permissions";
9
+import { addRequiredCheck, createMergeGate, getMergeGates } from "@/lib/ci-gates";
10
+
11
+const createPayloadSchema = z.discriminatedUnion("kind", [
12
+ z.object({
13
+ kind: z.literal("required_check"),
14
+ branch: z.string().min(1),
15
+ checkName: z.string().min(1),
16
+ strictMode: z.boolean().optional(),
17
+ }),
18
+ z.object({
19
+ kind: z.literal("merge_gate"),
20
+ name: z.string().min(1),
21
+ description: z.string().optional(),
22
+ gateType: z.enum(["status_check", "review", "label", "custom"]),
23
+ config: z.record(z.string(), z.unknown()).optional(),
24
+ conditionScript: z.string().optional(),
25
+ }),
26
+]);
27
+
28
+async function resolveRepository(owner: string, repoName: string) {
29
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
30
+ const ownerUser = await db.query.users.findFirst({
31
+ where: eq(schema.users.username, owner),
32
+ });
33
+ if (!ownerUser) return null;
34
+ return db.query.repositories.findFirst({
35
+ where: and(
36
+ eq(schema.repositories.ownerId, ownerUser.id),
37
+ eq(schema.repositories.name, repoName)
38
+ ),
39
+ });
40
+}
41
+
42
+export const GET: APIRoute = withErrorHandler(async ({ params, locals }) => {
43
+ const owner = params.owner;
44
+ const repoName = params.repo;
45
+ const user = locals.user;
46
+
47
+ if (!user) return unauthorized();
48
+ if (!owner || !repoName) return badRequest("Missing parameters");
49
+
50
+ const repository = await resolveRepository(owner, repoName);
51
+ if (!repository) return notFound("Repository not found");
52
+
53
+ if (!(await canReadRepo(user.id, repository, { isAdmin: user.isAdmin }))) {
54
+ return notFound("Repository not found");
55
+ }
56
+
57
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
58
+ const requiredChecks = await db.query.requiredStatusChecks.findMany({
59
+ where: eq(schema.requiredStatusChecks.repositoryId, repository.id),
60
+ });
61
+ const mergeGates = await getMergeGates(repository.id);
62
+
63
+ return success({
64
+ requiredChecks,
65
+ mergeGates,
66
+ });
67
+});
68
+
69
+export const POST: APIRoute = withErrorHandler(async ({ params, request, locals }) => {
70
+ const owner = params.owner;
71
+ const repoName = params.repo;
72
+ const user = locals.user;
73
+
74
+ if (!user) return unauthorized();
75
+ if (!owner || !repoName) return badRequest("Missing parameters");
76
+
77
+ const repository = await resolveRepository(owner, repoName);
78
+ if (!repository) return notFound("Repository not found");
79
+
80
+ if (!(await canAdminRepo(user.id, repository, { isAdmin: user.isAdmin }))) {
81
+ return forbidden();
82
+ }
83
+
84
+ const body = await request.json().catch(() => null);
85
+ const parsed = createPayloadSchema.safeParse(body || {});
86
+ if (!parsed.success) {
87
+ return badRequest(parsed.error.issues[0]?.message || "Invalid merge gate payload");
88
+ }
89
+
90
+ if (parsed.data.kind === "required_check") {
91
+ const check = await addRequiredCheck({
92
+ repositoryId: repository.id,
93
+ branch: parsed.data.branch,
94
+ checkName: parsed.data.checkName,
95
+ strictMode: parsed.data.strictMode,
96
+ });
97
+ return success({ kind: "required_check", check });
98
+ }
99
+
100
+ const gate = await createMergeGate({
101
+ repositoryId: repository.id,
102
+ name: parsed.data.name,
103
+ description: parsed.data.description,
104
+ gateType: parsed.data.gateType,
105
+ config: parsed.data.config,
106
+ conditionScript: parsed.data.conditionScript,
107
+ });
108
+ return success({ kind: "merge_gate", gate });
109
+});
0
110
▼tests/integration/merge-gates-route.test.ts
+184-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ canReadRepoMock,
5
+ canAdminRepoMock,
6
+ addRequiredCheckMock,
7
+ createMergeGateMock,
8
+ getMergeGatesMock,
9
+ fakeSchema,
10
+} = vi.hoisted(() => ({
11
+ canReadRepoMock: vi.fn(async () => true),
12
+ canAdminRepoMock: vi.fn(async () => true),
13
+ addRequiredCheckMock: vi.fn(async () => ({ id: "check-1", checkName: "ci/build", branch: "main" })),
14
+ createMergeGateMock: vi.fn(async () => ({ id: "gate-1", name: "PR Label Gate", gateType: "label" })),
15
+ getMergeGatesMock: vi.fn(async () => [{ id: "gate-1", name: "PR Label Gate", gateType: "label" }]),
16
+ fakeSchema: {
17
+ users: { username: {} },
18
+ repositories: { ownerId: {}, name: {}, id: {} },
19
+ requiredStatusChecks: { repositoryId: {} },
20
+ } as any,
21
+}));
22
+
23
+let mockDb: any;
24
+
25
+vi.mock("@/db", () => ({
26
+ getDatabase: () => mockDb,
27
+ schema: fakeSchema,
28
+}));
29
+
30
+vi.mock("@/lib/permissions", () => ({
31
+ canReadRepo: canReadRepoMock,
32
+ canAdminRepo: canAdminRepoMock,
33
+}));
34
+
35
+vi.mock("@/lib/ci-gates", () => ({
36
+ addRequiredCheck: addRequiredCheckMock,
37
+ createMergeGate: createMergeGateMock,
38
+ getMergeGates: getMergeGatesMock,
39
+}));
40
+
41
+import { GET as mergeGatesGet, POST as mergeGatesPost } from "@/pages/api/repos/[owner]/[repo]/merge-gates";
42
+
43
+function makeDb() {
44
+ return {
45
+ query: {
46
+ users: {
47
+ findFirst: vi.fn(async () => ({ id: "owner-1" })),
48
+ },
49
+ repositories: {
50
+ findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })),
51
+ },
52
+ requiredStatusChecks: {
53
+ findMany: vi.fn(async () => [{ id: "check-1", checkName: "ci/build", branch: "main" }]),
54
+ },
55
+ },
56
+ };
57
+}
58
+
59
+async function readJson(response: Response): Promise<any> {
60
+ return response.json();
61
+}
62
+
63
+describe("merge gates route", () => {
64
+ beforeEach(() => {
65
+ vi.clearAllMocks();
66
+ mockDb = makeDb();
67
+ canReadRepoMock.mockResolvedValue(true);
68
+ canAdminRepoMock.mockResolvedValue(true);
69
+ addRequiredCheckMock.mockResolvedValue({ id: "check-1", checkName: "ci/build", branch: "main" });
70
+ createMergeGateMock.mockResolvedValue({ id: "gate-1", name: "PR Label Gate", gateType: "label" });
71
+ getMergeGatesMock.mockResolvedValue([{ id: "gate-1", name: "PR Label Gate", gateType: "label" }]);
72
+ });
73
+
74
+ it("returns merge gate policy configuration for readers", async () => {
75
+ const response = await mergeGatesGet({
76
+ params: { owner: "owner-1", repo: "demo" },
77
+ locals: { user: { id: "user-1", isAdmin: false } },
78
+ } as any);
79
+
80
+ const body = await readJson(response);
81
+ expect(response.status).toBe(200);
82
+ expect(body?.data?.requiredChecks).toHaveLength(1);
83
+ expect(body?.data?.mergeGates).toHaveLength(1);
84
+ });
85
+
86
+ it("returns 401 for unauthenticated reads", async () => {
87
+ const response = await mergeGatesGet({
88
+ params: { owner: "owner-1", repo: "demo" },
89
+ locals: {},
90
+ } as any);
91
+
92
+ expect(response.status).toBe(401);
93
+ });
94
+
95
+ it("creates required check for admins", async () => {
96
+ const response = await mergeGatesPost({
97
+ params: { owner: "owner-1", repo: "demo" },
98
+ locals: { user: { id: "user-1", isAdmin: false } },
99
+ request: new Request("http://localhost/api/repos/owner-1/demo/merge-gates", {
100
+ method: "POST",
101
+ headers: { "content-type": "application/json" },
102
+ body: JSON.stringify({
103
+ kind: "required_check",
104
+ branch: "main",
105
+ checkName: "ci/build",
106
+ }),
107
+ }),
108
+ } as any);
109
+
110
+ expect(response.status).toBe(200);
111
+ expect(addRequiredCheckMock).toHaveBeenCalledWith({
112
+ repositoryId: "repo-1",
113
+ branch: "main",
114
+ checkName: "ci/build",
115
+ strictMode: undefined,
116
+ });
117
+ });
118
+
119
+ it("creates merge gate for admins", async () => {
120
+ const response = await mergeGatesPost({
121
+ params: { owner: "owner-1", repo: "demo" },
122
+ locals: { user: { id: "user-1", isAdmin: false } },
123
+ request: new Request("http://localhost/api/repos/owner-1/demo/merge-gates", {
124
+ method: "POST",
125
+ headers: { "content-type": "application/json" },
126
+ body: JSON.stringify({
127
+ kind: "merge_gate",
128
+ name: "PR Label Gate",
129
+ gateType: "label",
130
+ config: { required: "ready-to-merge" },
131
+ }),
132
+ }),
133
+ } as any);
134
+
135
+ expect(response.status).toBe(200);
136
+ expect(createMergeGateMock).toHaveBeenCalledWith({
137
+ repositoryId: "repo-1",
138
+ name: "PR Label Gate",
139
+ description: undefined,
140
+ gateType: "label",
141
+ config: { required: "ready-to-merge" },
142
+ conditionScript: undefined,
143
+ });
144
+ });
145
+
146
+ it("returns 403 when non-admin attempts write", async () => {
147
+ canAdminRepoMock.mockResolvedValue(false);
148
+ const response = await mergeGatesPost({
149
+ params: { owner: "owner-1", repo: "demo" },
150
+ locals: { user: { id: "user-1", isAdmin: false } },
151
+ request: new Request("http://localhost/api/repos/owner-1/demo/merge-gates", {
152
+ method: "POST",
153
+ headers: { "content-type": "application/json" },
154
+ body: JSON.stringify({
155
+ kind: "required_check",
156
+ branch: "main",
157
+ checkName: "ci/build",
158
+ }),
159
+ }),
160
+ } as any);
161
+
162
+ expect(response.status).toBe(403);
163
+ expect(addRequiredCheckMock).not.toHaveBeenCalled();
164
+ expect(createMergeGateMock).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it("returns 400 for invalid payload", async () => {
168
+ const response = await mergeGatesPost({
169
+ params: { owner: "owner-1", repo: "demo" },
170
+ locals: { user: { id: "user-1", isAdmin: false } },
171
+ request: new Request("http://localhost/api/repos/owner-1/demo/merge-gates", {
172
+ method: "POST",
173
+ headers: { "content-type": "application/json" },
174
+ body: JSON.stringify({
175
+ kind: "merge_gate",
176
+ gateType: "label",
177
+ }),
178
+ }),
179
+ } as any);
180
+
181
+ expect(response.status).toBe(400);
182
+ expect(createMergeGateMock).not.toHaveBeenCalled();
183
+ });
184
+});
0
185