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
▼doc/feature_audit.md
+1-1
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
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
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
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