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