Skip to content
264 linesCodeBlameRaw
1/**
2 * A job's log as GitHub shows one: lines, folded `##[group]`s, errors and
3 * warnings marked; searching it; and the files of a run's log archive.
4 */
5
6import type { JobLogText } from "@g1t/contracts";
7
8import { stripAnsi } from "./ansi.ts";
9
10export type LineKind = "text" | "error" | "warning" | "notice" | "debug" | "command";
11/**
12 * A line: its kind and its text, plain (what search reads). `at` is when
13 * the runner wrote it, from the time GitHub's logs stamp at the start of
14 * every line; `ansi` is its text with the colour codes it came with, when
15 * it had any.
16 */
17export type Line = { kind: LineKind; text: string; at?: string; ansi?: string };
18export type Block =
19 | { kind: "line"; line: Line; number: number }
20 | { kind: "group"; title: string; lines: { line: Line; number: number }[]; at?: string };
21
22/** The time GitHub's logs stamp at the start of a line: `2026-10-12T09:41:07.1234567Z `. */
23const STAMP = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z) /;
24
25/** A line's stamped time and the rest of it; no time for a line without one (a log from before). */
26export function splitStamp(raw: string): { at?: string; rest: string } {
27 const found = STAMP.exec(raw);
28 return found ? { at: found[1], rest: raw.slice(found[0].length) } : { rest: raw };
29}
30
31/** The time of a log's last stamped line, if it has one. */
32export function lastStamp(text: string): string | undefined {
33 let end = text.length;
34 while (end > 0) {
35 const start = text.lastIndexOf("\n", end - 1);
36 const at = splitStamp(text.slice(start + 1, end)).at;
37 if (at) return at;
38 end = start;
39 }
40 return undefined;
41}
42
43function lineOf(kind: LineKind, rest: string, at: string | undefined): Line {
44 const out: Line = { kind, text: stripAnsi(rest) };
45 if (at) out.at = at;
46 if (out.text !== rest) out.ansi = rest;
47 return out;
48}
49
50export function classify(stamped: string): Line | "group-end" | { group: string; at?: string } {
51 const { at, rest: raw } = splitStamp(stamped);
52 if (raw.startsWith("##[group]")) return at ? { group: stripAnsi(raw.slice(9)), at } : { group: stripAnsi(raw.slice(9)) };
53 if (raw.startsWith("##[endgroup]")) return "group-end";
54 for (const kind of ["error", "warning", "notice", "debug"] as const) {
55 if (raw.startsWith(`##[${kind}]`)) return lineOf(kind, raw.slice(kind.length + 4), at);
56 }
57 if (raw.startsWith("[command]")) return lineOf("command", raw.slice(9), at);
58 return lineOf("text", raw, at);
59}
60
61/** A line and its number in the step's log. */
62export type Numbered = { line: Line; number: number };
63
64/**
65 * A step's log, read as it arrives (`appendLog`): each chunk's lines are
66 * read once, however long the log grows, and a group a chunk leaves open
67 * holds the next chunk's lines until its `##[endgroup]`.
68 */
69export type StepLog = {
70 /** Plain lines and groups, as `blocks` reads them. */
71 blocks: Block[];
72 /** Every line in order, groups' lines among them: `lines[n - 1]` is line `n`. */
73 lines: Numbered[];
74 /** The index in `blocks` of the block that holds each line, by the same index. */
75 blockOf: number[];
76 /** The group still open at the end of what has arrived, by its index in `blocks`. */
77 group: number | null;
78 /** What came after the last newline: a line still being written, shown as `tail` meanwhile. */
79 partial: string;
80 tail?: Numbered;
81 /** The time of the last stamped line. */
82 lastAt?: string;
83 /** Whether any line holds more than spaces. */
84 hasText: boolean;
85};
86
87export function emptyLog(): StepLog {
88 return { blocks: [], lines: [], blockOf: [], group: null, partial: "", hasText: false };
89}
90
91/**
92 * `log` with `text` added to its end. Only the new lines are read: the
93 * answer shares `log`'s arrays, adding to them, so `log` is not to be read
94 * or added to again.
95 */
96export function appendLog(log: StepLog, text: string): StepLog {
97 const next: StepLog = { ...log };
98 delete next.tail;
99 const joined = next.partial + text;
100 const end = joined.lastIndexOf("\n");
101 const whole = end === -1 ? "" : joined.slice(0, end);
102 next.partial = end === -1 ? joined : joined.slice(end + 1);
103 if (end !== -1) {
104 for (const raw of whole.split("\n")) readLine(next, raw);
105 }
106 // A line that may yet be a group's marker (`##[gro`) waits to end.
107 if (next.partial.trim() && !splitStamp(next.partial).rest.startsWith("##")) {
108 const line = classify(next.partial);
109 if (typeof line === "object" && !("group" in line)) next.tail = { line, number: next.lines.length + 1 };
110 }
111 return next;
112}
113
114/** Reads one whole line into `log`, in place. */
115function readLine(log: StepLog, raw: string): void {
116 // A log ends with a newline; nothing follows the last one.
117 if (raw === "" && log.lines.length === 0) return;
118 const line = classify(raw);
119 if (line === "group-end") {
120 log.group = null;
121 return;
122 }
123 if ("group" in line) {
124 log.group = log.blocks.length;
125 log.blocks.push(line.at ? { kind: "group", title: line.group, lines: [], at: line.at } : { kind: "group", title: line.group, lines: [] });
126 return;
127 }
128 const entry = { line, number: log.lines.length + 1 };
129 log.lines.push(entry);
130 if (line.at) log.lastAt = line.at;
131 if (!log.hasText && line.text.trim()) log.hasText = true;
132 const open = log.group === null ? undefined : log.blocks[log.group];
133 if (open?.kind === "group") {
134 open.lines.push(entry);
135 log.blockOf.push(log.group!);
136 } else {
137 log.blockOf.push(log.blocks.length);
138 log.blocks.push({ kind: "line", ...entry });
139 }
140}
141
142/** A whole log, read at once. */
143export function readLog(text: string): StepLog {
144 return appendLog(emptyLog(), text.endsWith("\n") || text === "" ? text : `${text}\n`);
145}
146
147/** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */
148export function blocks(text: string): Block[] {
149 return readLog(text).blocks;
150}
151
152/** The last search of each log, so a step's count and its lines are found once a change. */
153const searched = new WeakMap<StepLog, { query: string; found: Numbered[] }>();
154
155/** The lines of a step's log holding `query` (any case), in order, folded groups' lines among them. */
156export function searchStepLog(log: StepLog, query: string): Numbered[] {
157 const wanted = query.trim().toLowerCase();
158 if (!wanted) return [];
159 const known = searched.get(log);
160 if (known && known.query === wanted) return known.found;
161 const found: Numbered[] = [];
162 for (const entry of log.lines) {
163 if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry);
164 }
165 if (log.tail && log.tail.line.text.toLowerCase().includes(wanted)) found.push(log.tail);
166 searched.set(log, { query: wanted, found });
167 return found;
168}
169
170/** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */
171export function searchLog(text: string, query: string): Numbered[] {
172 return searchStepLog(readLog(text), query);
173}
174
175/**
176 * What a step's log draws, one row each, top to bottom: its lines, and
177 * each group's title, with the lines of the groups in `open` (by their
178 * index in `blocks`) under it.
179 */
180export type Row =
181 | { kind: "line"; entry: Numbered }
182 | { kind: "group"; block: number; title: string; at?: string; open: boolean; size: number };
183
184export function logRows(log: StepLog, open: ReadonlySet<number>): Row[] {
185 const rows: Row[] = [];
186 log.blocks.forEach((block, index) => {
187 if (block.kind === "line") {
188 rows.push({ kind: "line", entry: block });
189 return;
190 }
191 const isOpen = open.has(index);
192 rows.push({ kind: "group", block: index, title: block.title, at: block.at, open: isOpen, size: block.lines.length });
193 if (isOpen) for (const entry of block.lines) rows.push({ kind: "line", entry });
194 });
195 if (log.tail) rows.push({ kind: "line", entry: log.tail });
196 return rows;
197}
198
199/** The group that holds line `number`, by its index in `blocks`, or null when it is no group's. */
200export function groupOf(log: StepLog, number: number): number | null {
201 const index = log.blockOf[number - 1];
202 return index !== undefined && log.blocks[index]?.kind === "group" ? index : null;
203}
204
205/** A row's key, the same however the rows above it change. */
206export function rowKey(row: Row): string {
207 return row.kind === "line" ? `L${row.entry.number}` : `G${row.block}`;
208}
209
210/** `text` cut where `query` (any case) appears, each piece marked as a match or not. */
211export function highlight(text: string, query: string): { text: string; match: boolean }[] {
212 const wanted = query.trim().toLowerCase();
213 if (!wanted) return [{ text, match: false }];
214 const lower = text.toLowerCase();
215 const out: { text: string; match: boolean }[] = [];
216 let at = 0;
217 for (let found = lower.indexOf(wanted); found !== -1; found = lower.indexOf(wanted, at)) {
218 if (found > at) out.push({ text: text.slice(at, found), match: false });
219 out.push({ text: text.slice(found, found + wanted.length), match: true });
220 at = found + wanted.length;
221 }
222 if (at < text.length) out.push({ text: text.slice(at), match: false });
223 return out;
224}
225
226/** A name safe as a file name in an archive, as the API's archive names them. */
227export function fileName(name: string): string {
228 const cleaned = Array.from(name)
229 .map((c) => (/[/\\:*?"<>|]/.test(c) || c.charCodeAt(0) < 32 ? "_" : c))
230 .slice(0, 100)
231 .join("")
232 .trim()
233 .replace(/^\.+|\.+$/g, "");
234 return cleaned || "job";
235}
236
237/** A job's whole log as one text. */
238export function jobText(job: JobLogText): string {
239 if (job.omitted) return "This job's log was left out: the run's logs are larger than one download holds. Download it on its own.\n";
240 return job.chunks.map((chunk) => chunk.text).join("");
241}
242
243/**
244 * The files of a run's log archive: `{n}_{job}.txt` with each job's whole
245 * log, and `{job}/{step}_{step name}.txt` for each of its steps.
246 */
247export function archiveFiles(jobs: JobLogText[]): { path: string; text: string }[] {
248 const files: { path: string; text: string }[] = [];
249 jobs.forEach((job, index) => {
250 const name = fileName(job.name);
251 files.push({ path: `${index + 1}_${name}.txt`, text: jobText(job) });
252 if (job.omitted) return;
253 const steps = [...new Set(job.chunks.map((chunk) => chunk.step))].sort((a, b) => a - b);
254 for (const step of steps) {
255 const title = step === 0 ? "Set up job" : (job.steps.find((s) => s.number === step)?.name ?? `Step ${step}`);
256 const text = job.chunks
257 .filter((chunk) => chunk.step === step)
258 .map((chunk) => chunk.text)
259 .join("");
260 files.push({ path: `${name}/${step}_${fileName(title)}.txt`, text });
261 }
262 });
263 return files;
264}