Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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