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.

151 linesCodeBlameRaw
1import { ChartColumn, Plus, Search, ShieldCheck } from "lucide-react";
2import { Form, Link, redirect, useSearchParams } from "react-router";
3
4import type { Route } from "./+types/settings-rules";
5import { page } from "../../lib/meta";
6import { RepoSettingsHeading } from "../../components/repo-settings-heading";
7import { EffectiveRulesView, InsightsView, RulesetList } from "../../components/rules";
8import { ButtonLink } from "../../components/ui";
9import { Button } from "../../components/ui/button";
10import { Input } from "../../components/ui/input";
11import { work } from "../../lib/services.server";
12import { getViewer, unwrap } from "../../lib/session.server";
13import { requireInsider } from "../../lib/access.server";
14import { movedTab, rulesAt, rulesPath } from "../../lib/tab-paths";
15
16export function meta({ params, ...args }: Route.MetaArgs) {
17 return page(args, { title: `Rules · ${params.owner}/${params.repo} · g1t` });
18}
19
20export async function loader({ params, context, request }: Route.LoaderArgs) {
21 const viewer = getViewer(context);
22 const url = new URL(request.url);
23 // Insights is a path: `?tab=insights`, the address before, is `/settings/rules/insights`.
24 const moved = movedTab(url, (asked) => rulesPath(`/${params.owner}/${params.repo}/settings/rules`, asked));
25 if (moved) throw redirect(moved, 301);
26 // Admin, as for branch protection.
27 const { repo, access } = await requireInsider(context, params, "manage_protection");
28 const tab = rulesAt(url.pathname);
29 const path = { namespace: params.owner, name: params.repo };
30 const tagged = url.searchParams.get("tag");
31 const branch = (tagged ?? url.searchParams.get("branch") ?? repo.defaultBranch).trim() || repo.defaultBranch;
32 const [rulesets, effective, evaluations] = await Promise.all([
33 work.listRulesets({ repo: path }, viewer, true),
34 tab === "rulesets" ? work.effectiveRules(path, branch, viewer, tagged ? "tag" : "branch") : Promise.resolve(null),
35 tab === "insights"
36 ? work.ruleEvaluations({ repo: path }, viewer, {
37 before: url.searchParams.get("before") ?? undefined,
38 problems_only: url.searchParams.get("problems") === "1",
39 })
40 : Promise.resolve(null),
41 ]);
42 return {
43 tab,
44 branch,
45 target: tagged ? ("tag" as const) : ("branch" as const),
46 defaultBranch: repo.defaultBranch,
47 rulesets: unwrap(rulesets),
48 effective: effective?.ok ? effective.value : null,
49 evaluations: evaluations?.ok ? evaluations.value : null,
50 editable: access.can.manage_protection && !repo.archivedAt,
51 };
52}
53
54export default function RepoRules({ loaderData, params }: Route.ComponentProps) {
55 const { tab, branch, target, defaultBranch, rulesets, effective, evaluations, editable } = loaderData;
56 const base = `/${params.owner}/${params.repo}`;
57 const [search] = useSearchParams();
58 const own = rulesets.filter((ruleset) => ruleset.level === "repository");
59 const inherited = rulesets.filter((ruleset) => ruleset.level === "workspace");
60 const hrefFor = (id: string, level: "repository" | "workspace") => (level === "workspace" ? `/${params.owner}/-/rules/${id}` : `${base}/settings/rules/${id}`);
61 const tabLink = (name: "rulesets" | "insights") => {
62 const next = new URLSearchParams(search);
63 next.delete("before");
64 const query = next.toString();
65 return `${rulesPath(`${base}/settings/rules`, name)}${query ? `?${query}` : ""}`;
66 };
67 return (
68 <div className="max-w-4xl">
69 <RepoSettingsHeading base={base} />
70 <nav className="mb-6 flex gap-1 border-b border-line" aria-label="Rules">
71 {(["rulesets", "insights"] as const).map((name) => (
72 <Link
73 key={name}
74 to={tabLink(name)}
75 aria-current={tab === name ? "page" : undefined}
76 className={`-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm ${tab === name ? "border-accent text-fg" : "border-transparent text-muted hover:text-fg"}`}
77 >
78 {name === "rulesets" ? <ShieldCheck size={14} /> : <ChartColumn size={14} />}
79 {name === "rulesets" ? "Rulesets" : "Insights"}
80 </Link>
81 ))}
82 </nav>
83
84 {tab === "rulesets" ? (
85 <div className="space-y-10">
86 <section>
87 <div className="mb-3 flex flex-wrap items-center justify-between gap-3">
88 <div>
89 <h2 className="font-medium">This repository's rulesets</h2>
90 <p className="mt-1 text-sm text-muted">They stack with the workspace's: every rule of each that targets a branch holds there.</p>
91 </div>
92 {editable && (
93 <ButtonLink to={`${base}/settings/rules/new`}>
94 <Plus size={15} /> New ruleset
95 </ButtonLink>
96 )}
97 </div>
98 <RulesetList
99 rulesets={own}
100 level="repository"
101 hrefFor={(ruleset) => hrefFor(ruleset.id, ruleset.level)}
102 empty={
103 <>
104 No rulesets yet. Anyone who may push can push to any branch, and pull requests merge without approvals or checks.
105 {editable && (
106 <>
107 {" "}
108 <Link to={`${base}/settings/rules/new`} className="text-fg underline underline-offset-2">
109 Create one
110 </Link>{" "}
111 to protect {defaultBranch}.
112 </>
113 )}
114 </>
115 }
116 />
117 </section>
118 {inherited.length > 0 && (
119 <section>
120 <h2 className="font-medium">From the workspace</h2>
121 <p className="mt-1 mb-3 text-sm text-muted">Set for every repository of {params.owner} they select. Owners change them in the workspace's settings.</p>
122 <RulesetList rulesets={inherited} level="repository" hrefFor={(ruleset) => hrefFor(ruleset.id, ruleset.level)} empty={null} />
123 </section>
124 )}
125 <section>
126 <h2 className="font-medium">What holds for a branch</h2>
127 <p className="mt-1 mb-3 text-sm text-muted">Every rule of every ruleset that targets it, active ones first.</p>
128 <Form method="get" className="mb-4 flex flex-wrap items-center gap-2">
129 <div className="relative w-72 max-w-full">
130 <Search size={14} className="absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
131 <Input name={target === "tag" ? "tag" : "branch"} defaultValue={branch} aria-label="Branch" placeholder={defaultBranch} className="pl-8 font-mono text-[0.8125rem]" />
132 </div>
133 <Button type="submit" variant="outline" size="sm" className="px-3 text-sm text-muted font-normal">
134 Show rules
135 </Button>
136 </Form>
137 {effective ? (
138 <EffectiveRulesView effective={effective} hrefFor={hrefFor} />
139 ) : (
140 <p className="text-sm text-muted">The rules for that name could not be read.</p>
141 )}
142 </section>
143 </div>
144 ) : evaluations ? (
145 <InsightsView page={evaluations} olderHref={evaluations.next ? `${base}/settings/rules/insights?before=${encodeURIComponent(evaluations.next)}` : null} />
146 ) : (
147 <p className="text-sm text-muted">Insights could not be read just now.</p>
148 )}
149 </div>
150 );
151}