Skip to content

g1t/apps/web/app/components/codeowners.tsx

345 lines13,474 bytesCodeBlame
1/**
2 * CODEOWNERS on the site: a file's problems listed like a linter's, and on
3 * a pull request, who owns what it changes and whose approval it still
4 * needs. Teams are named `@workspace/team` and link to their pages.
5 */
6import { CircleCheck, CircleDashed, CircleSlash, FileWarning, Hourglass, ShieldCheck, Users } from "lucide-react";
7import { Link } from "react-router";
8
9import {
10 CODEOWNERS_ERROR_LABELS,
11 CODEOWNERS_LOCATIONS,
12 type CodeownersError,
13 type CodeownersReport,
14 type OwnerReview,
15 type PullCodeOwners,
16} from "@g1t/contracts";
17
18import { Badge } from "./ui/badge";
19import { Skeleton } from "./ui/skeleton";
20
21/** The guide to the file. */
22export const CODEOWNERS_DOCS = "https://docs.g1t.sh/guides/codeowners/";
23
24/** Where a file at `path` on `ref` is shown, optionally at a line. */
25export function blobHref(base: string, ref: string, path: string, line?: number): string {
26 return `${base}/blob/${encodeURIComponent(ref)}/${path}${line ? `#L${line}` : ""}`;
27}
28
29/** A team's page, from `workspace/slug` (with or without the `@`). */
30export function teamHref(handle: string): string {
31 const [workspace, slug] = handle.replace(/^@/, "").split("/");
32 return `/${workspace}/-/teams/${slug}`;
33}
34
35/** An owner as written: a person, a team, or an email address, linked where it can be. */
36export function OwnerLink({ owner }: { owner: string }) {
37 const name = owner.replace(/^@/, "");
38 const className = "font-mono text-xs text-fg-soft hover:text-accent hover:underline";
39 if (owner.startsWith("@") && name.includes("/")) {
40 return (
41 <Link to={teamHref(name)} className={className}>
42 {owner}
43 </Link>
44 );
45 }
46 if (owner.startsWith("@") && /^[a-z0-9-]{1,39}$/i.test(name) && name !== "g1t") {
47 return (
48 <Link to={`/u/${name.toLowerCase()}`} className={className}>
49 {owner}
50 </Link>
51 );
52 }
53 return <span className="font-mono text-xs text-fg-soft">{owner}</span>;
54}
55
56/** A team asked to review: `@workspace/team`, linking to its page. */
57export function TeamReviewer({ team }: { team: string }) {
58 return (
59 <li className="flex items-center gap-2 px-1">
60 <span className="flex size-5 shrink-0 items-center justify-center rounded bg-raised text-muted ring-1 ring-line">
61 <Users size={12} />
62 </span>
63 <Link to={teamHref(team)} className="grow truncate font-mono text-xs hover:text-accent hover:underline">
64 @{team}
65 </Link>
66 <span className="text-xs text-faint">Review requested</span>
67 </li>
68 );
69}
70
71/** A file's problems, one row each: the line, what kind, the token at fault, and why. */
72export function CodeownersErrorList({ errors, lineHref }: { errors: CodeownersError[]; lineHref: (line: number) => string | null }) {
73 return (
74 <ul className="divide-y divide-line">
75 {errors.map((error, index) => {
76 const href = error.line > 0 ? lineHref(error.line) : null;
77 return (
78 <li key={`${error.line}-${index}`} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-2.5 text-sm">
79 <span className="w-14 shrink-0 font-mono text-xs text-faint">
80 {error.line === 0 ? (
81 "File"
82 ) : href ? (
83 <Link to={href} className="hover:text-accent hover:underline">
84 Line {error.line}
85 </Link>
86 ) : (
87 `Line ${error.line}`
88 )}
89 </span>
90 <Badge tone="danger">{CODEOWNERS_ERROR_LABELS[error.kind] ?? error.kind}</Badge>
91 {error.token && <code className="min-w-0 truncate font-mono text-xs text-fg">{error.token}</code>}
92 <span className="min-w-0 basis-full text-muted sm:pl-17">{error.message}</span>
93 </li>
94 );
95 })}
96 </ul>
97 );
98}
99
100/** The file a branch's code owners come from, and what is wrong with it: Settings → Branches and merging. */
101export function CodeownersReportPanel({ report, base, branch }: { report: CodeownersReport | null; base: string; branch: string }) {
102 if (!report) {
103 return (
104 <p className="rounded-xl border border-line bg-surface p-4 text-sm text-muted">
105 The CODEOWNERS file could not be read just now. Reload the page to try again.
106 </p>
107 );
108 }
109 if (!report.path) {
110 return (
111 <div className="rounded-xl border border-dashed border-line p-4 text-sm">
112 <p className="font-medium">No CODEOWNERS file</p>
113 <p className="mt-1 text-muted">
114 g1t looks on {branch} for the first of these, and asks the owners it names to review changes to their files:
115 </p>
116 <ul className="mt-2 flex flex-wrap gap-1.5">
117 {CODEOWNERS_LOCATIONS.map((location) => (
118 <li key={location}>
119 <code className="rounded border border-line bg-bg px-1.5 py-0.5 font-mono text-xs">{location}</code>
120 </li>
121 ))}
122 </ul>
123 <p className="mt-3">
124 <a href={CODEOWNERS_DOCS} className="text-accent hover:underline">
125 How to write one
126 </a>
127 </p>
128 </div>
129 );
130 }
131 const path = report.path;
132 const errors = report.errors.length;
133 return (
134 <div className="overflow-hidden rounded-xl border border-line">
135 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 bg-surface px-4 py-3 text-sm">
136 {errors > 0 ? (
137 <FileWarning size={16} className="shrink-0 text-danger" />
138 ) : (
139 <ShieldCheck size={16} className="shrink-0 text-accent" />
140 )}
141 <Link to={blobHref(base, branch, path)} className="font-mono text-sm hover:text-accent hover:underline">
142 {path}
143 </Link>
144 <span className="text-xs text-muted">
145 {report.rules === 1 ? "1 rule" : `${report.rules} rules`}
146 {report.sections.length > 0 &&
147 ` · ${report.sections.length === 1 ? "1 section" : `${report.sections.length} sections`}`}
148 </span>
149 <span className={`ml-auto text-xs ${errors > 0 ? "text-danger" : "text-muted"}`}>
150 {errors === 0 ? "No errors" : errors === 1 ? "1 error" : `${errors} errors`}
151 </span>
152 </div>
153 {errors > 0 && (
154 <div className="border-t border-line">
155 <CodeownersErrorList errors={report.errors} lineHref={(line) => blobHref(base, branch, path, line)} />
156 </div>
157 )}
158 </div>
159 );
160}
161
162/** The panel's shape while the file is read. */
163export function CodeownersReportSkeleton() {
164 return (
165 <div aria-busy="true" className="flex items-center gap-3 rounded-xl border border-line bg-surface px-4 py-3">
166 <Skeleton className="size-4 rounded-full" />
167 <Skeleton className="h-3 w-36" />
168 <Skeleton className="h-3 w-20" />
169 <Skeleton className="ml-auto h-3 w-14" />
170 </div>
171 );
172}
173
174/** Above a CODEOWNERS file: what is wrong with it, or that nothing is. */
175export function CodeownersFileErrors({ report, path, base }: { report: CodeownersReport; path: string; base: string }) {
176 // Not read at this ref (a commit, say): nothing to say about it.
177 if (!report.path) return null;
178 // Another location comes first on this branch, so this file is not read.
179 if (report.path !== path) {
180 return (
181 <p className="mb-4 flex items-center gap-2 text-sm text-muted">
182 <CircleDashed size={15} className="shrink-0 text-faint" />
183 <span>
184 Not in use: g1t reads{" "}
185 <Link to={blobHref(base, report.ref, report.path)} className="font-mono text-fg hover:underline">
186 {report.path}
187 </Link>{" "}
188 on this branch, which comes first.
189 </span>
190 </p>
191 );
192 }
193 const errors = report.errors.length;
194 if (errors === 0) {
195 return (
196 <p className="mb-4 flex items-center gap-2 text-sm text-muted" role="status">
197 <CircleCheck size={15} className="shrink-0 text-accent" />
198 No errors in this CODEOWNERS file.
199 </p>
200 );
201 }
202 return (
203 <section className="mb-4 overflow-hidden rounded-xl border border-danger/40" aria-label="CODEOWNERS errors">
204 <p className="flex items-center gap-2 border-b border-danger/30 bg-danger/5 px-4 py-2.5 text-sm font-medium">
205 <FileWarning size={15} className="shrink-0 text-danger" />
206 {errors === 1 ? "1 error" : `${errors} errors`} in this CODEOWNERS file
207 <a href={CODEOWNERS_DOCS} className="ml-auto text-xs font-normal text-muted hover:text-fg hover:underline">
208 Syntax
209 </a>
210 </p>
211 <CodeownersErrorList errors={report.errors} lineHref={(line) => `#L${line}`} />
212 </section>
213 );
214}
215
216/** Where one rule's review stands, in a few words. */
217function ReviewStatus({ review }: { review: OwnerReview }) {
218 const names = (list: string[]) => list.join(", ");
219 if (review.changes_requested_by.length > 0) {
220 return (
221 <span className="flex items-center gap-1 text-xs text-danger">
222 <CircleSlash size={13} className="shrink-0" /> Changes requested by {names(review.changes_requested_by)}
223 </span>
224 );
225 }
226 if (review.satisfied && review.approved_by.length > 0) {
227 return (
228 <span className="flex items-center gap-1 text-xs text-accent">
229 <CircleCheck size={13} className="shrink-0" /> Approved by {names(review.approved_by)}
230 </span>
231 );
232 }
233 if (review.optional) {
234 return <span className="text-xs text-faint">Optional</span>;
235 }
236 if (review.satisfied) {
237 return (
238 <span className="flex items-center gap-1 text-xs text-accent">
239 <CircleCheck size={13} className="shrink-0" /> Approved
240 </span>
241 );
242 }
243 const more = Math.max(review.required - review.approved_by.length, 1);
244 return (
245 <span className="flex items-center gap-1 text-xs text-warn">
246 <Hourglass size={13} className="shrink-0" />
247 Waiting for {more} more {more === 1 ? "approval" : "approvals"}
248 {review.approved_by.length > 0 && <span className="text-faint">· approved by {names(review.approved_by)}</span>}
249 </span>
250 );
251}
252
253/** One rule: its section, pattern and line, its owners, the files it covers, and where its review stands. */
254function OwnerReviewRow({ review, fileHref }: { review: OwnerReview; fileHref: (line: number) => string }) {
255 const many = review.files.length > 3;
256 const files = (
257 <ul className="mt-1 space-y-0.5">
258 {review.files.map((file) => (
259 <li key={file} className="truncate font-mono text-xs text-muted">
260 {file}
261 </li>
262 ))}
263 </ul>
264 );
265 return (
266 <li className="px-4 py-3 text-sm">
267 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
268 {review.section && <Badge>{review.section}</Badge>}
269 <code className="font-mono text-xs text-fg">{review.pattern}</code>
270 <Link to={fileHref(review.line)} className="font-mono text-xs text-faint hover:text-accent hover:underline">
271 line {review.line}
272 </Link>
273 <span className="ml-auto">
274 <ReviewStatus review={review} />
275 </span>
276 </div>
277 <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted">
278 <span>Owners</span>
279 {review.owners.length === 0 ? (
280 <span className="text-faint">none</span>
281 ) : (
282 review.owners.map((owner) => <OwnerLink key={owner} owner={owner} />)
283 )}
284 </p>
285 {many ? (
286 <details className="mt-1">
287 <summary className="cursor-pointer text-xs text-faint hover:text-fg">{review.files.length} files</summary>
288 {files}
289 </details>
290 ) : (
291 files
292 )}
293 </li>
294 );
295}
296
297/**
298 * Who owns what a pull request changes: each rule that matched, its
299 * owners and files, and whose approval it still needs.
300 */
301export function PullCodeOwnersPanel({
302 owners,
303 base,
304 branch,
305 errorsHref,
306}: {
307 owners: PullCodeOwners;
308 base: string;
309 /** The branch it merges into, where the file is read. */
310 branch: string;
311 /** Where the file's errors are listed. */
312 errorsHref: string;
313}) {
314 const fileHref = (line: number) => blobHref(base, branch, owners.path, line);
315 return (
316 <section className="overflow-hidden rounded-xl border border-line" aria-label="Code owners">
317 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-line bg-surface px-4 py-2.5 text-sm">
318 <span className="flex items-center gap-2 font-medium">
319 <ShieldCheck size={15} className="shrink-0 text-faint" />
320 Code owners
321 </span>
322 <Link to={blobHref(base, branch, owners.path)} className="font-mono text-xs text-muted hover:text-fg hover:underline">
323 {owners.path}
324 </Link>
325 <span className="ml-auto flex items-center gap-2">
326 {owners.errors > 0 && (
327 <Link to={errorsHref} className="text-xs text-danger hover:underline">
328 {owners.errors === 1 ? "1 error" : `${owners.errors} errors`} in the file
329 </Link>
330 )}
331 {owners.required ? <Badge tone="warn">Approval required</Badge> : <Badge>Approval not required</Badge>}
332 </span>
333 </div>
334 {owners.reviews.length === 0 ? (
335 <p className="px-4 py-3 text-sm text-muted">No rule in the file covers what this changes.</p>
336 ) : (
337 <ul className="divide-y divide-line">
338 {owners.reviews.map((review) => (
339 <OwnerReviewRow key={`${review.section ?? ""}:${review.line}`} review={review} fileHref={fileHref} />
340 ))}
341 </ul>
342 )}
343 </section>
344 );
345}