Add repository merge gates management API
swadhinbiswas committed 8 months ago
8879d41
1 parent 2c88b03
commit 8879d41
4 files changed
+361 -1
Showing 4 changed files+361-1
▼doc/feature_audit.md
+1-1
| @@ -94,7 +94,7 @@ | ||
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,6 +1000,73 @@ export const openApiSpec = { | ||
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
| @@ -0,0 +1,109 @@ | ||
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
| @@ -0,0 +1,184 @@ | ||
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 | |