AkurAI Build
Menu

Public repository / b9bf8c65e7cff19e76ae78f73b05d66f6a1b4729

AkurAI-Build / src/statuses.rs

Latest change e023668b5652d0489e44a3b96fb4b0bd5c1f13ea - Commit statuses API: per-SHA job statuses and rollup from runs, branch badges (OLI-269) by Ólafur Búi Ólafsson

//! Commit statuses: the per-SHA view of run and job results. Statuses are
//! derived from the runs/jobs tables, so they cannot drift from the pipeline.

use std::collections::BTreeMap;

use anyhow::Result;
use serde::Serialize;

use crate::db::Database;

/// Most SHAs a single batch lookup accepts.
pub const MAX_BATCH_SHAS: usize = 100;
const RUN_CONTEXT: &str = "run";

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum State {
    Pending,
    Success,
    Failure,
    Error,
    Skipped,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Rollup {
    None,
    Pending,
    Success,
    Failure,
}

#[derive(Clone, Debug, Serialize)]
pub struct CommitStatus {
    pub context: String,
    pub state: State,
    pub run_id: i64,
    pub git_ref: String,
    pub created_at: i64,
    pub finished_at: Option<i64>,
}

#[derive(Clone, Debug, Serialize)]
pub struct CombinedStatus {
    pub sha: String,
    pub state: Rollup,
    pub statuses: Vec<CommitStatus>,
}

pub fn state_of(pipeline_status: &str) -> State {
    match pipeline_status {
        "succeeded" => State::Success,
        "failed" => State::Failure,
        "skipped" => State::Skipped,
        "canceled" | "interrupted" => State::Error,
        _ => State::Pending,
    }
}

pub fn rollup(statuses: &[CommitStatus]) -> Rollup {
    let mut result = Rollup::None;
    for status in statuses {
        match status.state {
            State::Failure | State::Error => return Rollup::Failure,
            State::Pending => result = Rollup::Pending,
            State::Success if result == Rollup::None => result = Rollup::Success,
            State::Success | State::Skipped => {}
        }
    }
    result
}

/// One status per job name, taken from the latest run of the commit; a run
/// that has no jobs yet reports as a single `run` status.
pub fn combined(database: &Database, repository: &str, sha: &str) -> Result<CombinedStatus> {
    let mut latest: BTreeMap<String, CommitStatus> = BTreeMap::new();
    for run in database.runs_for_commit(repository, sha)? {
        let jobs = database.jobs(run.id)?;
        let entries = if jobs.is_empty() {
            vec![(RUN_CONTEXT.to_owned(), run.status.clone(), run.finished_at)]
        } else {
            jobs.into_iter()
                .map(|job| (job.name, job.status, job.finished_at))
                .collect()
        };
        for (context, status, finished_at) in entries {
            latest.insert(
                context.clone(),
                CommitStatus {
                    context,
                    state: state_of(&status),
                    run_id: run.id,
                    git_ref: run.git_ref.clone(),
                    created_at: run.created_at,
                    finished_at,
                },
            );
        }
    }
    let statuses: Vec<CommitStatus> = latest.into_values().collect();
    Ok(CombinedStatus {
        sha: sha.to_owned(),
        state: rollup(&statuses),
        statuses,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
    const SHA: &str = "1111111111111111111111111111111111111111";

    fn status(state: State) -> CommitStatus {
        CommitStatus {
            context: "j".into(),
            state,
            run_id: 1,
            git_ref: "main".into(),
            created_at: 0,
            finished_at: None,
        }
    }

    #[test]
    fn pipeline_states_map_to_commit_states() {
        for (raw, expected) in [
            ("queued", State::Pending),
            ("running", State::Pending),
            ("waiting", State::Pending),
            ("succeeded", State::Success),
            ("failed", State::Failure),
            ("canceled", State::Error),
            ("interrupted", State::Error),
            ("skipped", State::Skipped),
        ] {
            assert_eq!(state_of(raw), expected, "{raw}");
        }
    }

    #[test]
    fn rollup_prefers_failure_then_pending_then_success() {
        assert_eq!(rollup(&[]), Rollup::None);
        assert_eq!(rollup(&[status(State::Skipped)]), Rollup::None);
        assert_eq!(
            rollup(&[status(State::Success), status(State::Skipped)]),
            Rollup::Success
        );
        assert_eq!(
            rollup(&[status(State::Success), status(State::Pending)]),
            Rollup::Pending
        );
        assert_eq!(
            rollup(&[
                status(State::Pending),
                status(State::Error),
                status(State::Success)
            ]),
            Rollup::Failure
        );
    }

    #[test]
    fn combined_uses_latest_run_per_job_and_ignores_other_shas() -> Result<()> {
        let database = Database::memory(KEY)?;
        let repo = database.add_repository("demo", "https://example.com/demo.git", "main")?;
        let first = database.create_run(repo.id, "main", Some(SHA), "manual")?;
        let retry = database.create_run(repo.id, "main", Some(SHA), "retry")?;
        let other = database.create_run(repo.id, "main", Some(&"2".repeat(40)), "manual")?;
        for run in [first, retry, other] {
            database.insert_jobs(run, &[crate::db::tests::dummy_job_spec("verify", "verify")])?;
        }
        let job_of = |run| database.jobs(run).map(|jobs| jobs[0].id);
        database.set_job_status(job_of(first)?, "failed", None, Some(1))?;
        database.set_job_status(job_of(retry)?, "succeeded", None, Some(0))?;
        database.set_job_status(job_of(other)?, "failed", None, Some(1))?;

        let result = combined(&database, "demo", SHA)?;
        assert_eq!(result.state, Rollup::Success);
        assert_eq!(result.statuses.len(), 1);
        assert_eq!(result.statuses[0].run_id, retry);
        assert_eq!(
            combined(&database, "demo", &"3".repeat(40))?.state,
            Rollup::None
        );
        Ok(())
    }
}