Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 1 | /** |
| 2 | * Pieces of the Actions pages: how a run, job or step stands, how long it | |
| 3 | * took, and a job's log as GitHub shows one, with its groups folded and | |
| 4 | * its errors and warnings marked. | |
| 5 | */ | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 6 | import { AlertTriangle, Ban, Check, ChevronRight, CircleDashed, CircleSlash, Clock, Hourglass, Info, Loader2, ShieldAlert, X } from "lucide-react"; |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 7 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 8 | import { Link } from "react-router"; | |
| 9 | ||
| 10 | import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts"; | |
| 11 | ||
| Merge branch 'worktree-agent-ae1299e92e4462012' | 12 | import { Hint } from "./ui/hint"; |
| Run page: job summaries, attempts, re-run a job, debug, log search and downloads | 13 | import { type Line, blocks, highlight, searchLog } from "../lib/log-lines"; |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 14 | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 15 | /** |
| 16 | * Where a run, job or step stands. `of` says which: a run's `pending` waits | |
| 17 | * for its concurrency group and its `waiting` for its environments' rules, | |
| 18 | * while a job's `pending` is held at its environment and its `waiting` | |
| 19 | * waits for the jobs it needs. `environment` is the job's. | |
| 20 | */ | |
| 21 | type Standing = { status: string; conclusion: Conclusion | null; of?: "run" | "job"; environment?: string | null }; | |
| 22 | ||
| 23 | /** Held until a person (or a wait timer) lets it through. */ | |
| 24 | function heldWord({ status, of = "run", environment }: Standing): string | null { | |
| 25 | if (of === "run" && status === "action_required") return "Approval required"; | |
| 26 | if (of === "run" && status === "waiting") return "Waiting for review"; | |
| 27 | if (of === "job" && status === "pending") return environment ? `Waiting to deploy to ${environment}` : "Waiting at its environment"; | |
| 28 | return null; | |
| 29 | } | |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 30 | |
| 31 | /** One icon for where a run, job or step stands. */ | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 32 | export function StatusIcon({ status, conclusion, of = "run", environment, size = 16 }: Standing & { size?: number }) { |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 33 | if (status === "in_progress") return <Loader2 size={size} className="shrink-0 animate-spin text-warn" aria-label="Running" />; |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 34 | const held = heldWord({ status, conclusion, of, environment }); |
| 35 | if (held) | |
| 36 | return status === "action_required" ? ( | |
| 37 | <ShieldAlert size={size} className="shrink-0 text-warn" aria-label={held} /> | |
| 38 | ) : ( | |
| 39 | <Hourglass size={size} className="shrink-0 text-warn" aria-label={held} /> | |
| 40 | ); | |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 41 | if (status !== "completed") |
| 42 | return <Clock size={size} className="shrink-0 text-faint" aria-label={status === "pending" ? "Waiting its turn" : "Queued"} />; | |
| 43 | switch (conclusion) { | |
| 44 | case "success": | |
| 45 | return ( | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 46 | <span className="inline-flex shrink-0 rounded-full bg-success/15 p-0.5 text-success" aria-label="Succeeded"> |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 47 | <Check size={size - 4} strokeWidth={3} /> |
| 48 | </span> | |
| 49 | ); | |
| 50 | case "failure": | |
| 51 | return ( | |
| 52 | <span className="inline-flex shrink-0 rounded-full bg-danger/15 p-0.5 text-danger" aria-label="Failed"> | |
| 53 | <X size={size - 4} strokeWidth={3} /> | |
| 54 | </span> | |
| 55 | ); | |
| 56 | case "cancelled": | |
| 57 | return <Ban size={size} className="shrink-0 text-faint" aria-label="Cancelled" />; | |
| 58 | default: | |
| 59 | return <CircleDashed size={size} className="shrink-0 text-faint" aria-label="Skipped" />; | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 63 | export function standingWord(standing: Standing): string { |
| 64 | const { status, conclusion } = standing; | |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 65 | if (status === "in_progress") return "Running"; |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 66 | const held = heldWord(standing); |
| 67 | if (held) return held; | |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 68 | if (status === "pending") return "Waiting for its concurrency group"; |
| 69 | if (status === "waiting") return "Waiting for the jobs it needs"; | |
| Actions: reusable workflows in the repository | 70 | if (status === "calling") return "Running the workflow it calls"; |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 71 | if (status !== "completed") return "Queued"; |
| 72 | return { success: "Succeeded", failure: "Failed", cancelled: "Cancelled", skipped: "Skipped" }[conclusion ?? "skipped"]; | |
| 73 | } | |
| 74 | ||
| 75 | /** `1m 12s`, from two times, or from a start until now. */ | |
| 76 | export function duration(start: string | null, end: string | null): string { | |
| 77 | if (!start) return ""; | |
| 78 | const ms = (end ? new Date(end).getTime() : Date.now()) - new Date(start).getTime(); | |
| 79 | const seconds = Math.max(0, Math.round(ms / 1000)); | |
| 80 | if (seconds < 60) return `${seconds}s`; | |
| 81 | const minutes = Math.floor(seconds / 60); | |
| 82 | if (minutes < 60) return `${minutes}m ${seconds % 60}s`; | |
| 83 | return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; | |
| 84 | } | |
| 85 | ||
| 86 | /** `main` from `refs/heads/main`, `v1.2` from a tag, `#12` for a pull request. */ | |
| 87 | export function shortRef(ref: string): string { | |
| 88 | const pull = /^refs\/pull\/(\d+)\//.exec(ref); | |
| 89 | if (pull) return `#${pull[1]}`; | |
| 90 | return ref.replace(/^refs\/(heads|tags)\//, ""); | |
| 91 | } | |
| 92 | ||
| 93 | const LINE_STYLE: Record<Line["kind"], string> = { | |
| 94 | text: "text-fg/85", | |
| 95 | error: "text-danger", | |
| 96 | warning: "text-warn", | |
| 97 | notice: "text-accent", | |
| 98 | debug: "text-faint", | |
| 99 | command: "text-muted", | |
| 100 | }; | |
| 101 | ||
| Run page: job summaries, attempts, re-run a job, debug, log search and downloads | 102 | function LogLine({ line, number, query = "" }: { line: Line; number: number; query?: string }) { |
| 103 | const text = line.text.replace(/^(Error|Warning|Notice): /, ""); | |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 104 | return ( |
| 105 | <div className={`flex gap-4 px-4 hover:bg-raised/40 ${line.kind === "error" ? "bg-danger/5" : ""}`}> | |
| 106 | <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span> | |
| 107 | <span className={`min-w-0 whitespace-pre-wrap break-all ${LINE_STYLE[line.kind]}`}> | |
| 108 | {line.kind === "error" && <span className="font-semibold">Error: </span>} | |
| 109 | {line.kind === "warning" && <span className="font-semibold">Warning: </span>} | |
| Run page: job summaries, attempts, re-run a job, debug, log search and downloads | 110 | {query |
| 111 | ? highlight(text, query).map((piece, index) => | |
| 112 | piece.match ? ( | |
| 113 | <mark key={index} className="rounded-sm bg-accent/30 text-fg"> | |
| 114 | {piece.text} | |
| 115 | </mark> | |
| 116 | ) : ( | |
| 117 | piece.text | |
| 118 | ), | |
| 119 | ) | |
| 120 | : text} | |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 121 | </span> |
| 122 | </div> | |
| 123 | ); | |
| 124 | } | |
| 125 | ||
| Run page: job summaries, attempts, re-run a job, debug, log search and downloads | 126 | /** |
| 127 | * A step's log. With `query`, only the lines holding it (any case), groups | |
| 128 | * opened, each match marked. | |
| 129 | */ | |
| 130 | export function LogText({ text, query = "" }: { text: string; query?: string }) { | |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 131 | const parsed = useMemo(() => blocks(text), [text]); |
| Run page: job summaries, attempts, re-run a job, debug, log search and downloads | 132 | const found = useMemo(() => searchLog(text, query), [text, query]); |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 133 | if (!text.trim()) return <p className="px-4 py-2 text-xs text-faint">No output.</p>; |
| Run page: job summaries, attempts, re-run a job, debug, log search and downloads | 134 | if (query.trim()) { |
| 135 | return ( | |
| 136 | <div className="py-1 font-mono text-xs leading-5"> | |
| 137 | {found.map(({ line, number }) => ( | |
| 138 | <LogLine key={number} line={line} number={number} query={query} /> | |
| 139 | ))} | |
| 140 | </div> | |
| 141 | ); | |
| 142 | } | |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 143 | return ( |
| 144 | <div className="py-1 font-mono text-xs leading-5"> | |
| 145 | {parsed.map((block, index) => | |
| 146 | block.kind === "line" ? ( | |
| 147 | <LogLine key={index} line={block.line} number={block.number} /> | |
| 148 | ) : ( | |
| 149 | <details key={index} className="group/log"> | |
| 150 | <summary className="flex cursor-pointer list-none items-center gap-1.5 px-4 text-fg/85 hover:bg-raised/40"> | |
| 151 | <span className="w-8 shrink-0" /> | |
| 152 | <ChevronRight size={12} className="shrink-0 text-faint transition-transform group-open/log:rotate-90" /> | |
| 153 | {block.title} | |
| 154 | </summary> | |
| 155 | {block.lines.map(({ line, number }) => ( | |
| 156 | <LogLine key={number} line={line} number={number} /> | |
| 157 | ))} | |
| 158 | </details> | |
| 159 | ), | |
| 160 | )} | |
| 161 | </div> | |
| 162 | ); | |
| 163 | } | |
| 164 | ||
| 165 | /** | |
| 166 | * A job's log, fetched as it grows, split by step: `render` gets each | |
| 167 | * step's text (0 is the job's setup). | |
| 168 | */ | |
| 169 | export function useJobLog(url: string, live: boolean): Map<number, string> { | |
| 170 | const [chunks, setChunks] = useState<LogChunk[]>([]); | |
| 171 | const after = useRef(0); | |
| 172 | useEffect(() => { | |
| 173 | after.current = 0; | |
| 174 | setChunks([]); | |
| 175 | }, [url]); | |
| 176 | useEffect(() => { | |
| 177 | let stopped = false; | |
| 178 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 179 | const load = async () => { | |
| 180 | try { | |
| 181 | // Pages of up to 500 chunks, until there is no more for now. | |
| 182 | for (;;) { | |
| 183 | const response = await fetch(`${url}?after=${after.current}`); | |
| 184 | if (!response.ok) break; | |
| 185 | const page = (await response.json()) as { chunks: LogChunk[]; done: boolean }; | |
| 186 | if (stopped) return; | |
| 187 | if (page.chunks.length > 0) { | |
| 188 | after.current = page.chunks[page.chunks.length - 1].seq; | |
| 189 | setChunks((known) => [...known, ...page.chunks]); | |
| 190 | } | |
| 191 | if (page.chunks.length < 500) { | |
| 192 | if (!page.done && live) timer = setTimeout(load, 2000); | |
| 193 | break; | |
| 194 | } | |
| 195 | } | |
| 196 | } catch { | |
| 197 | if (!stopped && live) timer = setTimeout(load, 4000); | |
| 198 | } | |
| 199 | }; | |
| 200 | load(); | |
| 201 | return () => { | |
| 202 | stopped = true; | |
| 203 | if (timer) clearTimeout(timer); | |
| 204 | }; | |
| 205 | }, [url, live]); | |
| 206 | return useMemo(() => { | |
| 207 | const byStep = new Map<number, string>(); | |
| 208 | for (const chunk of chunks) byStep.set(chunk.step, (byStep.get(chunk.step) ?? "") + chunk.text); | |
| 209 | return byStep; | |
| 210 | }, [chunks]); | |
| 211 | } | |
| 212 | ||
| 213 | const NOTE_ICON = { | |
| 214 | info: <Info size={14} className="mt-0.5 shrink-0 text-muted" />, | |
| 215 | warning: <AlertTriangle size={14} className="mt-0.5 shrink-0 text-warn" />, | |
| 216 | unsupported: <CircleSlash size={14} className="mt-0.5 shrink-0 text-danger" />, | |
| 217 | }; | |
| 218 | ||
| 219 | /** What in a workflow runs differently on g1t, worst first. */ | |
| 220 | export function Notes({ notes }: { notes: WorkflowNote[] }) { | |
| 221 | if (notes.length === 0) return null; | |
| 222 | const order = { unsupported: 0, warning: 1, info: 2 }; | |
| 223 | const sorted = [...notes].sort((a, b) => order[a.severity] - order[b.severity]); | |
| 224 | const blocking = notes.filter((n) => n.severity === "unsupported").length; | |
| 225 | return ( | |
| 226 | <details className="group rounded-xl border border-line bg-surface" open={blocking > 0}> | |
| 227 | <summary className="cursor-pointer list-none px-4 py-2.5 text-sm"> | |
| 228 | <span className="font-medium">How this runs on g1t</span> | |
| 229 | <span className="text-muted"> | |
| 230 | {" · "} | |
| 231 | {blocking > 0 ? `${blocking} thing${blocking === 1 ? "" : "s"} g1t cannot run yet` : `${notes.length} note${notes.length === 1 ? "" : "s"}`} | |
| 232 | </span> | |
| 233 | </summary> | |
| 234 | <ul className="space-y-2 border-t border-line px-4 py-3 text-sm"> | |
| 235 | {sorted.map((note, index) => ( | |
| 236 | <li key={index} className="flex gap-2"> | |
| 237 | {NOTE_ICON[note.severity]} | |
| 238 | <span> | |
| 239 | {note.job && <span className="font-mono text-xs text-muted">{note.job}: </span>} | |
| 240 | {note.message} | |
| 241 | </span> | |
| 242 | </li> | |
| 243 | ))} | |
| 244 | </ul> | |
| 245 | </details> | |
| 246 | ); | |
| 247 | } | |
| 248 | ||
| 249 | ||
| 250 | /** What workflow runs said about a pull request's head, each linking to its run. */ | |
| 251 | export function WorkflowStatuses({ statuses }: { statuses: CommitStatus[] }) { | |
| 252 | if (statuses.length === 0) return null; | |
| 253 | const standing = (state: CommitStatus["state"]) => | |
| 254 | state === "pending" | |
| 255 | ? { status: "in_progress", conclusion: null } | |
| 256 | : { status: "completed", conclusion: (state === "success" ? "success" : "failure") as Conclusion }; | |
| 257 | const failed = statuses.filter((s) => s.state === "failure" || s.state === "error").length; | |
| 258 | const pending = statuses.filter((s) => s.state === "pending").length; | |
| 259 | return ( | |
| 260 | <section className="rounded-xl border border-line bg-surface p-4"> | |
| 261 | <h3 className="text-sm font-medium"> | |
| 262 | {failed > 0 ? `${failed} workflow${failed === 1 ? "" : "s"} failed` : pending > 0 ? "Workflows running" : "Workflows passed"} | |
| 263 | </h3> | |
| 264 | <ul className="mt-3 space-y-2 text-sm"> | |
| 265 | {statuses.map((status) => { | |
| 266 | const path = status.targetUrl?.replace(/^https:\/\/g1t\.sh/, "") ?? null; | |
| 267 | const row = ( | |
| 268 | <> | |
| 269 | <StatusIcon {...standing(status.state)} size={14} /> | |
| 270 | <span className="min-w-0 truncate">{status.context}</span> | |
| 271 | </> | |
| 272 | ); | |
| 273 | return ( | |
| 274 | <li key={status.context}> | |
| 275 | {path ? ( | |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 276 | <Hint label={status.description}> |
| 277 | <Link to={path} className="flex items-center gap-2 hover:text-fg"> | |
| 278 | {row} | |
| 279 | </Link> | |
| 280 | </Hint> | |
| GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs | 281 | ) : ( |
| 282 | <span className="flex items-center gap-2">{row}</span> | |
| 283 | )} | |
| 284 | </li> | |
| 285 | ); | |
| 286 | })} | |
| 287 | </ul> | |
| 288 | </section> | |
| 289 | ); | |
| 290 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.