Add pull-request workflow state transition API

swadhinbiswas committed 8 months ago
249d4ee
1 parent e10d22b
commit 249d4ee
4 files changed
+222 -1
▼doc/feature_audit.md
+1-1
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
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
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
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