OXYGENHUB

Coverage / crates/hub/src/autoland.rs 85.2%

69 of 81 lines run, 13 of 28 functions · run #481 on main · run missed

1
//! Land when checks pass (oxygenhub#261): someone who may land a proposal
2
//! sets it, and the hub lands it, as them, once its required checks pass
3
//! on its projected state and nothing else blocks it. A push to the source
4
//! makes a new projected state whose checks run again, so it waits again;
5
//! a required check that fails turns it off, with a note on the timeline;
6
//! so does the setter losing the right to land. Anyone who may land can
7
//! turn it off.
8
9
use crate::auth::roles::{self, Access};
10
use crate::db::{self, accounts, proposals as pdb};
11
use crate::repos::RepoName;
12
use crate::Shared;
13
14
/// One evaluation at a time across the hub: two checks finishing together
15
/// must not land a proposal twice.
16
static TURN: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
17
18
/// Check statuses that end a check without its passing.
19
const FAILED: [&str; 2] = ["failed", "cancelled"];
20
21
/// Turn it on (`by`, who may land) or off, and look at once: a proposal
22
/// whose checks have already passed lands now.
234×
pub async fn set(app: &Shared, row: &db::repos::RepoRow, p: &pdb::ProposalRow, by: &accounts::Account, on: bool) -> sqlx::Result<()> {
244×
    if on {
253×
        pdb::set_auto_land(&app.db, p.id, &by.username).await?;
263×
        pdb::add_event(&app.db, p.id, &by.username, "set it to land when its checks pass", "").await.ok();
273×
        accounts::audit(&app.db, Some(by), "proposal.auto_land", &format!("{}/{}#{}", row.owner, row.name, p.number), "on").await.ok();
283×
        let p = pdb::get(&app.db, row.id, p.number).await?.unwrap_or_else(|| p.clone());
293×
        evaluate(app, row, p).await;
301×
    } else if !p.auto_land_by.is_empty() {
310×
        pdb::set_auto_land(&app.db, p.id, "").await?;
320×
        pdb::add_event(&app.db, p.id, &by.username, "turned off landing when its checks pass", "").await.ok();
330×
        accounts::audit(&app.db, Some(by), "proposal.auto_land", &format!("{}/{}#{}", row.owner, row.name, p.number), "off").await.ok();
341×
    }
354×
    Ok(())
364×
}
37
38
/// A check was recorded on `state`: the proposals set to land on it are
39
/// looked at again.
40702×
pub async fn on_check(app: &Shared, row: &db::repos::RepoRow, state: &str) {
41702×
    let waiting = match pdb::auto_landing(&app.db, row.id, state).await {
42702×
        Ok(w) => w,
430×
        Err(e) => {
440×
            tracing::warn!("auto-land: {e}");
450×
            return;
46
        }
47
    };
48702×
    for p in waiting {
493×
        evaluate(app, row, p).await;
50
    }
51702×
}
52
53
/// [`on_check`] as a boxed task, for `hooks::emit` to spawn: landing emits
54
/// hooks itself, so the future's type would otherwise contain itself.
55702×
pub fn on_check_task(
56702×
    app: Shared,
57702×
    row: db::repos::RepoRow,
58702×
    state: String,
59702×
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
60702×
    Box::pin(async move { on_check(&app, &row, &state).await })
61702×
}
62
632×
async fn off(app: &Shared, p: &pdb::ProposalRow, why: &str) {
642×
    pdb::set_auto_land(&app.db, p.id, "").await.ok();
652×
    pdb::add_event(&app.db, p.id, "oxygenhub", "turned off landing when its checks pass", why).await.ok();
662×
}
67
68
/// Land it if its checks have passed and nothing else blocks it; turn it
69
/// off if a required check failed or its setter may no longer land; else
70
/// wait.
716×
async fn evaluate(app: &Shared, row: &db::repos::RepoRow, p: pdb::ProposalRow) {
726×
    let _turn = TURN.lock().await;
73
    // And one at a time for this proposal across the hub's processes
74
    // (#275): two web nodes recording its last checks land it once.
756×
    let Ok(_proposal) = crate::leader::exclusive(&app.db, &format!("autoland:{}", p.id)).await else { return };
76
    // Landed or closed meanwhile, or turned off.
776×
    let Ok(Some(p)) = pdb::get(&app.db, row.id, p.number).await else { return };
786×
    if !p.is_open() || p.auto_land_by.is_empty() {
790×
        return;
806×
    }
816×
    let setter = match accounts::account_by_name(&app.db, &p.auto_land_by).await {
826×
        Ok(Some(a)) if !a.blocked() => a,
830×
        _ => return off(app, &p, &format!("{} can no longer land it", p.auto_land_by)).await,
84
    };
856×
    let role = roles::role_for(&app.db, Some(&setter), row).await.ok().flatten();
866×
    if !roles::allows(role, Access::Write) {
870×
        return off(app, &p, &format!("{} can no longer land it", setter.username)).await;
886×
    }
896×
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return };
906×
    let number = p.number;
916×
    let Ok(loaded) = crate::web::proposals::load_summary(app, &repo, p.clone()).await else { return };
926×
    let required = db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default();
936×
    let checks = db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default();
946×
    let failed: Vec<&str> = required
956×
        .iter()
966×
        .filter(|n| checks.iter().any(|c| &c.name == *n && FAILED.contains(&c.status.as_str())))
976×
        .map(|n| n.as_str())
986×
        .collect();
996×
    if !failed.is_empty() {
1002×
        let why = format!("{} did not pass", failed.join(", "));
1012×
        return off(app, &p, &why).await;
1024×
    }
1034×
    let Ok(v) = crate::web::proposals::verdict(app, &loaded, row.id).await else { return };
1044×
    if !v.blockers.is_empty() {
1053×
        return;
1061×
    }
1071×
    let via = format!("when its checks passed (set by {})", setter.username);
1081×
    match crate::web::proposals::land_as(app, &repo, row, &setter, number, &via, false, None).await {
109
        Ok(_) => {
1101×
            pdb::set_auto_land(&app.db, p.id, "").await.ok();
111
        }
1120×
        Err(crate::web::proposals::Landing::Refused(why)) => off(app, &p, &why).await,
1130×
        Err(crate::web::proposals::Landing::NotFound) => {}
114
    }
1156×
}