//! Page: the CI status badge, `GET /{owner}/{repo}/badge.svg`. It shows the state of the newest run
//! on a branch (`?branch=`, default: the default branch), or of one job of that run (`&job=`). The
//! SVG is a flat shields-style image, rendered here with no outside assets.
//!
//! Access is the repo's read access: a private repo a viewer cannot read is a 404, like every other
//! repo page. A repo with no run on the branch still answers 200 with "no status".
use forge_core::ci::{JobState, RunState};
use http::header::{CACHE_CONTROL, CONTENT_TYPE};
use topcoat::Result;
use topcoat::context::{Cx, app_context};
use topcoat::router::request::uri;
use topcoat::router::response::Response;
use topcoat::router::{Body, HeaderValue, StatusCode, path_param, route};
use crate::pages::repo_ctx::load_repo;
use crate::state::AppState;
path_param!(owner);
path_param!(repo);
/// Longest label shown, in characters. A longer job name is cut here.
const MAX_LABEL_CHARS: usize = 64;
/// Height of the badge in pixels.
const HEIGHT: u32 = 20;
/// Padding on each side of a text, in pixels.
const PAD: u32 = 5;
/// Label half colour and the grey for "no status".
const LABEL_COLOR: &str = "#555";
const GREY: &str = "#9f9f9f";
/// What the right-hand half of the badge says.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BadgeStatus {
Passing,
Failing,
Error,
Running,
Pending,
Skipped,
NoStatus,
}
impl BadgeStatus {
/// The word shown on the badge.
pub fn text(self) -> &'static str {
match self {
BadgeStatus::Passing => "passing",
BadgeStatus::Failing => "failing",
BadgeStatus::Error => "error",
BadgeStatus::Running => "running",
BadgeStatus::Pending => "pending",
BadgeStatus::Skipped => "skipped",
BadgeStatus::NoStatus => "no status",
}
}
/// The fill of the right-hand half.
pub fn color(self) -> &'static str {
match self {
BadgeStatus::Passing => "#4c1",
BadgeStatus::Failing => "#e05d44",
BadgeStatus::Error => "#9a5b5b",
BadgeStatus::Running | BadgeStatus::Pending => "#dfb317",
BadgeStatus::Skipped | BadgeStatus::NoStatus => GREY,
}
}
}
/// The badge status of a run. Waiting for approval shows as pending.
pub fn status_of_run(state: RunState) -> BadgeStatus {
match state {
RunState::Success => BadgeStatus::Passing,
RunState::Failure => BadgeStatus::Failing,
RunState::Error | RunState::Cancelled => BadgeStatus::Error,
RunState::Queued | RunState::Running => BadgeStatus::Running,
RunState::WaitingApproval => BadgeStatus::Pending,
}
}
/// The badge status of one job.
pub fn status_of_job(state: JobState) -> BadgeStatus {
match state {
JobState::Success => BadgeStatus::Passing,
JobState::Failure => BadgeStatus::Failing,
JobState::Error | JobState::Cancelled => BadgeStatus::Error,
JobState::Blocked | JobState::Queued | JobState::Running => BadgeStatus::Running,
JobState::Skipped => BadgeStatus::Skipped,
}
}
/// Escapes text for XML element content and attribute values. Control characters, which XML
/// does not allow, become spaces.
pub fn escape_xml(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c if c.is_control() => out.push(' '),
c => out.push(c),
}
}
out
}
/// Width of a text box in pixels: about 6.5 px per character at 11 px Verdana, plus padding.
fn text_box(text: &str) -> u32 {
let chars = u32::try_from(text.chars().count()).unwrap_or(u32::MAX);
chars.saturating_mul(65) / 10 + 2 * PAD
}
/// Renders the flat badge: a grey label half and a coloured status half.
pub fn render_badge(label: &str, status: BadgeStatus) -> String {
let label: String = label.chars().take(MAX_LABEL_CHARS).collect();
let status_text = status.text();
let label_w = text_box(&label);
let status_w = text_box(status_text);
let total = label_w + status_w;
let label_x = label_w / 2;
let status_x = label_w + status_w / 2;
let label = escape_xml(&label);
let aria = format!("{label}: {status_text}");
let aria = escape_xml(&aria);
let status_color = status.color();
format!(
concat!(
r##""##,
),
total = total,
h = HEIGHT,
aria = aria,
label_w = label_w,
status_w = status_w,
label_color = LABEL_COLOR,
status_color = status_color,
label_x = label_x,
status_x = status_x,
label = label,
status_text = status_text,
)
}
/// The query values the badge reads. Empty values count as absent.
#[derive(Default)]
struct BadgeQuery {
branch: Option,
job: Option,
}
fn badge_query(cx: &Cx) -> BadgeQuery {
let mut query = BadgeQuery::default();
let Some(raw) = uri(cx).query() else {
return query;
};
for (key, value) in form_urlencoded::parse(raw.as_bytes()) {
let slot = match key.as_ref() {
"branch" => &mut query.branch,
"job" => &mut query.job,
_ => continue,
};
if !value.is_empty() {
*slot = Some(value.into_owned());
}
}
query
}
/// `GET /{owner}/{repo}/badge.svg`. The repo lookup and read check are the ones every repo page
/// uses, so a private repo the viewer cannot read is a 404.
#[route(GET "/{owner}/{repo}/badge.svg")]
async fn ci_badge(cx: &Cx) -> Result {
let owner = path_param::(cx);
let name = path_param::(cx);
let ctx = load_repo(cx, owner, name).await?;
let state = app_context::(cx);
let query = badge_query(cx);
let branch = query
.branch
.unwrap_or_else(|| ctx.repo.default_branch.clone());
let git_ref = format!("refs/heads/{branch}");
let run = state
.store
.latest_run_on_ref(&ctx.repo.id, &git_ref)
.await?;
let (label, status) = match (run, query.job) {
(None, job) => (
job.unwrap_or_else(|| "ci".to_owned()),
BadgeStatus::NoStatus,
),
(Some(run), None) => ("ci".to_owned(), status_of_run(run.state)),
(Some(run), Some(job)) => {
let jobs = state.store.jobs_for_run(&run.id).await?;
let status = jobs
.iter()
.find(|j| j.name == job)
.map_or(BadgeStatus::NoStatus, |j| status_of_job(j.state));
(job, status)
}
};
Ok(svg_response(render_badge(&label, status)))
}
/// The SVG response. The security headers come from the app-wide layer.
fn svg_response(body: String) -> Response {
let mut res = Response::new(Body::from(body));
*res.status_mut() = StatusCode::OK;
let headers = res.headers_mut();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"));
headers.insert(
CACHE_CONTROL,
HeaderValue::from_static("no-cache, max-age=0"),
);
res
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_markup_and_quotes() {
assert_eq!(
escape_xml(r#"'&'"#),
"<a href="x">'&'</a>"
);
assert_eq!(escape_xml("a\u{1}b"), "a b");
}
#[test]
fn hostile_label_never_reaches_the_markup_raw() {
let svg = render_badge("\"", BadgeStatus::Passing);
assert!(!svg.contains("