OXYGENHUB

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