When a proposal's target moves, its new projected state is run d943160dafeb
Landing #131 left #132 and #133, green a minute before, blocked on checks for states without #131's change; the only way on was rebuilding their bookmarks. When a target moves, by a push or a landing, each open proposal into it gets a run of its new projected state (labelled proposal/N), once per state, so its required checks follow the target. closes #134
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-13 12:21 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
d943160dafebcfb7757992bb2192edcb22a4371165b7fe67c4c78e4b10df7e5d- Depends on
- 1f1eb06a9009 42fa95540468 71e65fcf95ab 93be9ce4653e a7e6e8e6094c ac713df7b38b acc85ac0f70b de505df5cf8d
- Depended on by
- 3571c519ada6 2abea30809dd 5d90e0e045e2 f7bcee49bf24 93501da2a162
- In bookmarks
- api-unknown-fields cancel-landed-runs 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
- 6 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified crates/hub/src/web/proposals.rs +21 −0
| … | 538 unchanged lines |
||
| 539 | 539 | let Ok(open) = pdb::list(&app.db, row.id, &["open", "draft"]).await else { return }; |
|
| 540 | 540 | for p in open.into_iter().filter(|p| p.target == bookmark) { |
|
| 541 | 541 | let id = p.id; |
|
| 542 | + | let number = p.number; |
|
| 542 | 543 | if let Ok(l) = load(app, &repo, p).await { |
|
| 543 | 544 | if l.row.state == "landed" && l.row.landed_by.is_empty() { |
|
| 544 | 545 | pdb::set_landed(&app.db, id, &l.target_state, actor, "push").await.ok(); |
|
| 546 | + | } else if l.row.is_open() && !l.hashes.is_empty() && crate::flow::enabled(app, row) { |
|
| 547 | + | // The target moved, so the proposal's projected state is |
|
| 548 | + | // new and its checks would be for an older one (#134): run |
|
| 549 | + | // the pipeline on it, once. |
|
| 550 | + | let ran: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_runs WHERE repo_id = ? AND state = ?") |
|
| 551 | + | .bind(row.id) |
|
| 552 | + | .bind(&l.state) |
|
| 553 | + | .fetch_one(&app.db) |
|
| 554 | + | .await |
|
| 555 | + | .unwrap_or(1); |
|
| 556 | + | if ran == 0 { |
|
| 557 | + | let label = format!("proposal/{number}"); |
|
| 558 | + | match crate::flow::start_run_scoped(app, row, &label, &l.state, &l.heads, actor, "proposal", None).await { |
|
| 559 | + | Ok(Some(n)) => tracing::info!("proposal {number}: its target moved; run #{n} checks its new projected state"), |
|
| 560 | + | Ok(None) => {} |
|
| 561 | + | Err(e) => tracing::warn!("proposal {number}: a run of its new projected state did not start: {e}"), |
|
| 562 | + | } |
|
| 563 | + | } |
|
| 545 | 564 | } |
|
| 546 | 565 | } |
|
| 547 | 566 | } |
|
| … | 679 unchanged lines |
||
| 1227 | 1246 | ) |
|
| 1228 | 1247 | .await |
|
| 1229 | 1248 | .ok(); |
|
| 1249 | + | // The target moved: its other open proposals are checked again (#134). |
|
| 1250 | + | settle_after_push(app, row, &p.target, &me.username).await; |
|
| 1230 | 1251 | // A landing on the default bookmark closes the issues its changes (and |
|
| 1231 | 1252 | // its description) say they close, as a push there would. |
|
| 1232 | 1253 | if p.target == row.default_bookmark { |
|
| … | 492 unchanged lines |
modified crates/hub/tests/flow.rs +51 −0
| … | 3520 unchanged lines |
||
| 3521 | 3521 | } |
|
| 3522 | 3522 | assert!(labels.contains(&"upstream unverified".to_string()) && labels.contains(&"dependencies".to_string()), "{labels:?}"); |
|
| 3523 | 3523 | } |
|
| 3524 | + | ||
| 3525 | + | /// #134: when a proposal's target moves, its projected state is new; the |
|
| 3526 | + | /// hub runs the pipeline on it, so its checks are never for an older one. |
|
| 3527 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 3528 | + | async fn a_moved_target_runs_its_open_proposals_again() { |
|
| 3529 | + | let h = hub().await; |
|
| 3530 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 3531 | + | create(&h.app, "andy", "demo").await; |
|
| 3532 | + | let work = tempfile::tempdir().unwrap(); |
|
| 3533 | + | let mut c = client(&format!("{}/andy/demo", h.base), work.path()); |
|
| 3534 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 3535 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 3536 | + | c.snapshot("t").unwrap(); |
|
| 3537 | + | let base = c.commit("base", &[]).unwrap(); |
|
| 3538 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 3539 | + | // Two proposals, each main plus one change. |
|
| 3540 | + | for (b, file) in [("fix-a", "a.txt"), ("fix-b", "b.txt")] { |
|
| 3541 | + | c.bookmark_create(b, Some(&[base])).unwrap(); |
|
| 3542 | + | c.bookmark_switch(b, true).unwrap(); |
|
| 3543 | + | write(work.path(), file, "x\n"); |
|
| 3544 | + | c.snapshot("t").unwrap(); |
|
| 3545 | + | c.commit(&format!("add {file}"), &[]).unwrap(); |
|
| 3546 | + | assert!(c.push_bookmark("origin", b, false).unwrap().iter().all(|r| r.ok)); |
|
| 3547 | + | let body = serde_json::json!({ "title": b, "target": "main", "source": b }).to_string(); |
|
| 3548 | + | let (st, out) = call(&h.app, "POST", "/api/v1/repos/andy/demo/proposals", Some("site"), Some(body)).await; |
|
| 3549 | + | assert_eq!(st, StatusCode::CREATED, "{out}"); |
|
| 3550 | + | } |
|
| 3551 | + | let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap(); |
|
| 3552 | + | let props = db::proposals::list(&h.app.db, row.id, &["open"]).await.unwrap(); |
|
| 3553 | + | let a = props.iter().find(|p| p.source_bookmark == "fix-a").unwrap().number; |
|
| 3554 | + | let b = props.iter().find(|p| p.source_bookmark == "fix-b").unwrap().number; |
|
| 3555 | + | // Landing fix-a moves main: fix-b's projected state gets a run. |
|
| 3556 | + | let (st, out) = call(&h.app, "POST", &format!("/api/v1/repos/andy/demo/proposals/{a}/land"), Some("site"), Some("{}".into())).await; |
|
| 3557 | + | assert_eq!(st, StatusCode::OK, "{out}"); |
|
| 3558 | + | let label = format!("proposal/{b}"); |
|
| 3559 | + | let mut found = None; |
|
| 3560 | + | for _ in 0..100 { |
|
| 3561 | + | if let Some(r) = runs_of(&h.app, "andy", "demo").await.into_iter().find(|r| r.bookmark == label) { |
|
| 3562 | + | found = Some(r); |
|
| 3563 | + | break; |
|
| 3564 | + | } |
|
| 3565 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 3566 | + | } |
|
| 3567 | + | let run = found.expect("a run of fix-b's new projected state"); |
|
| 3568 | + | let projected = db::proposals::get(&h.app.db, row.id, b).await.unwrap().unwrap(); |
|
| 3569 | + | assert_eq!((run.trigger.as_str(), run.state.as_str()), ("proposal", projected.projected_state.as_str()), "on its projected state"); |
|
| 3570 | + | // Moving main again by a push does it once more; the same state is not run twice. |
|
| 3571 | + | let before = runs_of(&h.app, "andy", "demo").await.len(); |
|
| 3572 | + | oxygenhub::web::proposals::settle_after_push(&h.app, &row, "main", "andy").await; |
|
| 3573 | + | assert_eq!(runs_of(&h.app, "andy", "demo").await.len(), before, "not again for the same state"); |
|
| 3574 | + | } |
modified docs/spec.md +1 −1
| … | 373 unchanged lines |
||
| 374 | 374 | ||
| 375 | 375 | ### 7.4 Reviews and approvals |
|
| 376 | 376 | ||
| 377 | - | Its author, or anyone with triage access, closes and reopens a proposal on its page or with `PATCH /api/v1/repos/{o}/{n}/proposals/{number}` and `{"state": "closed"}` or `{"state": "open"}` (`oh proposal close N [-m MSG]`, `oh proposal reopen N`; #140); asking for the state it already has changes nothing, and landing has its own endpoint. The author or a writer can **ask for a review** from anyone who can read the repository (not the author), under Reviewers on the proposal page or with `POST /api/v1/repos/{o}/{n}/proposals/{number}/reviewers` and `{"reviewers": [...]}`; `DELETE .../reviewers/{username}` withdraws a request. Each person asked gets a notification (`proposal.review_requested`), the timeline records it, the proposal's JSON lists `requested_reviewers`, and a review from that person answers the request. Every review notifies the proposal's author (`proposal.review`). An AI assistant the repository may use (§13.4) can be asked the same way; its answer is a `comment` review recorded via the assistant under the asker's name. |
|
| 377 | + | When a proposal's target moves (a push, or another proposal landing), its projected state is new and its checks would be for the old one: the hub starts a run of the pipeline on each open proposal's new projected state, labelled `proposal/N` with the trigger `proposal`, unless that state has been run already (#134). Its author, or anyone with triage access, closes and reopens a proposal on its page or with `PATCH /api/v1/repos/{o}/{n}/proposals/{number}` and `{"state": "closed"}` or `{"state": "open"}` (`oh proposal close N [-m MSG]`, `oh proposal reopen N`; #140); asking for the state it already has changes nothing, and landing has its own endpoint. The author or a writer can **ask for a review** from anyone who can read the repository (not the author), under Reviewers on the proposal page or with `POST /api/v1/repos/{o}/{n}/proposals/{number}/reviewers` and `{"reviewers": [...]}`; `DELETE .../reviewers/{username}` withdraws a request. Each person asked gets a notification (`proposal.review_requested`), the timeline records it, the proposal's JSON lists `requested_reviewers`, and a review from that person answers the request. Every review notifies the proposal's author (`proposal.review`). An AI assistant the repository may use (§13.4) can be asked the same way; its answer is a `comment` review recorded via the assistant under the asker's name. |
|
| 378 | 378 | ||
| 379 | 379 | A review is `comment`, `request changes`, or `approve`. An approval MUST be backed by a **seal attestation** (spec §14.4, `kind = seal`) over the proposal's change hashes, signed with a key linked to the reviewer's account. Private keys never enter the browser, so the reviewer signs from a client: `oxy seal --proposal <url>` or `oxy seal <hashes>` (§18, a new command; the seal object already exists in the model) signs and pushes the seal; the hub matches the seal to the proposal and records the approval. The page shows the command ready to copy. An approval not yet backed by a seal is shown as "approval pending signature" and does not count towards requirements. |
|
| 380 | 380 | ||
| … | 372 unchanged lines |