Add repository workflow templates list/apply APIs
swadhinbiswas committed 8 months ago
a5ce3df
1 parent ec784ac
commit a5ce3df
4 files changed
+275 -1
Showing 4 changed files+275-1
▼doc/feature_audit.md
+1-1
| @@ -97,7 +97,7 @@ | ||
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,6 +1252,55 @@ export const openApiSpec = { | ||
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
| @@ -0,0 +1,86 @@ | ||
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
| @@ -0,0 +1,139 @@ | ||
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 | |