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.

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