Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

518 linesCodeBlameRaw
1import { ChevronDown, CircleDot, GitBranch, GitMerge, GitPullRequestArrow, RotateCcw, Trash2, Zap } from "lucide-react";
2import { type ReactNode, useEffect, useState } from "react";
3import { Form, Link, useSubmit } from "react-router";
4
5import type { AutoMerge, MergeMethod, Pull, UpdateMethod } from "@g1t/contracts";
6
7import { type MergeChoicesView, METHOD_INFO, rememberMethod, rememberedMethod, writesMessage } from "../lib/merge";
8import { ErrorText, SubmitButton, TimeAgo } from "./ui";
9import { Button } from "./ui/button";
10import { CheckboxOption } from "./ui/checkbox";
11import {
12 DropdownMenu,
13 DropdownMenuContent,
14 DropdownMenuItem,
15 DropdownMenuLabel,
16 DropdownMenuRadioGroup,
17 DropdownMenuRadioItem,
18 DropdownMenuTrigger,
19} from "./ui/dropdown-menu";
20import { Field, FieldLabel } from "./ui/field";
21import { Hint } from "./ui/hint";
22import { Input } from "./ui/input";
23import { Textarea } from "./ui/textarea";
24import { PersonLink } from "./work";
25import { itemPath } from "../lib/refs";
26
27/** The trigger half of a split button: the chevron that opens its menu. */
28function SplitTrigger({ label, variant, disabled }: { label: string; variant: "accent" | "outline"; disabled?: boolean }) {
29 return (
30 <DropdownMenuTrigger asChild disabled={disabled}>
31 <Button
32 type="button"
33 variant={variant}
34 size="icon"
35 aria-label={label}
36 className={`w-8 rounded-l-none ${variant === "accent" ? "border-l border-bg/30" : "-ml-px"}`}
37 >
38 <ChevronDown size={15} />
39 </Button>
40 </DropdownMenuTrigger>
41 );
42}
43
44/** Why a method cannot be used on this pull request now, if it cannot. */
45function methodBlocked(method: MergeMethod, choices: MergeChoicesView, base: string): string | null {
46 const said = choices.unavailable?.[method];
47 if (said) return said;
48 if (method === "fast_forward" && (choices.behind || !choices.canFastForward)) {
49 return `Fast-forward needs the branch to contain every commit of ${base}: update it first.`;
50 }
51 return null;
52}
53
54/** The methods to choose from, each with what it does; one that cannot be used now says why. */
55function MethodMenu({
56 choices,
57 method,
58 onPick,
59 base,
60 heading,
61}: {
62 choices: MergeChoicesView;
63 method: MergeMethod;
64 onPick: (method: MergeMethod) => void;
65 base: string;
66 heading: string;
67}) {
68 return (
69 <DropdownMenuContent align="end" className="w-80">
70 <DropdownMenuLabel>{heading}</DropdownMenuLabel>
71 <DropdownMenuRadioGroup value={method} onValueChange={(value) => onPick(value as MergeMethod)}>
72 {choices.methods.map((option) => {
73 const blocked = methodBlocked(option, choices, base);
74 return (
75 <DropdownMenuRadioItem key={option} value={option} disabled={blocked != null} className="items-start py-2">
76 <span className="min-w-0">
77 <span className="block font-medium">{METHOD_INFO[option].action}</span>
78 <span className="mt-0.5 block text-xs text-muted">{blocked ?? METHOD_INFO[option].about}</span>
79 </span>
80 </DropdownMenuRadioItem>
81 );
82 })}
83 </DropdownMenuRadioGroup>
84 </DropdownMenuContent>
85 );
86}
87
88/**
89 * The merge button: the method the viewer used here last (or the
90 * repository's default) with a menu of the others, then a confirm step to
91 * write the commit's message and choose what happens to the branch. When it
92 * cannot merge yet and the repository allows it, auto-merge is offered in its
93 * place. Inside the pull request's merge <Form>; `options` are the boxes
94 * (bypass checks, bypass rules) shown above the button.
95 */
96export function MergeButton({
97 repo,
98 pull,
99 choices,
100 base,
101 blocked,
102 queue,
103 canAutoMerge,
104 item,
105 options,
106 error,
107}: {
108 /** `owner/name`, under which the viewer's last method is remembered. */
109 repo: string;
110 pull: Pull;
111 choices: MergeChoicesView;
112 base: string;
113 /** Why it cannot merge now, if it cannot. */
114 blocked: string | null;
115 /** It merges through the merge queue, which picks the method. */
116 queue: boolean;
117 /** The viewer may turn auto-merge on: they may merge it. */
118 canAutoMerge: boolean;
119 /** The open work item it is for, which merging lands unless kept open. */
120 item: { number: number } | null;
121 options?: ReactNode;
122 error?: string | null;
123}) {
124 const usable = choices.methods.filter((option) => methodBlocked(option, choices, base) == null);
125 const fallback = usable.includes(choices.defaultMethod) ? choices.defaultMethod : (usable[0] ?? choices.defaultMethod);
126 const [method, setMethod] = useState<MergeMethod>(fallback);
127 // The viewer's last choice here, once the page is in the browser.
128 useEffect(() => {
129 const remembered = rememberedMethod(repo, usable);
130 if (remembered) setMethod(remembered);
131 // eslint-disable-next-line react-hooks/exhaustive-deps -- once, and when what is usable changes
132 }, [repo, usable.join()]);
133 const chosen = usable.includes(method) ? method : fallback;
134 const pick = (next: MergeMethod) => {
135 setMethod(next);
136 rememberMethod(repo, next);
137 };
138 // `merge` or `auto` while its confirm step is open.
139 const [confirming, setConfirming] = useState<"merge" | "auto" | null>(null);
140 const auto = blocked != null && choices.autoMergeAllowed && canAutoMerge && !pull.autoMerge && !queue;
141
142 if (queue) {
143 return (
144 <div className="space-y-3">
145 {options}
146 <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
147 <Hint label={blocked} disabled={blocked != null}>
148 <SubmitButton variant="accent" name="action" value="merge" disabled={blocked != null} pending="Adding to the queue…">
149 <GitMerge size={15} />
150 Add to the merge queue
151 </SubmitButton>
152 </Hint>
153 <span className={`text-xs ${blocked ? "text-danger" : "text-muted"}`}>
154 {blocked ?? `Tested together with everything ahead of it, then lands on ${base}.`}
155 </span>
156 </div>
157 {error && <ErrorText>{error}</ErrorText>}
158 </div>
159 );
160 }
161
162 if (confirming) {
163 return (
164 <ConfirmMerge
165 mode={confirming}
166 method={chosen}
167 choices={choices}
168 pull={pull}
169 base={base}
170 repo={repo}
171 item={item}
172 blocked={confirming === "merge" ? blocked : null}
173 options={confirming === "merge" ? options : null}
174 error={error}
175 onCancel={() => setConfirming(null)}
176 />
177 );
178 }
179
180 return (
181 <div className="space-y-3">
182 {options}
183 <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
184 <div className="flex flex-wrap gap-2">
185 <DropdownMenu>
186 <div className="flex">
187 <Hint label={blocked} disabled={blocked != null}>
188 <Button
189 type="button"
190 variant={auto ? "outline" : "accent"}
191 disabled={blocked != null || usable.length === 0}
192 onClick={() => setConfirming("merge")}
193 className="rounded-r-none"
194 >
195 <GitMerge size={15} />
196 {METHOD_INFO[chosen].action}
197 </Button>
198 </Hint>
199 <SplitTrigger label="Choose how to merge" variant={auto ? "outline" : "accent"} />
200 </div>
201 <MethodMenu choices={choices} method={chosen} onPick={pick} base={base} heading="Merge with" />
202 </DropdownMenu>
203 {auto && (
204 <Button type="button" variant="accent" onClick={() => setConfirming("auto")}>
205 <Zap size={15} />
206 Enable auto-merge
207 </Button>
208 )}
209 </div>
210 <span className={`text-xs ${blocked ? "text-danger" : "text-muted"}`}>
211 {blocked ?? (pull.closes?.length ? `Closes ${pull.closes.map((number) => `#${number}`).join(", ")}.` : `Lands its commits on ${base}.`)}
212 {blocked && auto && <span className="text-muted"> Or turn on auto-merge to land it once it can.</span>}
213 </span>
214 </div>
215 {error && <ErrorText>{error}</ErrorText>}
216 </div>
217 );
218}
219
220/**
221 * The commit's title and message when the work service could not say what
222 * they default to: left empty, and only what the merger types is sent. An
223 * untouched field is not sent at all, so the service writes the repository's
224 * default for it, from the pull request's commits.
225 */
226function UnwrittenMessage() {
227 const [title, setTitle] = useState("");
228 const [body, setBody] = useState("");
229 return (
230 <>
231 <Field>
232 <FieldLabel htmlFor="commit-title">Commit title</FieldLabel>
233 <Input
234 id="commit-title"
235 name={title.trim() ? "commitTitle" : undefined}
236 maxLength={500}
237 value={title}
238 onChange={(event) => setTitle(event.target.value)}
239 placeholder="The repository's default title"
240 />
241 </Field>
242 <Field>
243 <FieldLabel htmlFor="commit-message">Commit message</FieldLabel>
244 <Textarea
245 id="commit-message"
246 name={body.trim() ? "commitMessage" : undefined}
247 rows={6}
248 value={body}
249 onChange={(event) => setBody(event.target.value)}
250 placeholder="The repository's default message"
251 className="font-mono text-xs"
252 />
253 </Field>
254 </>
255 );
256}
257
258/** The confirm step: the commit's message, the branch, and the work items it closes. */
259function ConfirmMerge({
260 mode,
261 method: initial,
262 choices,
263 pull,
264 base,
265 repo,
266 item,
267 blocked,
268 options,
269 error,
270 onCancel,
271}: {
272 mode: "merge" | "auto";
273 method: MergeMethod;
274 choices: MergeChoicesView;
275 pull: Pull;
276 base: string;
277 repo: string;
278 item: { number: number } | null;
279 blocked: string | null;
280 options?: ReactNode;
281 error?: string | null;
282 onCancel: () => void;
283}) {
284 // Auto-merge happens later, when the branch may be up to date: every method is offered.
285 const [method, setMethod] = useState(initial);
286 const message = choices.messages[method];
287 const [keepOpen, setKeepOpen] = useState(false);
288 const closes = keepOpen ? (pull.closes ?? []).filter((number) => number !== item?.number) : (pull.closes ?? []);
289 const deletable = choices.canDeleteBranch ?? (pull.branch != null && !pull.fork);
290 return (
291 <div className="space-y-4">
292 <input type="hidden" name="mergeMethod" value={method} />
293 {pull.headCommit && <input type="hidden" name="expectedHead" value={pull.headCommit} />}
294 <div className="flex flex-wrap items-center gap-2 text-sm">
295 <span className="font-medium">{mode === "auto" ? "Enable auto-merge with" : "Merge with"}</span>
296 <DropdownMenu>
297 <DropdownMenuTrigger asChild>
298 <Button type="button" variant="outline" size="sm">
299 {METHOD_INFO[method].action}
300 <ChevronDown size={14} />
301 </Button>
302 </DropdownMenuTrigger>
303 <MethodMenu
304 choices={mode === "auto" ? { ...choices, behind: false, canFastForward: true, unavailable: {} } : choices}
305 method={method}
306 onPick={setMethod}
307 base={base}
308 heading={mode === "auto" ? "Once it can merge, merge with" : "Merge with"}
309 />
310 </DropdownMenu>
311 </div>
312 {writesMessage(method) ? (
313 // Keyed to the method, so switching starts from that method's default.
314 <div key={method} className="space-y-3">
315 {message ? (
316 <>
317 <Field>
318 <FieldLabel htmlFor="commit-title">Commit title</FieldLabel>
319 <Input id="commit-title" name="commitTitle" required maxLength={500} defaultValue={message.title} />
320 </Field>
321 <Field>
322 <FieldLabel htmlFor="commit-message">Commit message</FieldLabel>
323 <Textarea id="commit-message" name="commitMessage" rows={6} defaultValue={message.message} className="font-mono text-xs" />
324 </Field>
325 </>
326 ) : (
327 <UnwrittenMessage />
328 )}
329 {choices.signOff && (
330 <p className="text-xs text-faint">
331 This repository asks for sign-off: a <span className="font-mono">Signed-off-by:</span> line for you is added.
332 </p>
333 )}
334 </div>
335 ) : (
336 <p className="text-xs text-muted">{METHOD_INFO[method].about} No commit message to write.</p>
337 )}
338 <div className="space-y-2">
339 {mode === "merge" && deletable && (
340 <>
341 <input type="hidden" name="deleteBranchAsked" value="1" />
342 <CheckboxOption
343 name="deleteBranch"
344 defaultChecked={choices.deleteBranchDefault}
345 label={
346 <>
347 Delete <span className="font-mono">{pull.branch}</span> after merging
348 </>
349 }
350 description="It can be restored from this pull request."
351 labelClassName="text-xs"
352 />
353 </>
354 )}
355 {mode === "merge" && item && (
356 <CheckboxOption
357 name="keepItemOpen"
358 checked={keepOpen}
359 onCheckedChange={(checked) => setKeepOpen(checked === true)}
360 label={`Keep #${item.number} open. This is only part of the work.`}
361 labelClassName="text-xs text-muted"
362 />
363 )}
364 {options}
365 </div>
366 {closes.length > 0 && (
367 <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
368 <CircleDot size={13} className="text-success" />
369 {mode === "auto" ? "Once merged, closes" : "Closes"}
370 {closes.map((number) => (
371 <Link key={number} to={itemPath(repo.split("/")[0]!, number)} className="font-medium text-fg hover:underline">
372 #{number}
373 </Link>
374 ))}
375 </p>
376 )}
377 <div className="flex flex-wrap gap-2">
378 <Hint label={blocked} disabled={blocked != null}>
379 <SubmitButton
380 variant="accent"
381 name="action"
382 disabled={blocked != null}
383 value={mode === "auto" ? "auto-merge-on" : "merge"}
384 pending={mode === "auto" ? "Enabling…" : "Merging…"}
385 >
386 {mode === "auto" ? <Zap size={15} /> : <GitMerge size={15} />}
387 {mode === "auto" ? "Confirm auto-merge" : `Confirm ${METHOD_INFO[method].action.replace(/^Create a /, "").toLowerCase()}`}
388 </SubmitButton>
389 </Hint>
390 <Button type="button" variant="outline" onClick={onCancel}>
391 Cancel
392 </Button>
393 </div>
394 {error && <ErrorText>{error}</ErrorText>}
395 </div>
396 );
397}
398
399/** Auto-merge is on: who turned it on, how it will merge, and turning it off. */
400export function AutoMergeBanner({ auto, canChange }: { auto: AutoMerge; canChange: boolean }) {
401 return (
402 <div className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm">
403 <Zap size={16} className="shrink-0 text-accent" />
404 <div className="min-w-0 grow">
405 <p className="font-medium">
406 Auto-merge enabled by <PersonLink name={auto.enabledBy} className="hover:underline" />{" "}
407 <span className="font-normal text-muted">· {METHOD_INFO[auto.method].action.toLowerCase()}</span>
408 </p>
409 <p className="mt-0.5 text-muted">
410 It merges on its own once every review and check it needs has passed. Turned on <TimeAgo at={auto.enabledAt} />.
411 </p>
412 </div>
413 {canChange && (
414 <Form method="post">
415 <SubmitButton variant="outline" size="sm" name="action" value="auto-merge-off" pending="Disabling…">
416 Disable auto-merge
417 </SubmitButton>
418 </Form>
419 )}
420 </div>
421 );
422}
423
424/**
425 * Bringing a branch up to date: with a merge commit (the button), or a
426 * rebase (its menu). Each refuses if the branch moved since the page loaded.
427 */
428export function UpdateBranchButton({ head, base }: { head: string | null; base: string }) {
429 const submit = useSubmit();
430 const update = (method: UpdateMethod) => submit({ action: "update", updateMethod: method, ...(head ? { expectedHead: head } : {}) }, { method: "post" });
431 return (
432 <Form method="post" className="mt-2 flex">
433 <input type="hidden" name="action" value="update" />
434 <input type="hidden" name="updateMethod" value="merge" />
435 {head && <input type="hidden" name="expectedHead" value={head} />}
436 <DropdownMenu>
437 <SubmitButton variant="outline" size="sm" match={{ action: "update" }} pending="Updating…" className="rounded-r-none">
438 <GitPullRequestArrow size={14} />
439 Update branch
440 </SubmitButton>
441 <DropdownMenuTrigger asChild>
442 <Button type="button" variant="outline" size="icon-sm" aria-label="Choose how to update" className="-ml-px rounded-l-none">
443 <ChevronDown size={14} />
444 </Button>
445 </DropdownMenuTrigger>
446 <DropdownMenuContent align="start" className="w-80">
447 <DropdownMenuItem onSelect={() => update("merge")} className="items-start py-2">
448 <span>
449 <span className="block font-medium">Update with merge commit</span>
450 <span className="mt-0.5 block text-xs text-muted">Merges {base} into the branch. Its commits stay as they are.</span>
451 </span>
452 </DropdownMenuItem>
453 <DropdownMenuItem onSelect={() => update("rebase")} className="items-start py-2">
454 <span>
455 <span className="block font-medium">Update with rebase</span>
456 <span className="mt-0.5 block text-xs text-muted">
457 Makes its commits again on top of {base}. Anyone with the branch checked out has to reset to it.
458 </span>
459 </span>
460 </DropdownMenuItem>
461 </DropdownMenuContent>
462 </DropdownMenu>
463 </Form>
464 );
465}
466
467/** A merged or closed pull request's branch: delete it, or restore it once deleted. */
468export function BranchAfterwards({ pull, merged, canChange, error }: { pull: Pull; merged: boolean; canChange: boolean; error?: string | null }) {
469 if (!pull.branch || pull.fork) return null;
470 const deleted = Boolean(pull.headBranchDeleted);
471 return (
472 <div className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm">
473 <GitBranch size={16} className={`shrink-0 ${deleted ? "text-faint" : "text-merged"}`} />
474 <div className="min-w-0 grow">
475 <p className="font-medium">
476 {deleted ? (
477 <>
478 <span className="font-mono">{pull.branch}</span> was deleted
479 </>
480 ) : merged ? (
481 "Merged and closed: the branch can be deleted safely"
482 ) : (
483 <>
484 <span className="font-mono">{pull.branch}</span> is still there
485 </>
486 )}
487 </p>
488 <p className="mt-0.5 text-muted">
489 {deleted
490 ? `It can be made again at ${pull.headCommit?.slice(0, 7) ?? "its last commit"}, where it was.`
491 : merged
492 ? "Everything on it is in its base now."
493 : "Closed without merging. Deleting it can be undone here."}
494 </p>
495 </div>
496 {canChange && (
497 <Form method="post">
498 {deleted ? (
499 <SubmitButton variant="outline" size="sm" name="action" value="restore-branch" pending="Restoring…">
500 <RotateCcw size={14} />
501 Restore branch
502 </SubmitButton>
503 ) : (
504 <SubmitButton variant="outline" size="sm" name="action" value="delete-branch" pending="Deleting…">
505 <Trash2 size={14} />
506 Delete branch
507 </SubmitButton>
508 )}
509 </Form>
510 )}
511 {error && (
512 <div className="w-full">
513 <ErrorText>{error}</ErrorText>
514 </div>
515 )}
516 </div>
517 );
518}