Add stack auto-update API and document rebase coverage
swadhinbiswas committed 8 months ago
a6ea455
1 parent f003069
commit a6ea455
4 files changed
+158 -1
Showing 4 changed files+158-1
▼doc/feature_audit.md
+1-1
| @@ -46,7 +46,7 @@ | ||
46 | 46 | | Native stacked PRs | ✅ | `stacks.ts` - create, add, reorder, visualize | |
47 | 47 | | PR dependency graphs (DAG) | ✅ | `getStackVisualization()` implemented | |
48 | 48 | | Cross-PR dependency detection | ⚠️ | Automatic branch/file dependency graph API now implemented (`pulls/dependencies` via `pr-dependencies.ts`); UI-driven workflows remain partial | |
49 | -| Stack rebase & auto-update | ⚠️ | Partial - manual rebase exists | | |
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) | ⚠️ | Via merge queue, not explicit bulk action | |
52 | 52 | | Custom PR states | ⚠️ | Schema exists (`pr-states`), end-to-end workflow adoption is incomplete | |
53 | 53 | |
▼src/lib/openapi.ts
+19-0
| @@ -621,6 +621,25 @@ export const openApiSpec = { | ||
621 | 621 | }, |
622 | 622 | }, |
623 | 623 | }, |
624 | + "/repos/{owner}/{repo}/stacks/{stackId}/auto-update": { | |
625 | + post: { | |
626 | + tags: ["Stacks"], | |
627 | + summary: "Auto-update stack onto latest base branch", | |
628 | + description: "Checks whether a stack is behind and triggers stack auto-update/rebase when needed.", | |
629 | + security: [{ bearerAuth: [] }], | |
630 | + parameters: [ | |
631 | + { name: "owner", in: "path", required: true, schema: { type: "string" } }, | |
632 | + { name: "repo", in: "path", required: true, schema: { type: "string" } }, | |
633 | + { name: "stackId", in: "path", required: true, schema: { type: "string" } }, | |
634 | + ], | |
635 | + responses: { | |
636 | + 200: { description: "Auto-update result returned" }, | |
637 | + 401: { description: "Unauthorized", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
638 | + 403: { description: "Forbidden", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
639 | + 404: { description: "Repository or stack not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, | |
640 | + }, | |
641 | + }, | |
642 | + }, | |
624 | 643 | "/repos/{owner}/{repo}/pulls/{number}/comments": { |
625 | 644 | get: { |
626 | 645 | tags: ["Pull Requests"], |
627 | 646 | |
▼src/pages/api/repos/[owner]/[repo]/stacks/[stackId]/auto-update.ts
+49-0
| @@ -0,0 +1,49 @@ | ||
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 { canWriteRepo } from "@/lib/permissions"; | |
6 | +import { withErrorHandler } from "@/lib/errors"; | |
7 | +import { badRequest, forbidden, notFound, success, unauthorized } from "@/lib/api"; | |
8 | +import { autoUpdateStack, stackNeedsRebase } from "@/lib/stack-rebase"; | |
9 | + | |
10 | +export const POST: 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 | + const owner = await db.query.users.findFirst({ | |
19 | + where: eq(schema.users.username, ownerName), | |
20 | + }); | |
21 | + if (!owner) return notFound("Repository not found"); | |
22 | + | |
23 | + const repo = await db.query.repositories.findFirst({ | |
24 | + where: and( | |
25 | + eq(schema.repositories.ownerId, owner.id), | |
26 | + eq(schema.repositories.name, repoName) | |
27 | + ), | |
28 | + }); | |
29 | + if (!repo) return notFound("Repository not found"); | |
30 | + if (!(await canWriteRepo(user.id, repo))) return forbidden(); | |
31 | + | |
32 | + const stack = await db.query.prStacks.findFirst({ | |
33 | + where: and( | |
34 | + eq(schema.prStacks.id, stackId), | |
35 | + eq(schema.prStacks.repositoryId, repo.id) | |
36 | + ), | |
37 | + }); | |
38 | + if (!stack) return notFound("Stack not found"); | |
39 | + | |
40 | + const status = await stackNeedsRebase(stackId); | |
41 | + const result = await autoUpdateStack(stackId); | |
42 | + | |
43 | + return success({ | |
44 | + needsRebase: status.needsRebase, | |
45 | + behindBy: status.behindBy, | |
46 | + result, | |
47 | + }); | |
48 | +}); | |
49 | + | |
0 | 50 | |
▼tests/integration/stack-auto-update-route.test.ts
+89-0
| @@ -0,0 +1,89 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { | |
4 | + canWriteRepoMock, | |
5 | + stackNeedsRebaseMock, | |
6 | + autoUpdateStackMock, | |
7 | + fakeSchema, | |
8 | +} = vi.hoisted(() => ({ | |
9 | + canWriteRepoMock: vi.fn(async () => true), | |
10 | + stackNeedsRebaseMock: vi.fn(async () => ({ needsRebase: true, behindBy: 2 })), | |
11 | + autoUpdateStackMock: vi.fn(async () => ({ success: true, rebased: [], failed: [], conflicts: [] })), | |
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 | + canWriteRepo: canWriteRepoMock, | |
28 | +})); | |
29 | + | |
30 | +vi.mock("@/lib/stack-rebase", () => ({ | |
31 | + stackNeedsRebase: stackNeedsRebaseMock, | |
32 | + autoUpdateStack: autoUpdateStackMock, | |
33 | +})); | |
34 | + | |
35 | +import { POST as autoUpdatePost } from "@/pages/api/repos/[owner]/[repo]/stacks/[stackId]/auto-update"; | |
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 auto-update route", () => { | |
58 | + beforeEach(() => { | |
59 | + mockDb = makeDb(); | |
60 | + canWriteRepoMock.mockResolvedValue(true); | |
61 | + stackNeedsRebaseMock.mockResolvedValue({ needsRebase: true, behindBy: 2 }); | |
62 | + autoUpdateStackMock.mockResolvedValue({ success: true, rebased: [], failed: [], conflicts: [] }); | |
63 | + }); | |
64 | + | |
65 | + it("returns 403 when user lacks write access", async () => { | |
66 | + canWriteRepoMock.mockResolvedValue(false); | |
67 | + | |
68 | + const response = await autoUpdatePost({ | |
69 | + params: { owner: "owner-1", repo: "demo", stackId: "stack-1" }, | |
70 | + locals: { user: { id: "user-1" } }, | |
71 | + } as any); | |
72 | + | |
73 | + expect(response.status).toBe(403); | |
74 | + }); | |
75 | + | |
76 | + it("returns auto-update result with pre-check status", async () => { | |
77 | + const response = await autoUpdatePost({ | |
78 | + params: { owner: "owner-1", repo: "demo", stackId: "stack-1" }, | |
79 | + locals: { user: { id: "user-1" } }, | |
80 | + } as any); | |
81 | + | |
82 | + const body = await readJson(response); | |
83 | + expect(response.status).toBe(200); | |
84 | + expect(body?.data?.needsRebase).toBe(true); | |
85 | + expect(body?.data?.behindBy).toBe(2); | |
86 | + expect(autoUpdateStackMock).toHaveBeenCalledWith("stack-1"); | |
87 | + }); | |
88 | +}); | |
89 | + | |
0 | 90 | |