OXYGENHUB

Land proposals through the API and oh proposal land 1f1eb06a9009

POST /api/v1/repos/{o}/{n}/proposals/{n}/land lands a proposal as the
Land button does and answers 409 with the blockers when it cannot. The
page and the API share one land_as: same checks, delegate signing, hooks,
notifications, issue closing, audit and push record. oh proposal land N
uses it.

closes #44
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 11:06 UTC
Signature
✔ signed by Andy McCall
Hash
1f1eb06a9009cd6fd9d9b15fa690885eb50e15636ce49b078e5e1d908a82e25d
Depends on
145306044896 18e381ea88c2 5c6e92fd8d71 760a7dd30d6f 93be9ce4653e b64e2537d702 d23464c0ac60 de505df5cf8d
Depended on by
217814c239f0 dd0788b756f2 86a69b53aff0 2a8b621676b4 f4d6ef418a96 d943160dafeb acc85ac0f70b 757195ad7a5d 71e65fcf95ab 62ceb2eec18c 2291f8cc9c96 739a763a558c 0b20f1ec4007 a1b9869565aa
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 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
16 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/hub/src/api/mod.rs +18 −0
…
53 unchanged lines
54 54
        .route("/repos/{owner}/{name}/pushes", get(pushes))
55 55
        .route("/repos/{owner}/{name}/proposals", get(proposals).post(create_proposal))
56 56
        .route("/repos/{owner}/{name}/proposals/{number}", get(proposal))
57 +
        .route("/repos/{owner}/{name}/proposals/{number}/land", post(land_proposal))
57 58
        .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment))
58 59
        .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review))
59 60
        .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks))
…
632 unchanged lines
692 693
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
693 694
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
694 695
    Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await))
696 +
}
697 +
698 +
/// `POST /repos/{o}/{n}/proposals/{number}/land` (repo:write): land it as
699 +
/// the page's Land button does; `409` with the blockers when it cannot.
700 +
async fn land_proposal(
701 +
    State(app): State<Shared>,
702 +
    headers: HeaderMap,
703 +
    Path((owner, name, number)): Path<(String, String, i64)>,
704 +
) -> Result<Json<Proposal>, ApiError> {
705 +
    let c = caller(&app, &headers).await?;
706 +
    let me = c.require("repo:write")?.clone();
707 +
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
708 +
    match crate::web::proposals::land_as(&app, &repo, &row, &me, number).await {
709 +
        Ok(_) => proposal(State(app), headers, Path((owner, name, number))).await,
710 +
        Err(crate::web::proposals::Landing::NotFound) => Err(not_found("no such proposal")),
711 +
        Err(crate::web::proposals::Landing::Refused(m)) => Err(ApiError(StatusCode::CONFLICT, m)),
712 +
    }
695 713
}
696 714
697 715
/// The files a proposal changes (hub spec §12.1).
…
942 unchanged lines
modified crates/hub/src/web/proposals.rs +83 −73
…
951 unchanged lines
952 952
    let me = require(&viewer, &shell)?;
953 953
    csrf(&viewer, &f.csrf, &shell)?;
954 954
    let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
955 -
    let p = pdb::get(&app.db, row.id, number)
956 -
        .await
957 -
        .map_err(|e| Error::from_db(shell.clone(), e))?
958 -
        .ok_or_else(|| Error::not_found(shell.clone()))?;
959 -
    let loaded = load(&app, &repo, p.clone()).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
960 -
    let v = verdict(&app, &loaded, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
961 -
    if !p.is_open() || !v.blockers.is_empty() {
962 -
        return detail(
963 -
            &app,
964 -
            shell,
965 -
            &viewer,
966 -
            &owner,
967 -
            &name,
968 -
            number,
969 -
            "conversation".into(),
970 -
            None,
971 -
            Some(format!("Cannot land: {}", v.blockers.join("; "))),
972 -
        )
973 -
        .await;
955 +
    match land_as(&app, &repo, &row, &me.account, number).await {
956 +
        Ok(_) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Landed.".into()), None).await,
957 +
        Err(Landing::NotFound) => Err(Error::not_found(shell)),
958 +
        Err(Landing::Refused(m)) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some(m)).await,
959 +
    }
960 +
}
961 +
962 +
/// Why a proposal did not land.
963 +
pub enum Landing {
964 +
    NotFound,
965 +
    Refused(String),
966 +
}
967 +
968 +
/// Land proposal `number` as `me` (hub spec §8.4), who has write access:
969 +
/// refused while it is not open or has blockers. Moves the target bookmark
970 +
/// (attested by the hub key on `me`'s behalf when they delegated to it),
971 +
/// then records, announces and audits the landing. Returns the new state.
972 +
pub async fn land_as(
973 +
    app: &Shared,
974 +
    repo: &RepoName,
975 +
    row: &db::repos::RepoRow,
976 +
    me: &accounts::Account,
977 +
    number: i64,
978 +
) -> Result<String, Landing> {
979 +
    let (owner, name) = (row.owner.clone(), row.name.clone());
980 +
    let refused = |e: String| Landing::Refused(e);
981 +
    let p = pdb::get(&app.db, row.id, number).await.map_err(|e| refused(e.to_string()))?.ok_or(Landing::NotFound)?;
982 +
    let loaded = load(app, repo, p.clone()).await.map_err(|e| refused(e.to_string()))?;
983 +
    let v = verdict(app, &loaded, row.id).await.map_err(|e| refused(e.to_string()))?;
984 +
    if !p.is_open() {
985 +
        return Err(refused(format!("Cannot land: the proposal is {}", p.state)));
986 +
    }
987 +
    if !v.blockers.is_empty() {
988 +
        return Err(refused(format!("Cannot land: {}", v.blockers.join("; "))));
974 989
    }
975 990
    let target = p.target.clone();
976 991
    let heads = loaded.heads.clone();
…
1 unchanged lines
978 993
    // user's behalf, which a policy accepts when it lets the user update
979 994
    // the target (spec §14.5); without one, only a policy-free repository
980 995
    // can be landed here.
981 -
    let result = match super::edit::signer_for(&app, me.account.id).await {
982 -
        Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, &repo, target.clone(), heads.clone(), signer).await.map(|_| pl::state_ref(&heads).to_hex()),
996 +
    let result = match super::edit::signer_for(app, me.id).await {
997 +
        Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, repo, target.clone(), heads.clone(), signer).await.map(|_| pl::state_ref(&heads).to_hex()),
983 998
        Err(why) => {
984 999
            app.repos
985 -
                .write(&repo, move |r, _| {
1000 +
                .write(repo, move |r, _| {
986 1001
                    if r.current_policy()?.is_some() {
987 1002
                        return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
988 1003
                            "this repository has a trust policy, so the landing must be signed by a key it authorises. Land from a client with oxy proposal land, or delegate to the hub. {why}"
…
5 unchanged lines
994 1009
                .await
995 1010
        }
996 1011
    };
997 -
    match result {
998 -
        Ok(state) => {
999 -
            pdb::set_state(&app.db, p.id, "landed", &state).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1000 -
            pdb::add_event(&app.db, p.id, &me.account.username, "landed", "from the hub").await.ok();
1001 -
            crate::hooks::emit(
1002 -
                &app,
1003 -
                &row,
1004 -
                "proposal.landed",
1005 -
                &me.account.username,
1006 -
                serde_json::json!({ "number": number, "target": p.target, "state": state }),
1007 -
            )
1008 -
            .await;
1009 -
            crate::notify::deliver(
1010 -
                &app,
1011 -
                crate::notify::Event {
1012 -
                    kind: "proposal.landed",
1013 -
                    title: format!("{owner}/{name}#{number}: {} landed", p.title),
1014 -
                    url: format!("/{owner}/{name}/proposal/{number}"),
1015 -
                    actor: &me.account,
1016 -
                    repo_id: row.id,
1017 -
                    thread: Some(("proposal", p.id)),
1018 -
                    mention_text: "",
1019 -
                    direct: p.author_id.into_iter().collect(),
1020 -
                },
1021 -
            )
1022 -
            .await;
1023 -
            if p.target == row.default_bookmark {
1024 -
                let messages: Vec<(String, String)> =
1025 -
                    loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect();
1026 -
                super::issues::close_from_messages(&app, &row, &messages).await;
1027 -
            }
1028 -
            accounts::audit(&app.db, Some(&me.account), "proposal.landed", &format!("{owner}/{name}#{number}"), "").await.ok();
1029 -
            db::repos::record_push(
1030 -
                &app.db,
1031 -
                row.id,
1032 -
                Some((me.account.id, me.account.username.as_str())),
1033 -
                &[db::repos::PushOutcome {
1034 -
                    reference: p.target.clone(),
1035 -
                    ok: true,
1036 -
                    reason: format!("landed #{number}"),
1037 -
                    old_state: loaded.target_state.clone(),
1038 -
                    new_state: state,
1039 -
                    heads: loaded.heads.len() as i64,
1040 -
                    changes: loaded.hashes.len() as i64,
1041 -
                }],
1042 -
            )
1043 -
            .await
1044 -
            .ok();
1045 -
            detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Landed.".into()), None).await
1046 -
        }
1047 -
        Err(e) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some(e.to_string())).await,
1012 +
    let state = result.map_err(|e| refused(e.to_string()))?;
1013 +
    pdb::set_state(&app.db, p.id, "landed", &state).await.map_err(|e| refused(e.to_string()))?;
1014 +
    pdb::add_event(&app.db, p.id, &me.username, "landed", "from the hub").await.ok();
1015 +
    crate::hooks::emit(
1016 +
        app,
1017 +
        row,
1018 +
        "proposal.landed",
1019 +
        &me.username,
1020 +
        serde_json::json!({ "number": number, "target": p.target, "state": state }),
1021 +
    )
1022 +
    .await;
1023 +
    crate::notify::deliver(
1024 +
        app,
1025 +
        crate::notify::Event {
1026 +
            kind: "proposal.landed",
1027 +
            title: format!("{owner}/{name}#{number}: {} landed", p.title),
1028 +
            url: format!("/{owner}/{name}/proposal/{number}"),
1029 +
            actor: me,
1030 +
            repo_id: row.id,
1031 +
            thread: Some(("proposal", p.id)),
1032 +
            mention_text: "",
1033 +
            direct: p.author_id.into_iter().collect(),
1034 +
        },
1035 +
    )
1036 +
    .await;
1037 +
    if p.target == row.default_bookmark {
1038 +
        let messages: Vec<(String, String)> = loaded.changes.iter().map(|c| (c.hash.clone(), format!("{}\n{}", c.title, c.body))).collect();
1039 +
        super::issues::close_from_messages(app, row, &messages).await;
1048 1040
    }
1041 +
    accounts::audit(&app.db, Some(me), "proposal.landed", &format!("{owner}/{name}#{number}"), "").await.ok();
1042 +
    db::repos::record_push(
1043 +
        &app.db,
1044 +
        row.id,
1045 +
        Some((me.id, me.username.as_str())),
1046 +
        &[db::repos::PushOutcome {
1047 +
            reference: p.target.clone(),
1048 +
            ok: true,
1049 +
            reason: format!("landed #{number}"),
1050 +
            old_state: loaded.target_state.clone(),
1051 +
            new_state: state.clone(),
1052 +
            heads: loaded.heads.len() as i64,
1053 +
            changes: loaded.hashes.len() as i64,
1054 +
        }],
1055 +
    )
1056 +
    .await
1057 +
    .ok();
1058 +
    Ok(state)
1049 1059
}
1050 1060
1051 1061
#[derive(Deserialize)]
…
122 unchanged lines
modified crates/oh/src/main.rs +10 −0
…
165 unchanged lines
166 166
    },
167 167
    /// The checks on a proposal's projected state
168 168
    Checks { number: i64 },
169 +
    /// Land a proposal on its target, as the page's Land button does
170 +
    Land { number: i64 },
169 171
}
170 172
171 173
#[derive(Subcommand)]
…
313 unchanged lines
485 487
                println!("{}", serde_json::to_string(&p)?);
486 488
            } else {
487 489
                println!("{} proposed #{} {}", ui::ok("✔"), p.number, ui::dim(&p.url));
490 +
            }
491 +
        }
492 +
        ProposalCmd::Land { number } => {
493 +
            let p: Proposal = c.send("POST", &format!("{base}/proposals/{number}/land"), &serde_json::json!({}))?;
494 +
            if json {
495 +
                println!("{}", serde_json::to_string(&p)?);
496 +
            } else {
497 +
                println!("{} landed #{} on {}", ui::ok("✔"), p.number, p.target);
488 498
            }
489 499
        }
490 500
        ProposalCmd::Checks { number } => {
…
227 unchanged lines
modified crates/oh/tests/cli.rs +20 −0
…
140 unchanged lines
141 141
    let (code, out, err) = oh(w, &[], &["release", "upload", "v1", asset.to_str().unwrap()]).await;
142 142
    assert_eq!(code, 0, "{err}");
143 143
    assert!(out.contains("attached tool.bin to v1"));
144 +
    std::fs::remove_file(&asset).unwrap();
144 145
    let (_, out, _) = oh(w, &[], &["release", "list", "--json"]).await;
145 146
    let rel: serde_json::Value = serde_json::from_str(&out).unwrap();
146 147
    assert_eq!(rel[0]["assets"][0]["name"], "tool.bin");
147 148
    let (code, out, _) = oh(w, &[], &["release", "trust", "v1", "--plain"]).await;
148 149
    assert_eq!(code, 0, "warnings only: {out}");
149 150
    assert!(out.contains("Tag v1 is signed") && out.contains("tool.bin: no build found"), "{out}");
151 +
152 +
    // A proposal from a bookmark, landed from the command line.
153 +
    c.bookmark_create("topic", None).unwrap();
154 +
    c.bookmark_switch("topic", false).unwrap();
155 +
    write(w, "topic.txt", "more\n");
156 +
    c.snapshot("test").unwrap();
157 +
    c.commit("Add topic", &[]).unwrap();
158 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
159 +
    let (code, out, err) = oh(w, &[], &["proposal", "create", "--title", "Topic", "--from", "topic"]).await;
160 +
    assert_eq!(code, 0, "{err}");
161 +
    let n: String = out.split("proposed #").nth(1).unwrap().chars().take_while(|c| c.is_ascii_digit()).collect();
162 +
    let (_, out, _) = oh(w, &[], &["proposal", "view", &n, "--plain"]).await;
163 +
    assert!(out.contains("topic → main") && out.contains("Add topic"), "{out}");
164 +
    let (code, out, err) = oh(w, &[], &["proposal", "land", &n]).await;
165 +
    assert_eq!(code, 0, "{err}");
166 +
    assert!(out.contains(&format!("landed #{n} on main")), "{out}");
167 +
    let (code, _, err) = oh(w, &[], &["proposal", "land", &n]).await;
168 +
    assert_ne!(code, 0);
169 +
    assert!(err.contains("HTTP 409") && err.contains("landed"), "{err}");
150 170
151 171
    // Outside a checkout: --repo and a saved login.
152 172
    let elsewhere = tempfile::tempdir().unwrap();
…
26 unchanged lines
modified docs/oh.md +1 −1
…
39 unchanged lines
40 40
| `oh proposal list`, `oh proposal view N` | Proposals, with changes, approvals and blockers |
41 41
| `oh proposal create -t TITLE --from BOOKMARK [--into BOOKMARK] [--draft]` | Propose a bookmark's changes |
42 42
| `oh proposal checks N` | Checks on the projected state |
43 +
| `oh proposal land N` | Land it on its target, as the page's Land button does |
43 44
| `oh run list`, `oh run view N` | OxygenFlow runs and their jobs |
44 45
| `oh run start [--bookmark B \| --change HASH] [--watch]` | Run the pipeline without a push |
45 46
| `oh run watch N` | Follow a run; exit 0 if it passes, 1 if not |
…
9 unchanged lines
55 56
56 57
## Not yet
57 58
58 -
- Landing a proposal needs an API route, which comes next.
59 59
- Also still to come: reviews, labels and milestones as commands, runner management (oxygenhub#41), and shell completions.
modified docs/spec.md +1 −1
…
399 unchanged lines
400 400
401 401
### 12.1 API
402 402
403 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|files|land`, `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}`, repo:write: run a bookmark's current state, the default bookmark when none is named), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
403 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|files`, `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
404 404
405 405
### 12.2 Webhooks
406 406
…
178 unchanged lines