Add repository workflow templates list/apply APIs

swadhinbiswas committed 8 months ago
a5ce3df
1 parent ec784ac
commit a5ce3df
4 files changed
+275 -1
▼doc/feature_audit.md
+1-1
97
97
| Merge checks & gates | ⚠️ | Required status checks + external CI gate readiness enforced in merge queue; 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
-| Workflow templates | ⚠️ | Template library exists, broader UX/workflow adoption still maturing |
100
+| Workflow templates | ⚠️ | Template library plus repo-level listing/apply APIs are now implemented (`workflow/templates`); broader UX/workflow adoption still maturing |
101
101
102
102
## 6. Third-Party Integrations
103
103
### 6.1 Code Quality & Coverage
104
104
▼src/lib/openapi.ts
+49-0
1252
1252
},
1253
1253
},
1254
1254
},
1255
+ "/repos/{owner}/{repo}/workflow/templates": {
1256
+ get: {
1257
+ tags: ["CI/CD"],
1258
+ summary: "List workflow templates for repository setup",
1259
+ security: [{ bearerAuth: [] }],
1260
+ parameters: [
1261
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
1262
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
1263
+ { name: "category", in: "query", required: false, schema: { type: "string" } },
1264
+ { name: "language", in: "query", required: false, schema: { type: "string" } },
1265
+ ],
1266
+ responses: {
1267
+ 200: { description: "Workflow templates returned" },
1268
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1269
+ 404: { description: "Repository not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1270
+ },
1271
+ },
1272
+ post: {
1273
+ tags: ["CI/CD"],
1274
+ summary: "Apply workflow template to repository",
1275
+ security: [{ bearerAuth: [] }],
1276
+ parameters: [
1277
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
1278
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
1279
+ ],
1280
+ requestBody: {
1281
+ required: true,
1282
+ content: {
1283
+ "application/json": {
1284
+ schema: {
1285
+ type: "object",
1286
+ properties: {
1287
+ templateId: { type: "string" },
1288
+ workflowName: { type: "string" },
1289
+ },
1290
+ required: ["templateId"],
1291
+ },
1292
+ },
1293
+ },
1294
+ },
1295
+ responses: {
1296
+ 200: { description: "Workflow template applied" },
1297
+ 400: { description: "Invalid payload", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1298
+ 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1299
+ 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1300
+ 404: { description: "Repository not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
1301
+ },
1302
+ },
1303
+ },
1255
1304
"/repos/{owner}/{repo}/workflow/states": {
1256
1305
get: {
1257
1306
tags: ["Workflow"],
1258
1307
▼src/pages/api/repos/[owner]/[repo]/workflow/templates.ts
+86-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, parseBody, parseQuery, success, unauthorized } from "@/lib/api";
7
+import { withErrorHandler } from "@/lib/errors";
8
+import { canReadRepo, canWriteRepo } from "@/lib/permissions";
9
+import { applyTemplateToRepo, getWorkflowTemplates } from "@/lib/workflow-templates";
10
+
11
+const querySchema = z.object({
12
+ category: z.string().optional(),
13
+ language: z.string().optional(),
14
+});
15
+
16
+const applyTemplateSchema = z.object({
17
+ templateId: z.string().min(1),
18
+ workflowName: z.string().min(1).max(120).optional(),
19
+});
20
+
21
+async function resolveRepository(owner: string, repoName: string) {
22
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
23
+ const ownerUser = await db.query.users.findFirst({
24
+ where: eq(schema.users.username, owner),
25
+ });
26
+ if (!ownerUser) return null;
27
+ return db.query.repositories.findFirst({
28
+ where: and(
29
+ eq(schema.repositories.ownerId, ownerUser.id),
30
+ eq(schema.repositories.name, repoName)
31
+ ),
32
+ });
33
+}
34
+
35
+export const GET: APIRoute = withErrorHandler(async ({ params, url, locals }) => {
36
+ const owner = params.owner;
37
+ const repoName = params.repo;
38
+ if (!owner || !repoName) return badRequest("Missing route parameters");
39
+
40
+ const user = locals.user;
41
+ if (!user) return unauthorized();
42
+
43
+ const parsed = parseQuery(url, querySchema);
44
+ if ("error" in parsed) return parsed.error;
45
+
46
+ const repository = await resolveRepository(owner, repoName);
47
+ if (!repository) return notFound("Repository not found");
48
+
49
+ if (!(await canReadRepo(user.id, repository, { isAdmin: user.isAdmin }))) {
50
+ return notFound("Repository not found");
51
+ }
52
+
53
+ const templates = await getWorkflowTemplates({
54
+ category: parsed.data.category,
55
+ language: parsed.data.language,
56
+ });
57
+
58
+ return success({ templates });
59
+});
60
+
61
+export const POST: APIRoute = withErrorHandler(async ({ params, request, locals }) => {
62
+ const owner = params.owner;
63
+ const repoName = params.repo;
64
+ if (!owner || !repoName) return badRequest("Missing route parameters");
65
+
66
+ const user = locals.user;
67
+ if (!user) return unauthorized();
68
+
69
+ const repository = await resolveRepository(owner, repoName);
70
+ if (!repository) return notFound("Repository not found");
71
+
72
+ if (!(await canWriteRepo(user.id, repository, { isAdmin: user.isAdmin }))) {
73
+ return forbidden();
74
+ }
75
+
76
+ const parsed = await parseBody(request, applyTemplateSchema);
77
+ if ("error" in parsed) return parsed.error;
78
+
79
+ const workflow = await applyTemplateToRepo({
80
+ repositoryId: repository.id,
81
+ templateId: parsed.data.templateId,
82
+ workflowName: parsed.data.workflowName,
83
+ });
84
+
85
+ return success({ workflow });
86
+});
0
87
▼tests/integration/workflow-templates-route.test.ts
+139-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ canReadRepoMock,
5
+ canWriteRepoMock,
6
+ getWorkflowTemplatesMock,
7
+ applyTemplateToRepoMock,
8
+ fakeSchema,
9
+} = vi.hoisted(() => ({
10
+ canReadRepoMock: vi.fn(async () => true),
11
+ canWriteRepoMock: vi.fn(async () => true),
12
+ getWorkflowTemplatesMock: vi.fn(async () => [
13
+ { id: "default-1", name: "Node.js CI", category: "ci", language: "node" },
14
+ ]),
15
+ applyTemplateToRepoMock: vi.fn(async () => ({
16
+ id: "wf-1",
17
+ repositoryId: "repo-1",
18
+ name: "Node.js CI",
19
+ path: ".github/workflows/ci.yml",
20
+ content: "name: CI",
21
+ templateId: "default-1",
22
+ isEnabled: true,
23
+ })),
24
+ fakeSchema: {
25
+ users: { username: {} },
26
+ repositories: { ownerId: {}, name: {}, id: {} },
27
+ } as any,
28
+}));
29
+
30
+let mockDb: any;
31
+
32
+vi.mock("@/db", () => ({
33
+ getDatabase: () => mockDb,
34
+ schema: fakeSchema,
35
+}));
36
+
37
+vi.mock("@/lib/permissions", () => ({
38
+ canReadRepo: canReadRepoMock,
39
+ canWriteRepo: canWriteRepoMock,
40
+}));
41
+
42
+vi.mock("@/lib/workflow-templates", () => ({
43
+ getWorkflowTemplates: getWorkflowTemplatesMock,
44
+ applyTemplateToRepo: applyTemplateToRepoMock,
45
+}));
46
+
47
+import { GET as workflowTemplatesGet, POST as workflowTemplatesPost } from "@/pages/api/repos/[owner]/[repo]/workflow/templates";
48
+
49
+function makeDb() {
50
+ return {
51
+ query: {
52
+ users: {
53
+ findFirst: vi.fn(async () => ({ id: "owner-1" })),
54
+ },
55
+ repositories: {
56
+ findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })),
57
+ },
58
+ },
59
+ };
60
+}
61
+
62
+async function readJson(response: Response): Promise<any> {
63
+ return response.json();
64
+}
65
+
66
+describe("workflow templates route", () => {
67
+ beforeEach(() => {
68
+ mockDb = makeDb();
69
+ getWorkflowTemplatesMock.mockReset();
70
+ applyTemplateToRepoMock.mockReset();
71
+ canReadRepoMock.mockResolvedValue(true);
72
+ canWriteRepoMock.mockResolvedValue(true);
73
+ getWorkflowTemplatesMock.mockResolvedValue([
74
+ { id: "default-1", name: "Node.js CI", category: "ci", language: "node" },
75
+ ]);
76
+ applyTemplateToRepoMock.mockResolvedValue({
77
+ id: "wf-1",
78
+ repositoryId: "repo-1",
79
+ name: "Node.js CI",
80
+ path: ".github/workflows/ci.yml",
81
+ content: "name: CI",
82
+ templateId: "default-1",
83
+ isEnabled: true,
84
+ });
85
+ });
86
+
87
+ it("lists templates for readers", async () => {
88
+ const response = await workflowTemplatesGet({
89
+ params: { owner: "owner-1", repo: "demo" },
90
+ url: new URL("http://localhost/api/repos/owner-1/demo/workflow/templates?category=ci"),
91
+ locals: { user: { id: "user-1", isAdmin: false } },
92
+ } as any);
93
+
94
+ const body = await readJson(response);
95
+ expect(response.status).toBe(200);
96
+ expect(body?.data?.templates).toHaveLength(1);
97
+ expect(getWorkflowTemplatesMock).toHaveBeenCalledWith({
98
+ category: "ci",
99
+ language: undefined,
100
+ });
101
+ });
102
+
103
+ it("applies a template for writers", async () => {
104
+ const response = await workflowTemplatesPost({
105
+ params: { owner: "owner-1", repo: "demo" },
106
+ locals: { user: { id: "user-1", isAdmin: false } },
107
+ request: new Request("http://localhost/api/repos/owner-1/demo/workflow/templates", {
108
+ method: "POST",
109
+ headers: { "content-type": "application/json" },
110
+ body: JSON.stringify({ templateId: "default-1", workflowName: "ci" }),
111
+ }),
112
+ } as any);
113
+
114
+ const body = await readJson(response);
115
+ expect(response.status).toBe(200);
116
+ expect(body?.data?.workflow?.templateId).toBe("default-1");
117
+ expect(applyTemplateToRepoMock).toHaveBeenCalledWith({
118
+ repositoryId: "repo-1",
119
+ templateId: "default-1",
120
+ workflowName: "ci",
121
+ });
122
+ });
123
+
124
+ it("forbids apply when user lacks write access", async () => {
125
+ canWriteRepoMock.mockResolvedValue(false);
126
+ const response = await workflowTemplatesPost({
127
+ params: { owner: "owner-1", repo: "demo" },
128
+ locals: { user: { id: "user-1", isAdmin: false } },
129
+ request: new Request("http://localhost/api/repos/owner-1/demo/workflow/templates", {
130
+ method: "POST",
131
+ headers: { "content-type": "application/json" },
132
+ body: JSON.stringify({ templateId: "default-1" }),
133
+ }),
134
+ } as any);
135
+
136
+ expect(response.status).toBe(403);
137
+ expect(applyTemplateToRepoMock).not.toHaveBeenCalled();
138
+ });
139
+});
0
140