OXYGENHUB

Proposals: mark landed when their work reaches the target by push ac713df7b38b

A proposal landed from a client (as a trust policy requires) or merged by
hand stayed open saying it adds no changes. A push that moves a proposal's
target now settles it as landed via push, recording the pusher, and viewing
such a proposal settles it too.

closes #141
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-12 10:00 UTC
Signature
✔ signed by Andy McCall
Hash
ac713df7b38be8185f1def38b082813f9ba0e41a436a2958698802fa3b8fcbba
Depends on
0b20f1ec4007 42fa95540468 7d2eb590051f b64e2537d702 d23464c0ac60 de505df5cf8d
Depended on by
668b2ad7495c 3571c519ada6 2abea30809dd d943160dafeb 71e65fcf95ab
In bookmarks
api-unknown-fields cancel-landed-runs close-proposal-140 diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 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
7 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified crates/hub/src/protocol.rs +1 −0
…
286 unchanged lines
287 287
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
288 288
                for (bookmark, state, heads) in &moved {
289 289
                    crate::flow::on_push(&app, &row, bookmark, state, heads, &actor).await;
290 +
                    crate::web::proposals::settle_after_push(&app, &row, bookmark, &actor).await;
290 291
                }
291 292
            }
292 293
        });
…
28 unchanged lines
modified crates/hub/src/web/proposals.rs +38 −0
…
409 unchanged lines
410 410
    pub touched: Vec<String>,
411 411
    pub owners: Vec<String>,
412 412
    pub landed_already: bool,
413 +
    /// Whether its source has any changes at all (#141): an open proposal
414 +
    /// whose source has changes, none of them missing from the target, was
415 +
    /// landed by some other route.
416 +
    pub source_has_changes: bool,
413 417
}
414 418
415 419
/// Compute the projection and everything derived from it.
…
27 unchanged lines
443 447
                    Source::Bookmark(heads)
444 448
                }
445 449
            };
450 +
            let source_has_changes = match &source {
451 +
                Source::Changes(h) | Source::Bookmark(h) | Source::Fork { heads: h, .. } => !h.is_empty(),
452 +
            };
446 453
            let p: Projection = pl::project(r, cache, &r2.target, source)?;
447 454
            let target_loaded = cache.load(r, &p.target_heads)?;
448 455
            let files = render_files(&p.files, &p.bases);
…
42 unchanged lines
491 498
                touched,
492 499
                owners,
493 500
                landed_already,
501 +
                source_has_changes,
494 502
            })
495 503
        })
496 504
        .await?;
505 +
    // Landed by another route (#141): `oxy proposal land` from a client (a
506 +
    // repository with a trust policy needs it), or the work merged and
507 +
    // pushed by hand. Everything it proposes is in its target, so it is
508 +
    // landed, not open with nothing to land.
509 +
    let mut loaded = loaded;
510 +
    if loaded.row.is_open()
511 +
        && loaded.source_has_changes
512 +
        && loaded.hashes.is_empty()
513 +
        && pdb::set_landed(&app.db, loaded.row.id, &loaded.target_state, "", "push").await.is_ok()
514 +
    {
515 +
        loaded.row.state = "landed".into();
516 +
        loaded.row.landed_via = "push".into();
517 +
        loaded.row.landed_state = loaded.target_state.clone();
518 +
        return Ok(loaded);
519 +
    }
497 520
    // Remember the projection for the list, the API and other proposals'
498 521
    // stacking checks, while the proposal is open: a landed one keeps what
499 522
    // it landed.
…
6 unchanged lines
506 529
        .await
507 530
        .ok();
508 531
    Ok(loaded)
532 +
}
533 +
534 +
/// After a push moved `bookmark`: open proposals into it whose work it now
535 +
/// holds are marked landed, by the pusher (#141).
536 +
pub async fn settle_after_push(app: &Shared, row: &db::repos::RepoRow, bookmark: &str, actor: &str) {
537 +
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return };
538 +
    let Ok(open) = pdb::list(&app.db, row.id, &["open", "draft"]).await else { return };
539 +
    for p in open.into_iter().filter(|p| p.target == bookmark) {
540 +
        let id = p.id;
541 +
        if let Ok(l) = load(app, &repo, p).await {
542 +
            if l.row.state == "landed" && l.row.landed_by.is_empty() {
543 +
                pdb::set_landed(&app.db, id, &l.target_state, actor, "push").await.ok();
544 +
            }
545 +
        }
546 +
    }
509 547
}
510 548
511 549
/// Reviews, approvals and blockers.
…
1153 unchanged lines
modified crates/hub/tests/proposals.rs +47 −0
…
479 unchanged lines
480 480
    let r = act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=HAL", csrf_of(&page))).await;
481 481
    assert!(r.body.contains("No assistant called HAL"), "{}", r.body);
482 482
}
483 +
484 +
/// #141: a proposal whose work reaches its target another way (a client's
485 +
/// `oxy proposal land`, as a trust policy needs, or a merge pushed by hand)
486 +
/// is marked landed by that push, not left open with nothing to land.
487 +
#[tokio::test(flavor = "multi_thread")]
488 +
async fn a_proposal_landed_by_a_push_is_marked_landed() {
489 +
    let h = hub().await;
490 +
    let app = &h.app;
491 +
    let andy = register(app, "andy").await;
492 +
    let repo = RepoName::parse("andy/demo").unwrap();
493 +
    app.repos.create(&repo, None).unwrap();
494 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
495 +
    let work = tempfile::tempdir().unwrap();
496 +
    let mut client = Repo::init(work.path()).unwrap();
497 +
    client.create_identity("Andy McCall", "[email protected]", oxy_core::repo::IdentityScope::User).unwrap();
498 +
    client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap();
499 +
    client.config.set("remote.origin.token", "site").unwrap();
500 +
    client.save_config().unwrap();
501 +
    write(work.path(), "a.txt", b"one\n");
502 +
    client.snapshot("t").unwrap();
503 +
    let base = client.commit("base", &[]).unwrap();
504 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
505 +
    client.bookmark_create("feature", Some(&[base])).unwrap();
506 +
    client.bookmark_switch("feature", false).unwrap();
507 +
    write(work.path(), "a.txt", b"one\ntwo\n");
508 +
    client.snapshot("t").unwrap();
509 +
    let feat = client.commit("add two", &[]).unwrap();
510 +
    assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok));
511 +
    let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&andy)).await;
512 +
    let csrf = csrf_of(&preview.body);
513 +
    let r = post(app, "/andy/demo/proposals/new", Some(&andy), &format!("_csrf={csrf}&title=Two&body=&target=main&source=feature")).await;
514 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
515 +
    assert_eq!(db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap().state, "open");
516 +
517 +
    // The work goes onto main by a push, not by the Land button.
518 +
    client.bookmark_set("main", &[feat]).unwrap();
519 +
    assert!(client.push_bookmark("origin", "main", false).unwrap().iter().all(|r| r.ok));
520 +
    let mut p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap();
521 +
    for _ in 0..100 {
522 +
        if p.state == "landed" {
523 +
            break;
524 +
        }
525 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
526 +
        p = db::proposals::get(&app.db, 1, 1).await.unwrap().unwrap();
527 +
    }
528 +
    assert_eq!((p.state.as_str(), p.landed_via.as_str()), ("landed", "push"), "{p:?}");
529 +
}