Skip to content
301 linesCodeBlameRaw

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 docs1/**
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'6import { 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 docs7import { useEffect, useMemo, useRef, useState } from "react";
8import { Link } from "react-router";
9
10import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts";
11
Merge branch 'worktree-agent-ae1299e92e4462012'12import { 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 */
20type 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. */
23function 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 docs29
30/** One icon for where a run, job or step stands. */
Merge branch 'worktree-agent-a3abfcce648e87dca'31export function StatusIcon({ status, conclusion, of = "run", environment, size = 16 }: Standing & { size?: number }) {
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs32 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 docs40 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 docs46 <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'62export function standingWord(standing: Standing): string {
63 const { status, conclusion } = standing;
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs64 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 docs67 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 repository69 if (status === "calling") return "Running the workflow it calls";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs70 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. */
75export 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. */
86export 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
92type Line = { kind: "text" | "error" | "warning" | "notice" | "debug" | "command"; text: string };
93type Block = { kind: "line"; line: Line; number: number } | { kind: "group"; title: string; lines: { line: Line; number: number }[] };
94
95function 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. */
106function 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
129const 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
138function 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
151export 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 */
180export 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
224const 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. */
231export 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. */
262export 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 docs292 ) : (
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.