Skip to content
290 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";
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)13import { 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 */
21type 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. */
24function 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 docs30
31/** One icon for where a run, job or step stands. */
Merge branch 'worktree-agent-a3abfcce648e87dca'32export function StatusIcon({ status, conclusion, of = "run", environment, size = 16 }: Standing & { size?: number }) {
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs33 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 docs41 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 docs47 <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'63export function standingWord(standing: Standing): string {
64 const { status, conclusion } = standing;
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs65 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 docs68 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 repository70 if (status === "calling") return "Running the workflow it calls";
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs71 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. */
76export 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. */
87export 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
93const 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
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)102function 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 docs104 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>}
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)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 docs121 </span>
122 </div>
123 );
124}
125
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)126/**
127 * A step's log. With `query`, only the lines holding it (any case), groups
128 * opened, each match marked.
129 */
130export function LogText({ text, query = "" }: { text: string; query?: string }) {
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs131 const parsed = useMemo(() => blocks(text), [text]);
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)132 const found = useMemo(() => searchLog(text, query), [text, query]);
GitHub Actions on g1t, part three: .g1t/workflows, the pages, the docs133 if (!text.trim()) return <p className="px-4 py-2 text-xs text-faint">No output.</p>;
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)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 docs143 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 */
169export 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
213const 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. */
220export 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. */
251export 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 docs281 ) : (
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.