OXYGENHUB

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