Skip to content

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

245 lines12,710 bytesCodeBlame
1import { ChevronRight, ShieldCheck } from "lucide-react";
2import { Suspense } from "react";
3import { Await, Form, Link } from "react-router";
4
5import type { Route } from "./+types/settings-branches";
6import { page } from "../../lib/meta";
7import { AddCiPrompt } from "../../components/add-ci";
8import { CodeownersReportPanel, CodeownersReportSkeleton } from "../../components/codeowners";
9import { RepoSettingsHeading } from "../../components/repo-settings-heading";
10import { RequiredChecksPicker } from "../../components/required-checks";
11import { SettingChoice as Choice, SettingsSection as Section, SettingToggle as Toggle } from "../../components/settings-section";
12import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
13import { actions, repos, work } from "../../lib/services.server";
14import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
15import { requireCapability, requireInsider } from "../../lib/access.server";
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `Branches and merging · ${params.owner}/${params.repo} · g1t` });
19}
20
21export async function loader({ params, context }: Route.LoaderArgs) {
22 const viewer = getViewer(context);
23 // Maintain and up; to anyone without a role here the page does not exist.
24 const { access } = await requireInsider(context, params, "manage_protection");
25 const path = { namespace: params.owner, name: params.repo };
26 const [repo, settings, seen, workflows] = await Promise.all([
27 repos.get(path, viewer),
28 work.getSettings(path, viewer),
29 work.seenChecks(path, viewer),
30 actions.workflows(path, viewer),
31 ]);
32 // Streamed: the CODEOWNERS file is read and checked on its own time.
33 const codeowners = work
34 .codeownersErrors(path, viewer)
35 .then((found) => (found.ok ? found.value : null))
36 .catch(() => null);
37 return {
38 codeowners,
39 repo: unwrap(repo),
40 settings: unwrap(settings),
41 // The names to choose required checks from: what reported lately.
42 seen: seen.ok ? seen.value : [],
43 // Nothing to require until something runs: the page offers to add CI.
44 noChecks: workflows.ok && workflows.value.length === 0 && seen.ok && seen.value.length === 0,
45 canPush: access.can.push,
46 };
47}
48
49/** A whole number from a form field, kept within `min` and `max`. */
50function count(value: FormDataEntryValue | null, min: number, max: number): number {
51 const number = Math.trunc(Number(value));
52 return Number.isFinite(number) ? Math.min(Math.max(number, min), max) : min;
53}
54
55export async function action({ request, params, context }: Route.ActionArgs) {
56 assertSameOrigin(request);
57 const user = requireUser(context, request);
58 await requireCapability(context, params, "manage_protection");
59 const form = await request.formData();
60 const path = { namespace: params.owner, name: params.repo };
61 const on = (name: string) => form.get(name) === "on";
62 // Protection belongs to the repository and how its pull requests are
63 // handled to the work service; the page is one form over both.
64 const repo = await repos.update(user, path, { protected: on("protected") });
65 if (!repo.ok) return { saved: false, error: repo.error.message };
66 const settings = await work.updateSettings(user, path, {
67 autoMerge: on("autoMerge"),
68 requiredChecks: form.getAll("requiredChecks").map(String),
69 requireUpToDate: on("requireUpToDate"),
70 requiredApprovals: count(form.get("requiredApprovals"), 0, 6),
71 countAgentApprovals: on("countAgentApprovals"),
72 allowIgnoringChecks: on("bypassChecks"),
73 agentReview: on("agentReview"),
74 maxRevisions: count(form.get("maxRevisions"), 0, 5),
75 mergeQueue: on("mergeQueue"),
76 holdLowConfidence: on("holdLowConfidence"),
77 requireCodeOwnerReview: on("requireCodeOwnerReview"),
78 });
79 return settings.ok ? { saved: true, error: null } : { saved: false, error: settings.error.message };
80}
81
82export default function BranchSettings({ loaderData, actionData }: Route.ComponentProps) {
83 const { repo, settings, seen, noChecks, canPush, codeowners } = loaderData;
84 const branch = repo.defaultBranch;
85 const base = `/${repo.namespace}/${repo.name}`;
86 const archived = Boolean(repo.archivedAt);
87 return (
88 <>
89 <RepoSettingsHeading base={base} />
90 {/* Its own form, so outside the settings one. */}
91 {noChecks && (
92 <div className="mb-8 max-w-4xl">
93 <AddCiPrompt owner={repo.namespace} repo={repo.name} canAdd={canPush && !archived} />
94 </div>
95 )}
96 <Form method="post" className="max-w-4xl">
97 <fieldset disabled={archived} className="min-w-0 space-y-8">
98 <Section title="Branch protection" about={`Rules for ${branch}, the branch every pull request merges into. They hold for people and agents alike.`}>
99 <Toggle name="protected" on={repo.protected} title={`Require a pull request to change ${branch}`}>
100 Pushing to {branch} is refused, for members and agents alike, and git says why. Changes reach it only by
101 merging a pull request. The first push to an empty repository is still allowed.
102 </Toggle>
103 <RequiredChecksPicker
104 required={settings.requiredChecks ?? []}
105 seen={seen}
106 mergeQueue={settings.mergeQueue}
107 disabled={archived}
108 />
109 <p className="-mt-4 text-sm text-muted">
110 Code scanning results and dependency review gate merges the same way: require the <strong>Code scanning</strong> and{" "}
111 <strong>Dependency review</strong> checks here once they have reported on a pull request. When each one fails is set in{" "}
112 <Link to={`/${repo.namespace}/${repo.name}/security/settings`} className="underline underline-offset-2 hover:text-fg">
113 Security settings
114 </Link>
115 .
116 </p>
117 <Toggle name="bypassChecks" on={settings.allowIgnoringChecks} title="Allow bypassing required checks">
118 Someone who may merge can tick a box to merge although a required check failed or has not finished, and
119 the pull request says who did. With this off, nobody can, and auto-merge never does.
120 </Toggle>
121 <Toggle
122 name="requireUpToDate"
123 on={settings.requireUpToDate}
124 title="Require branches to be up to date before merging"
125 >
126 With this off, a pull request can be merged after {branch} has moved: g1t brings it up to date as part of
127 merging, and asks you only if there is a conflict it cannot resolve. With it on, it has to catch up first
128 and its required checks pass again on the result, so what lands is exactly what was checked.
129 </Toggle>
130 <Choice
131 name="requiredApprovals"
132 value={settings.requiredApprovals}
133 title="Required approvals"
134 options={[
135 [0, "None"],
136 [1, "1"],
137 [2, "2"],
138 [3, "3"],
139 ]}
140 >
141 How many reviewers must approve before a pull request can merge. A reviewer who has since asked for
142 changes blocks it, and nobody approves their own.
143 </Choice>
144 <Toggle
145 name="requireCodeOwnerReview"
146 on={settings.requireCodeOwnerReview ?? false}
147 title="Require review from code owners"
148 >
149 A pull request waits until the owners of every file it changes, as the CODEOWNERS file on {branch} names
150 them, have approved.
151 </Toggle>
152 <Toggle name="countAgentApprovals" on={settings.countAgentApprovals} title="g1t's approval counts">
153 With this off, required approvals have to come from people, and an agent's review is advice.
154 </Toggle>
155 <Toggle name="mergeQueue" on={settings.mergeQueue} title="Merge through a queue">
156 Merging adds a pull request to the queue instead of changing {branch} at once. g1t builds it together with
157 every pull request ahead of it, several combinations at a time, and runs the workflows that run on{" "}
158 <code className="text-fg">merge_group</code> on each. {branch} only ever moves to a combination whose
159 required checks passed. One that fails leaves the queue and goes back to its author.
160 </Toggle>
161 </Section>
162
163 <Section
164 id="codeowners"
165 title="CODEOWNERS"
166 about={`Who owns which files, read from ${branch}. Owners are asked to review changes to their files.`}
167 >
168 <Suspense fallback={<CodeownersReportSkeleton />}>
169 <Await resolve={codeowners}>
170 {(report) => <CodeownersReportPanel report={report} base={base} branch={branch} />}
171 </Await>
172 </Suspense>
173 </Section>
174
175 <Section
176 title="g1t"
177 about="What happens to a pull request g1t makes, from the moment it is ready. Its checks are the same workflows, and the rules above hold."
178 >
179 <Toggle name="agentReview" on={settings.agentReview} title="Review by a second agent">
180 A different agent reads each change and posts comments on lines, a summary and a verdict. If it asks for
181 changes, the author is sent back to make them. With this off, review is left to people.
182 </Toggle>
183 <Choice
184 name="maxRevisions"
185 value={settings.maxRevisions}
186 title="Revisions before asking you"
187 options={[
188 [0, "None"],
189 [1, "1"],
190 [2, "2"],
191 [3, "3"],
192 [5, "5"],
193 ]}
194 >
195 How many times an agent is sent back to fix a failed check, with what its jobs printed, or to address a
196 review, before g1t stops and the pull request says it needs you. After that, only a required check that
197 still fails holds it.
198 </Choice>
199 <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready">
200 A pull request g1t made lands without anyone pressing merge once every rule above is met, its required
201 checks included. With this off, it waits for a member. Pull requests from people and from other agents
202 always wait.
203 </Toggle>
204 <Toggle
205 name="holdLowConfidence"
206 on={settings.holdLowConfidence}
207 title="Ask a person before merging low-confidence changes"
208 >
209 g1t rates how sure it is of each change an agent finishes, from its required checks, revisions, review,
210 tests, size and guardrails. One it rates low waits for a member to approve it, instead of merging by itself
211 or joining the queue, and shows on Mission control as needing you.
212 </Toggle>
213 <Link
214 to={`${base}/settings/guardrails`}
215 className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
216 >
217 <ShieldCheck size={16} className="shrink-0 text-accent" />
218 <span className="min-w-0 grow">
219 <span className="block text-sm font-medium">Guardrails</span>
220 <span className="mt-0.5 block text-sm text-muted">
221 What agents may reach, run and spend while they work on this repository.
222 </span>
223 </span>
224 <ChevronRight size={16} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
225 </Link>
226 </Section>
227
228 <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">
229 <SubmitButton pending="Saving…" disabled={archived}>
230 Save settings
231 </SubmitButton>
232 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
233 <ErrorText>{actionData?.error}</ErrorText>
234 {settings.updatedBy && settings.updatedAt && !actionData && (
235 <span className="text-xs text-faint">
236 Merge rules last changed by <span className="font-mono">{settings.updatedBy}</span>{" "}
237 <TimeAgo at={settings.updatedAt} />
238 </span>
239 )}
240 </div>
241 </fieldset>
242 </Form>
243 </>
244 );
245}