g1t/apps/web/app/components/codeowners.tsx
| 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 | */ |
| 6 | import { CircleCheck, CircleDashed, CircleSlash, FileWarning, Hourglass, ShieldCheck, Users } from "lucide-react"; |
| 7 | import { Link } from "react-router"; |
| 8 | |
| 9 | import { |
| 10 | CODEOWNERS_ERROR_LABELS, |
| 11 | CODEOWNERS_LOCATIONS, |
| 12 | type CodeownersError, |
| 13 | type CodeownersReport, |
| 14 | type OwnerReview, |
| 15 | type PullCodeOwners, |
| 16 | } from "@g1t/contracts"; |
| 17 | |
| 18 | import { Badge } from "./ui/badge"; |
| 19 | import { Skeleton } from "./ui/skeleton"; |
| 20 | |
| 21 | /** The guide to the file. */ |
| 22 | export const CODEOWNERS_DOCS = "https://docs.g1t.sh/guides/codeowners/"; |
| 23 | |
| 24 | /** Where a file at `path` on `ref` is shown, optionally at a line. */ |
| 25 | export 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 `@`). */ |
| 30 | export 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. */ |
| 36 | export 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. */ |
| 57 | export 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. */ |
| 72 | export 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. */ |
| 101 | export 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-success" /> |
| 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. */ |
| 163 | export 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. */ |
| 175 | export 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-success" /> |
| 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. */ |
| 217 | function 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-success"> |
| 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-success"> |
| 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. */ |
| 254 | function 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 | */ |
| 301 | export 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 | } |