Complete stack rebase and auto-update workflow depth
swadhinbiswas committed 8 months ago
fcfb528
1 parent 22a7171
commit fcfb528
4 files changed
+179 -22
Showing 4 changed files+179-22
▼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 (`pulls/dependencies`) plus stack-order suggestion/apply API (`POST/PUT pulls/stack-order`) are implemented, and PR list UI now provides dependency analysis, selection-based order suggestion, and one-click stack creation workflow for detected dependency sets | |
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 | | |
49 | +| Stack rebase & auto-update | ✅ | Stack rebase/auto-update endpoints now expose both status and execution workflows (`GET/POST stacks/[stackId]/auto-update`, `stacks/[stackId]/rebase`), and PR stack UI includes guided maintenance actions (live behind-status checks, one-click auto-update, manual rebase fallback, and actionable success/conflict feedback) | | |
50 | 50 | | Stack-level approvals | ⚠️ | Stack approvals API now returns readiness + blocker context and richer per-stack approval summaries (pending counts/missing approvals/requested reviewers/missing required reviewer approvals), with dry-run reviewer eligibility previews; higher-level UI orchestration still partial | |
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 | 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 | |
▼src/pages/[owner]/[repo]/pulls/[number].astro
+93-7
| @@ -516,6 +516,9 @@ try { | ||
516 | 516 | <Button id="queue-stack-btn" variant="outline" className="text-sm"> |
517 | 517 | Add stack to merge queue |
518 | 518 | </Button> |
519 | + <Button id="auto-update-stack-btn" variant="outline" className="text-sm"> | |
520 | + Auto-update stack | |
521 | + </Button> | |
519 | 522 | <Button id="rebase-stack-btn" variant="outline" className="text-sm"> |
520 | 523 | Rebase stack |
521 | 524 | </Button> |
| @@ -630,6 +633,7 @@ try { | ||
630 | 633 | const linkIssueError = document.getElementById("link-issue-error"); |
631 | 634 | const queueStackBtn = document.getElementById("queue-stack-btn"); |
632 | 635 | const queueStackError = document.getElementById("queue-stack-error"); |
636 | + const autoUpdateStackBtn = document.getElementById("auto-update-stack-btn"); | |
633 | 637 | const rebaseStackBtn = document.getElementById("rebase-stack-btn"); |
634 | 638 | const rebaseStackMsg = document.getElementById("rebase-stack-msg"); |
635 | 639 | const stackApprovalsRefresh = document.getElementById("stack-approvals-refresh"); |
| @@ -771,12 +775,62 @@ try { | ||
771 | 775 | } |
772 | 776 | }); |
773 | 777 | |
778 | + function showStackActionMessage(message, isError = false) { | |
779 | + if (!rebaseStackMsg) return; | |
780 | + rebaseStackMsg.textContent = message || ""; | |
781 | + rebaseStackMsg.classList.remove("hidden", "text-muted-foreground", "text-red-500", "text-green-600"); | |
782 | + rebaseStackMsg.classList.add(isError ? "text-red-500" : "text-green-600"); | |
783 | + } | |
784 | + | |
785 | + function resetStackActionButtons() { | |
786 | + if (autoUpdateStackBtn) { | |
787 | + autoUpdateStackBtn.removeAttribute("disabled"); | |
788 | + autoUpdateStackBtn.textContent = "Auto-update stack"; | |
789 | + } | |
790 | + if (rebaseStackBtn) { | |
791 | + rebaseStackBtn.removeAttribute("disabled"); | |
792 | + rebaseStackBtn.textContent = "Rebase stack"; | |
793 | + } | |
794 | + } | |
795 | + | |
796 | + async function refreshStackMaintenanceStatus() { | |
797 | + if (!stackId) return; | |
798 | + try { | |
799 | + const res = await fetch( | |
800 | + `/api/repos/${repoOwner}/${repoName}/stacks/${stackId}/auto-update`, | |
801 | + ); | |
802 | + const data = await res.json(); | |
803 | + if (!res.ok) return; | |
804 | + | |
805 | + if (data?.needsRebase) { | |
806 | + if (autoUpdateStackBtn) { | |
807 | + autoUpdateStackBtn.removeAttribute("disabled"); | |
808 | + } | |
809 | + if (rebaseStackBtn) { | |
810 | + rebaseStackBtn.removeAttribute("disabled"); | |
811 | + } | |
812 | + showStackActionMessage(`Stack is ${data.behindBy} commit(s) behind base branch.`); | |
813 | + } else { | |
814 | + if (autoUpdateStackBtn) { | |
815 | + autoUpdateStackBtn.setAttribute("disabled", "true"); | |
816 | + autoUpdateStackBtn.textContent = "Stack up to date"; | |
817 | + } | |
818 | + showStackActionMessage("Stack is up to date."); | |
819 | + } | |
820 | + } catch { | |
821 | + // Non-fatal status refresh | |
822 | + } | |
823 | + } | |
824 | + | |
774 | 825 | rebaseStackBtn?.addEventListener("click", async () => { |
775 | 826 | if (!stackId) return; |
776 | 827 | |
828 | + if (autoUpdateStackBtn) { | |
829 | + autoUpdateStackBtn.setAttribute("disabled", "true"); | |
830 | + } | |
777 | 831 | rebaseStackBtn.setAttribute("disabled", "true"); |
778 | 832 | rebaseStackBtn.textContent = "Rebasing..."; |
779 | - rebaseStackMsg.classList.add("hidden"); | |
833 | + showStackActionMessage("Rebasing stack..."); | |
780 | 834 | |
781 | 835 | try { |
782 | 836 | const res = await fetch( |
| @@ -790,18 +844,49 @@ try { | ||
790 | 844 | throw new Error(data?.message || "Failed to rebase stack"); |
791 | 845 | } |
792 | 846 | |
793 | - rebaseStackMsg.textContent = data?.message || "Rebase complete"; | |
794 | - rebaseStackMsg.classList.remove("hidden"); | |
847 | + showStackActionMessage(data?.message || "Rebase complete"); | |
795 | 848 | |
796 | 849 | if (data?.result?.success) { |
797 | 850 | window.location.reload(); |
798 | 851 | } |
799 | 852 | } catch (e) { |
800 | - rebaseStackMsg.textContent = e.message || "Failed to rebase stack"; | |
801 | - rebaseStackMsg.classList.remove("hidden"); | |
853 | + showStackActionMessage(e.message || "Failed to rebase stack", true); | |
802 | 854 | } finally { |
803 | - rebaseStackBtn.removeAttribute("disabled"); | |
804 | - rebaseStackBtn.textContent = "Rebase stack"; | |
855 | + resetStackActionButtons(); | |
856 | + refreshStackMaintenanceStatus(); | |
857 | + } | |
858 | + }); | |
859 | + | |
860 | + autoUpdateStackBtn?.addEventListener("click", async () => { | |
861 | + if (!stackId) return; | |
862 | + | |
863 | + autoUpdateStackBtn.setAttribute("disabled", "true"); | |
864 | + autoUpdateStackBtn.textContent = "Auto-updating..."; | |
865 | + if (rebaseStackBtn) { | |
866 | + rebaseStackBtn.setAttribute("disabled", "true"); | |
867 | + } | |
868 | + showStackActionMessage("Running auto-update..."); | |
869 | + | |
870 | + try { | |
871 | + const res = await fetch( | |
872 | + `/api/repos/${repoOwner}/${repoName}/stacks/${stackId}/auto-update`, | |
873 | + { method: "POST" }, | |
874 | + ); | |
875 | + const data = await res.json(); | |
876 | + | |
877 | + if (!res.ok) { | |
878 | + throw new Error(data?.message || "Failed to auto-update stack"); | |
879 | + } | |
880 | + | |
881 | + showStackActionMessage(data?.message || "Auto-update completed"); | |
882 | + if (data?.performed && data?.result?.success) { | |
883 | + window.location.reload(); | |
884 | + } | |
885 | + } catch (e) { | |
886 | + showStackActionMessage(e.message || "Failed to auto-update stack", true); | |
887 | + } finally { | |
888 | + resetStackActionButtons(); | |
889 | + refreshStackMaintenanceStatus(); | |
805 | 890 | } |
806 | 891 | }); |
807 | 892 | |
| @@ -888,6 +973,7 @@ try { | ||
888 | 973 | |
889 | 974 | if (stackId) { |
890 | 975 | loadStackApprovals(); |
976 | + refreshStackMaintenanceStatus(); | |
891 | 977 | } |
892 | 978 | |
893 | 979 | mergeBtn?.addEventListener("click", async () => { |
894 | 980 | |
▼src/pages/api/repos/[owner]/[repo]/stacks/[stackId]/auto-update.ts
+55-12
| @@ -7,18 +7,15 @@ import { withErrorHandler } from "@/lib/errors"; | ||
7 | 7 | import { badRequest, forbidden, notFound, success, unauthorized } from "@/lib/api"; |
8 | 8 | import { autoUpdateStack, stackNeedsRebase } from "@/lib/stack-rebase"; |
9 | 9 | |
10 | -export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => { | |
10 | +async function resolveContext(params: Record<string, string | undefined>, userId: string) { | |
11 | 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"); | |
12 | + if (!ownerName || !repoName || !stackId) return { response: badRequest("Missing parameters") } as const; | |
16 | 13 | |
17 | 14 | const db = getDatabase() as NodePgDatabase<typeof schema>; |
18 | 15 | const owner = await db.query.users.findFirst({ |
19 | 16 | where: eq(schema.users.username, ownerName), |
20 | 17 | }); |
21 | - if (!owner) return notFound("Repository not found"); | |
18 | + if (!owner) return { response: notFound("Repository not found") } as const; | |
22 | 19 | |
23 | 20 | const repo = await db.query.repositories.findFirst({ |
24 | 21 | where: and( |
| @@ -26,8 +23,8 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
26 | 23 | eq(schema.repositories.name, repoName) |
27 | 24 | ), |
28 | 25 | }); |
29 | - if (!repo) return notFound("Repository not found"); | |
30 | - if (!(await canWriteRepo(user.id, repo))) return forbidden(); | |
26 | + if (!repo) return { response: notFound("Repository not found") } as const; | |
27 | + if (!(await canWriteRepo(userId, repo))) return { response: forbidden() } as const; | |
31 | 28 | |
32 | 29 | const stack = await db.query.prStacks.findFirst({ |
33 | 30 | where: and( |
| @@ -35,15 +32,61 @@ export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => { | ||
35 | 32 | eq(schema.prStacks.repositoryId, repo.id) |
36 | 33 | ), |
37 | 34 | }); |
38 | - if (!stack) return notFound("Stack not found"); | |
35 | + if (!stack) return { response: notFound("Stack not found") } as const; | |
36 | + | |
37 | + return { response: null, stackId } as const; | |
38 | +} | |
39 | + | |
40 | +export const GET: APIRoute = withErrorHandler(async ({ params, locals }) => { | |
41 | + const user = locals.user; | |
42 | + if (!user) return unauthorized(); | |
43 | + | |
44 | + const resolved = await resolveContext(params, user.id); | |
45 | + if (resolved.response || !resolved.stackId) { | |
46 | + return resolved.response || badRequest("Invalid request"); | |
47 | + } | |
48 | + | |
49 | + const status = await stackNeedsRebase(resolved.stackId); | |
50 | + return success({ | |
51 | + ...status, | |
52 | + upToDate: !status.needsRebase, | |
53 | + }); | |
54 | +}); | |
55 | + | |
56 | +export const POST: APIRoute = withErrorHandler(async ({ params, locals }) => { | |
57 | + const user = locals.user; | |
58 | + if (!user) return unauthorized(); | |
59 | + | |
60 | + const resolved = await resolveContext(params, user.id); | |
61 | + if (resolved.response || !resolved.stackId) { | |
62 | + return resolved.response || badRequest("Invalid request"); | |
63 | + } | |
39 | 64 | |
40 | - const status = await stackNeedsRebase(stackId); | |
41 | - const result = await autoUpdateStack(stackId); | |
65 | + const status = await stackNeedsRebase(resolved.stackId); | |
66 | + if (!status.needsRebase) { | |
67 | + return success({ | |
68 | + needsRebase: false, | |
69 | + behindBy: status.behindBy, | |
70 | + performed: false, | |
71 | + message: "Stack already up to date", | |
72 | + result: { | |
73 | + success: true, | |
74 | + rebased: [], | |
75 | + failed: [], | |
76 | + conflicts: [], | |
77 | + }, | |
78 | + }); | |
79 | + } | |
80 | + | |
81 | + const result = await autoUpdateStack(resolved.stackId); | |
42 | 82 | |
43 | 83 | return success({ |
44 | 84 | needsRebase: status.needsRebase, |
45 | 85 | behindBy: status.behindBy, |
86 | + performed: true, | |
87 | + message: result.success | |
88 | + ? `Auto-update complete (${result.rebased.length} PRs rebased)` | |
89 | + : "Auto-update completed with conflicts", | |
46 | 90 | result, |
47 | 91 | }); |
48 | 92 | }); |
49 | - | |
50 | 93 | |
▼tests/integration/stack-auto-update-route.test.ts
+30-2
| @@ -32,7 +32,7 @@ vi.mock("@/lib/stack-rebase", () => ({ | ||
32 | 32 | autoUpdateStack: autoUpdateStackMock, |
33 | 33 | })); |
34 | 34 | |
35 | -import { POST as autoUpdatePost } from "@/pages/api/repos/[owner]/[repo]/stacks/[stackId]/auto-update"; | |
35 | +import { GET as autoUpdateGet, POST as autoUpdatePost } from "@/pages/api/repos/[owner]/[repo]/stacks/[stackId]/auto-update"; | |
36 | 36 | |
37 | 37 | function makeDb() { |
38 | 38 | return { |
| @@ -56,6 +56,7 @@ async function readJson(response: Response): Promise<any> { | ||
56 | 56 | |
57 | 57 | describe("stack auto-update route", () => { |
58 | 58 | beforeEach(() => { |
59 | + vi.clearAllMocks(); | |
59 | 60 | mockDb = makeDb(); |
60 | 61 | canWriteRepoMock.mockResolvedValue(true); |
61 | 62 | stackNeedsRebaseMock.mockResolvedValue({ needsRebase: true, behindBy: 2 }); |
| @@ -73,6 +74,18 @@ describe("stack auto-update route", () => { | ||
73 | 74 | expect(response.status).toBe(403); |
74 | 75 | }); |
75 | 76 | |
77 | + it("returns rebase status via GET", async () => { | |
78 | + const response = await autoUpdateGet({ | |
79 | + params: { owner: "owner-1", repo: "demo", stackId: "stack-1" }, | |
80 | + locals: { user: { id: "user-1" } }, | |
81 | + } as any); | |
82 | + | |
83 | + const body = await readJson(response); | |
84 | + expect(response.status).toBe(200); | |
85 | + expect(body?.data?.needsRebase).toBe(true); | |
86 | + expect(body?.data?.upToDate).toBe(false); | |
87 | + }); | |
88 | + | |
76 | 89 | it("returns auto-update result with pre-check status", async () => { |
77 | 90 | const response = await autoUpdatePost({ |
78 | 91 | params: { owner: "owner-1", repo: "demo", stackId: "stack-1" }, |
| @@ -83,7 +96,22 @@ describe("stack auto-update route", () => { | ||
83 | 96 | expect(response.status).toBe(200); |
84 | 97 | expect(body?.data?.needsRebase).toBe(true); |
85 | 98 | expect(body?.data?.behindBy).toBe(2); |
99 | + expect(body?.data?.performed).toBe(true); | |
86 | 100 | expect(autoUpdateStackMock).toHaveBeenCalledWith("stack-1"); |
87 | 101 | }); |
88 | -}); | |
89 | 102 | |
103 | + it("returns a no-op result when stack is already up to date", async () => { | |
104 | + stackNeedsRebaseMock.mockResolvedValueOnce({ needsRebase: false, behindBy: 0 }); | |
105 | + | |
106 | + const response = await autoUpdatePost({ | |
107 | + params: { owner: "owner-1", repo: "demo", stackId: "stack-1" }, | |
108 | + locals: { user: { id: "user-1" } }, | |
109 | + } as any); | |
110 | + | |
111 | + const body = await readJson(response); | |
112 | + expect(response.status).toBe(200); | |
113 | + expect(body?.data?.performed).toBe(false); | |
114 | + expect(body?.data?.message).toMatch(/already up to date/i); | |
115 | + expect(autoUpdateStackMock).not.toHaveBeenCalled(); | |
116 | + }); | |
117 | +}); | |
90 | 118 | |