Complete custom PR state guidance and default adoption
swadhinbiswas committed 8 months ago
60d0c1b
1 parent 48df41e
commit 60d0c1b
3 files changed
+146 -3
Showing 3 changed files+146-3
▼doc/feature_audit.md
+1-1
| @@ -49,7 +49,7 @@ | ||
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 | 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 | -| 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 | | |
52 | +| Custom PR states | ✅ | State definitions and transition APIs are fully wired (`workflow/states`, `pulls/{number}/state`), PR detail UI now provides guided state transitions with inline policy hints and blocker feedback, and new PR creation auto-applies repository default custom state when configured (`pulls/index`) | | |
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 | | Conflict detection before merge | ✅ | `mergeable` field in PR schema | |
56 | 56 | |
▼src/pages/[owner]/[repo]/pulls/[number].astro
+133-1
| @@ -73,6 +73,15 @@ const pr = await db.query.pullRequests.findFirst({ | ||
73 | 73 | |
74 | 74 | if (!pr) return Astro.redirect("/404"); |
75 | 75 | |
76 | +const customStates = await db.query.prStateDefinitions.findMany({ | |
77 | + where: eq(schema.prStateDefinitions.repositoryId, repoData.id), | |
78 | + orderBy: (states, { asc }) => [asc(states.order)], | |
79 | +}); | |
80 | + | |
81 | +const currentCustomState = pr.stateId | |
82 | + ? customStates.find((state) => state.id === pr.stateId) || null | |
83 | + : null; | |
84 | + | |
76 | 85 | // Check Queue Status |
77 | 86 | const queueItem = await db.query.mergeQueueItems.findFirst({ |
78 | 87 | where: eq(schema.mergeQueueItems.pullRequestId, pr.id), |
| @@ -182,6 +191,16 @@ try { | ||
182 | 191 | </span> |
183 | 192 | ) |
184 | 193 | } |
194 | + { | |
195 | + currentCustomState && ( | |
196 | + <span | |
197 | + class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium border" | |
198 | + style={`background-color: ${currentCustomState.color}20; color: ${currentCustomState.color}; border-color: ${currentCustomState.color}55;`} | |
199 | + > | |
200 | + {currentCustomState.displayName} | |
201 | + </span> | |
202 | + ) | |
203 | + } | |
185 | 204 | { |
186 | 205 | pr.state === "merged" && ( |
187 | 206 | <span class="inline-flex items-center gap-1 rounded-full bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-500 border border-purple-500/20"> |
| @@ -209,7 +228,37 @@ try { | ||
209 | 228 | > |
210 | 229 | </div> |
211 | 230 | </div> |
212 | - {/* Action buttons could go here */} | |
231 | + {canWrite && pr.state !== "merged" && ( | |
232 | + <div class="w-full max-w-sm rounded-lg border p-3 space-y-2"> | |
233 | + <div class="text-xs font-medium text-muted-foreground">PR Workflow State</div> | |
234 | + <div class="flex items-center gap-2"> | |
235 | + <select | |
236 | + id="pr-state-select" | |
237 | + class="h-8 flex-1 rounded-md border bg-background px-2 text-xs" | |
238 | + > | |
239 | + <option value="builtin:open">Open</option> | |
240 | + <option value="builtin:closed">Closed</option> | |
241 | + {customStates.map((state) => ( | |
242 | + <option | |
243 | + value={`custom:${state.id}`} | |
244 | + data-name={state.name} | |
245 | + data-description={state.description || ""} | |
246 | + data-allow-merge={state.allowMerge ? "true" : "false"} | |
247 | + data-require-codeowner={state.requireCodeOwner ? "true" : "false"} | |
248 | + selected={pr.stateId === state.id} | |
249 | + > | |
250 | + {state.displayName} | |
251 | + </option> | |
252 | + ))} | |
253 | + </select> | |
254 | + <Button id="pr-state-apply" variant="outline" className="h-8 text-xs"> | |
255 | + Apply | |
256 | + </Button> | |
257 | + </div> | |
258 | + <div id="pr-state-hint" class="text-xs text-muted-foreground"></div> | |
259 | + <div id="pr-state-msg" class="text-xs text-muted-foreground"></div> | |
260 | + </div> | |
261 | + )} | |
213 | 262 | </div> |
214 | 263 | |
215 | 264 | <div class="grid gap-6 lg:grid-cols-4"> |
| @@ -647,6 +696,8 @@ try { | ||
647 | 696 | order: entry.order, |
648 | 697 | })) : [], |
649 | 698 | stackId: stackInfo ? stackInfo.stack.id : null, |
699 | + currentBuiltinState: pr.state, | |
700 | + currentCustomStateId: pr.stateId || null, | |
650 | 701 | }} |
651 | 702 | > |
652 | 703 | const mergeBtn = document.getElementById("merge-btn"); |
| @@ -677,6 +728,10 @@ try { | ||
677 | 728 | const stackApprovalsRequestRecommended = document.getElementById("stack-approvals-request-recommended"); |
678 | 729 | const stackApprovalsPreview = document.getElementById("stack-approvals-preview"); |
679 | 730 | const stackApprovalsMsg = document.getElementById("stack-approvals-msg"); |
731 | + const prStateSelect = document.getElementById("pr-state-select"); | |
732 | + const prStateApply = document.getElementById("pr-state-apply"); | |
733 | + const prStateHint = document.getElementById("pr-state-hint"); | |
734 | + const prStateMsg = document.getElementById("pr-state-msg"); | |
680 | 735 | let stackApprovalRecommendations = []; |
681 | 736 | |
682 | 737 | // Keyboard Shortcuts |
| @@ -714,6 +769,83 @@ try { | ||
714 | 769 | // '[' and ']' to jump specific sections could be added here |
715 | 770 | }); |
716 | 771 | |
772 | + function updatePrStateHint() { | |
773 | + if (!prStateSelect || !prStateHint) return; | |
774 | + const value = prStateSelect.value || ""; | |
775 | + if (!value.startsWith("custom:")) { | |
776 | + prStateHint.textContent = value === "builtin:closed" | |
777 | + ? "Transition to closed will close this pull request." | |
778 | + : "Transition to open keeps the pull request active."; | |
779 | + return; | |
780 | + } | |
781 | + | |
782 | + const selected = prStateSelect.selectedOptions?.[0]; | |
783 | + const description = selected?.dataset?.description || ""; | |
784 | + const allowMerge = selected?.dataset?.allowMerge === "true"; | |
785 | + const requireCodeOwner = selected?.dataset?.requireCodeowner === "true"; | |
786 | + const flags = [ | |
787 | + allowMerge ? "merge-enabled" : null, | |
788 | + requireCodeOwner ? "requires code owner approval" : null, | |
789 | + ].filter(Boolean); | |
790 | + | |
791 | + const parts = []; | |
792 | + if (description) parts.push(description); | |
793 | + if (flags.length > 0) parts.push(flags.join(" • ")); | |
794 | + prStateHint.textContent = parts.join(" | ") || "Custom workflow state."; | |
795 | + } | |
796 | + | |
797 | + function getCurrentPrStateSelection() { | |
798 | + if (currentCustomStateId) return `custom:${currentCustomStateId}`; | |
799 | + return `builtin:${currentBuiltinState || "open"}`; | |
800 | + } | |
801 | + | |
802 | + if (prStateSelect) { | |
803 | + const currentSelection = getCurrentPrStateSelection(); | |
804 | + const hasCurrent = Array.from(prStateSelect.options).some((option) => option.value === currentSelection); | |
805 | + if (hasCurrent) { | |
806 | + prStateSelect.value = currentSelection; | |
807 | + } else { | |
808 | + prStateSelect.value = currentBuiltinState === "closed" ? "builtin:closed" : "builtin:open"; | |
809 | + } | |
810 | + updatePrStateHint(); | |
811 | + prStateSelect.addEventListener("change", updatePrStateHint); | |
812 | + } | |
813 | + | |
814 | + prStateApply?.addEventListener("click", async (event) => { | |
815 | + event.preventDefault(); | |
816 | + if (!prStateSelect || !prStateMsg) return; | |
817 | + | |
818 | + const targetValue = prStateSelect.value; | |
819 | + prStateApply.setAttribute("disabled", "true"); | |
820 | + prStateMsg.textContent = "Applying state transition..."; | |
821 | + | |
822 | + try { | |
823 | + const payload = targetValue.startsWith("custom:") | |
824 | + ? { stateId: targetValue.replace("custom:", "") } | |
825 | + : { state: targetValue.replace("builtin:", "") }; | |
826 | + | |
827 | + const res = await fetch( | |
828 | + `/api/repos/${repoOwner}/${repoName}/pulls/${prNumber}/state`, | |
829 | + { | |
830 | + method: "POST", | |
831 | + headers: { "Content-Type": "application/json" }, | |
832 | + body: JSON.stringify(payload), | |
833 | + }, | |
834 | + ); | |
835 | + const data = await res.json(); | |
836 | + if (!res.ok) { | |
837 | + throw new Error(data?.error?.message || data?.message || "Failed to transition state"); | |
838 | + } | |
839 | + | |
840 | + prStateMsg.textContent = "State transition complete."; | |
841 | + window.location.reload(); | |
842 | + } catch (error) { | |
843 | + prStateMsg.textContent = error.message || "Failed to transition state"; | |
844 | + } finally { | |
845 | + prStateApply.removeAttribute("disabled"); | |
846 | + } | |
847 | + }); | |
848 | + | |
717 | 849 | aiReviewBtn?.addEventListener("click", async () => { |
718 | 850 | aiReviewBtn.setAttribute("disabled", "true"); |
719 | 851 | const originalText = aiReviewBtn.innerHTML; |
720 | 852 | |
▼src/pages/api/repos/[owner]/[repo]/pulls/index.ts
+12-1
| @@ -110,6 +110,15 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request, locals | ||
110 | 110 | |
111 | 111 | const number = (lastPr?.number || 0) + 1; |
112 | 112 | |
113 | + const defaultCustomState = await db.query.prStateDefinitions.findFirst({ | |
114 | + where: and( | |
115 | + eq(schema.prStateDefinitions.repositoryId, repo.id), | |
116 | + eq(schema.prStateDefinitions.isDefault, true) | |
117 | + ), | |
118 | + orderBy: (states, { asc }) => [asc(states.order)], | |
119 | + columns: { id: true }, | |
120 | + }); | |
121 | + | |
113 | 122 | // Create PR |
114 | 123 | const prId = nanoid(); |
115 | 124 | await db.insert(schema.pullRequests).values({ |
| @@ -126,7 +135,9 @@ export const POST: APIRoute = withErrorHandler(async ({ params, request, locals | ||
126 | 135 | baseSha: baseCommit.sha, |
127 | 136 | additions, |
128 | 137 | deletions, |
129 | - changedFiles | |
138 | + changedFiles, | |
139 | + stateId: defaultCustomState?.id || null, | |
140 | + customStateChangedAt: defaultCustomState ? new Date() : null, | |
130 | 141 | }); |
131 | 142 | |
132 | 143 | // Auto-detect PR dependencies (stack if base matches another PR's head) |
133 | 144 | |