OXYGENHUB

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