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

284 lines10,798 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.

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