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 |