g1t/apps/web/app/components/checks.tsx
| 1 | import { ChevronRight, CircleCheck, CircleX, LoaderCircle, RotateCw, TriangleAlert } from "lucide-react"; |
| 2 | import { Form } from "react-router"; |
| 3 | |
| 4 | import type { CheckRun, CheckStatus } from "@g1t/contracts"; |
| 5 | |
| 6 | const LABELS: Record<CheckStatus, string> = { |
| 7 | queued: "Checks queued", |
| 8 | running: "Checks running", |
| 9 | passed: "Checks passed", |
| 10 | failed: "Checks failed", |
| 11 | errored: "Checks could not run", |
| 12 | }; |
| 13 | |
| 14 | export function CheckIcon({ status, size = 15 }: { status: CheckStatus; size?: number }) { |
| 15 | if (status === "passed") return <CircleCheck size={size} className="shrink-0 text-accent" />; |
| 16 | if (status === "failed") return <CircleX size={size} className="shrink-0 text-danger" />; |
| 17 | if (status === "errored") return <TriangleAlert size={size} className="shrink-0 text-muted" />; |
| 18 | return <LoaderCircle size={size} className="shrink-0 animate-spin text-info" />; |
| 19 | } |
| 20 | |
| 21 | /** The state of a pull request's checks at a glance, for lists. */ |
| 22 | export function CheckBadge({ status }: { status: CheckStatus | null }) { |
| 23 | if (!status) return null; |
| 24 | return ( |
| 25 | <span className="flex shrink-0 items-center gap-1 text-xs text-muted" title={LABELS[status]}> |
| 26 | <CheckIcon status={status} size={13} /> |
| 27 | <span className="sr-only">{LABELS[status]}</span> |
| 28 | </span> |
| 29 | ); |
| 30 | } |
| 31 | |
| 32 | function seconds(ms: number): string { |
| 33 | return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`; |
| 34 | } |
| 35 | |
| 36 | /** The latest run of the acceptance checks: each command and what it printed. */ |
| 37 | export function ChecksPanel({ |
| 38 | run, |
| 39 | commands, |
| 40 | canRerun, |
| 41 | }: { |
| 42 | run: CheckRun | null; |
| 43 | /** The issue's checks, shown before any run exists. */ |
| 44 | commands: string[]; |
| 45 | canRerun: boolean; |
| 46 | }) { |
| 47 | if (commands.length === 0 && !run) return null; |
| 48 | const pending = run?.status === "queued" || run?.status === "running"; |
| 49 | return ( |
| 50 | <section className="rounded-xl border border-line bg-surface p-4"> |
| 51 | <div className="flex items-center gap-2"> |
| 52 | {run && <CheckIcon status={run.status} />} |
| 53 | <h3 className="grow text-sm font-medium"> |
| 54 | {run ? LABELS[run.status] : "Acceptance checks"} |
| 55 | </h3> |
| 56 | {canRerun && !pending && ( |
| 57 | <Form method="post"> |
| 58 | <button |
| 59 | type="submit" |
| 60 | name="action" |
| 61 | value="recheck" |
| 62 | title="Run the checks again" |
| 63 | className="rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg" |
| 64 | > |
| 65 | <RotateCw size={14} /> |
| 66 | <span className="sr-only">Run the checks again</span> |
| 67 | </button> |
| 68 | </Form> |
| 69 | )} |
| 70 | </div> |
| 71 | {run ? ( |
| 72 | <p className="mt-1 text-xs text-muted"> |
| 73 | On <span className="font-mono">{run.headCommit.slice(0, 7)}</span>, in a clean |
| 74 | sandbox. |
| 75 | </p> |
| 76 | ) : ( |
| 77 | <p className="mt-1 text-xs text-muted"> |
| 78 | They run in a clean sandbox once the pull request is ready for review. |
| 79 | </p> |
| 80 | )} |
| 81 | {run?.error && <p className="mt-2 text-xs text-muted">{run.error}</p>} |
| 82 | <ul className="mt-3 space-y-1.5"> |
| 83 | {run && run.results.length > 0 |
| 84 | ? run.results.map((result) => ( |
| 85 | <li key={result.command}> |
| 86 | <details className="group rounded-md border border-line bg-bg"> |
| 87 | <summary className="flex cursor-pointer list-none items-center gap-2 px-2.5 py-1.5"> |
| 88 | <ChevronRight |
| 89 | size={13} |
| 90 | className="shrink-0 text-faint transition-transform group-open:rotate-90" |
| 91 | /> |
| 92 | <span className="min-w-0 grow truncate font-mono text-xs"> |
| 93 | {result.command} |
| 94 | </span> |
| 95 | <span className="shrink-0 text-[0.6875rem] text-faint"> |
| 96 | {seconds(result.durationMs)} |
| 97 | </span> |
| 98 | {result.passed ? ( |
| 99 | <CircleCheck size={14} className="shrink-0 text-accent" /> |
| 100 | ) : ( |
| 101 | <CircleX size={14} className="shrink-0 text-danger" /> |
| 102 | )} |
| 103 | </summary> |
| 104 | <pre className="max-h-72 overflow-auto border-t border-line p-2.5 font-mono text-[0.6875rem] leading-relaxed whitespace-pre-wrap text-muted"> |
| 105 | {/* In a <code>: a newline right after <pre> is dropped by |
| 106 | the HTML parser, and the page would differ from React's. */} |
| 107 | <code> |
| 108 | {result.output || "No output."} |
| 109 | {result.exitCode != null && result.exitCode !== 0 && `\n\nExit code ${result.exitCode}.`} |
| 110 | </code> |
| 111 | </pre> |
| 112 | </details> |
| 113 | </li> |
| 114 | )) |
| 115 | : commands.map((command) => ( |
| 116 | <li |
| 117 | key={command} |
| 118 | className="truncate rounded-md border border-line bg-bg px-2.5 py-1.5 font-mono text-xs text-muted" |
| 119 | > |
| 120 | {command} |
| 121 | </li> |
| 122 | ))} |
| 123 | </ul> |
| 124 | </section> |
| 125 | ); |
| 126 | } |