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
Showing 3 changed files+146-1
▼doc/feature_audit.md
+1-1
| @@ -48,7 +48,7 @@ | ||
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,6 +112,8 @@ export default function PullRequestsList({ pullRequests, openCount, closedCount, | ||
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,6 +223,41 @@ export default function PullRequestsList({ pullRequests, openCount, closedCount, | ||
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,6 +363,24 @@ export default function PullRequestsList({ pullRequests, openCount, closedCount, | ||
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,10 +522,23 @@ try { | ||
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,6 +662,10 @@ try { | ||
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,6 +858,39 @@ try { | ||
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,6 +959,45 @@ try { | ||
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,6 +1149,7 @@ try { | ||
1060 | 1149 | if (stackId) { |
1061 | 1150 | loadStackApprovals(); |
1062 | 1151 | refreshStackMaintenanceStatus(); |
1152 | + refreshStackMergeReadiness(); | |
1063 | 1153 | } |
1064 | 1154 | |
1065 | 1155 | mergeBtn?.addEventListener("click", async () => { |
1066 | 1156 | |