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