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.

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