~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

7
174 lines · 6.7 KB Raw History
//! 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<String>,
    /// The refs that point anywhere, in full.
    pub refs: &'a HashSet<String>,
    /// 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<String> {
    let mut newest: HashMap<Of<'_>, &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);
    }
}