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.

356 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>The CODEOWNERS file could not be read just now. Reload the page to try again.</p>
110 </Card>
111 );
112 }
113 if (!report.path) {
114 return (
115 <Card tone="plain" className="border-dashed p-4 text-sm">
116 <p className="font-medium">No CODEOWNERS file</p>
117 <p className="mt-1 text-muted">g1t looks on {branch} for the first of these, and asks the owners it names to review changes to their files:</p>
118 <ul className="mt-2 flex flex-wrap gap-1.5">
119 {CODEOWNERS_LOCATIONS.map((location) => (
120 <li key={location}>
121 <code className="rounded border border-line bg-bg px-1.5 py-0.5 font-mono text-xs">{location}</code>
122 </li>
123 ))}
124 </ul>
125 <p className="mt-3">
126 <a href={CODEOWNERS_DOCS} className="text-accent hover:underline">
127 How to write one
128 </a>
129 </p>
130 </Card>
131 );
132 }
133 const path = report.path;
134 const errors = report.errors.length;
135 return (
136 <Card tone="plain" className="overflow-hidden">
137 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 bg-surface px-4 py-3 text-sm">
138 {errors > 0 ? <FileWarning size={16} className="shrink-0 text-danger" /> : <ShieldCheck size={16} className="shrink-0 text-success" />}
139 <Link to={blobHref(base, branch, path)} className="font-mono text-sm hover:text-accent hover:underline">
140 {path}
141 </Link>
142 <span className="text-xs text-muted">
143 {report.rules === 1 ? "1 rule" : `${report.rules} rules`}
144 {report.sections.length > 0 && ` · ${report.sections.length === 1 ? "1 section" : `${report.sections.length} sections`}`}
145 </span>
146 <span className={`ml-auto text-xs ${errors > 0 ? "text-danger" : "text-muted"}`}>
147 {errors === 0 ? "No errors" : errors === 1 ? "1 error" : `${errors} errors`}
148 </span>
149 </div>
150 {errors > 0 && (
151 <div className="border-t border-line">
152 <CodeownersErrorList errors={report.errors} lineHref={(line) => blobHref(base, branch, path, line)} />
153 </div>
154 )}
155 </Card>
156 );
157}
158
159/** The panel's shape while the file is read. */
160export function CodeownersReportSkeleton() {
161 return (
162 <Card aria-busy="true" className="flex items-center gap-3 px-4 py-3">
163 <Skeleton className="size-4 rounded-full" />
164 <Skeleton className="h-3 w-36" />
165 <Skeleton className="h-3 w-20" />
166 <Skeleton className="ml-auto h-3 w-14" />
167 </Card>
168 );
169}
170
171/** Above a CODEOWNERS file: what is wrong with it, or that nothing is. */
172export function CodeownersFileErrors({ report, path, base }: { report: CodeownersReport; path: string; base: string }) {
173 // Not read at this ref (a commit, say): nothing to say about it.
174 if (!report.path) return null;
175 // Another location comes first on this branch, so this file is not read.
176 if (report.path !== path) {
177 return (
178 <p className="mb-4 flex items-center gap-2 text-sm text-muted">
179 <CircleDashed size={15} className="shrink-0 text-faint" />
180 <span>
181 Not in use: g1t reads{" "}
182 <Link to={blobHref(base, report.ref, report.path)} className="font-mono text-fg hover:underline">
183 {report.path}
184 </Link>{" "}
185 on this branch, which comes first.
186 </span>
187 </p>
188 );
189 }
190 const errors = report.errors.length;
191 if (errors === 0) {
192 return (
193 <p className="mb-4 flex items-center gap-2 text-sm text-muted" role="status">
194 <CircleCheck size={15} className="shrink-0 text-success" />
195 No errors in this CODEOWNERS file.
196 </p>
197 );
198 }
199 return (
200 <section className="mb-4 overflow-hidden rounded-xl border border-danger/40" aria-label="CODEOWNERS errors">
201 <p className="flex items-center gap-2 border-b border-danger/30 bg-danger/5 px-4 py-2.5 text-sm font-medium">
202 <FileWarning size={15} className="shrink-0 text-danger" />
203 {errors === 1 ? "1 error" : `${errors} errors`} in this CODEOWNERS file
204 <a href={CODEOWNERS_DOCS} className="ml-auto text-xs font-normal text-muted hover:text-fg hover:underline">
205 Syntax
206 </a>
207 </p>
208 <CodeownersErrorList errors={report.errors} lineHref={(line) => `#L${line}`} />
209 </section>
210 );
211}
212
213/**
214 * Where one rule's review stands, in a few words. When the ruleset does not
215 * require code owners' approval (`required` false), a rule nobody approved
216 * yet is said plainly, not as something merging waits for.
217 */
218function ReviewStatus({ review, required }: { review: OwnerReview; required: boolean }) {
219 const names = (list: string[]) => list.join(", ");
220 if (review.changes_requested_by.length > 0) {
221 return (
222 <span className="flex items-center gap-1 text-xs text-danger">
223 <CircleSlash size={13} className="shrink-0" /> Changes requested by {names(review.changes_requested_by)}
224 </span>
225 );
226 }
227 if (review.satisfied && review.approved_by.length > 0) {
228 return (
229 <span className="flex items-center gap-1 text-xs text-success">
230 <CircleCheck size={13} className="shrink-0" /> Approved by {names(review.approved_by)}
231 </span>
232 );
233 }
234 if (review.optional) {
235 return <span className="text-xs text-faint">Optional</span>;
236 }
237 if (review.satisfied) {
238 return (
239 <span className="flex items-center gap-1 text-xs text-success">
240 <CircleCheck size={13} className="shrink-0" /> Approved
241 </span>
242 );
243 }
244 if (!required) {
245 return <span className="text-xs text-faint">{review.approved_by.length > 0 ? `Approved by ${names(review.approved_by)}` : "No approval yet"}</span>;
246 }
247 const more = Math.max(review.required - review.approved_by.length, 1);
248 return (
249 <span className="flex items-center gap-1 text-xs text-warn">
250 <Hourglass size={13} className="shrink-0" />
251 Waiting for {more} more {more === 1 ? "approval" : "approvals"}
252 {review.approved_by.length > 0 && <span className="text-faint">· approved by {names(review.approved_by)}</span>}
253 </span>
254 );
255}
256
257/** One rule: its section, pattern and line, its owners, the files it covers, and where its review stands. */
258function OwnerReviewRow({
259 review,
260 required,
261 fileHref,
262}: {
263 review: OwnerReview;
264 /** Whether the ruleset requires code owners' approval to merge. */
265 required: boolean;
266 fileHref: (line: number) => string;
267}) {
268 const many = review.files.length > 3;
269 const files = (
270 <ul className="mt-1 space-y-0.5">
271 {review.files.map((file) => (
272 <li key={file} className="truncate font-mono text-xs text-muted">
273 {file}
274 </li>
275 ))}
276 </ul>
277 );
278 return (
279 <li className="px-4 py-3 text-sm">
280 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
281 {review.section && <Badge>{review.section}</Badge>}
282 <code className="font-mono text-xs text-fg">{review.pattern}</code>
283 <Link to={fileHref(review.line)} className="font-mono text-xs text-faint hover:text-accent hover:underline">
284 line {review.line}
285 </Link>
286 <span className="ml-auto">
287 <ReviewStatus review={review} required={required} />
288 </span>
289 </div>
290 <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted">
291 <span>Owners</span>
292 {review.owners.length === 0 ? <span className="text-faint">none</span> : review.owners.map((owner) => <OwnerLink key={owner} owner={owner} />)}
293 </p>
294 {many ? (
295 <details className="mt-1">
296 <summary className="cursor-pointer text-xs text-faint hover:text-fg">{review.files.length} files</summary>
297 {files}
298 </details>
299 ) : (
300 files
301 )}
302 </li>
303 );
304}
305
306/**
307 * Who owns what a pull request changes: each rule that matched, its
308 * owners and files, and whose approval it still needs.
309 */
310export function PullCodeOwnersPanel({
311 owners,
312 base,
313 branch,
314 errorsHref,
315}: {
316 owners: PullCodeOwners;
317 base: string;
318 /** The branch it merges into, where the file is read. */
319 branch: string;
320 /** Where the file's errors are listed. */
321 errorsHref: string;
322}) {
323 const fileHref = (line: number) => blobHref(base, branch, owners.path, line);
324 return (
325 <Card asChild tone="plain" className="overflow-hidden">
326 <section aria-label="Code owners">
327 <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">
328 <span className="flex items-center gap-2 font-medium">
329 <ShieldCheck size={15} className="shrink-0 text-faint" />
330 Code owners
331 </span>
332 <Link to={blobHref(base, branch, owners.path)} className="font-mono text-xs text-muted hover:text-fg hover:underline">
333 {owners.path}
334 </Link>
335 <span className="ml-auto flex items-center gap-2">
336 {owners.errors > 0 && (
337 <Link to={errorsHref} className="text-xs text-danger hover:underline">
338 {owners.errors === 1 ? "1 error" : `${owners.errors} errors`} in the file
339 </Link>
340 )}
341 {owners.required ? <Badge tone="warn">Approval required</Badge> : <Badge>Approval not required</Badge>}
342 </span>
343 </div>
344 {owners.reviews.length === 0 ? (
345 <p className="px-4 py-3 text-sm text-muted">No rule in the file covers what this changes.</p>
346 ) : (
347 <ul className="divide-y divide-line">
348 {owners.reviews.map((review) => (
349 <OwnerReviewRow key={`${review.section ?? ""}:${review.line}`} review={review} required={owners.required} fileHref={fileHref} />
350 ))}
351 </ul>
352 )}
353 </section>
354 </Card>
355 );
356}