Skip to content

Commit fce07f8

Browse files
committed
feat(web): restore full interactivity on the vendored PR header
Upgrade each read-only PR-header control to its full hosted version: inline title edit, status dropdown (close/reopen/draft) with close confirmation, merge-status controls (merge + auto-merge), and reviewer add/remove with collaborator search. Vendored components keep hosted markup; their oRPC mutations are swapped for CLI-native react-query hooks backed by the new gh write routes. Adds the alert-dialog primitive.
1 parent d611f76 commit fce07f8

11 files changed

Lines changed: 1371 additions & 122 deletions

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
},
1313
"dependencies": {
1414
"@pierre/diffs": "^1.0.11",
15+
"@radix-ui/react-alert-dialog": "^1.1.15",
1516
"@radix-ui/react-avatar": "^1.1.10",
1617
"@radix-ui/react-checkbox": "^1.3.3",
1718
"@radix-ui/react-collapsible": "^1.1.12",
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import {
2+
type MergeStatusInfo,
3+
PULL_REQUEST_MERGE_METHOD,
4+
type PullRequestMergeMethod,
5+
} from "@stagereview/types/pull-request";
6+
import { useMutation } from "@tanstack/react-query";
7+
import { Check, ChevronDown, GitMerge, Loader2 } from "lucide-react";
8+
import { useState } from "react";
9+
import { Button } from "@/components/ui/button";
10+
import {
11+
DropdownMenu,
12+
DropdownMenuContent,
13+
DropdownMenuItem,
14+
DropdownMenuTrigger,
15+
} from "@/components/ui/dropdown-menu";
16+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
17+
import { toast } from "@/components/ui/sonner";
18+
import { Switch } from "@/components/ui/switch";
19+
import { usePullRequestContext } from "@/lib/pull-request-context";
20+
import {
21+
dequeueMutationOptions,
22+
enqueueMutationOptions,
23+
mergeMutationOptions,
24+
setAutoMergeMutationOptions,
25+
useInvalidatePullRequest,
26+
} from "@/lib/pull-request-mutations";
27+
import { cn } from "@/lib/utils";
28+
import {
29+
getMergeStatusSummary,
30+
MERGE_STATUS,
31+
type MergeStatusSummary,
32+
} from "./merge-status-summary";
33+
34+
const MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = {
35+
[PULL_REQUEST_MERGE_METHOD.SQUASH]: "Squash and merge",
36+
[PULL_REQUEST_MERGE_METHOD.MERGE]: "Merge pull request",
37+
[PULL_REQUEST_MERGE_METHOD.REBASE]: "Rebase and merge",
38+
};
39+
40+
function MergeActions({
41+
mergeInfo,
42+
summary,
43+
owner,
44+
repo,
45+
number,
46+
headSha,
47+
}: {
48+
mergeInfo: MergeStatusInfo;
49+
summary: MergeStatusSummary;
50+
owner: string;
51+
repo: string;
52+
number: number;
53+
headSha: string;
54+
}) {
55+
const { runId } = usePullRequestContext();
56+
const invalidateAfterMutation = useInvalidatePullRequest(runId);
57+
const [mergeMethod, setMergeMethod] = useState<PullRequestMergeMethod>(
58+
() => mergeInfo.allowedMergeMethods[0] ?? PULL_REQUEST_MERGE_METHOD.MERGE,
59+
);
60+
61+
const mergeMutation = useMutation({
62+
...mergeMutationOptions(runId),
63+
onSuccess: () => {
64+
toast.success("Pull request merged");
65+
invalidateAfterMutation();
66+
},
67+
onError: (error) => {
68+
toast.error(error instanceof Error ? error.message : "Failed to merge pull request");
69+
},
70+
});
71+
72+
const enqueueMutation = useMutation({
73+
...enqueueMutationOptions(runId),
74+
onError: (error) => {
75+
toast.error(error instanceof Error ? error.message : "Failed to add to merge queue");
76+
},
77+
onSettled: invalidateAfterMutation,
78+
});
79+
80+
const autoMergeMutation = useMutation({
81+
...setAutoMergeMutationOptions(runId),
82+
onError: (error) => {
83+
toast.error(error instanceof Error ? error.message : "Failed to update auto-merge");
84+
},
85+
onSettled: invalidateAfterMutation,
86+
});
87+
88+
const dequeueMutation = useMutation({
89+
...dequeueMutationOptions(runId),
90+
onError: (error) => {
91+
toast.error(error instanceof Error ? error.message : "Failed to remove from merge queue");
92+
},
93+
onSettled: invalidateAfterMutation,
94+
});
95+
96+
const anyPending =
97+
mergeMutation.isPending ||
98+
enqueueMutation.isPending ||
99+
autoMergeMutation.isPending ||
100+
dequeueMutation.isPending;
101+
102+
const optimisticAutoMergeEnabled =
103+
autoMergeMutation.isPending && autoMergeMutation.variables
104+
? autoMergeMutation.variables.enabled
105+
: mergeInfo.autoMergeEnabled;
106+
const optimisticInMergeQueue = enqueueMutation.isPending
107+
? true
108+
: dequeueMutation.isPending
109+
? false
110+
: mergeInfo.isInMergeQueue;
111+
112+
const isReady = summary.status === MERGE_STATUS.READY;
113+
114+
if (isReady && !mergeInfo.isMergeQueueEnabled && mergeInfo.allowedMergeMethods.length > 0) {
115+
return (
116+
<div className="flex flex-col gap-2">
117+
<div className="flex items-center gap-1.5">
118+
<Button
119+
size="sm"
120+
className="flex-1"
121+
disabled={anyPending}
122+
onClick={() =>
123+
mergeMutation.mutate({ owner, repo, number, mergeMethod, expectedHeadOid: headSha })
124+
}
125+
>
126+
{mergeMutation.isPending ? (
127+
<Loader2 className="size-3.5 animate-spin" />
128+
) : (
129+
<GitMerge className="size-3.5" />
130+
)}
131+
{MERGE_METHOD_LABELS[mergeMethod]}
132+
</Button>
133+
{mergeInfo.allowedMergeMethods.length > 1 && (
134+
<DropdownMenu>
135+
<DropdownMenuTrigger asChild>
136+
<Button variant="outline" size="sm" className="px-1.5">
137+
<ChevronDown className="size-3.5" />
138+
</Button>
139+
</DropdownMenuTrigger>
140+
<DropdownMenuContent align="end">
141+
{mergeInfo.allowedMergeMethods.map((method) => (
142+
<DropdownMenuItem key={method} onClick={() => setMergeMethod(method)}>
143+
<Check className={cn("size-3.5", mergeMethod !== method && "invisible")} />
144+
{MERGE_METHOD_LABELS[method]}
145+
</DropdownMenuItem>
146+
))}
147+
</DropdownMenuContent>
148+
</DropdownMenu>
149+
)}
150+
</div>
151+
</div>
152+
);
153+
}
154+
155+
if (mergeInfo.isMergeQueueEnabled) {
156+
const isMergeWhenReady = optimisticInMergeQueue || optimisticAutoMergeEnabled;
157+
return (
158+
<div className="flex items-center gap-2">
159+
<span
160+
className={cn(
161+
"text-sm",
162+
anyPending ? "cursor-not-allowed text-muted-foreground/50" : "text-muted-foreground",
163+
)}
164+
>
165+
{anyPending ? "Updating…" : "Merge when ready"}
166+
</span>
167+
<Switch
168+
checked={isMergeWhenReady}
169+
disabled={anyPending}
170+
onCheckedChange={(checked) => {
171+
if (checked) {
172+
if (isReady) {
173+
enqueueMutation.mutate({ owner, repo, number, expectedHeadOid: headSha });
174+
} else {
175+
autoMergeMutation.mutate({ owner, repo, number, enabled: true, mergeMethod });
176+
}
177+
} else {
178+
if (mergeInfo.isInMergeQueue && mergeInfo.entry) {
179+
dequeueMutation.mutate({
180+
owner,
181+
repo,
182+
number,
183+
mergeQueueEntryId: mergeInfo.entry.id,
184+
});
185+
}
186+
if (mergeInfo.autoMergeEnabled) {
187+
autoMergeMutation.mutate({ owner, repo, number, enabled: false });
188+
}
189+
}
190+
}}
191+
/>
192+
</div>
193+
);
194+
}
195+
196+
if (mergeInfo.autoMergeAllowed || mergeInfo.viewerCanDisableAutoMerge) {
197+
return (
198+
<div className="flex items-center gap-2">
199+
<span
200+
className={cn(
201+
"text-sm",
202+
anyPending ? "cursor-not-allowed text-muted-foreground/50" : "text-muted-foreground",
203+
)}
204+
>
205+
{anyPending ? "Updating…" : "Merge when ready"}
206+
</span>
207+
<Switch
208+
checked={optimisticAutoMergeEnabled}
209+
disabled={anyPending}
210+
onCheckedChange={(checked) => {
211+
autoMergeMutation.mutate({
212+
owner,
213+
repo,
214+
number,
215+
enabled: checked,
216+
...(checked && { mergeMethod }),
217+
});
218+
}}
219+
/>
220+
</div>
221+
);
222+
}
223+
224+
return (
225+
<p className="text-muted-foreground text-sm">
226+
{summary.isTransient
227+
? "Waiting for status checks to complete."
228+
: "This pull request cannot be merged yet."}
229+
</p>
230+
);
231+
}
232+
233+
export interface MergeStatusProps {
234+
mergeInfo: MergeStatusInfo;
235+
owner: string;
236+
repo: string;
237+
number: number;
238+
headSha: string;
239+
}
240+
241+
export function MergeStatus({ mergeInfo, owner, repo, number, headSha }: MergeStatusProps) {
242+
const summary = getMergeStatusSummary(mergeInfo);
243+
const MergeIcon = summary.icon;
244+
245+
return (
246+
<Popover>
247+
<PopoverTrigger asChild>
248+
<button
249+
type="button"
250+
className={cn(
251+
"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1 text-sm transition-opacity hover:opacity-80",
252+
summary.pillBg,
253+
)}
254+
>
255+
<MergeIcon className={cn("size-3.5", summary.iconColor)} />
256+
<span className={cn("font-medium", summary.accentColor)}>{summary.label}</span>
257+
<ChevronDown className="size-3 text-muted-foreground" />
258+
</button>
259+
</PopoverTrigger>
260+
<PopoverContent align="start" className="w-auto">
261+
<MergeActions
262+
mergeInfo={mergeInfo}
263+
summary={summary}
264+
owner={owner}
265+
repo={repo}
266+
number={number}
267+
headSha={headSha}
268+
/>
269+
</PopoverContent>
270+
</Popover>
271+
);
272+
}

0 commit comments

Comments
 (0)