Skip to content
116 linesCodeBlameRaw

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
6import type { JobLogText } from "@g1t/contracts";
7
8export type LineKind = "text" | "error" | "warning" | "notice" | "debug" | "command";
9export type Line = { kind: LineKind; text: string };
10export type Block =
11 | { kind: "line"; line: Line; number: number }
12 | { kind: "group"; title: string; lines: { line: Line; number: number }[] };
13
14export 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. */
25export 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. */
49export 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. */
63export 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. */
79export 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. */
90export 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 */
99export 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.