//! Land when checks pass (oxygenhub#261): someone who may land a proposal //! sets it, and the hub lands it, as them, once its required checks pass //! on its projected state and nothing else blocks it. A push to the source //! makes a new projected state whose checks run again, so it waits again; //! a required check that fails turns it off, with a note on the timeline; //! so does the setter losing the right to land. Anyone who may land can //! turn it off. use crate::auth::roles::{self, Access}; use crate::db::{self, accounts, proposals as pdb}; use crate::repos::RepoName; use crate::Shared; /// One evaluation at a time across the hub: two checks finishing together /// must not land a proposal twice. static TURN: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); /// Check statuses that end a check without its passing. const FAILED: [&str; 2] = ["failed", "cancelled"]; /// Turn it on (`by`, who may land) or off, and look at once: a proposal /// whose checks have already passed lands now. pub async fn set(app: &Shared, row: &db::repos::RepoRow, p: &pdb::ProposalRow, by: &accounts::Account, on: bool) -> sqlx::Result<()> { if on { pdb::set_auto_land(&app.db, p.id, &by.username).await?; pdb::add_event(&app.db, p.id, &by.username, "set it to land when its checks pass", "").await.ok(); accounts::audit(&app.db, Some(by), "proposal.auto_land", &format!("{}/{}#{}", row.owner, row.name, p.number), "on").await.ok(); let p = pdb::get(&app.db, row.id, p.number).await?.unwrap_or_else(|| p.clone()); evaluate(app, row, p).await; } else if !p.auto_land_by.is_empty() { pdb::set_auto_land(&app.db, p.id, "").await?; pdb::add_event(&app.db, p.id, &by.username, "turned off landing when its checks pass", "").await.ok(); accounts::audit(&app.db, Some(by), "proposal.auto_land", &format!("{}/{}#{}", row.owner, row.name, p.number), "off").await.ok(); } Ok(()) } /// A check was recorded on `state`: the proposals set to land on it are /// looked at again. pub async fn on_check(app: &Shared, row: &db::repos::RepoRow, state: &str) { let waiting = match pdb::auto_landing(&app.db, row.id, state).await { Ok(w) => w, Err(e) => { tracing::warn!("auto-land: {e}"); return; } }; for p in waiting { evaluate(app, row, p).await; } } /// [`on_check`] as a boxed task, for `hooks::emit` to spawn: landing emits /// hooks itself, so the future's type would otherwise contain itself. pub fn on_check_task( app: Shared, row: db::repos::RepoRow, state: String, ) -> std::pin::Pin + Send>> { Box::pin(async move { on_check(&app, &row, &state).await }) } async fn off(app: &Shared, p: &pdb::ProposalRow, why: &str) { pdb::set_auto_land(&app.db, p.id, "").await.ok(); pdb::add_event(&app.db, p.id, "oxygenhub", "turned off landing when its checks pass", why).await.ok(); } /// Land it if its checks have passed and nothing else blocks it; turn it /// off if a required check failed or its setter may no longer land; else /// wait. async fn evaluate(app: &Shared, row: &db::repos::RepoRow, p: pdb::ProposalRow) { let _turn = TURN.lock().await; // Landed or closed meanwhile, or turned off. let Ok(Some(p)) = pdb::get(&app.db, row.id, p.number).await else { return }; if !p.is_open() || p.auto_land_by.is_empty() { return; } let setter = match accounts::account_by_name(&app.db, &p.auto_land_by).await { Ok(Some(a)) if !a.blocked() => a, _ => return off(app, &p, &format!("{} can no longer land it", p.auto_land_by)).await, }; let role = roles::role_for(&app.db, Some(&setter), row).await.ok().flatten(); if !roles::allows(role, Access::Write) { return off(app, &p, &format!("{} can no longer land it", setter.username)).await; } let Some(repo) = RepoName::new(&row.owner, &row.name) else { return }; let number = p.number; let Ok(loaded) = crate::web::proposals::load(app, &repo, p.clone()).await else { return }; let required = db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(); let checks = db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(); let failed: Vec<&str> = required .iter() .filter(|n| checks.iter().any(|c| &c.name == *n && FAILED.contains(&c.status.as_str()))) .map(|n| n.as_str()) .collect(); if !failed.is_empty() { let why = format!("{} did not pass", failed.join(", ")); return off(app, &p, &why).await; } let Ok(v) = crate::web::proposals::verdict(app, &loaded, row.id).await else { return }; if !v.blockers.is_empty() { return; } let via = format!("when its checks passed (set by {})", setter.username); match crate::web::proposals::land_as(app, &repo, row, &setter, number, &via, false, None).await { Ok(_) => { pdb::set_auto_land(&app.db, p.id, "").await.ok(); } Err(crate::web::proposals::Landing::Refused(why)) => off(app, &p, &why).await, Err(crate::web::proposals::Landing::NotFound) => {} } }