Complete custom PR state guidance and default adoption

swadhinbiswas committed 8 months ago
60d0c1b
1 parent 48df41e
commit 60d0c1b
3 files changed
+146 -3
▼doc/feature_audit.md
+1-1
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
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
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
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
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
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
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
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
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