Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.