Land when checks pass 86a69b53aff0
Anyone who may land a proposal can set it to land by itself: on the
proposal, through POST/DELETE …/proposals/{n}/auto-land, or oh proposal
land N --when-ready (--off). Every check is announced through the check
hook; the open proposals set to land on that projected state are looked
at then: once every required check has passed and nothing else blocks
it, it lands as the person who set it, if they may still land. A
required check that fails or is cancelled turns it off with a note on
the timeline; a push to the source makes a new projected state, so it
waits again. Shown on the proposal and in the API (auto_land); set and
clear on the timeline and audited. Migration 0081 (both databases).
Closes #261
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 1d347095e737
- When
- · 2026-10-18 18:56 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
86a69b53aff0822640ce95374a134160b64e658d28a5df68c64f11155d3325f9- Depends on
- 0b20f1ec4007 18e381ea88c2 19bb31ff1f5c 1f1eb06a9009 2a8b621676b4 3b1bc27fe805 3f02e5ea4f2d 5c6e92fd8d71 739a763a558c 7e01a7665aa5 93be9ce4653e acc85ac0f70b b64e2537d702 cc1cd197df1d d23464c0ac60 d7d182467382 de505df5cf8d
- Depended on by
- 217814c239f0 356183b71fab 3571c519ada6 33d98147511b 2abea30809dd
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 24 atoms, 3 tree operations, 14 files
Browse the tree at this change
added crates/hub/migrations-pg/0081_proposal_auto_land.sql +3 −0
| 1 | + | -- Land when checks pass (oxygenhub#261): who set it (empty: off) and when. |
|
| 2 | + | ALTER TABLE proposals ADD COLUMN auto_land_by TEXT NOT NULL DEFAULT ''; |
|
| 3 | + | ALTER TABLE proposals ADD COLUMN auto_land_ms BIGINT; |
added crates/hub/migrations/0081_proposal_auto_land.sql +3 −0
| 1 | + | -- Land when checks pass (oxygenhub#261): who set it (empty: off) and when. |
|
| 2 | + | ALTER TABLE proposals ADD COLUMN auto_land_by TEXT NOT NULL DEFAULT ''; |
|
| 3 | + | ALTER TABLE proposals ADD COLUMN auto_land_ms INTEGER; |
added crates/hub/src/autoland.rs +112 −0
| 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. |
|
| 23 | + | pub async fn set(app: &Shared, row: &db::repos::RepoRow, p: &pdb::ProposalRow, by: &accounts::Account, on: bool) -> sqlx::Result<()> { |
|
| 24 | + | if on { |
|
| 25 | + | pdb::set_auto_land(&app.db, p.id, &by.username).await?; |
|
| 26 | + | pdb::add_event(&app.db, p.id, &by.username, "set it to land when its checks pass", "").await.ok(); |
|
| 27 | + | accounts::audit(&app.db, Some(by), "proposal.auto_land", &format!("{}/{}#{}", row.owner, row.name, p.number), "on").await.ok(); |
|
| 28 | + | let p = pdb::get(&app.db, row.id, p.number).await?.unwrap_or_else(|| p.clone()); |
|
| 29 | + | evaluate(app, row, p).await; |
|
| 30 | + | } else if !p.auto_land_by.is_empty() { |
|
| 31 | + | pdb::set_auto_land(&app.db, p.id, "").await?; |
|
| 32 | + | pdb::add_event(&app.db, p.id, &by.username, "turned off landing when its checks pass", "").await.ok(); |
|
| 33 | + | accounts::audit(&app.db, Some(by), "proposal.auto_land", &format!("{}/{}#{}", row.owner, row.name, p.number), "off").await.ok(); |
|
| 34 | + | } |
|
| 35 | + | Ok(()) |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | /// A check was recorded on `state`: the proposals set to land on it are |
|
| 39 | + | /// looked at again. |
|
| 40 | + | pub async fn on_check(app: &Shared, row: &db::repos::RepoRow, state: &str) { |
|
| 41 | + | let waiting = match pdb::auto_landing(&app.db, row.id, state).await { |
|
| 42 | + | Ok(w) => w, |
|
| 43 | + | Err(e) => { |
|
| 44 | + | tracing::warn!("auto-land: {e}"); |
|
| 45 | + | return; |
|
| 46 | + | } |
|
| 47 | + | }; |
|
| 48 | + | for p in waiting { |
|
| 49 | + | evaluate(app, row, p).await; |
|
| 50 | + | } |
|
| 51 | + | } |
|
| 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. |
|
| 55 | + | pub fn on_check_task( |
|
| 56 | + | app: Shared, |
|
| 57 | + | row: db::repos::RepoRow, |
|
| 58 | + | state: String, |
|
| 59 | + | ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> { |
|
| 60 | + | Box::pin(async move { on_check(&app, &row, &state).await }) |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | async fn off(app: &Shared, p: &pdb::ProposalRow, why: &str) { |
|
| 64 | + | pdb::set_auto_land(&app.db, p.id, "").await.ok(); |
|
| 65 | + | pdb::add_event(&app.db, p.id, "oxygenhub", "turned off landing when its checks pass", why).await.ok(); |
|
| 66 | + | } |
|
| 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. |
|
| 71 | + | async fn evaluate(app: &Shared, row: &db::repos::RepoRow, p: pdb::ProposalRow) { |
|
| 72 | + | let _turn = TURN.lock().await; |
|
| 73 | + | // Landed or closed meanwhile, or turned off. |
|
| 74 | + | let Ok(Some(p)) = pdb::get(&app.db, row.id, p.number).await else { return }; |
|
| 75 | + | if !p.is_open() || p.auto_land_by.is_empty() { |
|
| 76 | + | return; |
|
| 77 | + | } |
|
| 78 | + | let setter = match accounts::account_by_name(&app.db, &p.auto_land_by).await { |
|
| 79 | + | Ok(Some(a)) if !a.blocked() => a, |
|
| 80 | + | _ => return off(app, &p, &format!("{} can no longer land it", p.auto_land_by)).await, |
|
| 81 | + | }; |
|
| 82 | + | let role = roles::role_for(&app.db, Some(&setter), row).await.ok().flatten(); |
|
| 83 | + | if !roles::allows(role, Access::Write) { |
|
| 84 | + | return off(app, &p, &format!("{} can no longer land it", setter.username)).await; |
|
| 85 | + | } |
|
| 86 | + | let Some(repo) = RepoName::new(&row.owner, &row.name) else { return }; |
|
| 87 | + | let number = p.number; |
|
| 88 | + | let Ok(loaded) = crate::web::proposals::load(app, &repo, p.clone()).await else { return }; |
|
| 89 | + | let required = db::hooks::required_checks(&app.db, row.id).await.unwrap_or_default(); |
|
| 90 | + | let checks = db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await.unwrap_or_default(); |
|
| 91 | + | let failed: Vec<&str> = required |
|
| 92 | + | .iter() |
|
| 93 | + | .filter(|n| checks.iter().any(|c| &c.name == *n && FAILED.contains(&c.status.as_str()))) |
|
| 94 | + | .map(|n| n.as_str()) |
|
| 95 | + | .collect(); |
|
| 96 | + | if !failed.is_empty() { |
|
| 97 | + | let why = format!("{} did not pass", failed.join(", ")); |
|
| 98 | + | return off(app, &p, &why).await; |
|
| 99 | + | } |
|
| 100 | + | let Ok(v) = crate::web::proposals::verdict(app, &loaded, row.id).await else { return }; |
|
| 101 | + | if !v.blockers.is_empty() { |
|
| 102 | + | return; |
|
| 103 | + | } |
|
| 104 | + | let via = format!("when its checks passed (set by {})", setter.username); |
|
| 105 | + | match crate::web::proposals::land_as(app, &repo, row, &setter, number, &via, false, None).await { |
|
| 106 | + | Ok(_) => { |
|
| 107 | + | pdb::set_auto_land(&app.db, p.id, "").await.ok(); |
|
| 108 | + | } |
|
| 109 | + | Err(crate::web::proposals::Landing::Refused(why)) => off(app, &p, &why).await, |
|
| 110 | + | Err(crate::web::proposals::Landing::NotFound) => {} |
|
| 111 | + | } |
|
| 112 | + | } |
modified crates/hub-api/src/lib.rs +3 −0
| … | 184 unchanged lines |
||
| 185 | 185 | /// Who landed it, how and when, once landed. |
|
| 186 | 186 | #[serde(default)] |
|
| 187 | 187 | pub landed: Option<Landed>, |
|
| 188 | + | /// Who set it to land when its checks pass (oxygenhub#261), while set. |
|
| 189 | + | #[serde(default)] |
|
| 190 | + | pub auto_land: Option<String>, |
|
| 188 | 191 | /// Asked to review and not yet reviewing, in the order asked. |
|
| 189 | 192 | #[serde(default)] |
|
| 190 | 193 | pub requested_reviewers: Vec<String>, |
|
| … | 1122 unchanged lines |
modified crates/hub/src/api/mod.rs +34 −0
| … | 124 unchanged lines |
||
| 125 | 125 | .route("/repos/{owner}/{name}/proposals", get(proposals).post(create_proposal)) |
|
| 126 | 126 | .route("/repos/{owner}/{name}/proposals/{number}", get(proposal).patch(edit_proposal)) |
|
| 127 | 127 | .route("/repos/{owner}/{name}/proposals/{number}/land", post(land_proposal)) |
|
| 128 | + | .route("/repos/{owner}/{name}/proposals/{number}/auto-land", post(auto_land_on).delete(auto_land_off)) |
|
| 128 | 129 | .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment)) |
|
| 129 | 130 | .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review)) |
|
| 130 | 131 | .route("/repos/{owner}/{name}/proposals/{number}/reviewers", post(request_reviewers)) |
|
| … | 1023 unchanged lines |
||
| 1154 | 1155 | } |
|
| 1155 | 1156 | } |
|
| 1156 | 1157 | ||
| 1158 | + | /// `POST /repos/{o}/{n}/proposals/{number}/auto-land` (repo:write, #261): |
|
| 1159 | + | /// land it when its checks pass; at once if they already have. |
|
| 1160 | + | async fn auto_land_on( |
|
| 1161 | + | State(app): State<Shared>, |
|
| 1162 | + | headers: HeaderMap, |
|
| 1163 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 1164 | + | ) -> Result<Json<Proposal>, ApiError> { |
|
| 1165 | + | auto_land_set(&app, &headers, &owner, &name, number, true).await?; |
|
| 1166 | + | proposal(State(app), headers, Path((owner, name, number))).await |
|
| 1167 | + | } |
|
| 1168 | + | ||
| 1169 | + | /// `DELETE …/auto-land`: it will not land by itself. |
|
| 1170 | + | async fn auto_land_off( |
|
| 1171 | + | State(app): State<Shared>, |
|
| 1172 | + | headers: HeaderMap, |
|
| 1173 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 1174 | + | ) -> Result<Json<Proposal>, ApiError> { |
|
| 1175 | + | auto_land_set(&app, &headers, &owner, &name, number, false).await?; |
|
| 1176 | + | proposal(State(app), headers, Path((owner, name, number))).await |
|
| 1177 | + | } |
|
| 1178 | + | ||
| 1179 | + | async fn auto_land_set(app: &Shared, headers: &HeaderMap, owner: &str, name: &str, number: i64, on: bool) -> Result<(), ApiError> { |
|
| 1180 | + | let c = caller(app, headers).await?; |
|
| 1181 | + | let me = c.require("repo:write")?.clone(); |
|
| 1182 | + | let (_, row, _) = reach(app, &c, owner, name, Access::Write).await?; |
|
| 1183 | + | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
|
| 1184 | + | if !p.is_open() { |
|
| 1185 | + | return Err(ApiError(StatusCode::CONFLICT, format!("the proposal is {}", p.state))); |
|
| 1186 | + | } |
|
| 1187 | + | crate::autoland::set(app, &row, &p, &me, on).await?; |
|
| 1188 | + | Ok(()) |
|
| 1189 | + | } |
|
| 1190 | + | ||
| 1157 | 1191 | /// The files a proposal changes (hub spec §12.1). |
|
| 1158 | 1192 | async fn proposal_files( |
|
| 1159 | 1193 | State(app): State<Shared>, |
|
| … | 1554 unchanged lines |
modified crates/hub/src/db/proposals.rs +28 −0
| … | 29 unchanged lines |
||
| 30 | 30 | pub landed_ms: i64, |
|
| 31 | 31 | pub created_ms: i64, |
|
| 32 | 32 | pub updated_ms: i64, |
|
| 33 | + | /// Who set it to land when its checks pass (#261); empty when off. |
|
| 34 | + | #[sqlx(default)] |
|
| 35 | + | pub auto_land_by: String, |
|
| 36 | + | #[sqlx(default)] |
|
| 37 | + | pub auto_land_ms: Option<i64>, |
|
| 33 | 38 | } |
|
| 34 | 39 | ||
| 35 | 40 | impl ProposalRow { |
|
| … | 363 unchanged lines |
||
| 399 | 404 | WHERE r.proposal_id = $1 ORDER BY r.requested_ms, a.username", |
|
| 400 | 405 | ) |
|
| 401 | 406 | .bind(proposal_id) |
|
| 407 | + | .fetch_all(pool) |
|
| 408 | + | .await |
|
| 409 | + | } |
|
| 410 | + | ||
| 411 | + | /// Set, or with `by` empty clear, landing when checks pass (#261). |
|
| 412 | + | pub async fn set_auto_land(pool: &Pool, id: i64, by: &str) -> sqlx::Result<()> { |
|
| 413 | + | sqlx::query("UPDATE proposals SET auto_land_by = $1, auto_land_ms = $2 WHERE id = $3") |
|
| 414 | + | .bind(by) |
|
| 415 | + | .bind((!by.is_empty()).then(now_ms)) |
|
| 416 | + | .bind(id) |
|
| 417 | + | .execute(pool) |
|
| 418 | + | .await?; |
|
| 419 | + | Ok(()) |
|
| 420 | + | } |
|
| 421 | + | ||
| 422 | + | /// A repository's open proposals set to land when their checks pass, on |
|
| 423 | + | /// projected state `state`. |
|
| 424 | + | pub async fn auto_landing(pool: &Pool, repo_id: i64, state: &str) -> sqlx::Result<Vec<ProposalRow>> { |
|
| 425 | + | sqlx::query_as( |
|
| 426 | + | "SELECT * FROM proposals WHERE repo_id = $1 AND state = 'open' AND auto_land_by != '' AND projected_state = $2 ORDER BY number", |
|
| 427 | + | ) |
|
| 428 | + | .bind(repo_id) |
|
| 429 | + | .bind(state) |
|
| 402 | 430 | .fetch_all(pool) |
|
| 403 | 431 | .await |
|
| 404 | 432 | } |
modified crates/hub/src/hooks.rs +8 −0
| … | 8 unchanged lines |
||
| 9 | 9 | pub async fn emit(app: &Shared, repo: &db::repos::RepoRow, event: &str, actor: &str, data: serde_json::Value) { |
|
| 10 | 10 | // Live pages hear of it whether or not any hook does (oxygenhub#203). |
|
| 11 | 11 | app.events.live.publish_hook(repo.id, event, &data); |
|
| 12 | + | // A check on a state may let a proposal set to land when its checks |
|
| 13 | + | // pass land now (#261): every check is announced here. |
|
| 14 | + | if event == "check" { |
|
| 15 | + | if let Some(state) = data["check"]["state"].as_str() { |
|
| 16 | + | let (app, repo, state) = (app.clone(), repo.clone(), state.to_string()); |
|
| 17 | + | tokio::spawn(crate::autoland::on_check_task(app, repo, state)); |
|
| 18 | + | } |
|
| 19 | + | } |
|
| 12 | 20 | let hooks = match hdb::hooks_hearing(&app.db, repo.id, &repo.owner).await { |
|
| 13 | 21 | Ok(h) => h, |
|
| 14 | 22 | Err(e) => { |
|
| … | 319 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 6 unchanged lines |
||
| 7 | 7 | pub mod apt; |
|
| 8 | 8 | pub mod assistants; |
|
| 9 | 9 | pub mod auth; |
|
| 10 | + | pub mod autoland; |
|
| 10 | 11 | pub mod backups; |
|
| 11 | 12 | pub mod config; |
|
| 12 | 13 | pub mod coverage; |
|
| … | 343 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 251 unchanged lines |
||
| 252 | 252 | .route("/{owner}/{name}/proposal/{number}/edit", post(proposals::edit)) |
|
| 253 | 253 | .route("/{owner}/{name}/proposal/{number}/state", post(proposals::state)) |
|
| 254 | 254 | .route("/{owner}/{name}/proposal/{number}/land", post(proposals::land)) |
|
| 255 | + | .route("/{owner}/{name}/proposal/{number}/auto-land", post(proposals::auto_land)) |
|
| 255 | 256 | .route("/{owner}/{name}/proposal/{number}/labels", post(proposals::set_labels)) |
|
| 256 | 257 | .route("/{owner}/{name}/proposal/{number}/project", post(projects::place_proposal)) |
|
| 257 | 258 | .route("/{owner}/{name}/issue/{number}/project", post(projects::place_issue)) |
|
| … | 599 unchanged lines |
modified crates/hub/src/web/proposals.rs +38 −0
| … | 709 unchanged lines |
||
| 710 | 710 | deletable_source: Option<String>, |
|
| 711 | 711 | can_land: bool, |
|
| 712 | 712 | can_land_over_checks: bool, |
|
| 713 | + | can_auto_land: bool, |
|
| 713 | 714 | hub_can_land: bool, |
|
| 714 | 715 | notice: Option<String>, |
|
| 715 | 716 | error: Option<String>, |
|
| … | 102 unchanged lines |
||
| 818 | 819 | None |
|
| 819 | 820 | }; |
|
| 820 | 821 | let can_land = role >= Role::Write && p.is_open() && v.blockers.is_empty(); |
|
| 822 | + | // Not landable yet: anyone who may land can set it to land when its |
|
| 823 | + | // checks pass (#261). |
|
| 824 | + | let can_auto_land = role >= Role::Write && p.is_open() && !v.blockers.is_empty(); |
|
| 821 | 825 | // Checks alone in the way: a maintainer may land over them (#261). |
|
| 822 | 826 | let can_land_over_checks = role >= Role::Maintain && p.is_open() && v.checks_blocker.is_some() && v.blockers.len() == 1; |
|
| 823 | 827 | let can_request = p.is_open() && me.is_some_and(|a| Some(a.id) == p.author_id || role >= Role::Write); |
|
| … | 82 unchanged lines |
||
| 906 | 910 | deletable_source, |
|
| 907 | 911 | can_land, |
|
| 908 | 912 | can_land_over_checks, |
|
| 913 | + | can_auto_land, |
|
| 909 | 914 | hub_can_land, |
|
| 910 | 915 | notice, |
|
| 911 | 916 | error, |
|
| … | 279 unchanged lines |
||
| 1191 | 1196 | } |
|
| 1192 | 1197 | } |
|
| 1193 | 1198 | ||
| 1199 | + | #[derive(serde::Deserialize)] |
|
| 1200 | + | pub struct AutoLandForm { |
|
| 1201 | + | #[serde(rename = "_csrf")] |
|
| 1202 | + | csrf: String, |
|
| 1203 | + | /// `1` to set it, else off. |
|
| 1204 | + | #[serde(default)] |
|
| 1205 | + | on: String, |
|
| 1206 | + | } |
|
| 1207 | + | ||
| 1208 | + | /// `POST …/proposal/{n}/auto-land` (#261): land when its checks pass, set |
|
| 1209 | + | /// or cleared by someone who may land it. |
|
| 1210 | + | pub async fn auto_land( |
|
| 1211 | + | State(app): State<Shared>, |
|
| 1212 | + | viewer: Viewer, |
|
| 1213 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 1214 | + | Form(f): Form<AutoLandForm>, |
|
| 1215 | + | ) -> Result<Response, Error> { |
|
| 1216 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 1217 | + | let me = require(&viewer, &shell)?; |
|
| 1218 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 1219 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 1220 | + | let p = pdb::get(&app.db, row.id, number) |
|
| 1221 | + | .await |
|
| 1222 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 1223 | + | .filter(|p| p.is_open()) |
|
| 1224 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 1225 | + | let on = f.on == "1"; |
|
| 1226 | + | crate::autoland::set(&app, &row, &p, &me.account, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 1227 | + | let note = if on { "It will land when its checks pass." } else { "It will not land by itself." }; |
|
| 1228 | + | Ok(back(&owner, &name, number, "conversation", Some(note.into()), None)) |
|
| 1229 | + | } |
|
| 1230 | + | ||
| 1194 | 1231 | /// Why a proposal did not land. |
|
| 1195 | 1232 | pub enum Landing { |
|
| 1196 | 1233 | NotFound, |
|
| … | 270 unchanged lines |
||
| 1467 | 1504 | via: p.landed_via.clone(), |
|
| 1468 | 1505 | when_ms: p.landed_ms, |
|
| 1469 | 1506 | }), |
|
| 1507 | + | auto_land: (p.is_open() && !p.auto_land_by.is_empty()).then(|| p.auto_land_by.clone()), |
|
| 1470 | 1508 | requested_reviewers: pdb::review_requests(&app.db, p.id).await.unwrap_or_default(), |
|
| 1471 | 1509 | labels: db::issues::proposal_labels(&app.db, p.id) |
|
| 1472 | 1510 | .await |
|
| … | 496 unchanged lines |
modified crates/hub/tests/proposals.rs +88 −0
| … | 752 unchanged lines |
||
| 753 | 753 | .any(|a| a.action == "proposal.land_over_checks" && a.detail.contains("the runners are down") && a.detail.contains("flow/test"))); |
|
| 754 | 754 | } |
|
| 755 | 755 | ||
| 756 | + | /// Land when checks pass (#261): set by someone who may land, it lands as |
|
| 757 | + | /// them once the required check passes on the projected state; a failing |
|
| 758 | + | /// check turns it off, with a note; it can be turned off. |
|
| 759 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 760 | + | async fn a_proposal_lands_itself_when_its_checks_pass() { |
|
| 761 | + | let h = hub().await; |
|
| 762 | + | let app = &h.app; |
|
| 763 | + | let andy = register(app, "andy").await; |
|
| 764 | + | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| 765 | + | app.repos.create(&repo, None).unwrap(); |
|
| 766 | + | db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 767 | + | let row = &db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 768 | + | let work = tempfile::tempdir().unwrap(); |
|
| 769 | + | let mut client = Repo::init(work.path()).unwrap(); |
|
| 770 | + | client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap(); |
|
| 771 | + | client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
|
| 772 | + | client.config.set("remote.origin.token", "site").unwrap(); |
|
| 773 | + | client.save_config().unwrap(); |
|
| 774 | + | write(work.path(), "a.txt", b"one\n"); |
|
| 775 | + | client.snapshot("t").unwrap(); |
|
| 776 | + | let base = client.commit("base", &[]).unwrap(); |
|
| 777 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 778 | + | for (bm, file) in [("one", "b.txt"), ("two", "c.txt")] { |
|
| 779 | + | client.bookmark_create(bm, Some(&[base])).unwrap(); |
|
| 780 | + | client.bookmark_switch(bm, false).unwrap(); |
|
| 781 | + | write(work.path(), file, b"x\n"); |
|
| 782 | + | client.snapshot("t").unwrap(); |
|
| 783 | + | client.commit(&format!("on {bm}"), &[]).unwrap(); |
|
| 784 | + | assert!(client.push_bookmark("origin", bm, false).unwrap().iter().all(|r| r.ok)); |
|
| 785 | + | let preview = get(app, &format!("/andy/demo/proposals/new?source={bm}&target=main"), Some(&andy)).await; |
|
| 786 | + | let r = post( |
|
| 787 | + | app, |
|
| 788 | + | "/andy/demo/proposals/new", |
|
| 789 | + | Some(&andy), |
|
| 790 | + | &format!("_csrf={}&title={bm}&body=&target=main&source={bm}", csrf_of(&preview.body)), |
|
| 791 | + | ) |
|
| 792 | + | .await; |
|
| 793 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 794 | + | } |
|
| 795 | + | db::hooks::set_required_checks(&app.db, row.id, &["flow/test".to_string()]).await.unwrap(); |
|
| 796 | + | let check = |number: i64, status: &'static str| async move { |
|
| 797 | + | let p = db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap(); |
|
| 798 | + | let report = |
|
| 799 | + | db::hooks::CheckReport { state: &p.projected_state, name: "flow/test", status, summary: "", url: "", reporter: None, seal: "" }; |
|
| 800 | + | db::hooks::upsert_check(&app.db, row.id, report).await.unwrap(); |
|
| 801 | + | oxygenhub::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": { "state": p.projected_state } })).await; |
|
| 802 | + | }; |
|
| 803 | + | let state_of = |number: i64| async move { |
|
| 804 | + | for _ in 0..100 { |
|
| 805 | + | let p = db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap(); |
|
| 806 | + | if p.state != "open" || p.auto_land_by.is_empty() { |
|
| 807 | + | return p; |
|
| 808 | + | } |
|
| 809 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 810 | + | } |
|
| 811 | + | db::proposals::get(&app.db, row.id, number).await.unwrap().unwrap() |
|
| 812 | + | }; |
|
| 813 | + | ||
| 814 | + | // #1: set while its check is pending; it waits, then lands as Andy. |
|
| 815 | + | let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
|
| 816 | + | assert!(page.body.contains("LAND WHEN CHECKS PASS"), "{}", page.body); |
|
| 817 | + | let r = act(app, "/andy/demo/proposal/1/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await; |
|
| 818 | + | assert!(r.body.contains("lands when its checks pass (set by"), "{}", r.body); |
|
| 819 | + | assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open", "the check has not passed"); |
|
| 820 | + | check(1, "running").await; |
|
| 821 | + | tokio::time::sleep(std::time::Duration::from_millis(200)).await; |
|
| 822 | + | assert_eq!(db::proposals::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "open", "still running"); |
|
| 823 | + | check(1, "passed").await; |
|
| 824 | + | let p = state_of(1).await; |
|
| 825 | + | assert_eq!((p.state.as_str(), p.landed_by.as_str()), ("landed", "andy")); |
|
| 826 | + | assert!(p.landed_via.contains("when its checks passed (set by andy)"), "{}", p.landed_via); |
|
| 827 | + | ||
| 828 | + | // #2: a failing check turns it off, with a note; it stays open. |
|
| 829 | + | let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await; |
|
| 830 | + | act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await; |
|
| 831 | + | check(2, "failed").await; |
|
| 832 | + | let p = state_of(2).await; |
|
| 833 | + | assert_eq!((p.state.as_str(), p.auto_land_by.as_str()), ("open", "")); |
|
| 834 | + | let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await; |
|
| 835 | + | assert!(page.body.contains("turned off landing when its checks pass") && page.body.contains("flow/test did not pass"), "{}", page.body); |
|
| 836 | + | // Set again and turned off by hand. |
|
| 837 | + | act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=1", csrf_of(&page.body))).await; |
|
| 838 | + | let page = get(app, "/andy/demo/proposal/2", Some(&andy)).await; |
|
| 839 | + | let r = act(app, "/andy/demo/proposal/2/auto-land", Some(&andy), &format!("_csrf={}&on=0", csrf_of(&page.body))).await; |
|
| 840 | + | assert!(r.body.contains("will not land by itself"), "{}", r.body); |
|
| 841 | + | assert!(db::proposals::get(&app.db, row.id, 2).await.unwrap().unwrap().auto_land_by.is_empty()); |
|
| 842 | + | } |
|
| 843 | + | ||
| 756 | 844 | /// Metal Gear 2's finding: a landed proposal's "Closes #N" closed nothing, |
|
| 757 | 845 | /// because only changes a push imports were read. Changes that arrive on |
|
| 758 | 846 | /// the default bookmark close issues however they get there. |
|
| … | 173 unchanged lines |
modified crates/oh/src/main.rs +22 −2
| … | 299 unchanged lines |
||
| 300 | 300 | number: i64, |
|
| 301 | 301 | /// Land before its required checks pass, saying why (maintainers; |
|
| 302 | 302 | /// recorded and audited, #261) |
|
| 303 | - | #[arg(long, value_name = "REASON")] |
|
| 303 | + | #[arg(long, value_name = "REASON", conflicts_with = "when_ready")] |
|
| 304 | 304 | over_checks: Option<String>, |
|
| 305 | + | /// Land it by itself once its checks pass (#261); with --off, don't |
|
| 306 | + | #[arg(long)] |
|
| 307 | + | when_ready: bool, |
|
| 308 | + | /// With --when-ready: turn it off |
|
| 309 | + | #[arg(long, requires = "when_ready")] |
|
| 310 | + | off: bool, |
|
| 305 | 311 | }, |
|
| 306 | 312 | /// Close a proposal without landing it, with an optional comment |
|
| 307 | 313 | Close { |
|
| … | 840 unchanged lines |
||
| 1148 | 1154 | } |
|
| 1149 | 1155 | c.send_text("POST", &format!("{base}/proposals/{number}/comments"), &NewComment { body, path: None, line: None })?; |
|
| 1150 | 1156 | println!("{} commented on #{number}", ui::ok("✔")); |
|
| 1157 | + | } |
|
| 1158 | + | ProposalCmd::Land { number, when_ready: true, off, .. } => { |
|
| 1159 | + | let path = format!("{base}/proposals/{number}/auto-land"); |
|
| 1160 | + | let p: Proposal = |
|
| 1161 | + | if off { c.send("DELETE", &path, &serde_json::json!({}))? } else { c.send("POST", &path, &serde_json::json!({}))? }; |
|
| 1162 | + | if json { |
|
| 1163 | + | println!("{}", serde_json::to_string(&p)?); |
|
| 1164 | + | } else if p.landed.is_some() { |
|
| 1165 | + | println!("{} landed #{} on {}: its checks had passed", ui::ok("✔"), p.number, p.target); |
|
| 1166 | + | } else if p.auto_land.is_some() { |
|
| 1167 | + | println!("{} #{} will land when its checks pass", ui::ok("✔"), p.number); |
|
| 1168 | + | } else { |
|
| 1169 | + | println!("{} #{} will not land by itself", ui::ok("✔"), p.number); |
|
| 1170 | + | } |
|
| 1151 | 1171 | } |
|
| 1152 | - | ProposalCmd::Land { number, over_checks } => { |
|
| 1172 | + | ProposalCmd::Land { number, over_checks, .. } => { |
|
| 1153 | 1173 | let body = match &over_checks { |
|
| 1154 | 1174 | Some(r) => serde_json::json!({ "over_checks": r }), |
|
| 1155 | 1175 | None => serde_json::json!({}), |
|
| … | 602 unchanged lines |
modified docs/spec.md +2 −0
| … | 398 unchanged lines |
||
| 399 | 399 | ||
| 400 | 400 | **Landing over checks** (oxygenhub#261). When required checks are the only thing in the way (pending or failed on the projected state, for instance with the runners down and the fix in this very proposal), a repository maintainer or admin may land now with a reason: *Land now, over the checks* on the proposal (a reason field, and a confirmation), `POST /repos/{owner}/{name}/proposals/{n}/land` with `{"over_checks": "REASON"}`, or `oh proposal land N --over-checks REASON`. Only the checks are passed over: approvals, code owners, changes requested, drafts, dependencies and the trust policy still apply, a writer is refused, and an empty reason is refused. How it landed says so ("web, over checks: the runners are down"), on the proposal, its timeline and the pushes page, and it is audited (`proposal.land_over_checks`, with the reason and the checks that had not passed). |
|
| 401 | 401 | ||
| 402 | + | **Land when checks pass** (oxygenhub#261). Anyone who may land a proposal can set it to land by itself: *Land when checks pass* on the proposal, `POST /repos/{owner}/{name}/proposals/{n}/auto-land` (`DELETE` turns it off), or `oh proposal land N --when-ready` (`--off`). Whenever a check is recorded on a state, the open proposals set to land on that projected state are looked at: once every required check has passed and nothing else blocks it, it lands as the person who set it ("when its checks passed (set by NAME)"), if they may still land; a required check that fails or is cancelled turns it off, with a note on the timeline; a push to the source makes a new projected state whose checks run, so it waits again. It shows on the proposal ("lands when its checks pass (set by NAME)") and in the API (`auto_land`); setting and clearing it are on the timeline and audited (`proposal.auto_land`). Migration 0081 (`proposals.auto_land_by`, `auto_land_ms`). |
|
| 403 | + | ||
| 402 | 404 | ### 7.7 Closing and reopening |
|
| 403 | 405 | ||
| 404 | 406 | `closed` keeps everything; the source bookmark may be deleted on close if the user asks. A proposal whose changes all appear in the target through another route is marked `landed` automatically with a note. |
|
| … | 433 unchanged lines |
modified templates/proposal.html +3 −0
| … | 194 unchanged lines |
||
| 195 | 195 | {% if hub_can_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land"{% if approvals.is_empty() %} onsubmit="return confirm('Land #{{ p.number }} without any approval?')"{% endif %}><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" data-busy="LANDING">LAND</button></form> |
|
| 196 | 196 | {% else %}<p class="small">This repository has a trust policy, so land from a client with your key:</p><p class="secret">oxy proposal land {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</p>{% endif %} |
|
| 197 | 197 | {% endif %} |
|
| 198 | + | {% if !p.auto_land_by.is_empty() %}<p class="small"><span class="sig sig-pending" aria-hidden="true">◷</span> lands when its checks pass (set by <a href="/{{ p.auto_land_by }}">{{ p.auto_land_by }}</a>)</p> |
|
| 199 | + | {% if can_auto_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/auto-land"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="on" value="0"><button type="submit" class="small">don't land it by itself</button></form>{% endif %} |
|
| 200 | + | {% else if can_auto_land && hub_can_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/auto-land"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="on" value="1"><button type="submit" title="It lands, as you, once its required checks pass and nothing else blocks it">LAND WHEN CHECKS PASS</button></form>{% endif %} |
|
| 198 | 201 | {% if can_land_over_checks && hub_can_land %}<details class="land-over"><summary class="small">Land now, over the checks</summary> |
|
| 199 | 202 | <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land" data-confirm="Land #{{ p.number }} before its required checks pass?"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 200 | 203 | <label for="over-checks" class="small">Why it can't wait for them (shown on the proposal, and audited)</label> |
|
| … | 62 unchanged lines |