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.

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