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.

534 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 ??
212 (pull.closes?.length
213 ? `Closes ${pull.closes.map((number) => `#${number}`).join(", ")}.`
214 : `Lands its commits on ${base}.`)}
215 {blocked && auto && <span className="text-muted"> Or turn on auto-merge to land it once it can.</span>}
216 </span>
217 </div>
218 {error && <ErrorText>{error}</ErrorText>}
219 </div>
220 );
221}
222
223/**
224 * The commit's title and message when the work service could not say what
225 * they default to: left empty, and only what the merger types is sent. An
226 * untouched field is not sent at all, so the service writes the repository's
227 * default for it, from the pull request's commits.
228 */
229function UnwrittenMessage() {
230 const [title, setTitle] = useState("");
231 const [body, setBody] = useState("");
232 return (
233 <>
234 <Field>
235 <FieldLabel htmlFor="commit-title">Commit title</FieldLabel>
236 <Input
237 id="commit-title"
238 name={title.trim() ? "commitTitle" : undefined}
239 maxLength={500}
240 value={title}
241 onChange={(event) => setTitle(event.target.value)}
242 placeholder="The repository's default title"
243 />
244 </Field>
245 <Field>
246 <FieldLabel htmlFor="commit-message">Commit message</FieldLabel>
247 <Textarea
248 id="commit-message"
249 name={body.trim() ? "commitMessage" : undefined}
250 rows={6}
251 value={body}
252 onChange={(event) => setBody(event.target.value)}
253 placeholder="The repository's default message"
254 className="font-mono text-xs"
255 />
256 </Field>
257 </>
258 );
259}
260
261/** The confirm step: the commit's message, the branch, and the work items it closes. */
262function ConfirmMerge({
263 mode,
264 method: initial,
265 choices,
266 pull,
267 base,
268 repo,
269 item,
270 blocked,
271 options,
272 error,
273 onCancel,
274}: {
275 mode: "merge" | "auto";
276 method: MergeMethod;
277 choices: MergeChoicesView;
278 pull: Pull;
279 base: string;
280 repo: string;
281 item: { number: number } | null;
282 blocked: string | null;
283 options?: ReactNode;
284 error?: string | null;
285 onCancel: () => void;
286}) {
287 // Auto-merge happens later, when the branch may be up to date: every method is offered.
288 const [method, setMethod] = useState(initial);
289 const message = choices.messages[method];
290 const [keepOpen, setKeepOpen] = useState(false);
291 const closes = keepOpen ? (pull.closes ?? []).filter((number) => number !== item?.number) : (pull.closes ?? []);
292 const deletable = choices.canDeleteBranch ?? (pull.branch != null && !pull.fork);
293 return (
294 <div className="space-y-4">
295 <input type="hidden" name="mergeMethod" value={method} />
296 {pull.headCommit && <input type="hidden" name="expectedHead" value={pull.headCommit} />}
297 <div className="flex flex-wrap items-center gap-2 text-sm">
298 <span className="font-medium">{mode === "auto" ? "Enable auto-merge with" : "Merge with"}</span>
299 <DropdownMenu>
300 <DropdownMenuTrigger asChild>
301 <Button type="button" variant="outline" size="sm">
302 {METHOD_INFO[method].action}
303 <ChevronDown size={14} />
304 </Button>
305 </DropdownMenuTrigger>
306 <MethodMenu
307 choices={mode === "auto" ? { ...choices, behind: false, canFastForward: true, unavailable: {} } : choices}
308 method={method}
309 onPick={setMethod}
310 base={base}
311 heading={mode === "auto" ? "Once it can merge, merge with" : "Merge with"}
312 />
313 </DropdownMenu>
314 </div>
315 {writesMessage(method) ? (
316 // Keyed to the method, so switching starts from that method's default.
317 <div key={method} className="space-y-3">
318 {message ? (
319 <>
320 <Field>
321 <FieldLabel htmlFor="commit-title">Commit title</FieldLabel>
322 <Input id="commit-title" name="commitTitle" required maxLength={500} defaultValue={message.title} />
323 </Field>
324 <Field>
325 <FieldLabel htmlFor="commit-message">Commit message</FieldLabel>
326 <Textarea id="commit-message" name="commitMessage" rows={6} defaultValue={message.message} className="font-mono text-xs" />
327 </Field>
328 </>
329 ) : (
330 <UnwrittenMessage />
331 )}
332 {choices.signOff && (
333 <p className="text-xs text-faint">
334 This repository asks for sign-off: a <span className="font-mono">Signed-off-by:</span> line for you is added.
335 </p>
336 )}
337 </div>
338 ) : (
339 <p className="text-xs text-muted">{METHOD_INFO[method].about} No commit message to write.</p>
340 )}
341 <div className="space-y-2">
342 {mode === "merge" && deletable && (
343 <>
344 <input type="hidden" name="deleteBranchAsked" value="1" />
345 <CheckboxOption
346 name="deleteBranch"
347 defaultChecked={choices.deleteBranchDefault}
348 label={
349 <>
350 Delete <span className="font-mono">{pull.branch}</span> after merging
351 </>
352 }
353 description="It can be restored from this pull request."
354 labelClassName="text-xs"
355 />
356 </>
357 )}
358 {mode === "merge" && item && (
359 <CheckboxOption
360 name="keepItemOpen"
361 checked={keepOpen}
362 onCheckedChange={(checked) => setKeepOpen(checked === true)}
363 label={`Keep #${item.number} open. This is only part of the work.`}
364 labelClassName="text-xs text-muted"
365 />
366 )}
367 {options}
368 </div>
369 {closes.length > 0 && (
370 <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
371 <CircleDot size={13} className="text-success" />
372 {mode === "auto" ? "Once merged, closes" : "Closes"}
373 {closes.map((number) => (
374 <Link key={number} to={itemPath(repo.split("/")[0]!, number)} className="font-medium text-fg hover:underline">
375 #{number}
376 </Link>
377 ))}
378 </p>
379 )}
380 <div className="flex flex-wrap gap-2">
381 <Hint label={blocked} disabled={blocked != null}>
382 <SubmitButton
383 variant="accent"
384 name="action"
385 disabled={blocked != null}
386 value={mode === "auto" ? "auto-merge-on" : "merge"}
387 pending={mode === "auto" ? "Enabling…" : "Merging…"}
388 >
389 {mode === "auto" ? <Zap size={15} /> : <GitMerge size={15} />}
390 {mode === "auto" ? "Confirm auto-merge" : `Confirm ${METHOD_INFO[method].action.replace(/^Create a /, "").toLowerCase()}`}
391 </SubmitButton>
392 </Hint>
393 <Button type="button" variant="outline" onClick={onCancel}>
394 Cancel
395 </Button>
396 </div>
397 {error && <ErrorText>{error}</ErrorText>}
398 </div>
399 );
400}
401
402/** Auto-merge is on: who turned it on, how it will merge, and turning it off. */
403export function AutoMergeBanner({ auto, canChange }: { auto: AutoMerge; canChange: boolean }) {
404 return (
405 <div className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm">
406 <Zap size={16} className="shrink-0 text-accent" />
407 <div className="min-w-0 grow">
408 <p className="font-medium">
409 Auto-merge enabled by <PersonLink name={auto.enabledBy} className="hover:underline" />{" "}
410 <span className="font-normal text-muted">· {METHOD_INFO[auto.method].action.toLowerCase()}</span>
411 </p>
412 <p className="mt-0.5 text-muted">
413 It merges on its own once every review and check it needs has passed. Turned on <TimeAgo at={auto.enabledAt} />.
414 </p>
415 </div>
416 {canChange && (
417 <Form method="post">
418 <SubmitButton variant="outline" size="sm" name="action" value="auto-merge-off" pending="Disabling…">
419 Disable auto-merge
420 </SubmitButton>
421 </Form>
422 )}
423 </div>
424 );
425}
426
427/**
428 * Bringing a branch up to date: with a merge commit (the button), or a
429 * rebase (its menu). Each refuses if the branch moved since the page loaded.
430 */
431export function UpdateBranchButton({ head, base }: { head: string | null; base: string }) {
432 const submit = useSubmit();
433 const update = (method: UpdateMethod) =>
434 submit({ action: "update", updateMethod: method, ...(head ? { expectedHead: head } : {}) }, { method: "post" });
435 return (
436 <Form method="post" className="mt-2 flex">
437 <input type="hidden" name="action" value="update" />
438 <input type="hidden" name="updateMethod" value="merge" />
439 {head && <input type="hidden" name="expectedHead" value={head} />}
440 <DropdownMenu>
441 <SubmitButton variant="outline" size="sm" match={{ action: "update" }} pending="Updating…" className="rounded-r-none">
442 <GitPullRequestArrow size={14} />
443 Update branch
444 </SubmitButton>
445 <DropdownMenuTrigger asChild>
446 <Button type="button" variant="outline" size="icon-sm" aria-label="Choose how to update" className="-ml-px rounded-l-none">
447 <ChevronDown size={14} />
448 </Button>
449 </DropdownMenuTrigger>
450 <DropdownMenuContent align="start" className="w-80">
451 <DropdownMenuItem onSelect={() => update("merge")} className="items-start py-2">
452 <span>
453 <span className="block font-medium">Update with merge commit</span>
454 <span className="mt-0.5 block text-xs text-muted">
455 Merges {base} into the branch. Its commits stay as they are.
456 </span>
457 </span>
458 </DropdownMenuItem>
459 <DropdownMenuItem onSelect={() => update("rebase")} className="items-start py-2">
460 <span>
461 <span className="block font-medium">Update with rebase</span>
462 <span className="mt-0.5 block text-xs text-muted">
463 Makes its commits again on top of {base}. Anyone with the branch checked out has to reset to it.
464 </span>
465 </span>
466 </DropdownMenuItem>
467 </DropdownMenuContent>
468 </DropdownMenu>
469 </Form>
470 );
471}
472
473/** A merged or closed pull request's branch: delete it, or restore it once deleted. */
474export function BranchAfterwards({
475 pull,
476 merged,
477 canChange,
478 error,
479}: {
480 pull: Pull;
481 merged: boolean;
482 canChange: boolean;
483 error?: string | null;
484}) {
485 if (!pull.branch || pull.fork) return null;
486 const deleted = Boolean(pull.headBranchDeleted);
487 return (
488 <div className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm">
489 <GitBranch size={16} className={`shrink-0 ${deleted ? "text-faint" : "text-merged"}`} />
490 <div className="min-w-0 grow">
491 <p className="font-medium">
492 {deleted ? (
493 <>
494 <span className="font-mono">{pull.branch}</span> was deleted
495 </>
496 ) : merged ? (
497 "Merged and closed: the branch can be deleted safely"
498 ) : (
499 <>
500 <span className="font-mono">{pull.branch}</span> is still there
501 </>
502 )}
503 </p>
504 <p className="mt-0.5 text-muted">
505 {deleted
506 ? `It can be made again at ${pull.headCommit?.slice(0, 7) ?? "its last commit"}, where it was.`
507 : merged
508 ? "Everything on it is in its base now."
509 : "Closed without merging. Deleting it can be undone here."}
510 </p>
511 </div>
512 {canChange && (
513 <Form method="post">
514 {deleted ? (
515 <SubmitButton variant="outline" size="sm" name="action" value="restore-branch" pending="Restoring…">
516 <RotateCcw size={14} />
517 Restore branch
518 </SubmitButton>
519 ) : (
520 <SubmitButton variant="outline" size="sm" name="action" value="delete-branch" pending="Deleting…">
521 <Trash2 size={14} />
522 Delete branch
523 </SubmitButton>
524 )}
525 </Form>
526 )}
527 {error && (
528 <div className="w-full">
529 <ErrorText>{error}</ErrorText>
530 </div>
531 )}
532 </div>
533 );
534}