forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
11.2 KiB
1//! Page: the CI status badge, `GET /{owner}/{repo}/badge.svg`. It shows the state of the newest run
2//! on a branch (`?branch=`, default: the default branch), or of one job of that run (`&job=`). The
3//! SVG is a flat shields-style image, rendered here with no outside assets.
4//!
5//! Access is the repo's read access: a private repo a viewer cannot read is a 404, like every other
6//! repo page. A repo with no run on the branch still answers 200 with "no status".
7
8use forge_core::ci::{JobState, RunState};
9use http::header::{CACHE_CONTROL, CONTENT_TYPE};
10use topcoat::Result;
11use topcoat::context::{Cx, app_context};
12use topcoat::router::request::uri;
13use topcoat::router::response::Response;
14use topcoat::router::{Body, HeaderValue, StatusCode, path_param, route};
15
16use crate::pages::repo_ctx::load_repo;
17use crate::state::AppState;
18
19path_param!(owner);
20path_param!(repo);
21
22/// Longest label shown, in characters. A longer job name is cut here.
23const MAX_LABEL_CHARS: usize = 64;
24/// Height of the badge in pixels.
25const HEIGHT: u32 = 20;
26/// Padding on each side of a text, in pixels.
27const PAD: u32 = 5;
28/// Label half colour and the grey for "no status".
29const LABEL_COLOR: &str = "#555";
30const GREY: &str = "#9f9f9f";
31
32/// What the right-hand half of the badge says.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum BadgeStatus {
35 Passing,
36 Failing,
37 Error,
38 Running,
39 Pending,
40 Skipped,
41 NoStatus,
42}
43
44impl BadgeStatus {
45 /// The word shown on the badge.
46 pub fn text(self) -> &'static str {
47 match self {
48 BadgeStatus::Passing => "passing",
49 BadgeStatus::Failing => "failing",
50 BadgeStatus::Error => "error",
51 BadgeStatus::Running => "running",
52 BadgeStatus::Pending => "pending",
53 BadgeStatus::Skipped => "skipped",
54 BadgeStatus::NoStatus => "no status",
55 }
56 }
57
58 /// The fill of the right-hand half.
59 pub fn color(self) -> &'static str {
60 match self {
61 BadgeStatus::Passing => "#4c1",
62 BadgeStatus::Failing => "#e05d44",
63 BadgeStatus::Error => "#9a5b5b",
64 BadgeStatus::Running | BadgeStatus::Pending => "#dfb317",
65 BadgeStatus::Skipped | BadgeStatus::NoStatus => GREY,
66 }
67 }
68}
69
70/// The badge status of a run. Waiting for approval shows as pending.
71pub fn status_of_run(state: RunState) -> BadgeStatus {
72 match state {
73 RunState::Success => BadgeStatus::Passing,
74 RunState::Failure => BadgeStatus::Failing,
75 RunState::Error | RunState::Cancelled => BadgeStatus::Error,
76 RunState::Queued | RunState::Running => BadgeStatus::Running,
77 RunState::WaitingApproval => BadgeStatus::Pending,
78 }
79}
80
81/// The badge status of one job.
82pub fn status_of_job(state: JobState) -> BadgeStatus {
83 match state {
84 JobState::Success => BadgeStatus::Passing,
85 JobState::Failure => BadgeStatus::Failing,
86 JobState::Error | JobState::Cancelled => BadgeStatus::Error,
87 JobState::Blocked | JobState::Queued | JobState::Running => BadgeStatus::Running,
88 JobState::Skipped => BadgeStatus::Skipped,
89 }
90}
91
92/// Escapes text for XML element content and attribute values. Control characters, which XML
93/// does not allow, become spaces.
94pub fn escape_xml(text: &str) -> String {
95 let mut out = String::with_capacity(text.len());
96 for c in text.chars() {
97 match c {
98 '&' => out.push_str("&"),
99 '<' => out.push_str("&lt;"),
100 '>' => out.push_str("&gt;"),
101 '"' => out.push_str("&quot;"),
102 '\'' => out.push_str("&apos;"),
103 c if c.is_control() => out.push(' '),
104 c => out.push(c),
105 }
106 }
107 out
108}
109
110/// Width of a text box in pixels: about 6.5 px per character at 11 px Verdana, plus padding.
111fn text_box(text: &str) -> u32 {
112 let chars = u32::try_from(text.chars().count()).unwrap_or(u32::MAX);
113 chars.saturating_mul(65) / 10 + 2 * PAD
114}
115
116/// Renders the flat badge: a grey label half and a coloured status half.
117pub fn render_badge(label: &str, status: BadgeStatus) -> String {
118 let label: String = label.chars().take(MAX_LABEL_CHARS).collect();
119 let status_text = status.text();
120 let label_w = text_box(&label);
121 let status_w = text_box(status_text);
122 let total = label_w + status_w;
123 let label_x = label_w / 2;
124 let status_x = label_w + status_w / 2;
125 let label = escape_xml(&label);
126 let aria = format!("{label}: {status_text}");
127 let aria = escape_xml(&aria);
128 let status_color = status.color();
129 format!(
130 concat!(
131 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="{h}" role="img" aria-label="{aria}">"##,
132 r##"<title>{aria}</title>"##,
133 r##"<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>"##,
134 r##"<clipPath id="r"><rect width="{total}" height="{h}" rx="3" fill="#fff"/></clipPath>"##,
135 r##"<g clip-path="url(#r)">"##,
136 r##"<rect width="{label_w}" height="{h}" fill="{label_color}"/>"##,
137 r##"<rect x="{label_w}" width="{status_w}" height="{h}" fill="{status_color}"/>"##,
138 r##"<rect width="{total}" height="{h}" fill="url(#s)"/>"##,
139 r##"</g>"##,
140 r##"<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">"##,
141 r##"<text x="{label_x}" y="15" fill="#010101" fill-opacity=".3">{label}</text>"##,
142 r##"<text x="{label_x}" y="14">{label}</text>"##,
143 r##"<text x="{status_x}" y="15" fill="#010101" fill-opacity=".3">{status_text}</text>"##,
144 r##"<text x="{status_x}" y="14">{status_text}</text>"##,
145 r##"</g></svg>"##,
146 ),
147 total = total,
148 h = HEIGHT,
149 aria = aria,
150 label_w = label_w,
151 status_w = status_w,
152 label_color = LABEL_COLOR,
153 status_color = status_color,
154 label_x = label_x,
155 status_x = status_x,
156 label = label,
157 status_text = status_text,
158 )
159}
160
161/// The query values the badge reads. Empty values count as absent.
162#[derive(Default)]
163struct BadgeQuery {
164 branch: Option<String>,
165 job: Option<String>,
166}
167
168fn badge_query(cx: &Cx) -> BadgeQuery {
169 let mut query = BadgeQuery::default();
170 let Some(raw) = uri(cx).query() else {
171 return query;
172 };
173 for (key, value) in form_urlencoded::parse(raw.as_bytes()) {
174 let slot = match key.as_ref() {
175 "branch" => &mut query.branch,
176 "job" => &mut query.job,
177 _ => continue,
178 };
179 if !value.is_empty() {
180 *slot = Some(value.into_owned());
181 }
182 }
183 query
184}
185
186/// `GET /{owner}/{repo}/badge.svg`. The repo lookup and read check are the ones every repo page
187/// uses, so a private repo the viewer cannot read is a 404.
188#[route(GET "/{owner}/{repo}/badge.svg")]
189async fn ci_badge(cx: &Cx) -> Result<Response> {
190 let owner = path_param::<Owner>(cx);
191 let name = path_param::<Repo>(cx);
192 let ctx = load_repo(cx, owner, name).await?;
193 let state = app_context::<AppState>(cx);
194 let query = badge_query(cx);
195 let branch = query
196 .branch
197 .unwrap_or_else(|| ctx.repo.default_branch.clone());
198 let git_ref = format!("refs/heads/{branch}");
199 let run = state
200 .store
201 .latest_run_on_ref(&ctx.repo.id, &git_ref)
202 .await?;
203 let (label, status) = match (run, query.job) {
204 (None, job) => (
205 job.unwrap_or_else(|| "ci".to_owned()),
206 BadgeStatus::NoStatus,
207 ),
208 (Some(run), None) => ("ci".to_owned(), status_of_run(run.state)),
209 (Some(run), Some(job)) => {
210 let jobs = state.store.jobs_for_run(&run.id).await?;
211 let status = jobs
212 .iter()
213 .find(|j| j.name == job)
214 .map_or(BadgeStatus::NoStatus, |j| status_of_job(j.state));
215 (job, status)
216 }
217 };
218 Ok(svg_response(render_badge(&label, status)))
219}
220
221/// The SVG response. The security headers come from the app-wide layer.
222fn svg_response(body: String) -> Response {
223 let mut res = Response::new(Body::from(body));
224 *res.status_mut() = StatusCode::OK;
225 let headers = res.headers_mut();
226 headers.insert(CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"));
227 headers.insert(
228 CACHE_CONTROL,
229 HeaderValue::from_static("no-cache, max-age=0"),
230 );
231 res
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn escapes_markup_and_quotes() {
240 assert_eq!(
241 escape_xml(r#"<a href="x">'&'</a>"#),
242 "&lt;a href=&quot;x&quot;&gt;&apos;&amp;&apos;&lt;/a&gt;"
243 );
244 assert_eq!(escape_xml("a\u{1}b"), "a b");
245 }
246
247 #[test]
248 fn hostile_label_never_reaches_the_markup_raw() {
249 let svg = render_badge("<script>alert(1)</script>\"", BadgeStatus::Passing);
250 assert!(!svg.contains("<script"));
251 assert!(svg.contains("&lt;script&gt;alert(1)&lt;/script&gt;&quot;"));
252 }
253
254 #[test]
255 fn long_labels_are_cut() {
256 let long = "x".repeat(500);
257 let svg = render_badge(&long, BadgeStatus::Failing);
258 assert!(svg.contains(&"x".repeat(MAX_LABEL_CHARS)));
259 assert!(!svg.contains(&"x".repeat(MAX_LABEL_CHARS + 1)));
260 }
261
262 #[test]
263 fn every_status_has_its_colour_and_word() {
264 let cases = [
265 (BadgeStatus::Passing, "#4c1", "passing"),
266 (BadgeStatus::Failing, "#e05d44", "failing"),
267 (BadgeStatus::Error, "#9a5b5b", "error"),
268 (BadgeStatus::Running, "#dfb317", "running"),
269 (BadgeStatus::Pending, "#dfb317", "pending"),
270 (BadgeStatus::Skipped, "#9f9f9f", "skipped"),
271 (BadgeStatus::NoStatus, "#9f9f9f", "no status"),
272 ];
273 for (status, color, word) in cases {
274 let svg = render_badge("ci", status);
275 assert!(svg.contains(&format!("fill=\"{color}\"")), "{word}");
276 assert!(svg.contains(&format!(">{word}</text>")), "{word}");
277 assert!(svg.contains("fill=\"#555\""));
278 assert!(svg.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""));
279 }
280 }
281
282 #[test]
283 fn run_and_job_states_map_to_badge_statuses() {
284 assert_eq!(status_of_run(RunState::Success), BadgeStatus::Passing);
285 assert_eq!(status_of_run(RunState::Failure), BadgeStatus::Failing);
286 assert_eq!(status_of_run(RunState::Cancelled), BadgeStatus::Error);
287 assert_eq!(status_of_run(RunState::Error), BadgeStatus::Error);
288 assert_eq!(status_of_run(RunState::Queued), BadgeStatus::Running);
289 assert_eq!(status_of_run(RunState::Running), BadgeStatus::Running);
290 assert_eq!(
291 status_of_run(RunState::WaitingApproval),
292 BadgeStatus::Pending
293 );
294 assert_eq!(status_of_job(JobState::Blocked), BadgeStatus::Running);
295 assert_eq!(status_of_job(JobState::Skipped), BadgeStatus::Skipped);
296 }
297
298 #[test]
299 fn width_grows_with_text() {
300 assert!(text_box("no status") > text_box("ok"));
301 let svg = render_badge("ci", BadgeStatus::Passing);
302 assert!(svg.contains(&format!(
303 "width=\"{}\"",
304 text_box("ci") + text_box("passing")
305 )));
306 }
307}