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

294 lines11,318 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
Project settings move into the sidebar, grouped, with a heading per page5import { RepoSettingsHeading } from "../../components/repo-settings-heading";
Projects: what a workspace builds and runs, first on every page6import 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"),
Search across all of g1t, Explore, and a command palette56 // Typed with commas or spaces between them; the service tidies the rest.
57 topics: String(form.get("topics") ?? "")
58 .split(/[\s,]+/)
59 .filter(Boolean),
Projects: what a workspace builds and runs, first on every page60 });
61 if (!repo.ok) return { saved: false, error: repo.error.message };
62 const settings = await work.updateSettings(user, path, {
63 autoMerge: on("autoMerge"),
64 requireUpToDate: on("requireUpToDate"),
65 requiredApprovals: count(form.get("requiredApprovals"), 0, 6),
66 countAgentApprovals: on("countAgentApprovals"),
67 allowIgnoringChecks: !on("requireChecks"),
68 agentReview: on("agentReview"),
69 maxRevisions: count(form.get("maxRevisions"), 0, 5),
70 mergeQueue: on("mergeQueue"),
71 });
72 return settings.ok
73 ? { saved: true, error: null }
74 : { saved: false, error: settings.error.message };
75}
76
77function Section({
78 title,
79 about,
80 children,
81}: {
82 title: string;
83 about: string;
84 children: ReactNode;
85}) {
86 return (
87 <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]">
88 <div>
89 <h2 className="font-medium">{title}</h2>
90 <p className="mt-1 text-sm text-muted">{about}</p>
91 </div>
92 <div className="space-y-3">{children}</div>
93 </section>
94 );
95}
96
97/** A setting that is on or off, with what it means. */
98function Toggle({
99 name,
100 on,
101 title,
102 children,
103}: {
104 name: string;
105 on: boolean;
106 title: string;
107 children: ReactNode;
108}) {
109 return (
Workspace names and icons, and a component kit for every control110 <SwitchCard name={name} defaultChecked={on} title={title}>
111 {children}
112 </SwitchCard>
Projects: what a workspace builds and runs, first on every page113 );
114}
115
116/** A setting chosen from a few numbers. */
117function Choice({
118 name,
119 value,
120 options,
121 title,
122 children,
123}: {
124 name: string;
125 value: number;
126 options: [number, string][];
127 title: string;
128 children: ReactNode;
129}) {
130 return (
131 <div className="flex items-start gap-4 rounded-xl border border-line bg-surface p-4">
132 <div className="grow">
133 <p className="text-sm font-medium">{title}</p>
134 <p className="mt-1 text-sm text-muted">{children}</p>
135 </div>
Workspace names and icons, and a component kit for every control136 <Select name={name} defaultValue={String(value)}>
137 <SelectTrigger size="sm" aria-label={title} className="w-auto min-w-28 shrink-0">
138 <SelectValue />
139 </SelectTrigger>
140 <SelectContent align="end">
141 {options.map(([option, label]) => (
142 <SelectItem key={option} value={String(option)}>
143 {label}
144 </SelectItem>
145 ))}
146 </SelectContent>
147 </Select>
Projects: what a workspace builds and runs, first on every page148 </div>
149 );
150}
151
152export default function RepoSettings({ loaderData, actionData }: Route.ComponentProps) {
153 const { repo, settings } = loaderData;
154 const saving = useNavigation().state === "submitting";
155 const branch = repo.defaultBranch;
156 return (
157 <>
Project settings move into the sidebar, grouped, with a heading per page158 <RepoSettingsHeading base={`/${repo.namespace}/${repo.name}`} />
Projects: what a workspace builds and runs, first on every page159 <Form method="post" className="max-w-4xl space-y-8">
160 <Section title="General" about="What the repository is and who can see it.">
161 <Field label="Description">
162 <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} />
163 </Field>
Search across all of g1t, Explore, and a command palette164 <Field
165 label="Topics"
166 hint="What it is about, for search and Explore: words such as cli, rust or design-system, separated by commas or spaces. Up to 20."
167 >
168 <Input name="topics" maxLength={800} defaultValue={(repo.topics ?? []).join(", ")} placeholder="cli, rust" />
169 </Field>
Workspace names and icons, and a component kit for every control170 <fieldset>
Projects: what a workspace builds and runs, first on every page171 <legend className="sr-only">Visibility</legend>
Workspace names and icons, and a component kit for every control172 <RadioGroup
173 name="visibility"
174 defaultValue={repo.isPrivate ? "private" : "public"}
175 aria-label="Visibility"
176 className="gap-3 sm:grid-cols-2"
177 >
178 <RadioCard value="public" icon={<Globe />} title="Public" description="Anyone can see and clone it." />
179 <RadioCard value="private" icon={<Lock />} title="Private" description="Only members of the workspace can see it." />
180 </RadioGroup>
Projects: what a workspace builds and runs, first on every page181 </fieldset>
182 </Section>
183
184 <Section
185 title="Branch protection"
186 about={`Rules for ${branch}, the branch everything lands on.`}
187 >
188 <Toggle
189 name="protected"
190 on={repo.protected}
191 title={`Require a pull request to change ${branch}`}
192 >
193 Pushing to {branch} is refused, for members and agents alike, and git says why.
194 Changes reach it only by merging a pull request. The first push to an empty
195 repository is still allowed.
196 </Toggle>
197 <Choice
198 name="requiredApprovals"
199 value={settings.requiredApprovals}
200 title="Required approvals"
201 options={[
202 [0, "None"],
203 [1, "1"],
204 [2, "2"],
205 [3, "3"],
206 ]}
207 >
208 How many reviewers must approve before a pull request can merge. A reviewer
209 who has since asked for changes blocks it, and nobody approves their own.
210 </Choice>
211 <Toggle
212 name="countAgentApprovals"
213 on={settings.countAgentApprovals}
214 title="A g1t agent's approval counts"
215 >
216 With this off, required approvals have to come from people, and an agent's
217 review is advice.
218 </Toggle>
219 <Toggle
220 name="requireChecks"
221 on={!settings.allowIgnoringChecks}
222 title="Require acceptance checks to pass"
223 >
224 With this off, a member can choose to merge although the issue's checks
225 failed or have not finished. With it on, nobody can.
226 </Toggle>
227 <Toggle
228 name="requireUpToDate"
229 on={settings.requireUpToDate}
230 title="Require pull requests to be up to date before merging"
231 >
232 With this off, a pull request can be merged after {branch} has moved: g1t
233 brings it up to date as part of merging, and asks you only if there is a
234 conflict it cannot resolve. With it on, it has to catch up first and its
235 checks run again on the result, so what lands is exactly what was checked.
236 </Toggle>
237 <Toggle name="mergeQueue" on={settings.mergeQueue} title="Merge through a queue">
238 Merging adds a pull request to the queue instead of changing {branch} at once.
239 g1t tests it together with every pull request ahead of it, several
240 combinations at a time, and {branch} only ever moves to a combination whose
241 checks passed. One that fails leaves the queue and goes back to its author,
242 and the ones behind it are tested again without it.
243 </Toggle>
244 </Section>
245
246 <Section
247 title="g1t agents"
248 about="What happens to a pull request a g1t agent makes, from the moment it is ready."
249 >
250 <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent">
251 A different agent reads each change and posts comments on lines, a summary
252 and a verdict. If it asks for changes, the author is sent back to make them.
253 With this off, review is left to people.
254 </Toggle>
255 <Choice
256 name="maxRevisions"
257 value={settings.maxRevisions}
258 title="Revisions before asking you"
259 options={[
260 [0, "None"],
261 [1, "1"],
262 [2, "2"],
263 [3, "3"],
264 [5, "5"],
265 ]}
266 >
267 How many times an agent is sent back to fix failed checks or address a
268 review before g1t stops and the pull request says it needs you.
269 </Choice>
270 <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready">
271 A g1t agent's pull request lands without anyone pressing merge once every
272 rule above is met. With this off, it waits for a member. Pull requests from
273 people and from other agents always wait.
274 </Toggle>
275 </Section>
276
277 <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">
278 <Button type="submit" disabled={saving}>
279 {saving ? "Saving…" : "Save settings"}
280 </Button>
281 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
282 <ErrorText>{actionData?.error}</ErrorText>
283 {settings.updatedBy && settings.updatedAt && !actionData && (
284 <span className="text-xs text-faint">
285 Merge rules last changed by{" "}
286 <span className="font-mono">{settings.updatedBy}</span>{" "}
287 <TimeAgo at={settings.updatedAt} />
288 </span>
289 )}
290 </div>
291 </Form>
292 </>
293 );
294}