| 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 | |
| 6 | import type { JobLogText } from "@g1t/contracts"; |
| 7 | |
| 8 | import { stripAnsi } from "./ansi.ts"; |
| 9 | |
| 10 | export 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 | */ |
| 17 | export type Line = { kind: LineKind; text: string; at?: string; ansi?: string }; |
| 18 | export 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 `. */ |
| 23 | const 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). */ |
| 26 | export 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. */ |
| 32 | export 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 | |
| 43 | function 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 | |
| 50 | export 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. */ |
| 62 | export 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 | */ |
| 69 | export 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 | |
| 87 | export 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 | */ |
| 96 | export 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. */ |
| 115 | function 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. */ |
| 143 | export 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. */ |
| 148 | export 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. */ |
| 153 | const 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. */ |
| 156 | export 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. */ |
| 171 | export 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 | */ |
| 180 | export type Row = |
| 181 | | { kind: "line"; entry: Numbered } |
| 182 | | { kind: "group"; block: number; title: string; at?: string; open: boolean; size: number }; |
| 183 | |
| 184 | export 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. */ |
| 200 | export 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. */ |
| 206 | export 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. */ |
| 211 | export 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. */ |
| 227 | export 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. */ |
| 238 | export 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 | */ |
| 247 | export 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 | } |