//! Pages: CI runs (`/{owner}/{repo}/ci` and `/{owner}/{repo}/ci/runs/{number}`). Anyone who can read //! the repo sees its runs, their jobs and the config error of a run. Starting a manual run, cancelling, //! re-running and approving need Write, and the buttons are shown only to writers. Anyone else gets a //! 404 for a private repo and a refusal for a change. Every change notifies the job waker and syncs //! the commit status of each job it changed. use std::collections::HashMap; use forge_core::ci::{CiJob, CiRun, RunState}; use forge_core::{Action, Denied, User, authorize}; use forge_git::browse::{RefInfo, RefKind}; use serde::Deserialize; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, not_found, see_other}; use topcoat::router::request::uri; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, component, view}; use crate::auth::{current_user, require_csrf}; use crate::i18n::{ self, Lang, releases_ci::{self as g, K}, }; use crate::pages::refs::load_refs; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; use crate::state::AppState; use crate::views::{alert, enc, now_unix, relative_time, shell, short_oid}; path_param!(owner); path_param!(repo); path_param!(run_number); /// Runs per list page. const PER_PAGE: u32 = 25; /// Highest accepted `?page=` value. Anything else falls back to page 1. const MAX_PAGE: u32 = 1000; /// Notice words (the only text a `?notice=` query can show), their text and whether they are errors. const NOTICES: [(&str, K, bool); 8] = [ ("started", K::CiNoticeStarted, false), ("cancelled", K::CiNoticeCancelled, false), ("approved", K::CiNoticeApproved, false), ("rerun", K::CiNoticeRerun, false), ("finished", K::CiNoticeFinished, true), ("not-waiting", K::CiNoticeNotWaiting, true), ("no-branch", K::CiNoticeNoBranch, true), ("no-manual", K::CiNoticeNoManual, true), ]; #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, } #[derive(Deserialize)] struct ManualForm { #[serde(default)] csrf: String, #[serde(default)] branch: String, } /// The viewer, if signed in. fn viewer_user(ctx: &RepoCtx) -> Option<&User> { ctx.viewer.as_ref().map(|(user, _)| user) } /// Whether the viewer may change CI runs of this repo. Archived repos refuse. fn can_write(ctx: &RepoCtx) -> bool { authorize( viewer_user(ctx), &ctx.repo, ctx.role, ctx.org_role, Action::Write, ) .is_ok() } /// Write access, or the refusal that matches why it is missing. fn require_write(ctx: &RepoCtx) -> Result<()> { match authorize( viewer_user(ctx), &ctx.repo, ctx.role, ctx.org_role, Action::Write, ) { Ok(()) => Ok(()), Err(Denied::NotFound) => Err(not_found().into()), Err(Denied::Forbidden) => Err(forbidden().into()), } } fn title_of(ctx: &RepoCtx) -> String { format!("{}/{}", ctx.repo.owner_name, ctx.repo.name) } fn ci_href(title: &str) -> String { format!("/{title}/ci") } /// The page of run `number`. pub fn run_href(title: &str, number: i64) -> String { format!("/{title}/ci/runs/{number}") } /// The page of one job. pub fn job_href(title: &str, job_id: &str) -> String { format!("/{title}/ci/jobs/{}", enc(job_id)) } /// The notice from `?notice=`. Anything else is ignored. fn notice_from_query(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> { let query = uri(cx).query()?; let word = form_urlencoded::parse(query.as_bytes()) .find(|(key, _)| key == "notice") .map(|(_, value)| value.into_owned())?; NOTICES .iter() .find(|(name, _, _)| *name == word) .map(|(_, key, is_error)| (g::t(lang, *key), *is_error)) } fn notice_redirect(href: &str, word: &str) -> SeeOther { see_other(format!("{href}?notice={word}")) } /// The page number from `?page=`. Anything unreadable or out of range is page 1. fn page_from_query(cx: &Cx) -> u32 { uri(cx) .query() .and_then(|query| { form_urlencoded::parse(query.as_bytes()) .find(|(key, _)| key == "page") .and_then(|(_, value)| value.parse::().ok()) }) .filter(|page| (1..=MAX_PAGE).contains(page)) .unwrap_or(1) } /// Unix seconds of an RFC 3339 stamp, if it parses. fn unix_of(stamp: &str) -> Option { OffsetDateTime::parse(stamp, &Rfc3339) .ok() .map(|at| at.unix_timestamp()) } /// "3 minutes ago" for a stamp. An unreadable stamp is shown as it is stored. fn age_text(lang: Lang, stamp: &str) -> String { match unix_of(stamp) { Some(unix) => relative_time(lang, unix, now_unix()), None => stamp.to_owned(), } } /// "1m 5s", or "" when the job never started. A running job counts up to now. fn duration_text(lang: Lang, started: Option<&str>, finished: Option<&str>) -> String { let Some(start) = started.and_then(unix_of) else { return String::new(); }; let end = finished.and_then(unix_of).unwrap_or_else(now_unix); let secs = (end - start).max(0); if secs >= 60 { g::duration_minutes(lang, secs / 60, secs % 60) } else { g::duration_seconds(lang, secs) } } /// Words for a state name, as the pages show it: `waiting_approval` reads "waiting approval". /// A state this build does not know is shown with its underscores as spaces. pub fn state_text(lang: Lang, state: &str) -> String { let key = match state { "waiting_approval" => K::StateWaitingApproval, "queued" => K::StateQueued, "running" => K::StateRunning, "success" => K::StateSuccess, "failure" => K::StateFailure, "cancelled" => K::StateCancelled, "error" => K::StateError, "blocked" => K::StateBlocked, "skipped" => K::StateSkipped, other => return other.replace('_', " "), }; g::t(lang, key).to_owned() } /// Whether a run has finished. Waiting for approval is not finished. pub fn run_finished(state: &RunState) -> bool { matches!( state, RunState::Success | RunState::Failure | RunState::Cancelled | RunState::Error ) } /// Branch names of the repo, the default branch first. fn branch_choices(refs: &[RefInfo], default_branch: &str) -> Vec { let mut names: Vec = refs .iter() .filter(|r| r.kind == RefKind::Branch) .map(|r| r.name.clone()) .collect(); names.sort(); names.dedup(); if let Some(pos) = names.iter().position(|name| name == default_branch) { let default = names.remove(pos); names.insert(0, default); } names } /// The run with `number` in this repo, or 404. async fn run_of(state: &AppState, ctx: &RepoCtx, number: &str) -> Result { let Ok(number) = number.parse::() else { return Err(not_found().into()); }; match state.store.run_by_number(&ctx.repo.id, number).await? { Some(run) => Ok(run), None => Err(not_found().into()), } } /// Wakes the job queue and syncs the commit status of every job whose state changed since `before`. /// A job missing from `before` (a fresh run) counts as changed. async fn after_change(state: &AppState, run_id: &str, before: &[CiJob]) -> Result<()> { state.ci_waker.notify_one(); let Some(run) = state.store.run_by_id(run_id).await? else { return Ok(()); }; for job in state.store.jobs_for_run(run_id).await? { let changed = before .iter() .find(|old| old.id == job.id) .is_none_or(|old| old.state != job.state); if changed { crate::ci::sync_status(state, &run, &job).await; } } Ok(()) } /// One run in the list, with its values already shown as text. struct RunRow { href: String, number: i64, state: String, state_class: String, trigger: String, git_ref: String, commit: String, actor: String, when: String, } /// The run-list form that starts a manual run. struct ManualRun { action: String, csrf: String, branches: Vec, } /// A job row on the run page. struct JobRow { href: String, name: String, state: String, state_class: String, duration: String, runner: String, message: String, } /// The run page's header values. struct RunView { number: i64, state: String, state_class: String, trigger: String, git_ref: String, commit: String, actor: String, when: String, config_error: Option, action_base: String, can_approve: bool, can_cancel: bool, } /// Maps each user id in `ids` to a display name. A user that no longer exists shows as "deleted user". async fn usernames( state: &AppState, lang: Lang, ids: impl Iterator, ) -> Result> { let mut names = HashMap::new(); for id in ids { if names.contains_key(id) { continue; } let name = match state.store.user_by_id(id).await? { Some(user) => user.username, None => g::t(lang, K::DeletedUser).to_owned(), }; names.insert(id.to_owned(), name); } Ok(names) } /// A small form that POSTs the CSRF token to `action`, with one button. #[component] async fn action_button(action: String, csrf: String, label: &'static str) -> Result { Ok(view! {
}) } /// The run list body: the manual run form for writers, the runs, and the pager. #[component] async fn runs_body( head: RepoHead, lang: Lang, notice: Option<(&'static str, bool)>, manual: Option, rows: Vec, newer_href: Option, older_href: Option, page_label: String, ) -> Result { Ok(view! { repo_head(head: head) if let Some((text, is_error)) = notice { if is_error { alert(message: text) } else {
(text)
} } if let Some(form) = manual {

(g::t(lang, K::RunWorkflow))

(g::t(lang, K::RunWorkflowHelp))

}

(g::t(lang, K::Runs))

if rows.is_empty() {

(g::t(lang, K::NoRunsYet))

} else {
for row in rows.iter() { }
(g::t(lang, K::ColRun)) (g::t(lang, K::ColState)) (g::t(lang, K::ColTrigger)) (g::t(lang, K::ColRef)) (g::t(lang, K::ColCommit)) (g::t(lang, K::ColActor)) (g::t(lang, K::ColAge))
"#" (row.number) (row.state.as_str()) (row.trigger.as_str()) (row.git_ref.as_str()) (row.commit.as_str()) (row.actor.as_str()) (row.when.as_str())
}
}) } /// Lists the repo's runs, newest first, 25 to a page. The manual run form is for writers only. #[page("/{owner}/{repo}/ci")] async fn runs_list_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let lang = i18n::lang(cx, viewer_user(&ctx)); let state = app_context::(cx); let page = page_from_query(cx); let (runs, total) = state.store.list_runs(&ctx.repo.id, page, PER_PAGE).await?; let names = usernames(state, lang, runs.iter().map(|run| run.actor_id.as_str())).await?; let rows: Vec = runs .iter() .map(|run| RunRow { href: run_href(&title_of(&ctx), run.number), number: run.number, state: state_text(lang, run.state.as_str()), state_class: format!("ci-state ci-{}", run.state.as_str()), trigger: run.trigger.clone(), git_ref: run.git_ref.clone(), commit: short_oid(&run.commit_oid).to_owned(), actor: names.get(&run.actor_id).cloned().unwrap_or_default(), when: age_text(lang, &run.created_at), }) .collect(); let last_page = total.div_ceil(u64::from(PER_PAGE)).max(1); let base = ci_href(&title_of(&ctx)); let newer_href = (page > 1).then(|| format!("{base}?page={}", page - 1)); let older_href = (u64::from(page) < last_page).then(|| format!("{base}?page={}", page + 1)); let page_label = g::page_label(lang, page); let manual = if can_write(&ctx) { let csrf = match current_user(cx).await? { Some((_, session)) => session.csrf_token.clone(), None => String::new(), }; let refs = load_refs(ctx.path.clone()).await?; Some(ManualRun { action: base.clone(), csrf, branches: branch_choices(&refs, &ctx.repo.default_branch), }) } else { None }; let notice = notice_from_query(cx, lang); let head = ctx.head(state, "ci").await?; let title = format!("{} \u{b7} CI", title_of(&ctx)); Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), runs_body(head: head, lang: lang, notice: notice, manual: manual, rows: rows, newer_href: newer_href, older_href: older_href, page_label: page_label)) }) } /// Starts a manual run on a branch. Writers only. The branch must be one of the repo's branches. #[route(POST "/{owner}/{repo}/ci")] async fn manual_run_post(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; require_write(&ctx)?; let state = app_context::(cx); let title = title_of(&ctx); let branch = form.branch.trim(); let refs = load_refs(ctx.path.clone()).await?; if !branch_choices(&refs, &ctx.repo.default_branch) .iter() .any(|choice| choice == branch) { return Ok(notice_redirect(&ci_href(&title), "no-branch")); } let run = match crate::ci::manual_run(state, &ctx.repo, branch, &user.id).await { Ok(run) => run, Err(forge_core::Error::Invalid(_)) => { return Ok(notice_redirect(&ci_href(&title), "no-manual")); } Err(e) => return Err(e.into()), }; after_change(state, &run.id, &[]).await?; Ok(see_other(run_href(&title, run.number))) } /// The header values of a run for its page. async fn run_view( state: &AppState, ctx: &RepoCtx, run: &CiRun, writer: bool, lang: Lang, ) -> Result { let names = usernames(state, lang, std::iter::once(run.actor_id.as_str())).await?; let title = title_of(ctx); let action_base = run_href(&title, run.number); Ok(RunView { number: run.number, state: state_text(lang, run.state.as_str()), state_class: format!("ci-state ci-{}", run.state.as_str()), trigger: run.trigger.clone(), git_ref: run.git_ref.clone(), commit: run.commit_oid.clone(), actor: names.get(&run.actor_id).cloned().unwrap_or_default(), when: age_text(lang, &run.created_at), config_error: run.config_error.clone(), can_approve: writer && run.state == RunState::WaitingApproval, can_cancel: writer && !run_finished(&run.state), action_base, }) } /// The job rows of a run page. Runner names come from the runner list. fn job_rows( title: &str, jobs: &[CiJob], runners: &HashMap, lang: Lang, ) -> Vec { jobs.iter() .map(|job| JobRow { href: job_href(title, &job.id), name: job.name.clone(), state: state_text(lang, job.state.as_str()), state_class: format!("ci-state ci-{}", job.state.as_str()), duration: duration_text(lang, job.started_at.as_deref(), job.finished_at.as_deref()), runner: job .runner_id .as_ref() .and_then(|id| runners.get(id).cloned()) .unwrap_or_default(), message: job.message.clone().unwrap_or_default(), }) .collect() } /// The run page body: the header, the buttons for writers, the config error, and the jobs. #[component] async fn run_body( head: RepoHead, lang: Lang, notice: Option<(&'static str, bool)>, run: RunView, jobs: Vec, writer_csrf: Option, refresh: bool, ) -> Result { Ok(view! { repo_head(head: head) if refresh { } if let Some((text, is_error)) = notice { if is_error { alert(message: text) } else {
(text)
} }

(g::t(lang, K::RunNumber)) (run.number)

(run.state.as_str()) " \u{b7} " (run.trigger.as_str()) " \u{b7} " (run.git_ref.as_str()) " \u{b7} " (short_oid(&run.commit).to_owned()) if !run.actor.is_empty() { " \u{b7} " (run.actor.as_str()) } " \u{b7} " (run.when.as_str())

if let Some(message) = run.config_error.as_ref() {
(message.as_str())
} if let Some(csrf) = writer_csrf {
if run.can_approve { action_button(action: format!("{}/approve", run.action_base), csrf: csrf.clone(), label: g::t(lang, K::ApproveAndRun)) } if run.can_cancel { action_button(action: format!("{}/cancel", run.action_base), csrf: csrf.clone(), label: g::t(lang, K::Cancel)) } action_button(action: format!("{}/rerun", run.action_base), csrf: csrf, label: g::t(lang, K::Rerun))
}

(g::t(lang, K::Jobs))

if jobs.is_empty() {

(g::t(lang, K::NoJobs))

} else {
for job in jobs.iter() { }
(g::t(lang, K::ColJob)) (g::t(lang, K::ColState)) (g::t(lang, K::ColDuration)) (g::t(lang, K::ColRunner)) (g::t(lang, K::ColMessage))
(job.name.as_str()) (job.state.as_str()) (job.duration.as_str()) (job.runner.as_str()) (job.message.as_str())
}
}) } /// One run: its state, the buttons for writers, the config error and its jobs. #[page("/{owner}/{repo}/ci/runs/{run_number}")] async fn run_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let number = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, name).await?; let lang = i18n::lang(cx, viewer_user(&ctx)); let state = app_context::(cx); let run = run_of(state, &ctx, &number).await?; let writer = can_write(&ctx); let writer_csrf = if writer { match current_user(cx).await? { Some((_, session)) => Some(session.csrf_token.clone()), None => None, } } else { None }; let jobs = state.store.jobs_for_run(&run.id).await?; let runners: HashMap = state .store .list_runners() .await? .into_iter() .map(|runner| (runner.id, runner.name)) .collect(); let title = title_of(&ctx); let rows = job_rows(&title, &jobs, &runners, lang); let refresh = !run_finished(&run.state) && run.state != RunState::WaitingApproval; let notice = notice_from_query(cx, lang); let header = run_view(state, &ctx, &run, writer, lang).await?; let head = ctx.head(state, "ci").await?; let page_title = format!("{title} \u{b7} CI #{}", run.number); Ok(view! { shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), run_body(head: head, lang: lang, notice: notice, run: header, jobs: rows, writer_csrf: writer_csrf, refresh: refresh)) }) } /// Cancels a run that has not finished. Queued and blocked jobs are cancelled at once; running jobs /// get a cancel request that their runner sees on its next heartbeat. #[route(POST "/{owner}/{repo}/ci/runs/{run_number}/cancel")] async fn run_cancel(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let number = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, name).await?; require_csrf(cx, &form.csrf).await?; require_write(&ctx)?; let state = app_context::(cx); let run = run_of(state, &ctx, &number).await?; let href = run_href(&title_of(&ctx), run.number); if run_finished(&run.state) { return Ok(notice_redirect(&href, "finished")); } let before = state.store.jobs_for_run(&run.id).await?; state.store.request_cancel(&run.id).await?; after_change(state, &run.id, &before).await?; Ok(notice_redirect(&href, "cancelled")) } /// Approves a run that waits for approval: its jobs are queued. Anything else is refused. #[route(POST "/{owner}/{repo}/ci/runs/{run_number}/approve")] async fn run_approve(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let number = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, name).await?; require_csrf(cx, &form.csrf).await?; require_write(&ctx)?; let state = app_context::(cx); let run = run_of(state, &ctx, &number).await?; let href = run_href(&title_of(&ctx), run.number); if run.state != RunState::WaitingApproval { return Ok(notice_redirect(&href, "not-waiting")); } let before = state.store.jobs_for_run(&run.id).await?; state.store.approve_run(&run.id).await?; after_change(state, &run.id, &before).await?; Ok(notice_redirect(&href, "approved")) } /// Starts a new run with the same commit, ref, trigger and job specs, then opens it. #[route(POST "/{owner}/{repo}/ci/runs/{run_number}/rerun")] async fn run_rerun(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let number = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; require_write(&ctx)?; let state = app_context::(cx); let run = run_of(state, &ctx, &number).await?; let new_run = state.store.rerun(&run.id, &user.id).await?; after_change(state, &new_run.id, &[]).await?; Ok(notice_redirect( &run_href(&title_of(&ctx), new_run.number), "rerun", )) } #[cfg(test)] mod tests { use super::*; #[test] fn durations_read_well() { assert_eq!( duration_text( Lang::En, Some("2026-10-01T10:00:00.000000000Z"), Some("2026-10-01T10:01:05.000000000Z") ), "1m 5s" ); assert_eq!( duration_text( Lang::En, Some("2026-10-01T10:00:00.000000000Z"), Some("2026-10-01T10:00:09.000000000Z") ), "9s" ); assert_eq!(duration_text(Lang::En, None, None), ""); } #[test] fn state_names_read_as_words() { assert_eq!(state_text(Lang::En, "waiting_approval"), "waiting approval"); assert_eq!(state_text(Lang::En, "success"), "success"); assert_eq!(state_text(Lang::Tr, "waiting_approval"), "onay bekliyor"); assert_eq!(state_text(Lang::Tr, "queued"), "sırada"); } #[test] fn finished_runs_are_the_terminal_ones() { assert!(run_finished(&RunState::Success)); assert!(run_finished(&RunState::Cancelled)); assert!(!run_finished(&RunState::WaitingApproval)); assert!(!run_finished(&RunState::Running)); } #[test] fn default_branch_comes_first() { let refs = vec![ RefInfo { name: "dev".into(), full_name: "refs/heads/dev".into(), target: "a".into(), kind: RefKind::Branch, }, RefInfo { name: "main".into(), full_name: "refs/heads/main".into(), target: "b".into(), kind: RefKind::Branch, }, RefInfo { name: "v1".into(), full_name: "refs/tags/v1".into(), target: "c".into(), kind: RefKind::Tag, }, ]; assert_eq!(branch_choices(&refs, "main"), vec!["main", "dev"]); } }