OXYGENHUB

Coverage / crates/flow/src/wait.rs 50.8%

62 of 122 lines run, 10 of 34 functions · run #186 on main · run missed

1
//! `flow wait`: follow the checks a push started on OxygenHub until they
2
//! finish, so scripts (and people) can gate on the pipeline: exit 0 when
3
//! every check passed, 1 when one failed, 2 on the time limit.
4
5
use anyhow::{bail, Context, Result};
6
use std::collections::BTreeMap;
7
use std::time::{Duration, Instant};
8
9
#[derive(Debug, PartialEq, Eq)]
10
pub enum Verdict {
11
    /// Checks still to come or running.
12
    Pending,
13
    Passed,
14
    /// The checks that failed (or, if required, were skipped).
15
    Failed(Vec<String>),
16
}
17
18
/// Judge a state's checks, `name → status`, against the repository's
19
/// required checks. A required check that has not reported yet is pending;
20
/// a skipped one (a job it depends on failed) is a failure.
218×
pub fn verdict(required: &[String], checks: &BTreeMap<String, String>) -> Verdict {
2210×
    if checks.is_empty() || required.iter().any(|r| !checks.contains_key(r)) {
232×
        return Verdict::Pending;
246×
    }
2512×
    if checks.values().any(|s| s == "queued" || s == "running") {
261×
        return Verdict::Pending;
275×
    }
285×
    let failed: Vec<String> = checks
295×
        .iter()
3010×
        .filter(|(name, status)| match status.as_str() {
3110×
            "passed" => false,
325×
            "skipped" => required.contains(name),
333×
            _ => true,
3410×
        })
355×
        .map(|(name, _)| name.clone())
365×
        .collect();
375×
    if failed.is_empty() {
382×
        Verdict::Passed
39
    } else {
403×
        Verdict::Failed(failed)
41
    }
428×
}
43
44
/// `https://hub/owner/name` to the hub's base URL and `owner/name`.
454×
pub fn split_repo_url(url: &str) -> Result<(String, String)> {
464×
    let trimmed = url.trim_end_matches('/');
474×
    let (scheme, rest) = trimmed.split_once("://").context("a repository URL starts with https:// or http://")?;
483×
    let parts: Vec<&str> = rest.split('/').collect();
493×
    if parts.len() < 3 || parts[parts.len() - 2].is_empty() || parts[parts.len() - 1].is_empty() {
501×
        bail!("{url} is not a repository URL (https://hub/owner/name)");
512×
    }
522×
    let n = parts.len();
532×
    Ok((format!("{scheme}://{}", parts[..n - 2].join("/")), format!("{}/{}", parts[n - 2], parts[n - 1])))
544×
}
55
56
/// Poll every `every` until the checks of `state` are decided or `limit`
57
/// passes; print each check when its status changes. Returns the process
58
/// exit code.
590×
pub fn wait(url: &str, state: &str, token: Option<&str>, ca: Option<&std::path::Path>, limit: Duration, every: Duration) -> Result<i32> {
600×
    if state.len() != 64 || !state.chars().all(|c| c.is_ascii_hexdigit()) {
610×
        bail!("the state is a 64-character hex state hash (oxy push --json prints it)");
620×
    }
630×
    let (base, repo) = split_repo_url(url)?;
640×
    let tls = oxy_core::protocol::tls_client_config(ca).map_err(|e| anyhow::anyhow!("{e}"))?;
650×
    let agent = ureq::AgentBuilder::new().tls_config(tls).timeout(Duration::from_secs(30)).build();
660×
    let get = |path: &str| -> Result<serde_json::Value> {
670×
        let url = format!("{base}/api/v1/repos/{repo}{path}");
680×
        let mut req = agent.get(&url);
690×
        if let Some(t) = token {
700×
            req = req.set("Authorization", &format!("Bearer {t}"));
710×
        }
720×
        match req.call() {
730×
            Ok(r) => Ok(r.into_json()?),
740×
            Err(ureq::Error::Status(code, r)) => bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default()),
750×
            Err(e) => bail!("{url}: {e}"),
76
        }
770×
    };
780×
    let required: Vec<String> = get("/checks/required")?["names"]
790×
        .as_array()
800×
        .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
810×
        .unwrap_or_default();
820×
    let started = Instant::now();
830×
    let mut seen: BTreeMap<String, String> = BTreeMap::new();
84
    loop {
850×
        let checks: BTreeMap<String, (String, String)> = get(&format!("/checks/{state}"))?
860×
            .as_array()
870×
            .map(|a| {
880×
                a.iter()
890×
                    .filter_map(|c| {
90
                        Some((
910×
                            c["name"].as_str()?.to_string(),
920×
                            (c["status"].as_str()?.to_string(), c["summary"].as_str().unwrap_or("").to_string()),
93
                        ))
940×
                    })
950×
                    .collect()
960×
            })
970×
            .unwrap_or_default();
980×
        for (name, (status, summary)) in &checks {
990×
            if seen.get(name) != Some(status) {
1000×
                println!("{name:<24} {status}{}", if summary.is_empty() { String::new() } else { format!("  {summary}") });
1010×
                seen.insert(name.clone(), status.clone());
1020×
            }
103
        }
1040×
        let statuses: BTreeMap<String, String> = checks.into_iter().map(|(n, (s, _))| (n, s)).collect();
1050×
        match verdict(&required, &statuses) {
106
            Verdict::Passed => {
1070×
                println!("every check passed");
1080×
                return Ok(0);
109
            }
1100×
            Verdict::Failed(names) => {
1110×
                println!("failed: {}", names.join(", "));
1120×
                return Ok(1);
113
            }
1140×
            Verdict::Pending if started.elapsed() >= limit => {
1150×
                println!("still pending after {} min", limit.as_secs() / 60);
1160×
                return Ok(2);
117
            }
1180×
            Verdict::Pending => std::thread::sleep(every),
119
        }
120
    }
1210×
}
122
123
#[cfg(test)]
124
mod tests {
125
    use super::*;
126
1278×
    fn checks(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
12813×
        pairs.iter().map(|(n, s)| (n.to_string(), s.to_string())).collect()
1298×
    }
130
131
    #[test]
1321×
    fn verdicts() {
1331×
        let req = vec!["flow/fmt".to_string(), "flow/test".to_string()];
1341×
        assert_eq!(verdict(&req, &checks(&[])), Verdict::Pending, "nothing reported yet");
1351×
        assert_eq!(verdict(&req, &checks(&[("flow/fmt", "passed")])), Verdict::Pending, "a required check still to come");
1361×
        assert_eq!(verdict(&req, &checks(&[("flow/fmt", "passed"), ("flow/test", "running")])), Verdict::Pending);
1371×
        assert_eq!(verdict(&req, &checks(&[("flow/fmt", "passed"), ("flow/test", "passed")])), Verdict::Passed);
1381×
        assert_eq!(
1391×
            verdict(&req, &checks(&[("flow/fmt", "passed"), ("flow/test", "failed"), ("lint", "passed")])),
1401×
            Verdict::Failed(vec!["flow/test".into()])
141
        );
1421×
        assert_eq!(
1431×
            verdict(&req, &checks(&[("flow/fmt", "failed"), ("flow/test", "skipped")])),
1441×
            Verdict::Failed(vec!["flow/fmt".into(), "flow/test".into()]),
145
            "a skipped required check fails"
146
        );
1471×
        assert_eq!(
1481×
            verdict(&[], &checks(&[("extra", "skipped"), ("other", "passed")])),
149
            Verdict::Passed,
150
            "an optional skipped check does not"
151
        );
1521×
        assert_eq!(verdict(&[], &checks(&[("x", "error")])), Verdict::Failed(vec!["x".into()]));
1531×
    }
154
155
    #[test]
1561×
    fn repository_urls() {
1571×
        assert_eq!(
1581×
            split_repo_url("https://earth.example:7343/andy/oxygen").unwrap(),
1591×
            ("https://earth.example:7343".into(), "andy/oxygen".into())
160
        );
1611×
        assert_eq!(split_repo_url("http://h/sub/path/a/b/").unwrap(), ("http://h/sub/path".into(), "a/b".into()));
1621×
        assert!(split_repo_url("https://h/only").is_err());
1631×
        assert!(split_repo_url("h/a/b").is_err());
1641×
    }
165
}