Complete stack rebase and auto-update workflow depth

swadhinbiswas committed 8 months ago
fcfb528
1 parent 22a7171
commit fcfb528
4 files changed
+179 -22
▼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 (`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
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
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
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
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
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
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
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
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
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
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
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
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