Add stack merge-readiness API and OpenAPI docs
swadhinbiswas committed 8 months ago
d6cd63a
1 parent 4187d66
commit d6cd63a
3 files changed
+151 -0
Showing 3 changed files+151-0
▼src/lib/openapi.ts
+18-0
| @@ -505,6 +505,24 @@ export const openApiSpec = { | ||
505 | 505 | }, |
506 | 506 | }, |
507 | 507 | }, |
508 | + "/repos/{owner}/{repo}/stacks/{stackId}/merge-readiness": { | |
509 | + get: { | |
510 | + tags: ["Stacks"], | |
511 | + summary: "Get stack merge readiness", | |
512 | + description: "Returns whether the full stack can be merged now, including blocking approval reasons.", | |
513 | + security: [{ bearerAuth: [] }], | |
514 | + parameters: [ | |
515 | + { name: "owner", in: "path", required: true, schema: { type: "string" } }, | |
516 | + { name: "repo", in: "path", required: true, schema: { type: "string" } }, | |
517 | + { name: "stackId", in: "path", required: true, schema: { type: "string" } }, | |
518 | + ], | |
519 | + responses: { | |
520 | + 200: { description: "Merge readiness returned" }, | |
521 | + 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
522 | + 404: { description: "Repository or stack not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
523 | + }, | |
524 | + }, | |
525 | + }, | |
508 | 526 | "/repos/{owner}/{repo}/pulls/{number}/comments": { |
509 | 527 | get: { |
510 | 528 | tags: ["Pull Requests"], |
511 | 529 | |
▼src/pages/api/repos/[owner]/[repo]/stacks/[stackId]/merge-readiness.ts
+51-0
| @@ -0,0 +1,51 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, eq } from "drizzle-orm"; | |
4 | +import { getDatabase, schema } from "@/db"; | |
5 | +import { canReadRepo } from "@/lib/permissions"; | |
6 | +import { withErrorHandler } from "@/lib/errors"; | |
7 | +import { badRequest, notFound, success, unauthorized } from "@/lib/api"; | |
8 | +import { canMergeStack, getStackApprovalStatus } from "@/lib/stack-approvals"; | |
9 | + | |
10 | +export const GET: APIRoute = withErrorHandler(async ({ params, locals }) => { | |
11 | + const { owner: ownerName, repo: repoName, stackId } = params; | |
12 | + const user = locals.user; | |
13 | + | |
14 | + if (!user) return unauthorized(); | |
15 | + if (!ownerName || !repoName || !stackId) return badRequest("Missing parameters"); | |
16 | + | |
17 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
18 | + | |
19 | + const owner = await db.query.users.findFirst({ | |
20 | + where: eq(schema.users.username, ownerName), | |
21 | + }); | |
22 | + if (!owner) return notFound("Repository not found"); | |
23 | + | |
24 | + const repo = await db.query.repositories.findFirst({ | |
25 | + where: and( | |
26 | + eq(schema.repositories.ownerId, owner.id), | |
27 | + eq(schema.repositories.name, repoName) | |
28 | + ), | |
29 | + }); | |
30 | + if (!repo) return notFound("Repository not found"); | |
31 | + if (!(await canReadRepo(user.id, repo))) return notFound("Repository not found"); | |
32 | + | |
33 | + const stack = await db.query.prStacks.findFirst({ | |
34 | + where: and( | |
35 | + eq(schema.prStacks.id, stackId), | |
36 | + eq(schema.prStacks.repositoryId, repo.id) | |
37 | + ), | |
38 | + }); | |
39 | + if (!stack) return notFound("Stack not found"); | |
40 | + | |
41 | + const readiness = await canMergeStack(stackId); | |
42 | + const approvalStatus = await getStackApprovalStatus(stackId); | |
43 | + | |
44 | + return success({ | |
45 | + stackId, | |
46 | + canMerge: readiness.canMerge, | |
47 | + blockers: readiness.blockers, | |
48 | + approvalStatus, | |
49 | + }); | |
50 | +}); | |
51 | + | |
0 | 52 | |
▼tests/integration/stack-merge-readiness-route.test.ts
+82-0
| @@ -0,0 +1,82 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { | |
4 | + canReadRepoMock, | |
5 | + canMergeStackMock, | |
6 | + getStackApprovalStatusMock, | |
7 | + fakeSchema, | |
8 | +} = vi.hoisted(() => ({ | |
9 | + canReadRepoMock: vi.fn(async () => true), | |
10 | + canMergeStackMock: vi.fn(async () => ({ canMerge: true, blockers: [] })), | |
11 | + getStackApprovalStatusMock: vi.fn(async () => ({ stackId: "stack-1", allApproved: true, prs: [] })), | |
12 | + fakeSchema: { | |
13 | + users: { username: {} }, | |
14 | + repositories: { ownerId: {}, name: {}, id: {} }, | |
15 | + prStacks: { id: {}, repositoryId: {} }, | |
16 | + } as any, | |
17 | +})); | |
18 | + | |
19 | +let mockDb: any; | |
20 | + | |
21 | +vi.mock("@/db", () => ({ | |
22 | + getDatabase: () => mockDb, | |
23 | + schema: fakeSchema, | |
24 | +})); | |
25 | + | |
26 | +vi.mock("@/lib/permissions", () => ({ | |
27 | + canReadRepo: canReadRepoMock, | |
28 | +})); | |
29 | + | |
30 | +vi.mock("@/lib/stack-approvals", () => ({ | |
31 | + canMergeStack: canMergeStackMock, | |
32 | + getStackApprovalStatus: getStackApprovalStatusMock, | |
33 | +})); | |
34 | + | |
35 | +import { GET as getMergeReadiness } from "@/pages/api/repos/[owner]/[repo]/stacks/[stackId]/merge-readiness"; | |
36 | + | |
37 | +function makeDb() { | |
38 | + return { | |
39 | + query: { | |
40 | + users: { | |
41 | + findFirst: vi.fn(async () => ({ id: "owner-1" })), | |
42 | + }, | |
43 | + repositories: { | |
44 | + findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })), | |
45 | + }, | |
46 | + prStacks: { | |
47 | + findFirst: vi.fn(async () => ({ id: "stack-1", repositoryId: "repo-1" })), | |
48 | + }, | |
49 | + }, | |
50 | + }; | |
51 | +} | |
52 | + | |
53 | +async function readJson(response: Response): Promise<any> { | |
54 | + return response.json(); | |
55 | +} | |
56 | + | |
57 | +describe("stack merge readiness route", () => { | |
58 | + beforeEach(() => { | |
59 | + mockDb = makeDb(); | |
60 | + canReadRepoMock.mockResolvedValue(true); | |
61 | + canMergeStackMock.mockResolvedValue({ canMerge: true, blockers: [] }); | |
62 | + getStackApprovalStatusMock.mockResolvedValue({ stackId: "stack-1", allApproved: true, prs: [] }); | |
63 | + }); | |
64 | + | |
65 | + it("returns readiness and blockers for authorized requests", async () => { | |
66 | + canMergeStackMock.mockResolvedValueOnce({ | |
67 | + canMerge: false, | |
68 | + blockers: ["PR #12: Needs 1 more approval(s)"], | |
69 | + }); | |
70 | + | |
71 | + const response = await getMergeReadiness({ | |
72 | + params: { owner: "owner-1", repo: "demo", stackId: "stack-1" }, | |
73 | + locals: { user: { id: "user-1" } }, | |
74 | + } as any); | |
75 | + | |
76 | + const body = await readJson(response); | |
77 | + expect(response.status).toBe(200); | |
78 | + expect(body?.data?.canMerge).toBe(false); | |
79 | + expect(body?.data?.blockers).toEqual(["PR #12: Needs 1 more approval(s)"]); | |
80 | + }); | |
81 | +}); | |
82 | + | |
0 | 83 | |