Complete bulk merge orchestration UX for stacks and PR selection

swadhinbiswas committed 8 months ago
48df41e
1 parent 83de74b
commit 48df41e
3 files changed
+146 -1
▼doc/feature_audit.md
+1-1
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
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 now include end-to-end orchestration: readiness/blocker context + per-PR summary APIs (`stacks/[stackId]/approvals`), recommended reviewer derivation, dry-run reviewer eligibility previews, and PR stack UI actions for previewing eligibility, requesting recommended reviewers, and requesting custom reviewer batches with live blocker/status feedback |
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 |
51
+| Bulk merge (merge stacks) | ✅ | Explicit stack and selected-PR bulk merge APIs are implemented (`stacks/[stackId]/merge`, `pulls/bulk-merge`) with orchestration UX in PR surfaces: stack merge readiness/blocker checks + merge-method controls on PR stack panel, and selected-PR bulk-merge controls on the PR list workflow panel |
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
| Required reviewers per state | ⚠️ | State reviewer requirements are enforced during transitions, and explicit required-reviewer status introspection is available (`pulls/{number}/required-reviewers`); richer state-specific policy UX remains incomplete |
54
54
| PR merge queues | ✅ | `merge-queue.ts` (590+ lines) |
55
55
▼src/components/pulls/PullRequestsList.tsx
+55-0
112
112
const [isAnalyzing, setIsAnalyzing] = useState(false);
113
113
const [isSuggesting, setIsSuggesting] = useState(false);
114
114
const [isApplying, setIsApplying] = useState(false);
115
+ const [isBulkMerging, setIsBulkMerging] = useState(false);
116
+ const [bulkMergeMethod, setBulkMergeMethod] = useState<"merge" | "squash" | "rebase">("merge");
115
117
116
118
const prById = new Map(pullRequests.map((pr) => [pr.id, pr]));
117
119
221
223
}
222
224
};
223
225
226
+ const bulkMergeSelected = async () => {
227
+ if (selectedPrIds.length === 0) {
228
+ setWorkflowMsg("Select at least one open PR to bulk merge.");
229
+ return;
230
+ }
231
+
232
+ setIsBulkMerging(true);
233
+ setWorkflowMsg("");
234
+ try {
235
+ const response = await fetch(`/api/repos/${repoOwner}/${repoName}/pulls/bulk-merge`, {
236
+ method: "POST",
237
+ headers: { "content-type": "application/json" },
238
+ body: JSON.stringify({
239
+ prIds: selectedPrIds,
240
+ mergeMethod: bulkMergeMethod,
241
+ }),
242
+ });
243
+ const payload = await response.json();
244
+ if (!response.ok) {
245
+ throw new Error(payload?.error?.message || "Failed to bulk merge selected PRs");
246
+ }
247
+
248
+ const merged = payload?.data?.merged?.length || 0;
249
+ const failed = payload?.data?.failed?.length || 0;
250
+ const skipped = payload?.data?.skipped?.length || 0;
251
+ setWorkflowMsg(`Bulk merge complete: merged ${merged}, failed ${failed}, skipped ${skipped}.`);
252
+ setSelectedPrIds([]);
253
+ window.location.reload();
254
+ } catch (error: any) {
255
+ setWorkflowMsg(error?.message || "Failed to bulk merge selected PRs");
256
+ } finally {
257
+ setIsBulkMerging(false);
258
+ }
259
+ };
260
+
224
261
return (
225
262
<div className="space-y-6">
226
263
{/* Enhanced Header with Glow */}
326
363
{isApplying ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
327
364
Apply As Stack
328
365
</button>
366
+ <select
367
+ value={bulkMergeMethod}
368
+ onChange={(event) => setBulkMergeMethod(event.target.value as "merge" | "squash" | "rebase")}
369
+ className="rounded-lg border border-white/15 bg-white/[0.04] px-2 py-1.5 text-xs text-gray-200"
370
+ >
371
+ <option value="merge">Merge</option>
372
+ <option value="squash">Squash</option>
373
+ <option value="rebase">Rebase</option>
374
+ </select>
375
+ <button
376
+ type="button"
377
+ onClick={bulkMergeSelected}
378
+ disabled={isBulkMerging || selectedPrIds.length === 0}
379
+ className="inline-flex items-center gap-1 rounded-lg bg-gradient-to-r from-emerald-600 to-green-600 px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
380
+ >
381
+ {isBulkMerging ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <GitMerge className="h-3.5 w-3.5" />}
382
+ Bulk Merge Selected
383
+ </button>
329
384
</div>
330
385
</div>
331
386
{graph && (
332
387
▼src/pages/[owner]/[repo]/pulls/[number].astro
+90-0
522
522
<Button id="rebase-stack-btn" variant="outline" className="text-sm">
523
523
Rebase stack
524
524
</Button>
525
+ <select
526
+ id="stack-merge-method"
527
+ class="h-8 rounded-md border bg-background px-2 text-xs"
528
+ >
529
+ <option value="merge">Merge</option>
530
+ <option value="squash">Squash</option>
531
+ <option value="rebase">Rebase</option>
532
+ </select>
533
+ <Button id="merge-stack-btn" variant="outline" className="text-sm">
534
+ Merge stack
535
+ </Button>
525
536
<div id="queue-stack-error" class="text-xs text-red-500 hidden"></div>
526
537
<div id="rebase-stack-msg" class="text-xs text-muted-foreground hidden"></div>
527
538
</div>
528
539
)}
540
+ <div id="stack-merge-readiness" class="mt-2 text-xs text-muted-foreground"></div>
541
+ <div id="stack-merge-blockers" class="mt-2 space-y-1 text-xs text-amber-500"></div>
529
542
<div class="mt-4 rounded-md border border-dashed px-3 py-2 text-xs">
530
543
<div class="flex items-center justify-between">
531
544
<div class="text-muted-foreground">Stack approvals</div>
649
662
const queueStackError = document.getElementById("queue-stack-error");
650
663
const autoUpdateStackBtn = document.getElementById("auto-update-stack-btn");
651
664
const rebaseStackBtn = document.getElementById("rebase-stack-btn");
665
+ const mergeStackBtn = document.getElementById("merge-stack-btn");
666
+ const stackMergeMethod = document.getElementById("stack-merge-method");
667
+ const stackMergeReadiness = document.getElementById("stack-merge-readiness");
668
+ const stackMergeBlockers = document.getElementById("stack-merge-blockers");
652
669
const rebaseStackMsg = document.getElementById("rebase-stack-msg");
653
670
const stackApprovalsRefresh = document.getElementById("stack-approvals-refresh");
654
671
const stackApprovalsStatus = document.getElementById("stack-approvals-status");
841
858
}
842
859
}
843
860
861
+ async function refreshStackMergeReadiness() {
862
+ if (!stackId || !stackMergeReadiness) return;
863
+
864
+ try {
865
+ const res = await fetch(
866
+ `/api/repos/${repoOwner}/${repoName}/stacks/${stackId}/merge-readiness`,
867
+ );
868
+ const data = await res.json();
869
+ if (!res.ok) return;
870
+
871
+ const blockers = data?.blockers || [];
872
+ stackMergeReadiness.textContent = data?.canMerge
873
+ ? "Stack is ready to merge."
874
+ : "Stack is not merge-ready.";
875
+
876
+ if (stackMergeBlockers) {
877
+ stackMergeBlockers.innerHTML = blockers
878
+ .map((blocker) => `<div>• ${blocker}</div>`)
879
+ .join("");
880
+ }
881
+
882
+ if (mergeStackBtn) {
883
+ if (data?.canMerge) {
884
+ mergeStackBtn.removeAttribute("disabled");
885
+ } else {
886
+ mergeStackBtn.setAttribute("disabled", "true");
887
+ }
888
+ }
889
+ } catch {
890
+ // Non-fatal readiness refresh failure
891
+ }
892
+ }
893
+
844
894
rebaseStackBtn?.addEventListener("click", async () => {
845
895
if (!stackId) return;
846
896
909
959
}
910
960
});
911
961
962
+ mergeStackBtn?.addEventListener("click", async () => {
963
+ if (!stackId) return;
964
+ const mergeMethod = stackMergeMethod?.value || "merge";
965
+
966
+ mergeStackBtn.setAttribute("disabled", "true");
967
+ mergeStackBtn.textContent = "Merging stack...";
968
+ showStackActionMessage("Merging stack...");
969
+
970
+ try {
971
+ const res = await fetch(
972
+ `/api/repos/${repoOwner}/${repoName}/stacks/${stackId}/merge`,
973
+ {
974
+ method: "POST",
975
+ headers: { "Content-Type": "application/json" },
976
+ body: JSON.stringify({ mergeMethod }),
977
+ },
978
+ );
979
+ const data = await res.json();
980
+ if (!res.ok) {
981
+ throw new Error(data?.error?.message || data?.message || "Failed to merge stack");
982
+ }
983
+
984
+ const mergedCount = data?.merged?.length || 0;
985
+ const failedCount = data?.failed?.length || 0;
986
+ const skippedCount = data?.skipped?.length || 0;
987
+ showStackActionMessage(
988
+ `Stack merge complete: merged ${mergedCount}, failed ${failedCount}, skipped ${skippedCount}.`
989
+ );
990
+ window.location.reload();
991
+ } catch (e) {
992
+ showStackActionMessage(e.message || "Failed to merge stack", true);
993
+ } finally {
994
+ if (mergeStackBtn) {
995
+ mergeStackBtn.textContent = "Merge stack";
996
+ }
997
+ refreshStackMergeReadiness();
998
+ }
999
+ });
1000
+
912
1001
async function loadStackApprovals() {
913
1002
if (!stackId || !stackApprovalsStatus || !stackApprovalsList) return;
914
1003
1060
1149
if (stackId) {
1061
1150
loadStackApprovals();
1062
1151
refreshStackMaintenanceStatus();
1152
+ refreshStackMergeReadiness();
1063
1153
}
1064
1154
1065
1155
mergeBtn?.addEventListener("click", async () => {
1066
1156