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-repository.tsx

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