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

301 lines11,322 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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