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.

200 linesCodeBlameRaw
1import { useRef, useState } from "react";
2
3import type { CommitMessageDefault, MergeMethod, RepoSettings } from "@g1t/contracts";
4
5import { MERGE_METHODS, MESSAGE_CHOICES, METHOD_INFO, PLACEHOLDERS, SAMPLE_PULL, defaultMessage } from "../lib/merge";
6import { Badge } from "./ui/badge";
7import { Button } from "./ui/button";
8import { Card } from "./ui/card";
9import { CheckboxOption } from "./ui/checkbox";
10import { Field, FieldDescription, FieldLabel } from "./ui/field";
11import { Hint } from "./ui/hint";
12import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
13import { Textarea } from "./ui/textarea";
14
15type Settings = Pick<
16 RepoSettings,
17 | "allowMergeCommit"
18 | "allowSquashMerge"
19 | "allowRebaseMerge"
20 | "allowFastForward"
21 | "defaultMergeMethod"
22 | "mergeCommitMessage"
23 | "squashCommitMessage"
24 | "mergeCommitTemplate"
25 | "squashCommitTemplate"
26>;
27
28/**
29 * Which merge methods pull requests may use, what merge and squash commits
30 * say by default, and which method is offered first. Posts `allowMergeCommit`
31 * and the rest by their settings' names. At least one method stays on.
32 */
33export function MergeMethodSettings({ settings, disabled }: { settings: Settings; disabled?: boolean }) {
34 const [allowed, setAllowed] = useState<Record<MergeMethod, boolean>>({
35 merge: settings.allowMergeCommit,
36 squash: settings.allowSquashMerge,
37 rebase: settings.allowRebaseMerge,
38 fast_forward: settings.allowFastForward,
39 });
40 const on = MERGE_METHODS.filter((method) => allowed[method]);
41 const [chosenDefault, setDefault] = useState<MergeMethod>(settings.defaultMergeMethod);
42 // A default that is no longer allowed gives way to the first that is.
43 const defaultMethod = on.includes(chosenDefault) ? chosenDefault : (on[0] ?? "merge");
44 return (
45 <>
46 <Card divided className="overflow-hidden">
47 {MERGE_METHODS.map((method) => {
48 const info = METHOD_INFO[method];
49 const last = on.length === 1 && allowed[method];
50 return (
51 <div key={method} className="space-y-3 p-4">
52 <Hint label={last ? "At least one merge method stays on." : null} disabled={last} align="start">
53 <CheckboxOption
54 name={info.setting}
55 checked={allowed[method]}
56 disabled={disabled || last}
57 onCheckedChange={(checked) => setAllowed((now) => ({ ...now, [method]: checked === true }))}
58 label={
59 <span className="flex flex-wrap items-center gap-2 font-medium">
60 Allow {info.label.toLowerCase()}
61 {defaultMethod === method && on.length > 1 && <Badge tone="accent">Default</Badge>}
62 </span>
63 }
64 description={info.about}
65 />
66 </Hint>
67 {(method === "merge" || method === "squash") && allowed[method] && (
68 <MessageDefault
69 method={method}
70 choice={method === "merge" ? settings.mergeCommitMessage : settings.squashCommitMessage}
71 template={method === "merge" ? settings.mergeCommitTemplate : settings.squashCommitTemplate}
72 disabled={disabled}
73 />
74 )}
75 </div>
76 );
77 })}
78 </Card>
79 {/* A method whose box is unticked does not post: its template is kept by the action. */}
80 <Card className="flex flex-col gap-3 p-4 sm:flex-row sm:items-start">
81 <div className="grow">
82 <p className="text-sm font-medium">Default merge method</p>
83 <p className="mt-1 text-sm text-muted">
84 Offered first on the merge button, and used by the API, the CLI and auto-merge when no method is named. A person's last choice in this repository is
85 offered to them first.
86 </p>
87 </div>
88 <Select
89 name="defaultMergeMethod"
90 value={defaultMethod}
91 onValueChange={(value) => setDefault(value as MergeMethod)}
92 disabled={disabled || on.length < 2}
93 >
94 <SelectTrigger size="sm" aria-label="Default merge method" className="w-full shrink-0 sm:w-52">
95 <SelectValue />
96 </SelectTrigger>
97 <SelectContent align="end">
98 {on.map((method) => (
99 <SelectItem key={method} value={method}>
100 {METHOD_INFO[method].action}
101 </SelectItem>
102 ))}
103 </SelectContent>
104 </Select>
105 </Card>
106 </>
107 );
108}
109
110/** What a merge or squash commit says unless whoever merges writes it: a choice, or a template. */
111function MessageDefault({
112 method,
113 choice: saved,
114 template: savedTemplate,
115 disabled,
116}: {
117 method: "merge" | "squash";
118 choice: CommitMessageDefault;
119 template: string | null;
120 disabled?: boolean;
121}) {
122 const [choice, setChoice] = useState<CommitMessageDefault>(saved);
123 const [template, setTemplate] = useState(savedTemplate ?? "{title} (#{number})\n\n{body}\n\nCloses {items}\n{co_authors}");
124 const box = useRef<HTMLTextAreaElement>(null);
125 const name = method === "merge" ? "mergeCommit" : "squashCommit";
126 const preview = defaultMessage(method, choice, template, SAMPLE_PULL);
127 // A placeholder goes in where the cursor is, or at the end.
128 const insert = (placeholder: string) => {
129 const area = box.current;
130 const text = `{${placeholder}}`;
131 if (!area) return setTemplate((now) => now + text);
132 const start = area.selectionStart ?? template.length;
133 const end = area.selectionEnd ?? start;
134 const next = template.slice(0, start) + text + template.slice(end);
135 setTemplate(next);
136 requestAnimationFrame(() => {
137 area.focus();
138 area.setSelectionRange(start + text.length, start + text.length);
139 });
140 };
141 return (
142 <div className="ml-6.5 space-y-3">
143 <Field>
144 <FieldLabel htmlFor={`${name}-message`} className="text-xs text-muted">
145 Default commit message
146 </FieldLabel>
147 <Select name={`${name}Message`} value={choice} onValueChange={(value) => setChoice(value as CommitMessageDefault)} disabled={disabled}>
148 <SelectTrigger id={`${name}-message`} size="sm" className="w-full sm:w-80">
149 <SelectValue />
150 </SelectTrigger>
151 <SelectContent>
152 {MESSAGE_CHOICES[method].map((option) => (
153 <SelectItem key={option.value} value={option.value}>
154 {option.label}
155 </SelectItem>
156 ))}
157 </SelectContent>
158 </Select>
159 </Field>
160 {choice === "custom" ? (
161 <Field>
162 <FieldLabel htmlFor={`${name}-template`} className="text-xs text-muted">
163 Template
164 </FieldLabel>
165 <Textarea
166 ref={box}
167 id={`${name}-template`}
168 name={`${name}Template`}
169 rows={6}
170 maxLength={4000}
171 value={template}
172 onChange={(event) => setTemplate(event.target.value)}
173 disabled={disabled}
174 className="font-mono text-xs"
175 />
176 <FieldDescription>The first line is the commit's title. Press a placeholder to put it in:</FieldDescription>
177 <div className="flex flex-wrap gap-1.5">
178 {PLACEHOLDERS.map((placeholder) => (
179 <Hint key={placeholder.name} label={placeholder.about}>
180 <Button type="button" variant="outline" size="xs" disabled={disabled} onClick={() => insert(placeholder.name)} className="font-mono">
181 {`{${placeholder.name}}`}
182 </Button>
183 </Hint>
184 ))}
185 </div>
186 </Field>
187 ) : (
188 // Kept while another choice is made, so switching back finds it.
189 savedTemplate != null && <input type="hidden" name={`${name}Template`} value={savedTemplate} />
190 )}
191 <div>
192 <p className="text-xs text-faint">Preview, for a sample pull request</p>
193 <Card tone="bg" radius="lg" className="mt-1.5 px-3 py-2 font-mono text-xs">
194 <p className="font-semibold text-fg wrap-break-word">{preview.title || <span className="text-faint">(no title)</span>}</p>
195 {preview.message && <p className="mt-2 whitespace-pre-wrap text-muted wrap-break-word">{preview.message}</p>}
196 </Card>
197 </div>
198 </div>
199 );
200}