Edit a proposal through the API; oh proposal edit, comment and create -F 739a763a558c
PATCH /api/v1/repos/{owner}/{name}/proposals/{number} changes the title
or description, keeping what is not given, for the proposal's author or
a maintainer (as the page allows); it records the edit on the timeline.
oh proposal edit N [-t] [-b | -F], oh proposal comment N [-b | -F], and
-F on oh proposal create. Test. The spec lists both PATCH routes.
closes #76
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 11:25 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
739a763a558c04cb6e59e66ed47efa071fa5e97b75c38648dfd040da6c249098- Depends on
- 0b20f1ec4007 1f1eb06a9009 3f4b0ea6eabf 5c6e92fd8d71 749d9a5fcd9f 93be9ce4653e b5668108487f b64e2537d702 ced6d3afc5cf d23464c0ac60 de505df5cf8d eef677c48cd1
- Depended on by
- d7e2c2754807 5a9a6e9a15bc 09ffa7b600a9 3571c519ada6 86a69b53aff0 71e65fcf95ab 2291f8cc9c96 34f40eb4b205
- 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.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
- 15 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Edit a proposal and comment on one from `oh` (#76).** `PATCH /api/v1/repos/{owner}/{name}/proposals/{n}` changes the title or description, for the author or a maintainer as on the page; `oh proposal edit N [-t] [-b | -F FILE]`, `oh proposal comment N -b/-F`, and `oh proposal create -F FILE` ("-" reads stdin). |
|
| 8 | + | ||
| 7 | 9 | - **A restarted runner's lost jobs end at once (#71).** A runner lists the jobs it holds at each claim (OxygenFlow does), and the hub ends any other job it has as running on that runner, with the reason, rather than an hour later at the time limit. |
|
| 8 | 10 | ||
| 9 | 11 | - **Edit a release (#72).** `PATCH /api/v1/repos/{owner}/{name}/releases/{tag}` changes a release's title, notes or prerelease flag, keeping what it is not given, and `oh release edit TAG [--title] [--notes TEXT|-] [--prerelease true|false]` sends it. `oh api -X METHOD` without a body now sends the method asked for (#75). |
|
| … | 25 unchanged lines |
modified crates/hub-api/src/lib.rs +10 −0
| … | 852 unchanged lines |
||
| 853 | 853 | pub signer: Option<String>, |
|
| 854 | 854 | } |
|
| 855 | 855 | ||
| 856 | + | /// `PATCH /repos/{o}/{n}/proposals/{number}`: what is given changes, |
|
| 857 | + | /// the rest stays (oxygenhub#76). |
|
| 858 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 859 | + | pub struct ProposalEdit { |
|
| 860 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 861 | + | pub title: Option<String>, |
|
| 862 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 863 | + | pub body: Option<String>, |
|
| 864 | + | } |
|
| 865 | + | ||
| 856 | 866 | /// `PATCH /repos/{owner}/{name}/releases/{tag}`: what to change; fields |
|
| 857 | 867 | /// left out stay as they are. |
|
| 858 | 868 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| … | 133 unchanged lines |
modified crates/hub/src/api/mod.rs +35 −1
| … | 74 unchanged lines |
||
| 75 | 75 | .route("/repos/{owner}/{name}/releases/trust/{*tag}", get(release_trust)) |
|
| 76 | 76 | .route("/repos/{owner}/{name}/pushes", get(pushes)) |
|
| 77 | 77 | .route("/repos/{owner}/{name}/proposals", get(proposals).post(create_proposal)) |
|
| 78 | - | .route("/repos/{owner}/{name}/proposals/{number}", get(proposal)) |
|
| 78 | + | .route("/repos/{owner}/{name}/proposals/{number}", get(proposal).patch(edit_proposal)) |
|
| 79 | 79 | .route("/repos/{owner}/{name}/proposals/{number}/land", post(land_proposal)) |
|
| 80 | 80 | .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment)) |
|
| 81 | 81 | .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review)) |
|
| … | 736 unchanged lines |
||
| 818 | 818 | Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await)) |
|
| 819 | 819 | } |
|
| 820 | 820 | ||
| 821 | + | /// `PATCH /repos/{o}/{n}/proposals/{number}`: change the title or body, |
|
| 822 | + | /// keeping what is not given; the author or a maintainer, as on the page |
|
| 823 | + | /// (oxygenhub#76). The target is changed on the page, which re-projects. |
|
| 824 | + | async fn edit_proposal( |
|
| 825 | + | State(app): State<Shared>, |
|
| 826 | + | headers: HeaderMap, |
|
| 827 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 828 | + | Json(body): Json<hub_api::ProposalEdit>, |
|
| 829 | + | ) -> Result<Json<Proposal>, ApiError> { |
|
| 830 | + | let c = caller(&app, &headers).await?; |
|
| 831 | + | let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); |
|
| 832 | + | let (repo, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 833 | + | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
|
| 834 | + | if Some(me.id) != p.author_id && role < Role::Maintain { |
|
| 835 | + | return Err(ApiError(StatusCode::FORBIDDEN, "only the proposal's author or a maintainer can edit it".into())); |
|
| 836 | + | } |
|
| 837 | + | let title = match body.title.as_deref().map(str::trim) { |
|
| 838 | + | Some("") => return Err(ApiError(StatusCode::BAD_REQUEST, "a proposal needs a title".into())), |
|
| 839 | + | Some(t) => t, |
|
| 840 | + | None => p.title.as_str(), |
|
| 841 | + | }; |
|
| 842 | + | let text = body.body.as_deref().map(str::trim).unwrap_or(&p.body); |
|
| 843 | + | db::proposals::edit(&app.db, p.id, title, text, &p.target).await?; |
|
| 844 | + | db::proposals::add_event(&app.db, p.id, &me.username, "edited", "").await.ok(); |
|
| 845 | + | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
|
| 846 | + | let source_url = match p.source_repo_id { |
|
| 847 | + | Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())), |
|
| 848 | + | None => None, |
|
| 849 | + | }; |
|
| 850 | + | let loaded = crate::web::proposals::load(&app, &repo, p).await?; |
|
| 851 | + | let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; |
|
| 852 | + | Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await)) |
|
| 853 | + | } |
|
| 854 | + | ||
| 821 | 855 | /// `POST /repos/{o}/{n}/proposals/{number}/land` (repo:write): land it as |
|
| 822 | 856 | /// the page's Land button does; `409` with the blockers when it cannot. |
|
| 823 | 857 | async fn land_proposal( |
|
| … | 1078 unchanged lines |
modified crates/hub/tests/api.rs +28 −0
| … | 397 unchanged lines |
||
| 398 | 398 | let body = r#"{"title":"None","target":"main","source":"feature/3-none"}"#; |
|
| 399 | 399 | let (st, v) = api(app, "POST", "/api/v1/repos/andy/demo/proposals", Some(&bob_rw), Some(body)).await; |
|
| 400 | 400 | assert!(st.is_client_error() && v.to_string().contains("no bookmark feature/3-none"), "{st} {v}"); |
|
| 401 | + | ||
| 402 | + | // oxygenhub#76: a proposal's title and description are edited through |
|
| 403 | + | // the API by its author or a maintainer; what is not given stays. |
|
| 404 | + | let edit = |token: &str, number: i64, body: &str| { |
|
| 405 | + | let (app, token, body) = (app.clone(), token.to_string(), body.to_string()); |
|
| 406 | + | async move { api(&app, "PATCH", &format!("/api/v1/repos/andy/demo/proposals/{number}"), Some(&token), Some(&body)).await } |
|
| 407 | + | }; |
|
| 408 | + | let (st, v) = edit(&bob_rw, 2, r#"{"body":"8 of 8, not 9 of 9"}"#).await; |
|
| 409 | + | assert_eq!(st, StatusCode::OK, "{v}"); |
|
| 410 | + | assert_eq!((v["title"].as_str(), v["body"].as_str()), (Some("Feat"), Some("8 of 8, not 9 of 9")), "{v}"); |
|
| 411 | + | let (st, v) = edit(&andy_pw, 2, r#"{"title":"Feat, renamed"}"#).await; |
|
| 412 | + | assert_eq!(st, StatusCode::OK, "a maintainer edits another's proposal: {v}"); |
|
| 413 | + | assert_eq!((v["title"].as_str(), v["body"].as_str()), (Some("Feat, renamed"), Some("8 of 8, not 9 of 9"))); |
|
| 414 | + | assert_eq!(edit(&bob_rw, 2, r#"{"title":" "}"#).await.0, StatusCode::BAD_REQUEST, "a title is required"); |
|
| 415 | + | assert_eq!(edit(&bob_rw, 99, r#"{"body":"x"}"#).await.0, StatusCode::NOT_FOUND); |
|
| 416 | + | // Andy's own proposal: Bob, a writer but not a maintainer, cannot edit it. |
|
| 417 | + | let (st, v) = api( |
|
| 418 | + | app, |
|
| 419 | + | "POST", |
|
| 420 | + | "/api/v1/repos/andy/demo/proposals", |
|
| 421 | + | Some(&andy_pw), |
|
| 422 | + | Some(r#"{"title":"Andy's","target":"main","source":"feature/1-one"}"#), |
|
| 423 | + | ) |
|
| 424 | + | .await; |
|
| 425 | + | assert_eq!(st, StatusCode::CREATED, "{v}"); |
|
| 426 | + | let n = v["number"].as_i64().unwrap(); |
|
| 427 | + | assert_eq!(edit(&bob_rw, n, r#"{"body":"mine now"}"#).await.0, StatusCode::FORBIDDEN); |
|
| 428 | + | assert_eq!(edit(&andy_ro, 2, r#"{"body":"x"}"#).await.0, StatusCode::FORBIDDEN, "a read-only token cannot edit"); |
|
| 401 | 429 | } |
|
| 402 | 430 | ||
| 403 | 431 | #[tokio::test(flavor = "multi_thread")] |
|
| … | 841 unchanged lines |
modified crates/oh/src/main.rs +49 −2
| … | 159 unchanged lines |
||
| 160 | 160 | title: String, |
|
| 161 | 161 | #[arg(long, short)] |
|
| 162 | 162 | body: Option<String>, |
|
| 163 | + | /// Read the body from a file ("-" for stdin) |
|
| 164 | + | #[arg(long, short = 'F', conflicts_with = "body")] |
|
| 165 | + | body_file: Option<String>, |
|
| 163 | 166 | /// The bookmark with the changes (or OWNER/NAME:BOOKMARK of a fork) |
|
| 164 | 167 | #[arg(long)] |
|
| 165 | 168 | from: String, |
|
| … | 2 unchanged lines |
||
| 168 | 171 | into: Option<String>, |
|
| 169 | 172 | #[arg(long)] |
|
| 170 | 173 | draft: bool, |
|
| 174 | + | }, |
|
| 175 | + | /// Change a proposal's title or description (its author or a maintainer) |
|
| 176 | + | Edit { |
|
| 177 | + | number: i64, |
|
| 178 | + | #[arg(long, short)] |
|
| 179 | + | title: Option<String>, |
|
| 180 | + | #[arg(long, short)] |
|
| 181 | + | body: Option<String>, |
|
| 182 | + | /// Read the body from a file ("-" for stdin) |
|
| 183 | + | #[arg(long, short = 'F', conflicts_with = "body")] |
|
| 184 | + | body_file: Option<String>, |
|
| 171 | 185 | }, |
|
| 186 | + | /// Comment on a proposal's conversation |
|
| 187 | + | Comment { |
|
| 188 | + | number: i64, |
|
| 189 | + | #[arg(long, short)] |
|
| 190 | + | body: Option<String>, |
|
| 191 | + | /// Read the comment from a file ("-" for stdin) |
|
| 192 | + | #[arg(long, short = 'F', conflicts_with = "body")] |
|
| 193 | + | body_file: Option<String>, |
|
| 194 | + | }, |
|
| 172 | 195 | /// The checks on a proposal's projected state |
|
| 173 | 196 | Checks { number: i64 }, |
|
| 174 | 197 | /// Land a proposal on its target, as the page's Land button does |
|
| … | 371 unchanged lines |
||
| 546 | 569 | } |
|
| 547 | 570 | println!("{}", ui::dim(&p.url)); |
|
| 548 | 571 | } |
|
| 549 | - | ProposalCmd::Create { title, body, from, into, draft } => { |
|
| 572 | + | ProposalCmd::Create { title, body, body_file, from, into, draft } => { |
|
| 550 | 573 | let target = match into { |
|
| 551 | 574 | Some(i) => i, |
|
| 552 | 575 | None => c.get::<serde_json::Value>(&base)?["default_bookmark"].as_str().unwrap_or("main").to_string(), |
|
| 553 | 576 | }; |
|
| 554 | - | let new = NewProposal { title, body: body.unwrap_or_default(), target, source: from, draft }; |
|
| 577 | + | let new = NewProposal { title, body: body_of(body, body_file)?, target, source: from, draft }; |
|
| 555 | 578 | let p: Proposal = c.send("POST", &format!("{base}/proposals"), &new)?; |
|
| 556 | 579 | if json { |
|
| 557 | 580 | println!("{}", serde_json::to_string(&p)?); |
|
| 558 | 581 | } else { |
|
| 559 | 582 | println!("{} proposed #{} {}", ui::ok("✔"), p.number, ui::dim(&p.url)); |
|
| 560 | 583 | } |
|
| 584 | + | } |
|
| 585 | + | ProposalCmd::Edit { number, title, body, body_file } => { |
|
| 586 | + | let body = match (&body, &body_file) { |
|
| 587 | + | (None, None) => None, |
|
| 588 | + | _ => Some(body_of(body, body_file)?), |
|
| 589 | + | }; |
|
| 590 | + | if title.is_none() && body.is_none() { |
|
| 591 | + | bail!("say what to change: --title, --body or --body-file"); |
|
| 592 | + | } |
|
| 593 | + | let edit = hub_api::ProposalEdit { title, body }; |
|
| 594 | + | let p: Proposal = c.send("PATCH", &format!("{base}/proposals/{number}"), &edit)?; |
|
| 595 | + | if json { |
|
| 596 | + | println!("{}", serde_json::to_string(&p)?); |
|
| 597 | + | } else { |
|
| 598 | + | println!("{} edited #{} {}", ui::ok("✔"), p.number, ui::dim(&p.url)); |
|
| 599 | + | } |
|
| 600 | + | } |
|
| 601 | + | ProposalCmd::Comment { number, body, body_file } => { |
|
| 602 | + | let body = body_of(body, body_file)?; |
|
| 603 | + | if body.trim().is_empty() { |
|
| 604 | + | bail!("give the comment with --body or --body-file"); |
|
| 605 | + | } |
|
| 606 | + | c.send_text("POST", &format!("{base}/proposals/{number}/comments"), &NewComment { body, path: None, line: None })?; |
|
| 607 | + | println!("{} commented on #{number}", ui::ok("✔")); |
|
| 561 | 608 | } |
|
| 562 | 609 | ProposalCmd::Land { number } => { |
|
| 563 | 610 | let p: Proposal = c.send("POST", &format!("{base}/proposals/{number}/land"), &serde_json::json!({}))?; |
|
| … | 250 unchanged lines |
modified docs/spec.md +1 −1
| … | 438 unchanged lines |
||
| 439 | 439 | ||
| 440 | 440 | ### 12.1 API |
|
| 441 | 441 | ||
| 442 | - | 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` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/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. |
|
| 442 | + | 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` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/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` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/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. |
|
| 443 | 443 | ||
| 444 | 444 | ### 12.2 Webhooks |
|
| 445 | 445 | ||
| … | 215 unchanged lines |