Skip to content

g1t/apps/web/app/routes/repo/settings-rules.tsx

148 lines7,229 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.