OXYGENHUB

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