flagon-io/g1t

public

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

g1t/apps/web/app/routes/repo/settings.tsx

296 lines10,805 bytesCodeBlame
1import type { ReactNode } from "react";
2import { Form, data, useNavigation } from "react-router";
3
4import type { Route } from "./+types/settings";
5import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
6import { repos, work } from "../../lib/services.server";
7import {
8 assertSameOrigin,
9 getViewer,
10 requireUser,
11 roleIn,
12 unwrap,
13} from "../../lib/session.server";
14
15export function meta({ params }: Route.MetaArgs) {
16 return [{ title: `Settings · ${params.owner}/${params.repo} · g1t` }];
17}
18
19export async function loader({ params, context }: Route.LoaderArgs) {
20 const viewer = getViewer(context);
21 // Members only; to anyone else the page does not exist.
22 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
23 const path = { namespace: params.owner, name: params.repo };
24 const [repo, settings] = await Promise.all([
25 repos.get(path, viewer),
26 work.getSettings(path, viewer),
27 ]);
28 return { repo: unwrap(repo), settings: unwrap(settings) };
29}
30
31/** A whole number from a form field, kept within `min` and `max`. */
32function count(value: FormDataEntryValue | null, min: number, max: number): number {
33 const number = Math.trunc(Number(value));
34 return Number.isFinite(number) ? Math.min(Math.max(number, min), max) : min;
35}
36
37export async function action({ request, params, context }: Route.ActionArgs) {
38 assertSameOrigin(request);
39 const user = requireUser(context, request);
40 const form = await request.formData();
41 const path = { namespace: params.owner, name: params.repo };
42 const on = (name: string) => form.get(name) === "on";
43
44 // The repository's own details belong to one service and how its pull
45 // requests are handled to another; the page is one form over both.
46 const repo = await repos.update(user, path, {
47 description: String(form.get("description") ?? ""),
48 isPrivate: form.get("visibility") === "private",
49 protected: on("protected"),
50 });
51 if (!repo.ok) return { saved: false, error: repo.error.message };
52 const settings = await work.updateSettings(user, path, {
53 autoMerge: on("autoMerge"),
54 requireUpToDate: on("requireUpToDate"),
55 requiredApprovals: count(form.get("requiredApprovals"), 0, 6),
56 countAgentApprovals: on("countAgentApprovals"),
57 allowIgnoringChecks: !on("requireChecks"),
58 agentReview: on("agentReview"),
59 maxRevisions: count(form.get("maxRevisions"), 0, 5),
60 mergeQueue: on("mergeQueue"),
61 });
62 return settings.ok
63 ? { saved: true, error: null }
64 : { saved: false, error: settings.error.message };
65}
66
67function Section({
68 title,
69 about,
70 children,
71}: {
72 title: string;
73 about: string;
74 children: ReactNode;
75}) {
76 return (
77 <section className="grid gap-x-10 gap-y-4 border-t border-line pt-8 first:border-t-0 first:pt-0 lg:grid-cols-[16rem_1fr]">
78 <div>
79 <h2 className="font-medium">{title}</h2>
80 <p className="mt-1 text-sm text-muted">{about}</p>
81 </div>
82 <div className="space-y-3">{children}</div>
83 </section>
84 );
85}
86
87/** A setting that is on or off, with what it means. */
88function Toggle({
89 name,
90 on,
91 title,
92 children,
93}: {
94 name: string;
95 on: boolean;
96 title: string;
97 children: ReactNode;
98}) {
99 return (
100 <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
101 <input type="checkbox" name={name} defaultChecked={on} className="mt-1 accent-accent" />
102 <span>
103 <span className="block text-sm font-medium">{title}</span>
104 <span className="mt-1 block text-sm text-muted">{children}</span>
105 </span>
106 </label>
107 );
108}
109
110/** A setting chosen from a few numbers. */
111function Choice({
112 name,
113 value,
114 options,
115 title,
116 children,
117}: {
118 name: string;
119 value: number;
120 options: [number, string][];
121 title: string;
122 children: ReactNode;
123}) {
124 return (
125 <div className="flex items-start gap-4 rounded-xl border border-line bg-surface p-4">
126 <div className="grow">
127 <p className="text-sm font-medium">{title}</p>
128 <p className="mt-1 text-sm text-muted">{children}</p>
129 </div>
130 <select
131 name={name}
132 defaultValue={value}
133 aria-label={title}
134 className="shrink-0 rounded-md border border-line bg-bg px-2 py-1.5 text-sm"
135 >
136 {options.map(([option, label]) => (
137 <option key={option} value={option}>
138 {label}
139 </option>
140 ))}
141 </select>
142 </div>
143 );
144}
145
146export default function RepoSettings({ loaderData, actionData }: Route.ComponentProps) {
147 const { repo, settings } = loaderData;
148 const saving = useNavigation().state === "submitting";
149 const branch = repo.defaultBranch;
150 return (
151 <Form method="post" className="max-w-4xl space-y-8">
152 <Section title="General" about="What the repository is and who can see it.">
153 <Field label="Description">
154 <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} />
155 </Field>
156 <fieldset className="space-y-2 rounded-xl border border-line bg-surface p-4 text-sm">
157 <legend className="sr-only">Visibility</legend>
158 <label className="flex items-start gap-3">
159 <input
160 type="radio"
161 name="visibility"
162 value="public"
163 defaultChecked={!repo.isPrivate}
164 className="mt-1 accent-accent"
165 />
166 <span>
167 <span className="block font-medium">Public</span>
168 <span className="text-muted">Anyone can see and clone it.</span>
169 </span>
170 </label>
171 <label className="flex items-start gap-3">
172 <input
173 type="radio"
174 name="visibility"
175 value="private"
176 defaultChecked={repo.isPrivate}
177 className="mt-1 accent-accent"
178 />
179 <span>
180 <span className="block font-medium">Private</span>
181 <span className="text-muted">Only members of the workspace can see it.</span>
182 </span>
183 </label>
184 </fieldset>
185 </Section>
186
187 <Section
188 title="Branch protection"
189 about={`Rules for ${branch}, the branch everything lands on.`}
190 >
191 <Toggle
192 name="protected"
193 on={repo.protected}
194 title={`Require a pull request to change ${branch}`}
195 >
196 Pushing to {branch} is refused, for members and agents alike, and git says why.
197 Changes reach it only by merging a pull request. The first push to an empty
198 repository is still allowed.
199 </Toggle>
200 <Choice
201 name="requiredApprovals"
202 value={settings.requiredApprovals}
203 title="Required approvals"
204 options={[
205 [0, "None"],
206 [1, "1"],
207 [2, "2"],
208 [3, "3"],
209 ]}
210 >
211 How many reviewers must approve before a pull request can merge. A reviewer
212 who has since asked for changes blocks it, and nobody approves their own.
213 </Choice>
214 <Toggle
215 name="countAgentApprovals"
216 on={settings.countAgentApprovals}
217 title="A g1t agent's approval counts"
218 >
219 With this off, required approvals have to come from people, and an agent's
220 review is advice.
221 </Toggle>
222 <Toggle
223 name="requireChecks"
224 on={!settings.allowIgnoringChecks}
225 title="Require acceptance checks to pass"
226 >
227 With this off, a member can choose to merge although the issue's checks
228 failed or have not finished. With it on, nobody can.
229 </Toggle>
230 <Toggle
231 name="requireUpToDate"
232 on={settings.requireUpToDate}
233 title="Require pull requests to be up to date before merging"
234 >
235 With this off, a pull request can be merged after {branch} has moved: g1t
236 brings it up to date as part of merging, and asks you only if there is a
237 conflict it cannot resolve. With it on, it has to catch up first and its
238 checks run again on the result, so what lands is exactly what was checked.
239 </Toggle>
240 <Toggle name="mergeQueue" on={settings.mergeQueue} title="Merge through a queue">
241 Merging adds a pull request to the queue instead of changing {branch} at once.
242 g1t tests it together with every pull request ahead of it, several
243 combinations at a time, and {branch} only ever moves to a combination whose
244 checks passed. One that fails leaves the queue and goes back to its author,
245 and the ones behind it are tested again without it.
246 </Toggle>
247 </Section>
248
249 <Section
250 title="g1t agents"
251 about="What happens to a pull request a g1t agent makes, from the moment it is ready."
252 >
253 <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent">
254 A different agent reads each change and posts comments on lines, a summary
255 and a verdict. If it asks for changes, the author is sent back to make them.
256 With this off, review is left to people.
257 </Toggle>
258 <Choice
259 name="maxRevisions"
260 value={settings.maxRevisions}
261 title="Revisions before asking you"
262 options={[
263 [0, "None"],
264 [1, "1"],
265 [2, "2"],
266 [3, "3"],
267 [5, "5"],
268 ]}
269 >
270 How many times an agent is sent back to fix failed checks or address a
271 review before g1t stops and the pull request says it needs you.
272 </Choice>
273 <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready">
274 A g1t agent's pull request lands without anyone pressing merge once every
275 rule above is met. With this off, it waits for a member. Pull requests from
276 people and from other agents always wait.
277 </Toggle>
278 </Section>
279
280 <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
281 <Button type="submit" disabled={saving}>
282 {saving ? "Saving…" : "Save settings"}
283 </Button>
284 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
285 <ErrorText>{actionData?.error}</ErrorText>
286 {settings.updatedBy && settings.updatedAt && !actionData && (
287 <span className="text-xs text-faint">
288 Merge rules last changed by{" "}
289 <span className="font-mono">{settings.updatedBy}</span>{" "}
290 <TimeAgo at={settings.updatedAt} />
291 </span>
292 )}
293 </div>
294 </Form>
295 );
296}