Edit a release through the API and oh release edit ced6d3afc5cf
PATCH /api/v1/repos/{owner}/{name}/releases/{tag} changes the title,
notes or prerelease flag, keeping fields left out. oh release edit
sends it; --notes - reads the notes from stdin.
closes #72
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-02 02:02 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
ced6d3afc5cf135158603e2acf3c2fa8473f93488c5a9320df2e064d80db00a1- Depends on
- 3f4b0ea6eabf 5533e91a2aa4 5c6e92fd8d71 749d9a5fcd9f b0612e7dce30 b5668108487f d23464c0ac60
- Depended on by
- 3d086bc3d69b 851a26a2a6d1 5a9a6e9a15bc 166f6e9bf4f1 739a763a558c eef677c48cd1
- 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
- 7 atoms, 0 tree operations, 5 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 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). |
|
| 8 | + | ||
| 7 | 9 | - **Runners get only jobs they can run (#73).** A runner that lists the rootfs images it holds (OxygenFlow does, at every claim) is offered only jobs whose image is among them, rather than jobs it would fail for want of one. |
|
| 8 | 10 | ||
| 9 | 11 | - **Lost jobs end by themselves (#71).** A job still running five minutes past the job time limit is one its runner no longer reports (it restarted, or lost the hub as the job ended). The hub now looks for those once a minute and ends them as errors with the reason, skipping the jobs that depended on them and settling the run, so a runner is not shown busy with jobs it is not running. Earth's reset stranded five such jobs, closed by hand. |
|
| … | 21 unchanged lines |
modified crates/hub-api/src/lib.rs +12 −0
| … | 852 unchanged lines |
||
| 853 | 853 | pub signer: Option<String>, |
|
| 854 | 854 | } |
|
| 855 | 855 | ||
| 856 | + | /// `PATCH /repos/{owner}/{name}/releases/{tag}`: what to change; fields |
|
| 857 | + | /// left out stay as they are. |
|
| 858 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 859 | + | pub struct ReleaseEdit { |
|
| 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 notes: Option<String>, |
|
| 864 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 865 | + | pub prerelease: Option<bool>, |
|
| 866 | + | } |
|
| 867 | + | ||
| 856 | 868 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 857 | 869 | pub struct NewRelease { |
|
| 858 | 870 | pub tag: String, |
|
| … | 121 unchanged lines |
modified crates/hub/src/api/mod.rs +25 −0
| … | 97 unchanged lines |
||
| 98 | 98 | .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks)) |
|
| 99 | 99 | .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state)) |
|
| 100 | 100 | .route("/repos/{owner}/{name}/releases", get(releases).post(create_release)) |
|
| 101 | + | .route("/repos/{owner}/{name}/releases/{tag}", axum::routing::patch(edit_release)) |
|
| 101 | 102 | .route( |
|
| 102 | 103 | "/repos/{owner}/{name}/releases/{tag}/assets", |
|
| 103 | 104 | post(upload_asset).layer(axum::extract::DefaultBodyLimit::max(asset_limit + (1 << 20))), |
|
| … | 30 unchanged lines |
||
| 134 | 135 | Ok((StatusCode::CREATED, Json(r))) |
|
| 135 | 136 | } |
|
| 136 | 137 | ||
| 138 | + | /// `PATCH …/releases/{tag}` (oxygenhub#72): change a release's title, |
|
| 139 | + | /// notes or prerelease flag, as the release page's Edit does. |
|
| 140 | + | async fn edit_release( |
|
| 141 | + | State(app): State<Shared>, |
|
| 142 | + | headers: HeaderMap, |
|
| 143 | + | Path((owner, name, tag)): Path<(String, String, String)>, |
|
| 144 | + | Json(body): Json<ReleaseEdit>, |
|
| 145 | + | ) -> Result<Json<Release>, ApiError> { |
|
| 146 | + | let c = caller(&app, &headers).await?; |
|
| 147 | + | let me = c.require("repo:write")?.clone(); |
|
| 148 | + | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
|
| 149 | + | let r = db::releases::get(&app.db, row.id, &tag).await?.ok_or_else(|| not_found("no such release"))?; |
|
| 150 | + | let title = body.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or(&r.title); |
|
| 151 | + | let notes = body.notes.as_deref().map(str::trim).unwrap_or(&r.notes); |
|
| 152 | + | db::releases::edit(&app.db, r.id, title, notes, body.prerelease.unwrap_or(r.prerelease)).await?; |
|
| 153 | + | db::accounts::audit(&app.db, Some(&me), "release.edited", &format!("{owner}/{name}@{tag}"), "").await.ok(); |
|
| 154 | + | let r = crate::web::releases::api_views(&app, &repo, &row) |
|
| 155 | + | .await? |
|
| 156 | + | .into_iter() |
|
| 157 | + | .find(|r| r.tag == tag) |
|
| 158 | + | .ok_or_else(|| not_found("no such release"))?; |
|
| 159 | + | Ok(Json(r)) |
|
| 160 | + | } |
|
| 161 | + | ||
| 137 | 162 | #[derive(Deserialize)] |
|
| 138 | 163 | struct AssetQuery { |
|
| 139 | 164 | name: String, |
|
| … | 1737 unchanged lines |
modified crates/hub/tests/releases.rs +21 −0
| … | 173 unchanged lines |
||
| 174 | 174 | .await; |
|
| 175 | 175 | assert_eq!(r.status, StatusCode::BAD_REQUEST); |
|
| 176 | 176 | ||
| 177 | + | // oxygenhub#72: a release's notes and flags change through the API; |
|
| 178 | + | // what is left out stays. |
|
| 179 | + | let r = send( |
|
| 180 | + | app, |
|
| 181 | + | "PATCH", |
|
| 182 | + | "/api/v1/repos/andy/demo/releases/v0.2", |
|
| 183 | + | None, |
|
| 184 | + | Some("site"), |
|
| 185 | + | Some("application/json"), |
|
| 186 | + | br#"{"notes":"Corrected notes.","prerelease":true}"#.to_vec(), |
|
| 187 | + | ) |
|
| 188 | + | .await; |
|
| 189 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.text()); |
|
| 190 | + | let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap(); |
|
| 191 | + | assert_eq!( |
|
| 192 | + | (v["notes"].as_str(), v["prerelease"].as_bool(), v["title"].as_str()), |
|
| 193 | + | (Some("Corrected notes."), Some(true), Some("Zero two")) |
|
| 194 | + | ); |
|
| 195 | + | let r = send(app, "PATCH", "/api/v1/repos/andy/demo/releases/none", None, Some("site"), Some("application/json"), b"{}".to_vec()).await; |
|
| 196 | + | assert_eq!(r.status, StatusCode::NOT_FOUND); |
|
| 197 | + | ||
| 177 | 198 | // An asset through the API; download it back; the page lists it. |
|
| 178 | 199 | let r = send( |
|
| 179 | 200 | app, |
|
| … | 162 unchanged lines |
modified crates/oh/src/main.rs +28 −0
| … | 217 unchanged lines |
||
| 218 | 218 | #[arg(long)] |
|
| 219 | 219 | prerelease: bool, |
|
| 220 | 220 | }, |
|
| 221 | + | /// Change a release's title, notes or prerelease flag |
|
| 222 | + | Edit { |
|
| 223 | + | tag: String, |
|
| 224 | + | #[arg(long, short)] |
|
| 225 | + | title: Option<String>, |
|
| 226 | + | /// New notes in Markdown ("-" reads them from stdin) |
|
| 227 | + | #[arg(long, short)] |
|
| 228 | + | notes: Option<String>, |
|
| 229 | + | /// Mark it a prerelease (true) or not (false) |
|
| 230 | + | #[arg(long)] |
|
| 231 | + | prerelease: Option<bool>, |
|
| 232 | + | }, |
|
| 221 | 233 | /// Attach files to a release |
|
| 222 | 234 | Upload { tag: String, files: Vec<std::path::PathBuf> }, |
|
| 223 | 235 | /// The release's trust report: signatures, builds, receipts and audit |
|
| … | 518 unchanged lines |
||
| 742 | 754 | println!("{}", serde_json::to_string(&r)?); |
|
| 743 | 755 | } else { |
|
| 744 | 756 | println!("{} released {} {}", ui::ok("✔"), r.tag, ui::dim(&r.url)); |
|
| 757 | + | } |
|
| 758 | + | } |
|
| 759 | + | ReleaseCmd::Edit { tag, title, notes, prerelease } => { |
|
| 760 | + | let notes = match notes.as_deref() { |
|
| 761 | + | Some("-") => Some(body_of(None, Some("-".into()))?), |
|
| 762 | + | _ => notes, |
|
| 763 | + | }; |
|
| 764 | + | if title.is_none() && notes.is_none() && prerelease.is_none() { |
|
| 765 | + | bail!("say what to change: --title, --notes or --prerelease"); |
|
| 766 | + | } |
|
| 767 | + | let edit = hub_api::ReleaseEdit { title, notes, prerelease }; |
|
| 768 | + | let r: Release = c.send("PATCH", &format!("{base}/releases/{tag}"), &edit)?; |
|
| 769 | + | if json { |
|
| 770 | + | println!("{}", serde_json::to_string(&r)?); |
|
| 771 | + | } else { |
|
| 772 | + | println!("{} edited {} {}", ui::ok("✔"), r.tag, ui::dim(&r.url)); |
|
| 745 | 773 | } |
|
| 746 | 774 | } |
|
| 747 | 775 | ReleaseCmd::Upload { tag, files } => { |
|
| … | 38 unchanged lines |