forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0

✨ feat(ci): status badges

Tahinli/forge

c1ec07a4b5e03c20292506600d58d6c1327534dd

Author Ahmet Kaan Gümüş ·

Parent: bfa240d

Browse files at this commit

✨ feat(ci): status badges

3 changed files +393 -0

crates/forge-core/src/store/ci.rsmodified+68 -0
@@ -886,6 +886,22 @@ impl Store {
886886 load_run(&conn, id).await
887887 }
888888
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+
889905 /// One page of a repo's runs, newest first, and the total count. `page` starts at 1 and
890906 /// `per_page` is capped at 100.
891907 pub async fn list_runs(
@@ -1408,6 +1424,58 @@ mod tests {
14081424 assert_eq!(page2.iter().map(|r| r.number).collect::<Vec<_>>(), vec![1]);
14091425 }
14101426
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+
14111479 #[tokio::test]
14121480 async fn approval_flow() {
14131481 let fx = fixture().await;
crates/forge-web/src/i18n/releases_ci.rsmodified+18 -0
@@ -55,6 +55,11 @@ pub enum K {
5555 LogTruncated,
5656 Artifacts,
5757
58+ // CI status badge, on the run list.
59+ BadgeHeading,
60+ BadgeHelp,
61+ BadgeSnippet,
62+
5863 // CI state words.
5964 StateWaitingApproval,
6065 StateQueued,
@@ -220,6 +225,16 @@ pub fn t(lang: Lang, key: K) -> &'static str {
220225 }
221226 (Artifacts, En) => "Artifacts",
222227 (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",
223238
224239 (StateWaitingApproval, En) => "waiting approval",
225240 (StateWaitingApproval, Tr) => "onay bekliyor",
@@ -480,6 +495,9 @@ mod tests {
480495 K::FullLog,
481496 K::LogTruncated,
482497 K::Artifacts,
498+ K::BadgeHeading,
499+ K::BadgeHelp,
500+ K::BadgeSnippet,
483501 K::StateWaitingApproval,
484502 K::StateQueued,
485503 K::StateRunning,
crates/forge-web/src/pages/ci_badge.rsadded+307 -0
@@ -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("&amp;"),
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.
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+ "&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+}