Tahinli/forge
public0 watching
★ 0
✨ feat(ci): status badges
c1ec07a4b5e03c20292506600d58d6c1327534dd
Author Ahmet Kaan Gümüş ·
Parent: bfa240d
3 changed files +393 -0
- crates/forge-core/src/store/ci.rs +68 -0
- crates/forge-web/src/i18n/releases_ci.rs +18 -0
- crates/forge-web/src/pages/ci_badge.rs +307 -0
crates/forge-core/src/store/ci.rs| @@ -886,6 +886,22 @@ impl Store { | ||
| 886 | 886 | load_run(&conn, id).await |
| 887 | 887 | } |
| 888 | 888 | |
| 889 | + /// The newest run of a repo on a full ref name such as `refs/heads/main`. Newest is the | |
| 890 | + /// highest run number, which is assigned at creation. | |
| 891 | + pub async fn latest_run_on_ref(&self, repo_id: &str, git_ref: &str) -> Result<Option<CiRun>> { | |
| 892 | + let conn = self.read_conn().await; | |
| 893 | + fetch_opt( | |
| 894 | + &conn, | |
| 895 | + &format!( | |
| 896 | + "SELECT {RUN_COLS} FROM ci_runs WHERE repo_id = ?1 AND git_ref = ?2 \ | |
| 897 | + ORDER BY number DESC LIMIT 1" | |
| 898 | + ), | |
| 899 | + (repo_id.to_string(), git_ref.to_string()), | |
| 900 | + map_run, | |
| 901 | + ) | |
| 902 | + .await | |
| 903 | + } | |
| 904 | + | |
| 889 | 905 | /// One page of a repo's runs, newest first, and the total count. `page` starts at 1 and |
| 890 | 906 | /// `per_page` is capped at 100. |
| 891 | 907 | pub async fn list_runs( |
| @@ -1408,6 +1424,58 @@ mod tests { | ||
| 1408 | 1424 | assert_eq!(page2.iter().map(|r| r.number).collect::<Vec<_>>(), vec![1]); |
| 1409 | 1425 | } |
| 1410 | 1426 | |
| 1427 | + #[tokio::test] | |
| 1428 | + async fn latest_run_on_ref_picks_newest_per_branch() { | |
| 1429 | + let fx = fixture().await; | |
| 1430 | + let jobs = [job("build", &[], &[])]; | |
| 1431 | + assert!( | |
| 1432 | + fx.store | |
| 1433 | + .latest_run_on_ref(&fx.repo.id, "refs/heads/main") | |
| 1434 | + .await | |
| 1435 | + .unwrap() | |
| 1436 | + .is_none() | |
| 1437 | + ); | |
| 1438 | + let main1 = create(&fx.store, &fx.repo.id, &fx.alice.id, &jobs, false).await; | |
| 1439 | + let dev = fx | |
| 1440 | + .store | |
| 1441 | + .create_run( | |
| 1442 | + &fx.repo.id, | |
| 1443 | + "push", | |
| 1444 | + "refs/heads/dev", | |
| 1445 | + "abc123", | |
| 1446 | + None, | |
| 1447 | + &fx.alice.id, | |
| 1448 | + false, | |
| 1449 | + &[], | |
| 1450 | + Some("broken"), | |
| 1451 | + ) | |
| 1452 | + .await | |
| 1453 | + .unwrap(); | |
| 1454 | + let main2 = create(&fx.store, &fx.repo.id, &fx.alice.id, &jobs, false).await; | |
| 1455 | + let latest = fx | |
| 1456 | + .store | |
| 1457 | + .latest_run_on_ref(&fx.repo.id, "refs/heads/main") | |
| 1458 | + .await | |
| 1459 | + .unwrap() | |
| 1460 | + .unwrap(); | |
| 1461 | + assert_eq!(latest.id, main2.id); | |
| 1462 | + assert_ne!(latest.id, main1.id); | |
| 1463 | + let on_dev = fx | |
| 1464 | + .store | |
| 1465 | + .latest_run_on_ref(&fx.repo.id, "refs/heads/dev") | |
| 1466 | + .await | |
| 1467 | + .unwrap() | |
| 1468 | + .unwrap(); | |
| 1469 | + assert_eq!(on_dev.id, dev.id); | |
| 1470 | + assert!( | |
| 1471 | + fx.store | |
| 1472 | + .latest_run_on_ref(&fx.repo.id, "refs/heads/nope") | |
| 1473 | + .await | |
| 1474 | + .unwrap() | |
| 1475 | + .is_none() | |
| 1476 | + ); | |
| 1477 | + } | |
| 1478 | + | |
| 1411 | 1479 | #[tokio::test] |
| 1412 | 1480 | async fn approval_flow() { |
| 1413 | 1481 | let fx = fixture().await; |
crates/forge-web/src/i18n/releases_ci.rs| @@ -55,6 +55,11 @@ pub enum K { | ||
| 55 | 55 | LogTruncated, |
| 56 | 56 | Artifacts, |
| 57 | 57 | |
| 58 | + // CI status badge, on the run list. | |
| 59 | + BadgeHeading, | |
| 60 | + BadgeHelp, | |
| 61 | + BadgeSnippet, | |
| 62 | + | |
| 58 | 63 | // CI state words. |
| 59 | 64 | StateWaitingApproval, |
| 60 | 65 | StateQueued, |
| @@ -220,6 +225,16 @@ pub fn t(lang: Lang, key: K) -> &'static str { | ||
| 220 | 225 | } |
| 221 | 226 | (Artifacts, En) => "Artifacts", |
| 222 | 227 | (Artifacts, Tr) => "Yapıtlar", |
| 228 | + (BadgeHeading, En) => "Status badge", | |
| 229 | + (BadgeHeading, Tr) => "Durum rozeti", | |
| 230 | + (BadgeHelp, En) => { | |
| 231 | + "Embed this image in a README to show the newest run on the branch. It refreshes on every load." | |
| 232 | + } | |
| 233 | + (BadgeHelp, Tr) => { | |
| 234 | + "Bu resmi bir README'ye ekleyin; dalın en son çalıştırmasını gösterir. Her yüklemede yenilenir." | |
| 235 | + } | |
| 236 | + (BadgeSnippet, En) => "Markdown", | |
| 237 | + (BadgeSnippet, Tr) => "Markdown", | |
| 223 | 238 | |
| 224 | 239 | (StateWaitingApproval, En) => "waiting approval", |
| 225 | 240 | (StateWaitingApproval, Tr) => "onay bekliyor", |
| @@ -480,6 +495,9 @@ mod tests { | ||
| 480 | 495 | K::FullLog, |
| 481 | 496 | K::LogTruncated, |
| 482 | 497 | K::Artifacts, |
| 498 | + K::BadgeHeading, | |
| 499 | + K::BadgeHelp, | |
| 500 | + K::BadgeSnippet, | |
| 483 | 501 | K::StateWaitingApproval, |
| 484 | 502 | K::StateQueued, |
| 485 | 503 | K::StateRunning, |
crates/forge-web/src/pages/ci_badge.rs| @@ -0,0 +1,307 @@ | ||
| 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 | + | |
| 8 | +use forge_core::ci::{JobState, RunState}; | |
| 9 | +use http::header::{CACHE_CONTROL, CONTENT_TYPE}; | |
| 10 | +use topcoat::Result; | |
| 11 | +use topcoat::context::{Cx, app_context}; | |
| 12 | +use topcoat::router::request::uri; | |
| 13 | +use topcoat::router::response::Response; | |
| 14 | +use topcoat::router::{Body, HeaderValue, StatusCode, path_param, route}; | |
| 15 | + | |
| 16 | +use crate::pages::repo_ctx::load_repo; | |
| 17 | +use crate::state::AppState; | |
| 18 | + | |
| 19 | +path_param!(owner); | |
| 20 | +path_param!(repo); | |
| 21 | + | |
| 22 | +/// Longest label shown, in characters. A longer job name is cut here. | |
| 23 | +const MAX_LABEL_CHARS: usize = 64; | |
| 24 | +/// Height of the badge in pixels. | |
| 25 | +const HEIGHT: u32 = 20; | |
| 26 | +/// Padding on each side of a text, in pixels. | |
| 27 | +const PAD: u32 = 5; | |
| 28 | +/// Label half colour and the grey for "no status". | |
| 29 | +const LABEL_COLOR: &str = "#555"; | |
| 30 | +const GREY: &str = "#9f9f9f"; | |
| 31 | + | |
| 32 | +/// What the right-hand half of the badge says. | |
| 33 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 34 | +pub enum BadgeStatus { | |
| 35 | + Passing, | |
| 36 | + Failing, | |
| 37 | + Error, | |
| 38 | + Running, | |
| 39 | + Pending, | |
| 40 | + Skipped, | |
| 41 | + NoStatus, | |
| 42 | +} | |
| 43 | + | |
| 44 | +impl 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. | |
| 71 | +pub 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. | |
| 82 | +pub 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. | |
| 94 | +pub 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("<"), | |
| 100 | + '>' => out.push_str(">"), | |
| 101 | + '"' => out.push_str("""), | |
| 102 | + '\'' => out.push_str("'"), | |
| 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. | |
| 111 | +fn 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. | |
| 117 | +pub 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)] | |
| 163 | +struct BadgeQuery { | |
| 164 | + branch: Option<String>, | |
| 165 | + job: Option<String>, | |
| 166 | +} | |
| 167 | + | |
| 168 | +fn 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")] | |
| 189 | +async 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. | |
| 222 | +fn 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)] | |
| 235 | +mod 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 | + "<a href="x">'&'</a>" | |
| 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("<script>alert(1)</script>"")); | |
| 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 | +} |