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