//! Which of a runner's checks of a repository it deletes: nothing deletes //! a check otherwise, and most are soon of no use. A check still running, //! or finished within the grace, stays; of the rest, a check goes when it //! is: //! //! - a merge-queue batch's run, whose pulls have landed or been ejected; //! - a pull's run, once the pull is closed or merged, or a newer run of //! the same pull and workflow was filed; //! - a run on a ref other than the default branch, once that ref is gone //! or a newer run of the same ref and workflow was filed; //! - a run on the default branch older than what is kept, unless it is //! the newest of its workflow there. //! //! A push that takes a pull's or a batch's pass copies its log first (see //! [`crate::check::copied`]), so the default branch's checks stand alone. use crate::check::{Check, status}; use std::collections::{HashMap, HashSet}; use std::time::{Duration, SystemTime}; /// What a repository is now, to judge its checks by. pub struct Now<'a> { /// The open pulls, by AT-URI. pub open: &'a HashSet, /// The refs that point anywhere, in full. pub refs: &'a HashSet, /// The default branch, in full. pub main: &'a str, pub at: SystemTime, /// How long a check that finished stays whatever it is. pub grace: Duration, /// How long the default branch's checks stay. pub keep: Duration, } /// What a check is a run of, for the newest of each to be found. #[derive(PartialEq, Eq, Hash)] enum Of<'a> { Batch, Pull(&'a str, &'a str), Ref(&'a str, &'a str), } fn of(check: &Check) -> Of<'_> { let workflow = check.workflow.as_str(); match &check.pull { _ if !check.batch.is_empty() => Of::Batch, Some(pull) => Of::Pull(pull, workflow), None => Of::Ref(&check.ref_name, workflow), } } /// When `stamp`, an RFC 3339 time, was: never, if it does not read as one. fn when(stamp: &str) -> SystemTime { humantime::parse_rfc3339_weak(stamp.trim_end_matches('Z')).unwrap_or(SystemTime::UNIX_EPOCH) } /// How long before `now` the time `stamp` was. fn age(now: SystemTime, stamp: &str) -> Duration { now.duration_since(when(stamp)).unwrap_or_default() } /// The keys of the `checks`, by key, that go, as the repository is `now`. pub fn pruned(checks: &[(String, Check)], now: &Now<'_>) -> Vec { let mut newest: HashMap, &str> = HashMap::new(); for (_, check) in checks { let started = newest.entry(of(check)).or_default(); *started = (*started).max(&check.started_at); } let goes = |check: &Check| { let finished = check.finished_at.as_deref().unwrap_or(&check.started_at); if status::going(&check.status) || age(now.at, finished) < now.grace { return false; } let superseded = newest[&of(check)] != check.started_at; match of(check) { Of::Batch => true, Of::Pull(pull, _) => superseded || !now.open.contains(pull), Of::Ref(name, _) if name == now.main => { superseded && age(now.at, &check.started_at) >= now.keep } Of::Ref(name, _) => superseded || !now.refs.contains(name), } }; let gone = checks.iter().filter(|(_, check)| goes(check)); gone.map(|(rkey, _)| rkey.clone()).collect() } #[cfg(test)] mod tests { use super::*; fn check(of: &str, workflow: &str, started: &str, finished: Option<&str>) -> Check { let mut check: Check = serde_json::from_value(serde_json::json!({ "repo": {"uri": "at://did:plc:o/dev.knotless.repo/r", "key": "k"}, "ref": "refs/heads/main", "commit": "c", "workflow": workflow, "status": status::SUCCESS, "startedAt": started, })) .unwrap(); check.finished_at = finished.map(str::to_string); match of { "batch" => { check.batch = serde_json::from_str(r#"[{"pull": "at://p", "commit": "c"}]"#).unwrap() } pull if pull.starts_with("at://") => check.pull = Some(pull.into()), name => check.ref_name = name.into(), } check } #[test] fn prunes_what_is_of_no_more_use() { let day = |d: u32| format!("2026-10-{d:02}T12:00:00.000Z"); let done = |of: &str, wf: &str, d: u32| check(of, wf, &day(d), Some(&day(d))); let all = [ // A batch's run goes; one finished within the grace stays. ("batch-old", done("batch", "ci", 1)), ("batch-new", done("batch", "ci", 25)), // An open pull keeps its newest run of each workflow. ("open-1", done("at://open", "ci", 2)), ("open-2", done("at://open", "ci", 3)), ("open-docs", done("at://open", "docs", 2)), // A closed or merged one keeps none. ("merged", done("at://merged", "ci", 3)), // A branch keeps its newest of each, while it is there. ("topic-1", done("refs/heads/topic", "ci", 2)), ("topic-2", done("refs/heads/topic", "ci", 3)), ("gone", done("refs/heads/gone", "ci", 3)), // Main keeps what is newer than its window, and its newest of // each workflow whatever its age. ("main-old", done("refs/heads/main", "ci", 1)), ("main-kept", done("refs/heads/main", "ci", 20)), ("main-newest", done("refs/heads/main", "ci", 21)), ("main-only", done("refs/heads/main", "deploy", 1)), // One still running stays, as does one never finished but // started within the grace. ("running", { let mut c = check("batch", "ci", &day(1), None); c.status = status::RUNNING.into(); c }), ("unfinished", check("batch", "ci", &day(25), None)), ]; let checks: Vec<(String, Check)> = all.into_iter().map(|(k, c)| (k.into(), c)).collect(); let open = HashSet::from(["at://open".to_string()]); let refs = HashSet::from(["refs/heads/main".into(), "refs/heads/topic".into()]); let now = Now { open: &open, refs: &refs, main: "refs/heads/main", at: when("2026-10-25T13:00:00Z"), grace: Duration::from_secs(86_400), keep: Duration::from_secs(10 * 86_400), }; let gone = pruned(&checks, &now); assert_eq!( gone, [ "batch-old", "open-1", "merged", "topic-1", "gone", "main-old" ] ); // A time that does not read as one is long ago. assert_eq!(when("then"), SystemTime::UNIX_EPOCH); } }