Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

240 linesCodeBlameRaw
1import { ChevronRight, Scale, ShieldCheck } from "lucide-react";
2import { Suspense } from "react";
3import { Await, Form, Link } from "react-router";
4
5import type { CommitMessageDefault } from "@g1t/contracts";
6
7import { allowedMethods, isMergeMethod } from "../../lib/merge";
8import { ruleInfo } from "../../lib/rules";
9
10import type { Route } from "./+types/settings-branches";
11import { page } from "../../lib/meta";
12import { AddCiPrompt } from "../../components/add-ci";
13import { CodeownersReportPanel, CodeownersReportSkeleton } from "../../components/codeowners";
14import { RepoSettingsHeading } from "../../components/repo-settings-heading";
15import { MergeMethodSettings } from "../../components/merge-settings";
16import { EnforcementBadge } from "../../components/rules";
17import { SettingsSection as Section, SettingToggle as Toggle } from "../../components/settings-section";
18import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
19import { actions, repos, work } from "../../lib/services.server";
20import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
21import { requireCapability, requireInsider } from "../../lib/access.server";
22import { Unavailable } from "../../components/boundary";
23import { isFailed, part } from "../../lib/partial";
24
25const COMMIT_MESSAGE_DEFAULTS: CommitMessageDefault[] = [
26 "default",
27 "pull_title",
28 "pull_title_and_description",
29 "pull_title_and_commits",
30 "custom",
31];
32
33export function meta({ params, ...args }: Route.MetaArgs) {
34 return page(args, { title: `Branches and merging · ${params.owner}/${params.repo} · g1t` });
35}
36
37export async function loader({ params, context }: Route.LoaderArgs) {
38 const viewer = getViewer(context);
39 // Maintain and up: it shows the rules and changes only how ready pull requests merge; to anyone without a role here the page does not exist.
40 const { access } = await requireInsider(context, params, "manage_settings");
41 const path = { namespace: params.owner, name: params.repo };
42 const [repo, settings, seen, workflows] = await Promise.all([
43 repos.get(path, viewer),
44 work.getSettings(path, viewer),
45 // Only for offering to add CI: when either cannot be read, it is not offered.
46 part(work.seenChecks(path, viewer), "Checks seen"),
47 part(actions.workflows(path, viewer), "Workflows"),
48 ]);
49 const found = unwrap(repo);
50 // What holds for the default branch: shown here, changed in Rules. A
51 // failure says so in its place: read as no rules, it would say the
52 // branch is unprotected.
53 const effective = await part(work.effectiveRules(path, found.defaultBranch, viewer), `The rules for ${found.defaultBranch}`);
54 // Streamed: the CODEOWNERS file is read and checked on its own time.
55 const codeowners = work
56 .codeownersErrors(path, viewer)
57 .then((report) => (report.ok ? report.value : null))
58 .catch(() => null);
59 return {
60 codeowners,
61 repo: found,
62 settings: unwrap(settings),
63 effective: isFailed(effective) ? effective : effective.ok ? effective.value : null,
64 // Nothing to require until something runs: the page offers to add CI.
65 noChecks: !isFailed(workflows) && workflows.ok && workflows.value.length === 0 && !isFailed(seen) && seen.ok && seen.value.length === 0,
66 canPush: access.can.push,
67 };
68}
69
70export async function action({ request, params, context }: Route.ActionArgs) {
71 assertSameOrigin(request);
72 const user = requireUser(context, request);
73 await requireCapability(context, params, "manage_settings");
74 const form = await request.formData();
75 const path = { namespace: params.owner, name: params.repo };
76 const on = (name: string) => form.get(name) === "on";
77 // Branch protection is the repository's rulesets' (Settings → Rules):
78 // what it holds is sent back as it is, so only how pull requests merge
79 // changes here.
80 const current = await work.getSettings(path, user);
81 if (!current.ok) return { saved: false, error: current.error.message };
82 const choice = (name: string, fallback: CommitMessageDefault): CommitMessageDefault => {
83 const value = form.get(name);
84 return typeof value === "string" && COMMIT_MESSAGE_DEFAULTS.includes(value as CommitMessageDefault) ? (value as CommitMessageDefault) : fallback;
85 };
86 // A template is posted only while it is shown; otherwise the saved one stays.
87 const template = (name: string, fallback: string | null) =>
88 form.has(name) ? String(form.get(name) ?? "").trim() || null : fallback;
89 const allowed = {
90 allowMergeCommit: on("allowMergeCommit"),
91 allowSquashMerge: on("allowSquashMerge"),
92 allowRebaseMerge: on("allowRebaseMerge"),
93 allowFastForward: on("allowFastForward"),
94 };
95 if (!Object.values(allowed).some(Boolean)) return { saved: false, error: "Allow at least one merge method." };
96 const asked = form.get("defaultMergeMethod");
97 const methods = allowedMethods(allowed);
98 const settings = await work.updateSettings(user, path, {
99 ...current.value,
100 ...allowed,
101 defaultMergeMethod: isMergeMethod(asked) && methods.includes(asked) ? asked : methods[0]!,
102 mergeCommitMessage: choice("mergeCommitMessage", current.value.mergeCommitMessage),
103 squashCommitMessage: choice("squashCommitMessage", current.value.squashCommitMessage),
104 mergeCommitTemplate: template("mergeCommitTemplate", current.value.mergeCommitTemplate),
105 squashCommitTemplate: template("squashCommitTemplate", current.value.squashCommitTemplate),
106 suggestUpdatingBranches: on("suggestUpdatingBranches"),
107 allowAutoMerge: on("allowAutoMerge"),
108 autoMerge: on("autoMerge"),
109 deleteBranchOnMerge: on("deleteBranchOnMerge"),
110 });
111 return settings.ok ? { saved: true, error: null } : { saved: false, error: settings.error.message };
112}
113
114export default function BranchSettings({ loaderData, actionData }: Route.ComponentProps) {
115 const { repo, settings, effective, noChecks, canPush, codeowners } = loaderData;
116 const branch = repo.defaultBranch;
117 const base = `/${repo.namespace}/${repo.name}`;
118 const archived = Boolean(repo.archivedAt);
119 const rules = isFailed(effective) ? null : effective;
120 const active = rules?.rules.filter((rule) => rule.enforcement === "active") ?? [];
121 const labels = [...new Set(active.map((rule) => ruleInfo(rule.type)?.label ?? rule.type))];
122 return (
123 <>
124 <RepoSettingsHeading base={base} />
125 {/* Its own form, so outside the settings one. */}
126 {noChecks && (
127 <div className="mb-8 max-w-4xl">
128 <AddCiPrompt owner={repo.namespace} repo={repo.name} canAdd={canPush && !archived} />
129 </div>
130 )}
131 <div className="max-w-4xl space-y-8">
132 <Section
133 title="Branch protection"
134 about={`What holds for ${branch} and every other branch is set by rulesets, across the workspace.`}
135 >
136 <Link
137 to={`${base}/settings/rules`}
138 className="group flex items-start gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
139 >
140 <Scale size={16} className="mt-0.5 shrink-0 text-accent" />
141 <span className="min-w-0 grow">
142 <span className="block text-sm font-medium">Rules for {branch}</span>
143 {!isFailed(effective) && (
144 <span className="mt-1 block text-sm text-muted">
145 {labels.length > 0 ? labels.join(" · ") : `No active rules hold for ${branch}: anyone who may push can change it.`}
146 </span>
147 )}
148 {rules && rules.rulesets.length > 0 && (
149 <span className="mt-2 flex flex-wrap gap-2">
150 {rules.rulesets.map((ruleset) => (
151 <span key={ruleset.id} className="inline-flex items-center gap-1.5 text-xs text-muted">
152 {ruleset.name} <EnforcementBadge enforcement={ruleset.enforcement} />
153 </span>
154 ))}
155 </span>
156 )}
157 </span>
158 <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
159 </Link>
160 {isFailed(effective) && <Unavailable what={effective.what} compact />}
161 <Link
162 to={`${base}/settings/guardrails`}
163 className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
164 >
165 <ShieldCheck size={16} className="shrink-0 text-accent" />
166 <span className="min-w-0 grow">
167 <span className="block text-sm font-medium">Guardrails</span>
168 <span className="mt-0.5 block text-sm text-muted">How long this repository's runs may take.</span>
169 </span>
170 <ChevronRight size={16} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
171 </Link>
172 </Section>
173
174 <Section
175 id="codeowners"
176 title="CODEOWNERS"
177 about={`Who owns which files, read from ${branch}. Owners are asked to review changes to their files.`}
178 >
179 <Suspense fallback={<CodeownersReportSkeleton />}>
180 <Await resolve={codeowners}>
181 {(report) => <CodeownersReportPanel report={report} base={base} branch={branch} />}
182 </Await>
183 </Suspense>
184 </Section>
185
186 <Form method="post">
187 <fieldset disabled={archived} className="min-w-0 space-y-8">
188 <Section
189 id="merge-methods"
190 title="Merge methods"
191 about="How pull requests may land on their base branch. At least one stays on; a ruleset can narrow them for some branches."
192 >
193 <MergeMethodSettings settings={settings} disabled={archived} />
194 </Section>
195
196 <Section id="updating-branches" title="Updating branches" about="Bringing a pull request's branch up to date with its base.">
197 <Toggle name="suggestUpdatingBranches" on={settings.suggestUpdatingBranches} title="Always suggest updating pull request branches">
198 Whenever a pull request's base has commits its branch does not, offer to bring the branch up to date, with
199 a merge commit or a rebase, even when nothing requires it. Off, the offer shows only when a rule requires
200 pull requests to be up to date.
201 </Toggle>
202 </Section>
203
204 <Section id="auto-merge" title="Auto-merge" about="Pull requests that merge on their own once everything they need is met.">
205 <Toggle name="allowAutoMerge" on={settings.allowAutoMerge} title="Allow auto-merge">
206 Whoever may merge a pull request can turn on auto-merge for it with the method and message they choose:
207 it merges, as them, the moment its required reviews and checks pass.
208 </Toggle>
209 <Toggle name="autoMerge" on={settings.autoMerge} title="Land pull requests g1t sees through when ready">
210 A pull request g1t is seeing through for a work item lands without anyone pressing merge once every rule of
211 its branch is met, its required checks included. A ruleset can turn this off for some branches. Off, it
212 waits for a member to merge it.
213 </Toggle>
214 </Section>
215
216 <Section id="after-merging" title="After merging" about="What happens to a pull request's branch once it lands.">
217 <Toggle name="deleteBranchOnMerge" on={settings.deleteBranchOnMerge} title="Automatically delete head branches">
218 A pull request's branch is deleted once it merges. Whoever merges can keep it that time, and a deleted
219 branch can be restored from its pull request.
220 </Toggle>
221 </Section>
222
223 <div className="sticky bottom-(--tabbar-h) in-data-[keyboard=open]:bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
224 <SubmitButton pending="Saving…" disabled={archived}>
225 Save settings
226 </SubmitButton>
227 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
228 <ErrorText>{actionData?.error}</ErrorText>
229 {settings.updatedBy && settings.updatedAt && !actionData && (
230 <span className="text-xs text-faint">
231 Last changed by <span className="font-mono">{settings.updatedBy}</span> <TimeAgo at={settings.updatedAt} />
232 </span>
233 )}
234 </div>
235 </fieldset>
236 </Form>
237 </div>
238 </>
239 );
240}