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.

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