Add pull-request workflow state transition API
swadhinbiswas committed 8 months ago
249d4ee
1 parent e10d22b
commit 249d4ee
4 files changed
+222 -1
Showing 4 changed files+222-1
▼doc/feature_audit.md
+1-1
| @@ -49,7 +49,7 @@ | ||
49 | 49 | | Stack rebase & auto-update | ⚠️ | Manual rebase + auto-update API endpoints now implemented (`stacks/[stackId]/rebase`, `stacks/[stackId]/auto-update`); workflow automation/UI depth remains partial | |
50 | 50 | | Stack-level approvals | ⚠️ | Basic - individual PR approvals only | |
51 | 51 | | Bulk merge (merge stacks) | ⚠️ | Explicit bulk merge queue APIs now exist for stacks (`stacks/[stackId]/merge`) and selected PRs (`pulls/bulk-merge`); higher-level stack orchestration UX remains partial | |
52 | -| Custom PR states | ⚠️ | Schema exists (`pr-states`), end-to-end workflow adoption is incomplete | | |
52 | +| Custom PR states | ⚠️ | State definitions plus dedicated PR state-transition API are implemented (`workflow/states`, `pulls/{number}/state`); richer UI guidance and automation-driven adoption remain partial | | |
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 | 56 | |
▼src/lib/openapi.ts
+34-0
| @@ -1298,6 +1298,40 @@ export const openApiSpec = { | ||
1298 | 1298 | }, |
1299 | 1299 | }, |
1300 | 1300 | }, |
1301 | + "/repos/{owner}/{repo}/pulls/{number}/state": { | |
1302 | + post: { | |
1303 | + tags: ["Pull Requests"], | |
1304 | + summary: "Transition pull request workflow state", | |
1305 | + description: "Transitions a PR by canonical state (`open|closed|custom-name`) or by `stateId` mapped from repository workflow states.", | |
1306 | + security: [{ bearerAuth: [] }], | |
1307 | + parameters: [ | |
1308 | + { name: "owner", in: "path", required: true, schema: { type: "string" } }, | |
1309 | + { name: "repo", in: "path", required: true, schema: { type: "string" } }, | |
1310 | + { name: "number", in: "path", required: true, schema: { type: "integer" } }, | |
1311 | + ], | |
1312 | + requestBody: { | |
1313 | + required: true, | |
1314 | + content: { | |
1315 | + "application/json": { | |
1316 | + schema: { | |
1317 | + type: "object", | |
1318 | + properties: { | |
1319 | + state: { type: "string" }, | |
1320 | + stateId: { type: "string" }, | |
1321 | + }, | |
1322 | + }, | |
1323 | + }, | |
1324 | + }, | |
1325 | + }, | |
1326 | + responses: { | |
1327 | + 200: { description: "State transition applied" }, | |
1328 | + 400: { description: "Invalid transition payload", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1329 | + 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1330 | + 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1331 | + 404: { description: "Repository, PR, or state not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
1332 | + }, | |
1333 | + }, | |
1334 | + }, | |
1301 | 1335 | "/repos/{owner}/{repo}/workflow/templates": { |
1302 | 1336 | get: { |
1303 | 1337 | tags: ["CI/CD"], |
1304 | 1338 | |
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/state.ts
+85-0
| @@ -0,0 +1,85 @@ | ||
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, notFound } from "@/lib/api"; | |
7 | +import { withErrorHandler } from "@/lib/errors"; | |
8 | +import { PATCH as patchPullRequest } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/index"; | |
9 | + | |
10 | +const transitionSchema = z.object({ | |
11 | + state: z.string().optional(), | |
12 | + stateId: z.string().optional(), | |
13 | +}); | |
14 | + | |
15 | +async function resolveRepositoryId(owner: string, repo: string): Promise<string | null> { | |
16 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
17 | + const ownerUser = await db.query.users.findFirst({ | |
18 | + where: eq(schema.users.username, owner), | |
19 | + }); | |
20 | + if (!ownerUser) return null; | |
21 | + | |
22 | + const repository = await db.query.repositories.findFirst({ | |
23 | + where: and( | |
24 | + eq(schema.repositories.ownerId, ownerUser.id), | |
25 | + eq(schema.repositories.name, repo) | |
26 | + ), | |
27 | + columns: { id: true }, | |
28 | + }); | |
29 | + return repository?.id || null; | |
30 | +} | |
31 | + | |
32 | +export const POST: APIRoute = withErrorHandler(async ({ params, locals, request }) => { | |
33 | + const owner = params.owner; | |
34 | + const repo = params.repo; | |
35 | + const number = params.number; | |
36 | + if (!owner || !repo || !number) return badRequest("Missing parameters"); | |
37 | + | |
38 | + const body = await request.json().catch(() => null); | |
39 | + const parsed = transitionSchema.safeParse(body || {}); | |
40 | + if (!parsed.success) { | |
41 | + return badRequest(parsed.error.issues[0]?.message || "Invalid state transition payload"); | |
42 | + } | |
43 | + | |
44 | + const hasState = typeof parsed.data.state === "string" && parsed.data.state.trim() !== ""; | |
45 | + const hasStateId = typeof parsed.data.stateId === "string" && parsed.data.stateId.trim() !== ""; | |
46 | + if (!hasState && !hasStateId) { | |
47 | + return badRequest("Either state or stateId is required"); | |
48 | + } | |
49 | + if (hasState && hasStateId) { | |
50 | + return badRequest("Provide either state or stateId, not both"); | |
51 | + } | |
52 | + | |
53 | + let targetState = parsed.data.state?.trim(); | |
54 | + if (hasStateId) { | |
55 | + const repositoryId = await resolveRepositoryId(owner, repo); | |
56 | + if (!repositoryId) return notFound("Repository not found"); | |
57 | + | |
58 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
59 | + const stateDef = await db.query.prStateDefinitions.findFirst({ | |
60 | + where: and( | |
61 | + eq(schema.prStateDefinitions.id, parsed.data.stateId!), | |
62 | + eq(schema.prStateDefinitions.repositoryId, repositoryId) | |
63 | + ), | |
64 | + columns: { name: true }, | |
65 | + }); | |
66 | + if (!stateDef) { | |
67 | + return notFound("State definition not found"); | |
68 | + } | |
69 | + targetState = stateDef.name; | |
70 | + } | |
71 | + | |
72 | + const forwardedRequest = new Request(request.url, { | |
73 | + method: "PATCH", | |
74 | + headers: { | |
75 | + "content-type": "application/json", | |
76 | + }, | |
77 | + body: JSON.stringify({ state: targetState }), | |
78 | + }); | |
79 | + | |
80 | + return patchPullRequest({ | |
81 | + params: { owner, repo, number }, | |
82 | + locals, | |
83 | + request: forwardedRequest, | |
84 | + } as any); | |
85 | +}); | |
0 | 86 | |
▼tests/integration/pr-state-transition-route.test.ts
+102-0
| @@ -0,0 +1,102 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { | |
4 | + patchPullRequestMock, | |
5 | + fakeSchema, | |
6 | +} = vi.hoisted(() => ({ | |
7 | + patchPullRequestMock: vi.fn(async () => | |
8 | + new Response(JSON.stringify({ success: true, data: { success: true } }), { | |
9 | + status: 200, | |
10 | + headers: { "content-type": "application/json" }, | |
11 | + }) | |
12 | + ), | |
13 | + fakeSchema: { | |
14 | + users: { username: {} }, | |
15 | + repositories: { ownerId: {}, name: {}, id: {} }, | |
16 | + prStateDefinitions: { id: {}, repositoryId: {} }, | |
17 | + } as any, | |
18 | +})); | |
19 | + | |
20 | +let mockDb: any; | |
21 | + | |
22 | +vi.mock("@/db", () => ({ | |
23 | + getDatabase: () => mockDb, | |
24 | + schema: fakeSchema, | |
25 | +})); | |
26 | + | |
27 | +vi.mock("@/pages/api/repos/[owner]/[repo]/pulls/[number]/index", () => ({ | |
28 | + PATCH: patchPullRequestMock, | |
29 | +})); | |
30 | + | |
31 | +import { POST as transitionPost } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/state"; | |
32 | + | |
33 | +function makeDb() { | |
34 | + return { | |
35 | + query: { | |
36 | + users: { | |
37 | + findFirst: vi.fn(async () => ({ id: "owner-1" })), | |
38 | + }, | |
39 | + repositories: { | |
40 | + findFirst: vi.fn(async () => ({ id: "repo-1" })), | |
41 | + }, | |
42 | + prStateDefinitions: { | |
43 | + findFirst: vi.fn(async () => ({ name: "in_review" })), | |
44 | + }, | |
45 | + }, | |
46 | + }; | |
47 | +} | |
48 | + | |
49 | +describe("PR state transition route", () => { | |
50 | + beforeEach(() => { | |
51 | + mockDb = makeDb(); | |
52 | + patchPullRequestMock.mockClear(); | |
53 | + }); | |
54 | + | |
55 | + it("rejects requests with neither state nor stateId", async () => { | |
56 | + const response = await transitionPost({ | |
57 | + params: { owner: "owner-1", repo: "demo", number: "12" }, | |
58 | + locals: { user: { id: "user-1" } }, | |
59 | + request: new Request("http://localhost/api/repos/owner-1/demo/pulls/12/state", { | |
60 | + method: "POST", | |
61 | + headers: { "content-type": "application/json" }, | |
62 | + body: JSON.stringify({}), | |
63 | + }), | |
64 | + } as any); | |
65 | + | |
66 | + expect(response.status).toBe(400); | |
67 | + expect(patchPullRequestMock).not.toHaveBeenCalled(); | |
68 | + }); | |
69 | + | |
70 | + it("maps stateId to state name and delegates transition", async () => { | |
71 | + await transitionPost({ | |
72 | + params: { owner: "owner-1", repo: "demo", number: "12" }, | |
73 | + locals: { user: { id: "user-1" } }, | |
74 | + request: new Request("http://localhost/api/repos/owner-1/demo/pulls/12/state", { | |
75 | + method: "POST", | |
76 | + headers: { "content-type": "application/json" }, | |
77 | + body: JSON.stringify({ stateId: "state-1" }), | |
78 | + }), | |
79 | + } as any); | |
80 | + | |
81 | + expect(patchPullRequestMock).toHaveBeenCalledTimes(1); | |
82 | + const args = patchPullRequestMock.mock.calls[0][0]; | |
83 | + const forwardedBody = await args.request.json(); | |
84 | + expect(forwardedBody.state).toBe("in_review"); | |
85 | + }); | |
86 | + | |
87 | + it("passes explicit state through to patch handler", async () => { | |
88 | + await transitionPost({ | |
89 | + params: { owner: "owner-1", repo: "demo", number: "12" }, | |
90 | + locals: { user: { id: "user-1" } }, | |
91 | + request: new Request("http://localhost/api/repos/owner-1/demo/pulls/12/state", { | |
92 | + method: "POST", | |
93 | + headers: { "content-type": "application/json" }, | |
94 | + body: JSON.stringify({ state: "closed" }), | |
95 | + }), | |
96 | + } as any); | |
97 | + | |
98 | + const args = patchPullRequestMock.mock.calls[0][0]; | |
99 | + const forwardedBody = await args.request.json(); | |
100 | + expect(forwardedBody.state).toBe("closed"); | |
101 | + }); | |
102 | +}); | |
0 | 103 | |