//! Continuous integration on the forge side: schedules runs for pushes, tags, pull requests and //! manual requests (`schedule`), reaps jobs whose runner went silent (`reaper`), and mirrors job //! states into commit statuses (`sync_status`). The workflow file is `.forge/ci.toml`. use std::path::{Path, PathBuf}; use forge_core::Repo; use forge_core::ci::{CiJob, CiRun, JobState, RunState}; use forge_core::store::commit_status::StatusState; use crate::state::AppState; pub mod api; pub mod reaper; pub mod schedule; pub use schedule::{manual_run, on_pull_event, on_ref_update}; /// Longest status description written, in characters. Longer text is cut. const MAX_DESCRIPTION: usize = 140; /// Where the log of job `job_id` is kept: `/ci/logs/.log`. pub fn log_path(data_dir: &Path, job_id: &str) -> PathBuf { data_dir .join("ci") .join("logs") .join(format!("{job_id}.log")) } /// The blob store key of artifact `artifact_id`. pub fn artifact_key(artifact_id: &str) -> String { format!("ci/{artifact_id}") } /// Sets the `ci/` commit status of `job` to match its state. A failed write is logged and /// never fails the caller. pub async fn sync_status(state: &AppState, run: &CiRun, job: &CiJob) { let repo = match state.store.repo_by_id(&run.repo_id).await { Ok(Some(repo)) => repo, Ok(None) => return, Err(e) => { tracing::error!(error = %e, run_id = %run.id, "ci: loading repo for status failed"); return; } }; let (status, description) = status_for(run, job); let url = format!( "{}/{}/{}/ci/jobs/{}", state.config.base_url.trim_end_matches('/'), repo.owner_name, repo.name, job.id ); publish( state, &repo, run, &format!("ci/{}", job.name), status, &description, &url, ) .await; } /// Syncs the status of every job of `run`. Returns whether any job is Queued, so the caller knows /// to wake the runner claims. pub async fn sync_run_statuses(state: &AppState, run: &CiRun) -> forge_core::Result { let jobs = state.store.jobs_for_run(&run.id).await?; let mut queued = false; for job in &jobs { sync_status(state, run, job).await; queued |= matches!(job.state, JobState::Queued); } Ok(queued) } /// Writes one commit status for `run`. The creator is the run's actor, or the repo owner for a /// system run (the status table references users). pub(crate) async fn publish( state: &AppState, repo: &Repo, run: &CiRun, context: &str, status: StatusState, description: &str, target_url: &str, ) { let Some(creator) = status_creator(state, repo, &run.actor_id).await else { tracing::error!(run_id = %run.id, context, "ci: no person to record the status for"); return; }; let description = short(description); if let Err(e) = state .store .set_status( &repo.id, &run.commit_oid, context, status, Some(&description), Some(target_url), &creator, ) .await { tracing::error!(error = %e, run_id = %run.id, context, "ci: writing commit status failed"); } } /// The person a CI status is recorded for: the run's actor, or the repo owner for a system run. /// An organization never acts, so an org owner (or a missing user) falls back to the ghost /// account. `None` only when even the ghost account cannot be read or created. async fn status_creator(state: &AppState, repo: &Repo, actor_id: &str) -> Option { let candidate = if actor_id.is_empty() { repo.owner_id.clone() } else { actor_id.to_owned() }; match state.store.user_by_id(&candidate).await { Ok(Some(user)) if !user.is_org() => Some(candidate), _ => state .store .ensure_ghost_user() .await .ok() .map(|ghost| ghost.id), } } /// The commit status state and description for a job. fn status_for(run: &CiRun, job: &CiJob) -> (StatusState, String) { let message = |fallback: &str| job.message.clone().unwrap_or_else(|| fallback.to_owned()); match job.state { JobState::Blocked => { let why = if matches!(run.state, RunState::WaitingApproval) { "Waiting for approval" } else { "Waiting for other jobs" }; (StatusState::Pending, why.to_owned()) } JobState::Queued => (StatusState::Pending, "Queued".to_owned()), JobState::Running => (StatusState::Pending, "Running".to_owned()), JobState::Success => (StatusState::Success, "Succeeded".to_owned()), JobState::Failure => (StatusState::Failure, "Failed".to_owned()), JobState::Cancelled => (StatusState::Error, message("Cancelled")), JobState::Error => (StatusState::Error, message("Errored")), JobState::Skipped => (StatusState::Error, "skipped".to_owned()), } } /// The first line of `text`, cut to `MAX_DESCRIPTION` characters. fn short(text: &str) -> String { text.lines() .next() .unwrap_or_default() .chars() .take(MAX_DESCRIPTION) .collect() } #[cfg(test)] mod org_tests { use super::*; use forge_core::Visibility; /// A status is recorded for a person. An org actor, or an org owner of a system run, falls /// back to the ghost account. #[tokio::test] async fn an_org_never_is_the_status_creator() { let dir = tempfile::tempdir().unwrap(); let state = crate::test_support::test_state(dir.path()).await; let alice = state .store .create_user("alice", None, "h", false) .await .unwrap(); let org = state.store.create_org("acme", &alice.id).await.unwrap(); let repo = state .store .create_repo(&org.id, "proj", "", Visibility::Private, "main") .await .unwrap(); let ghost = state.store.ensure_ghost_user().await.unwrap(); assert_eq!( status_creator(&state, &repo, &alice.id).await, Some(alice.id.clone()) ); assert_eq!( status_creator(&state, &repo, &org.id).await, Some(ghost.id.clone()) ); assert_eq!(status_creator(&state, &repo, "").await, Some(ghost.id)); } }