| 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 | /** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */ |
| 62 | export 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. */ |
| 87 | export 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. */ |
| 101 | export 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. */ |
| 117 | export 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. */ |
| 128 | export 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 | */ |
| 137 | export 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 | } |