| 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 | export type LineKind = "text" | "error" | "warning" | "notice" | "debug" | "command"; |
| 9 | export type Line = { kind: LineKind; text: string }; |
| 10 | export type Block = |
| 11 | | { kind: "line"; line: Line; number: number } |
| 12 | | { kind: "group"; title: string; lines: { line: Line; number: number }[] }; |
| 13 | |
| 14 | export function classify(raw: string): Line | "group-end" | { group: string } { |
| 15 | if (raw.startsWith("##[group]")) return { group: raw.slice(9) }; |
| 16 | if (raw.startsWith("##[endgroup]")) return "group-end"; |
| 17 | for (const kind of ["error", "warning", "notice", "debug"] as const) { |
| 18 | if (raw.startsWith(`##[${kind}]`)) return { kind, text: raw.slice(kind.length + 4) }; |
| 19 | } |
| 20 | if (raw.startsWith("[command]")) return { kind: "command", text: raw.slice(9) }; |
| 21 | return { kind: "text", text: raw }; |
| 22 | } |
| 23 | |
| 24 | /** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */ |
| 25 | export function blocks(text: string): Block[] { |
| 26 | const out: Block[] = []; |
| 27 | let group: Extract<Block, { kind: "group" }> | null = null; |
| 28 | let number = 0; |
| 29 | for (const raw of text.split("\n")) { |
| 30 | if (raw === "" && number === 0) continue; |
| 31 | const line = classify(raw); |
| 32 | if (line === "group-end") { |
| 33 | group = null; |
| 34 | continue; |
| 35 | } |
| 36 | if ("group" in line) { |
| 37 | group = { kind: "group", title: line.group, lines: [] }; |
| 38 | out.push(group); |
| 39 | continue; |
| 40 | } |
| 41 | number += 1; |
| 42 | if (group) group.lines.push({ line, number }); |
| 43 | else out.push({ kind: "line", line, number }); |
| 44 | } |
| 45 | return out; |
| 46 | } |
| 47 | |
| 48 | /** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */ |
| 49 | export function searchLog(text: string, query: string): { line: Line; number: number }[] { |
| 50 | const wanted = query.trim().toLowerCase(); |
| 51 | if (!wanted) return []; |
| 52 | const found: { line: Line; number: number }[] = []; |
| 53 | for (const block of blocks(text)) { |
| 54 | const lines = block.kind === "line" ? [{ line: block.line, number: block.number }] : block.lines; |
| 55 | for (const entry of lines) { |
| 56 | if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry); |
| 57 | } |
| 58 | } |
| 59 | return found; |
| 60 | } |
| 61 | |
| 62 | /** `text` cut where `query` (any case) appears, each piece marked as a match or not. */ |
| 63 | export function highlight(text: string, query: string): { text: string; match: boolean }[] { |
| 64 | const wanted = query.trim().toLowerCase(); |
| 65 | if (!wanted) return [{ text, match: false }]; |
| 66 | const lower = text.toLowerCase(); |
| 67 | const out: { text: string; match: boolean }[] = []; |
| 68 | let at = 0; |
| 69 | for (let found = lower.indexOf(wanted); found !== -1; found = lower.indexOf(wanted, at)) { |
| 70 | if (found > at) out.push({ text: text.slice(at, found), match: false }); |
| 71 | out.push({ text: text.slice(found, found + wanted.length), match: true }); |
| 72 | at = found + wanted.length; |
| 73 | } |
| 74 | if (at < text.length) out.push({ text: text.slice(at), match: false }); |
| 75 | return out; |
| 76 | } |
| 77 | |
| 78 | /** A name safe as a file name in an archive, as the API's archive names them. */ |
| 79 | export function fileName(name: string): string { |
| 80 | const cleaned = Array.from(name) |
| 81 | .map((c) => (/[/\\:*?"<>|]/.test(c) || c.charCodeAt(0) < 32 ? "_" : c)) |
| 82 | .slice(0, 100) |
| 83 | .join("") |
| 84 | .trim() |
| 85 | .replace(/^\.+|\.+$/g, ""); |
| 86 | return cleaned || "job"; |
| 87 | } |
| 88 | |
| 89 | /** A job's whole log as one text. */ |
| 90 | export function jobText(job: JobLogText): string { |
| 91 | 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"; |
| 92 | return job.chunks.map((chunk) => chunk.text).join(""); |
| 93 | } |
| 94 | |
| 95 | /** |
| 96 | * The files of a run's log archive: `{n}_{job}.txt` with each job's whole |
| 97 | * log, and `{job}/{step}_{step name}.txt` for each of its steps. |
| 98 | */ |
| 99 | export function archiveFiles(jobs: JobLogText[]): { path: string; text: string }[] { |
| 100 | const files: { path: string; text: string }[] = []; |
| 101 | jobs.forEach((job, index) => { |
| 102 | const name = fileName(job.name); |
| 103 | files.push({ path: `${index + 1}_${name}.txt`, text: jobText(job) }); |
| 104 | if (job.omitted) return; |
| 105 | const steps = [...new Set(job.chunks.map((chunk) => chunk.step))].sort((a, b) => a - b); |
| 106 | for (const step of steps) { |
| 107 | const title = step === 0 ? "Set up job" : (job.steps.find((s) => s.number === step)?.name ?? `Step ${step}`); |
| 108 | const text = job.chunks |
| 109 | .filter((chunk) => chunk.step === step) |
| 110 | .map((chunk) => chunk.text) |
| 111 | .join(""); |
| 112 | files.push({ path: `${name}/${step}_${fileName(title)}.txt`, text }); |
| 113 | } |
| 114 | }); |
| 115 | return files; |
| 116 | } |