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.

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