//! `flow wait`: follow the checks a push started on OxygenHub until they //! finish, so scripts (and people) can gate on the pipeline: exit 0 when //! every check passed, 1 when one failed, 2 on the time limit. use anyhow::{bail, Context, Result}; use std::collections::BTreeMap; use std::time::{Duration, Instant}; #[derive(Debug, PartialEq, Eq)] pub enum Verdict { /// Checks still to come or running. Pending, Passed, /// The checks that failed (or, if required, were skipped). Failed(Vec), } /// Judge a state's checks, `name → status`, against the repository's /// required checks. A required check that has not reported yet is pending; /// a skipped one (a job it depends on failed) is a failure. pub fn verdict(required: &[String], checks: &BTreeMap) -> Verdict { if checks.is_empty() || required.iter().any(|r| !checks.contains_key(r)) { return Verdict::Pending; } if checks.values().any(|s| s == "queued" || s == "running") { return Verdict::Pending; } let failed: Vec = checks .iter() .filter(|(name, status)| match status.as_str() { "passed" => false, "skipped" => required.contains(name), _ => true, }) .map(|(name, _)| name.clone()) .collect(); if failed.is_empty() { Verdict::Passed } else { Verdict::Failed(failed) } } /// `https://hub/owner/name` to the hub's base URL and `owner/name`. pub fn split_repo_url(url: &str) -> Result<(String, String)> { let trimmed = url.trim_end_matches('/'); let (scheme, rest) = trimmed.split_once("://").context("a repository URL starts with https:// or http://")?; let parts: Vec<&str> = rest.split('/').collect(); if parts.len() < 3 || parts[parts.len() - 2].is_empty() || parts[parts.len() - 1].is_empty() { bail!("{url} is not a repository URL (https://hub/owner/name)"); } let n = parts.len(); Ok((format!("{scheme}://{}", parts[..n - 2].join("/")), format!("{}/{}", parts[n - 2], parts[n - 1]))) } /// Poll every `every` until the checks of `state` are decided or `limit` /// passes; print each check when its status changes. Returns the process /// exit code. pub fn wait(url: &str, state: &str, token: Option<&str>, ca: Option<&std::path::Path>, limit: Duration, every: Duration) -> Result { if state.len() != 64 || !state.chars().all(|c| c.is_ascii_hexdigit()) { bail!("the state is a 64-character hex state hash (oxy push --json prints it)"); } let (base, repo) = split_repo_url(url)?; let tls = oxy_core::protocol::tls_client_config(ca).map_err(|e| anyhow::anyhow!("{e}"))?; let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(30)).build(); let get = |path: &str| -> Result { let url = format!("{base}/api/v1/repos/{repo}{path}"); let mut req = agent.get(&url); if let Some(t) = token { req = req.set("Authorization", &format!("Bearer {t}")); } match req.call() { Ok(r) => Ok(r.into_json()?), Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()), Err(e) => bail!("{url}: {e}"), } }; let required: Vec = get("/checks/required")?["names"] .as_array() .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(); let started = Instant::now(); let mut seen: BTreeMap = BTreeMap::new(); loop { let checks: BTreeMap = get(&format!("/checks/{state}"))? .as_array() .map(|a| { a.iter() .filter_map(|c| { Some(( c["name"].as_str()?.to_string(), (c["status"].as_str()?.to_string(), c["summary"].as_str().unwrap_or("").to_string()), )) }) .collect() }) .unwrap_or_default(); for (name, (status, summary)) in &checks { if seen.get(name) != Some(status) { println!("{name:<24} {status}{}", if summary.is_empty() { String::new() } else { format!(" {summary}") }); seen.insert(name.clone(), status.clone()); } } let statuses: BTreeMap = checks.into_iter().map(|(n, (s, _))| (n, s)).collect(); match verdict(&required, &statuses) { Verdict::Passed => { println!("every check passed"); return Ok(0); } Verdict::Failed(names) => { println!("failed: {}", names.join(", ")); return Ok(1); } Verdict::Pending if started.elapsed() >= limit => { println!("still pending after {} min", limit.as_secs() / 60); return Ok(2); } Verdict::Pending => std::thread::sleep(every), } } } #[cfg(test)] mod tests { use super::*; fn checks(pairs: &[(&str, &str)]) -> BTreeMap { pairs.iter().map(|(n, s)| (n.to_string(), s.to_string())).collect() } #[test] fn verdicts() { let req = vec!["flow/fmt".to_string(), "flow/test".to_string()]; assert_eq!(verdict(&req, &checks(&[])), Verdict::Pending, "nothing reported yet"); assert_eq!(verdict(&req, &checks(&[("flow/fmt", "passed")])), Verdict::Pending, "a required check still to come"); assert_eq!(verdict(&req, &checks(&[("flow/fmt", "passed"), ("flow/test", "running")])), Verdict::Pending); assert_eq!(verdict(&req, &checks(&[("flow/fmt", "passed"), ("flow/test", "passed")])), Verdict::Passed); assert_eq!( verdict(&req, &checks(&[("flow/fmt", "passed"), ("flow/test", "failed"), ("lint", "passed")])), Verdict::Failed(vec!["flow/test".into()]) ); assert_eq!( verdict(&req, &checks(&[("flow/fmt", "failed"), ("flow/test", "skipped")])), Verdict::Failed(vec!["flow/fmt".into(), "flow/test".into()]), "a skipped required check fails" ); assert_eq!( verdict(&[], &checks(&[("extra", "skipped"), ("other", "passed")])), Verdict::Passed, "an optional skipped check does not" ); assert_eq!(verdict(&[], &checks(&[("x", "error")])), Verdict::Failed(vec!["x".into()])); } #[test] fn repository_urls() { assert_eq!( split_repo_url("https://earth.example:7343/andy/oxygen").unwrap(), ("https://earth.example:7343".into(), "andy/oxygen".into()) ); assert_eq!(split_repo_url("http://h/sub/path/a/b/").unwrap(), ("http://h/sub/path".into(), "a/b".into())); assert!(split_repo_url("https://h/only").is_err()); assert!(split_repo_url("h/a/b").is_err()); } }