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.

171 linesCodeBlameRaw
1import { ChevronRight, Scale, ShieldCheck } from "lucide-react";
2import { Suspense } from "react";
3import { Await, Form, Link } from "react-router";
4
5import { ruleInfo } from "../../lib/rules";
6
7import type { Route } from "./+types/settings-branches";
8import { page } from "../../lib/meta";
9import { AddCiPrompt } from "../../components/add-ci";
10import { CodeownersReportPanel, CodeownersReportSkeleton } from "../../components/codeowners";
11import { RepoSettingsHeading } from "../../components/repo-settings-heading";
12import { EnforcementBadge } from "../../components/rules";
13import { SettingsSection as Section, SettingToggle as Toggle } from "../../components/settings-section";
14import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
15import { actions, repos, work } from "../../lib/services.server";
16import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
17import { requireCapability, requireInsider } from "../../lib/access.server";
18
19export function meta({ params, ...args }: Route.MetaArgs) {
20 return page(args, { title: `Branches and merging · ${params.owner}/${params.repo} · g1t` });
21}
22
23export async function loader({ params, context }: Route.LoaderArgs) {
24 const viewer = getViewer(context);
25 // 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.
26 const { access } = await requireInsider(context, params, "manage_settings");
27 const path = { namespace: params.owner, name: params.repo };
28 const [repo, settings, seen, workflows] = await Promise.all([
29 repos.get(path, viewer),
30 work.getSettings(path, viewer),
31 work.seenChecks(path, viewer),
32 actions.workflows(path, viewer),
33 ]);
34 const found = unwrap(repo);
35 // What holds for the default branch: shown here, changed in Rules.
36 const effective = await work.effectiveRules(path, found.defaultBranch, viewer).catch(() => null);
37 // Streamed: the CODEOWNERS file is read and checked on its own time.
38 const codeowners = work
39 .codeownersErrors(path, viewer)
40 .then((report) => (report.ok ? report.value : null))
41 .catch(() => null);
42 return {
43 codeowners,
44 repo: found,
45 settings: unwrap(settings),
46 effective: effective?.ok ? effective.value : null,
47 // Nothing to require until something runs: the page offers to add CI.
48 noChecks: workflows.ok && workflows.value.length === 0 && seen.ok && seen.value.length === 0,
49 canPush: access.can.push,
50 };
51}
52
53export async function action({ request, params, context }: Route.ActionArgs) {
54 assertSameOrigin(request);
55 const user = requireUser(context, request);
56 await requireCapability(context, params, "manage_settings");
57 const form = await request.formData();
58 const path = { namespace: params.owner, name: params.repo };
59 const on = (name: string) => form.get(name) === "on";
60 // Branch protection is the repository's rulesets' (Settings → Rules):
61 // what it holds is sent back as it is, so only auto-merge changes here.
62 const current = await work.getSettings(path, user);
63 if (!current.ok) return { saved: false, error: current.error.message };
64 const settings = await work.updateSettings(user, path, {
65 ...current.value,
66 autoMerge: on("autoMerge"),
67 });
68 return settings.ok ? { saved: true, error: null } : { saved: false, error: settings.error.message };
69}
70
71export default function BranchSettings({ loaderData, actionData }: Route.ComponentProps) {
72 const { repo, settings, effective, noChecks, canPush, codeowners } = loaderData;
73 const branch = repo.defaultBranch;
74 const base = `/${repo.namespace}/${repo.name}`;
75 const archived = Boolean(repo.archivedAt);
76 const active = effective?.rules.filter((rule) => rule.enforcement === "active") ?? [];
77 const labels = [...new Set(active.map((rule) => ruleInfo(rule.type)?.label ?? rule.type))];
78 return (
79 <>
80 <RepoSettingsHeading base={base} />
81 {/* Its own form, so outside the settings one. */}
82 {noChecks && (
83 <div className="mb-8 max-w-4xl">
84 <AddCiPrompt owner={repo.namespace} repo={repo.name} canAdd={canPush && !archived} />
85 </div>
86 )}
87 <div className="max-w-4xl space-y-8">
88 <Section
89 title="Branch protection"
90 about={`What holds for ${branch} and every other branch is set by rulesets, across the workspace.`}
91 >
92 <Link
93 to={`${base}/settings/rules`}
94 className="group flex items-start gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
95 >
96 <Scale size={16} className="mt-0.5 shrink-0 text-accent" />
97 <span className="min-w-0 grow">
98 <span className="block text-sm font-medium">Rules for {branch}</span>
99 <span className="mt-1 block text-sm text-muted">
100 {labels.length > 0 ? labels.join(" · ") : `No active rules hold for ${branch}: anyone who may push can change it.`}
101 </span>
102 {effective && effective.rulesets.length > 0 && (
103 <span className="mt-2 flex flex-wrap gap-2">
104 {effective.rulesets.map((ruleset) => (
105 <span key={ruleset.id} className="inline-flex items-center gap-1.5 text-xs text-muted">
106 {ruleset.name} <EnforcementBadge enforcement={ruleset.enforcement} />
107 </span>
108 ))}
109 </span>
110 )}
111 </span>
112 <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
113 </Link>
114 </Section>
115
116 <Section
117 id="codeowners"
118 title="CODEOWNERS"
119 about={`Who owns which files, read from ${branch}. Owners are asked to review changes to their files.`}
120 >
121 <Suspense fallback={<CodeownersReportSkeleton />}>
122 <Await resolve={codeowners}>
123 {(report) => <CodeownersReportPanel report={report} base={base} branch={branch} />}
124 </Await>
125 </Suspense>
126 </Section>
127
128 <Form method="post">
129 <fieldset disabled={archived} className="min-w-0 space-y-8">
130 <Section
131 title="Merging"
132 about="What happens to a pull request once it is ready. The branch's rules hold."
133 >
134 <Toggle name="autoMerge" on={settings.autoMerge} title="Merge automatically when ready">
135 A ready pull request lands without anyone pressing merge once every rule of its branch is met, its
136 required checks included. A ruleset can turn this off for some branches. With this off, it waits for a
137 member to merge it.
138 </Toggle>
139 <Link
140 to={`${base}/settings/guardrails`}
141 className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
142 >
143 <ShieldCheck size={16} className="shrink-0 text-accent" />
144 <span className="min-w-0 grow">
145 <span className="block text-sm font-medium">Guardrails</span>
146 <span className="mt-0.5 block text-sm text-muted">
147 What this repository's sandboxes may reach, and how long their runs may take.
148 </span>
149 </span>
150 <ChevronRight size={16} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
151 </Link>
152 </Section>
153
154 <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">
155 <SubmitButton pending="Saving…" disabled={archived}>
156 Save settings
157 </SubmitButton>
158 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
159 <ErrorText>{actionData?.error}</ErrorText>
160 {settings.updatedBy && settings.updatedAt && !actionData && (
161 <span className="text-xs text-faint">
162 Last changed by <span className="font-mono">{settings.updatedBy}</span> <TimeAgo at={settings.updatedAt} />
163 </span>
164 )}
165 </div>
166 </fieldset>
167 </Form>
168 </div>
169 </>
170 );
171}