Skip to content

Commit 05b6c5c

Browse files
sunshowersjclulow
andauthored
github: support ANSI color codes in basic renderer (#63)
Co-authored-by: Joshua M. Clulow <[email protected]>
1 parent 78547b3 commit 05b6c5c

File tree

6 files changed

+197
-7
lines changed

6 files changed

+197
-7
lines changed

.gitignore

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
/target/
1+
/target
22
/config.toml
33
/data.sqlite3
4-
/cache/
5-
/etc/
6-
/var/
4+
/cache
5+
/etc
6+
/var

Cargo.lock

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ members = [
2525
resolver = "2"
2626

2727
[workspace.dependencies]
28+
ansi-to-html = "0.2"
2829
anyhow = "1"
2930
aws-config = "1"
3031
aws-credential-types = "1"
@@ -73,6 +74,7 @@ slog = { version = "2.7", features = [ "release_max_level_debug" ] }
7374
slog-bunyan = "2.4"
7475
slog-term = "2.7"
7576
smf = { git = "https://github.com/illumos/smf-rs.git" }
77+
strip-ansi-escapes = "0.2"
7678
strum = { version = "0.25", features = [ "derive" ] }
7779
tempfile = "3.3"
7880
thiserror = "1"

github/server/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ buildomat-github-database = { path = "../database" }
1414
buildomat-github-hooktypes = { path = "../hooktypes" }
1515
buildomat-sse = { path = "../../sse" }
1616

17+
ansi-to-html = { workspace = true }
1718
anyhow = { workspace = true }
1819
base64 = { workspace = true }
1920
chrono = { workspace = true }
@@ -30,6 +31,7 @@ schemars = { workspace = true }
3031
serde = { workspace = true }
3132
serde_json = { workspace = true }
3233
slog = { workspace = true }
34+
strip-ansi-escapes = { workspace = true }
3335
tempfile = { workspace = true }
3436
thiserror = { workspace = true }
3537
tokio = { workspace = true }

github/server/src/variety/basic.rs

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use futures::StreamExt;
1414
use serde::{Deserialize, Serialize};
1515
#[allow(unused_imports)]
1616
use slog::{debug, error, info, o, trace, warn, Logger};
17+
use std::borrow::Cow;
1718
use std::collections::{HashMap, VecDeque};
1819
use std::sync::Arc;
1920
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
@@ -81,7 +82,7 @@ impl JobEventEx for JobEvent {
8182
* Do the HTML escaping of the payload one canonical way, in the
8283
* server:
8384
*/
84-
html_escape::encode_safe(&self.payload),
85+
encode_payload(&self.payload),
8586
);
8687

8788
EventRow {
@@ -122,6 +123,37 @@ impl JobEventEx for JobEvent {
122123
}
123124
}
124125

126+
fn encode_payload(payload: &str) -> Cow<'_, str> {
127+
/*
128+
* Apply ANSI formatting to the payload after escaping it (we want to
129+
* transmit the corresponding HTML tags over the wire).
130+
*
131+
* One of the cases this does not handle is multi-line color output split
132+
* across several payloads. Doing so is quite tricky, because buildomat
133+
* works with a single bash script and doesn't know when commands are
134+
* completed. Other systems like GitHub Actions (as checked on 2024-09-03)
135+
* don't handle multiline color either, so it's fine to punt on that.
136+
*/
137+
ansi_to_html::convert_with_opts(
138+
payload,
139+
&ansi_to_html::Opts::default()
140+
.four_bit_var_prefix(Some("ansi-".to_string())),
141+
)
142+
.map_or_else(
143+
|_| {
144+
/*
145+
* Invalid ANSI code: only escape HTML in case the conversion to
146+
* ANSI fails. To maintain consistency we use the same logic as
147+
* ansi-to-html: do not escape "/". (There are other differences,
148+
* such as ansi-to-html using decimal escapes while html_escape uses
149+
* hex, but those are immaterial.)
150+
*/
151+
html_escape::encode_quoted_attribute(payload)
152+
},
153+
Cow::Owned,
154+
)
155+
}
156+
125157
#[derive(Debug, Serialize)]
126158
struct EventRow {
127159
task: Option<u32>,
@@ -573,7 +605,16 @@ pub(crate) async fn run(
573605
*/
574606
let mut line =
575607
if console { "|C| " } else { "| " }.to_string();
576-
let mut chars = ev.payload.chars();
608+
609+
/*
610+
* We support ANSI escapes in the log renderer, which means
611+
* that tools will generate ANSI sequences. That doesn't
612+
* work in the GitHub renderer, so we need to strip them out
613+
* entirely.
614+
*/
615+
let payload = strip_ansi_escapes::strip_str(&ev.payload);
616+
let mut chars = payload.chars();
617+
577618
for _ in 0..MAX_LINE_LENGTH {
578619
if let Some(c) = chars.next() {
579620
line.push(c);
@@ -1745,6 +1786,78 @@ pub mod test {
17451786
use super::*;
17461787
use buildomat_github_testdata::*;
17471788

1789+
#[test]
1790+
fn test_encode_payload() {
1791+
let data = &[
1792+
("Hello, world!", "Hello, world!"),
1793+
/*
1794+
* HTML escapes:
1795+
*/
1796+
(
1797+
"2 & 3 < 4 > 5 / 6 ' 7 \" 8",
1798+
"2 &amp; 3 &lt; 4 &gt; 5 / 6 &#39; 7 &quot; 8",
1799+
),
1800+
/*
1801+
* ANSI color codes:
1802+
*/
1803+
(
1804+
/*
1805+
* Basic 16-color example; also tests a bright color (96).
1806+
* (ansi-to-html 0.2.1 claims not to support bright colors, but
1807+
* it actually does.)
1808+
*/
1809+
"\x1b[31mHello, world!\x1b[0m \x1b[96mAnother message\x1b[0m",
1810+
"<span style='color:var(--ansi-red,#a00)'>Hello, world!</span> \
1811+
<span style='color:var(--ansi-bright-cyan,#5ff)'>\
1812+
Another message</span>",
1813+
),
1814+
(
1815+
/*
1816+
* Truecolor, bold, italic, underline, and also with escapes.
1817+
* The second code ("another") does not have a reset, but we
1818+
* want to ensure that we generate closing HTML tags anyway.
1819+
*/
1820+
"\x1b[38;2;255;0;0;1;3;4mTest message\x1b[0m and &/' \
1821+
\x1b[38;2;0;255;0;1;3;4manother",
1822+
"<span style='color:#ff0000'><b><i><u>Test message</u></i></b>\
1823+
</span> and &amp;/&#39; <span style='color:#00ff00'><b><i>\
1824+
<u>another</u></i></b></span>",
1825+
),
1826+
(
1827+
/*
1828+
* Invalid ANSI code "xx"; should be HTML-escaped but the
1829+
* invalid ANSI code should remain as-is. (The second ANSI code
1830+
* is valid, and ansi-to-html should handle it.)
1831+
*/
1832+
"\x1b[xx;2;255;0;0;1;3;4mTest message\x1b[0m and &/' \
1833+
\x1b[38;2;0;255;0;1;3;4manother",
1834+
"\u{1b}[xx;2;255;0;0;1;3;4mTest message and &amp;/&#39; <span \
1835+
style='color:#00ff00'><b><i><u>another</u></i></b></span>",
1836+
),
1837+
(
1838+
/*
1839+
* Invalid ANSI code "9000"; should be HTML-escaped but the
1840+
* invalid ANSI code should remain as-is. (The second ANSI code
1841+
* is valid, but ansi-to-html's current behavior is to error out
1842+
* in this case. This can probably be improved.)
1843+
*/
1844+
"\x1b[9000;2;255;0;0;1;3;4mTest message\x1b[0m and &/' \
1845+
\x1b[38;2;0;255;0;1;3;4manother",
1846+
"\u{1b}[9000;2;255;0;0;1;3;4mTest message\u{1b}[0m and \
1847+
&amp;/&#x27; \u{1b}[38;2;0;255;0;1;3;4manother",
1848+
)
1849+
];
1850+
1851+
for (input, expected) in data {
1852+
let output = encode_payload(input);
1853+
assert_eq!(
1854+
output, *expected,
1855+
"output != expected: input: {:?}",
1856+
input
1857+
);
1858+
}
1859+
}
1860+
17481861
#[test]
17491862
fn basic_parse_basic() -> Result<()> {
17501863
let (path, content, _) = real0();

github/server/www/variety/basic/style.css

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@
22
* Copyright 2024 Oxide Computer Company
33
*/
44

5+
/*
6+
* The "ansi-to-html" crate uses CSS variables when emitting text that uses the
7+
* classic ANSI colour palette. Adjust the default colours to be a little
8+
* darker for more contrast against "s_stdout" and "s_stderr" backgrounds,
9+
* which are both quite light.
10+
*/
11+
:root {
12+
--ansi-black: #000000;
13+
--ansi-red: #b0000f;
14+
--ansi-green: #007000;
15+
--ansi-yellow: #808000;
16+
--ansi-blue: #1d1dc9;
17+
--ansi-magenta: #7027b9;
18+
--ansi-cyan: #0a8080;
19+
--ansi-white: #ffffff;
20+
21+
--ansi-bright-black: #000000;
22+
--ansi-bright-red: #b20f00;
23+
--ansi-bright-green: #557000;
24+
--ansi-bright-yellow: #b44405;
25+
--ansi-bright-blue: #5f55df;
26+
--ansi-bright-magenta: #bf2c90;
27+
--ansi-bright-cyan: #30a0a0;
28+
--ansi-bright-white: #ffffff;
29+
}
30+
531
table.table_output {
632
border: none;
733
}
@@ -19,7 +45,7 @@ tr.s_stdout {
1945
}
2046

2147
tr.s_stderr {
22-
background-color: #ffd9da;
48+
background-color: #f3f3f3;
2349
}
2450

2551
tr.s_task {

0 commit comments

Comments
 (0)