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 | + | } |