Skip to content
154 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/** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */
62export function blocks(text: string): Block[] {
63 const out: Block[] = [];
64 let group: Extract<Block, { kind: "group" }> | null = null;
65 let number = 0;
66 // A log ends with a newline; nothing follows the last one.
67 for (const raw of (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n")) {
68 if (raw === "" && number === 0) continue;
69 const line = classify(raw);
70 if (line === "group-end") {
71 group = null;
72 continue;
73 }
74 if ("group" in line) {
75 group = line.at ? { kind: "group", title: line.group, lines: [], at: line.at } : { kind: "group", title: line.group, lines: [] };
76 out.push(group);
77 continue;
78 }
79 number += 1;
80 if (group) group.lines.push({ line, number });
81 else out.push({ kind: "line", line, number });
82 }
83 return out;
84}
85
86/** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */
87export function searchLog(text: string, query: string): { line: Line; number: number }[] {
88 const wanted = query.trim().toLowerCase();
89 if (!wanted) return [];
90 const found: { line: Line; number: number }[] = [];
91 for (const block of blocks(text)) {
92 const lines = block.kind === "line" ? [{ line: block.line, number: block.number }] : block.lines;
93 for (const entry of lines) {
94 if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry);
95 }
96 }
97 return found;
98}
99
100/** `text` cut where `query` (any case) appears, each piece marked as a match or not. */
101export function highlight(text: string, query: string): { text: string; match: boolean }[] {
102 const wanted = query.trim().toLowerCase();
103 if (!wanted) return [{ text, match: false }];
104 const lower = text.toLowerCase();
105 const out: { text: string; match: boolean }[] = [];
106 let at = 0;
107 for (let found = lower.indexOf(wanted); found !== -1; found = lower.indexOf(wanted, at)) {
108 if (found > at) out.push({ text: text.slice(at, found), match: false });
109 out.push({ text: text.slice(found, found + wanted.length), match: true });
110 at = found + wanted.length;
111 }
112 if (at < text.length) out.push({ text: text.slice(at), match: false });
113 return out;
114}
115
116/** A name safe as a file name in an archive, as the API's archive names them. */
117export function fileName(name: string): string {
118 const cleaned = Array.from(name)
119 .map((c) => (/[/\\:*?"<>|]/.test(c) || c.charCodeAt(0) < 32 ? "_" : c))
120 .slice(0, 100)
121 .join("")
122 .trim()
123 .replace(/^\.+|\.+$/g, "");
124 return cleaned || "job";
125}
126
127/** A job's whole log as one text. */
128export function jobText(job: JobLogText): string {
129 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";
130 return job.chunks.map((chunk) => chunk.text).join("");
131}
132
133/**
134 * The files of a run's log archive: `{n}_{job}.txt` with each job's whole
135 * log, and `{job}/{step}_{step name}.txt` for each of its steps.
136 */
137export function archiveFiles(jobs: JobLogText[]): { path: string; text: string }[] {
138 const files: { path: string; text: string }[] = [];
139 jobs.forEach((job, index) => {
140 const name = fileName(job.name);
141 files.push({ path: `${index + 1}_${name}.txt`, text: jobText(job) });
142 if (job.omitted) return;
143 const steps = [...new Set(job.chunks.map((chunk) => chunk.step))].sort((a, b) => a - b);
144 for (const step of steps) {
145 const title = step === 0 ? "Set up job" : (job.steps.find((s) => s.number === step)?.name ?? `Step ${step}`);
146 const text = job.chunks
147 .filter((chunk) => chunk.step === step)
148 .map((chunk) => chunk.text)
149 .join("");
150 files.push({ path: `${name}/${step}_${fileName(title)}.txt`, text });
151 }
152 });
153 return files;
154}