Skip to main content

torii_lib/platforms/gitea/
release.rs

1//! Gitea / Codeberg / Forgejo — release client.
2
3use crate::error::{Result, ToriiError};
4use crate::platforms::release::*;
5use reqwest::blocking::Client;
6
7pub struct GiteaReleaseClient {
8    token: String,
9    base_url: String,
10}
11
12impl GiteaReleaseClient {
13    pub fn new() -> Result<Self> {
14        Self::new_with_host(crate::pr::gitea_base_url())
15    }
16
17    /// Construct against an arbitrary Gitea/Forgejo host. Today only
18    /// called with codeberg.org; in 0.8.0 the platforms.toml resolver
19    /// will pass user-declared self-hosted URLs through here.
20    pub fn new_with_host(base_url: &str) -> Result<Self> {
21        let token = crate::pr::resolve_gitea_token()?;
22        Ok(Self {
23            token,
24            base_url: base_url.trim_end_matches('/').to_string(),
25        })
26    }
27
28    fn client(&self) -> Client {
29        crate::http::make_client()
30    }
31    fn auth(&self) -> String {
32        format!("token {}", self.token)
33    }
34}
35
36impl ReleaseClient for GiteaReleaseClient {
37    fn list(&self, owner: &str, repo: &str, limit: usize) -> Result<Vec<Release>> {
38        let url = format!(
39            "{}/api/v1/repos/{}/{}/releases?limit={}",
40            self.base_url,
41            owner,
42            repo,
43            limit.clamp(1, 50)
44        );
45        let req = self
46            .client()
47            .get(&url)
48            .header("Authorization", self.auth())
49            .header("Accept", "application/json");
50        let json = crate::http::send_json(req, &format!("Gitea (url: {})", url))?;
51        crate::http::extract_array(&json, &url)?
52            .iter()
53            .map(parse_gitea_release)
54            .collect()
55    }
56
57    fn get(&self, owner: &str, repo: &str, tag: &str) -> Result<Release> {
58        let url = format!(
59            "{}/api/v1/repos/{}/{}/releases/tags/{}",
60            self.base_url, owner, repo, tag
61        );
62        let req = self.client().get(&url).header("Authorization", self.auth());
63        let json = crate::http::send_json(req, &format!("Gitea (tag: {})", tag))?;
64        parse_gitea_release(&json)
65    }
66
67    fn edit(
68        &self,
69        owner: &str,
70        repo: &str,
71        tag: &str,
72        name: Option<&str>,
73        description: Option<&str>,
74    ) -> Result<()> {
75        // Gitea's edit takes the integer release id, not the tag. Resolve
76        // it via `get` first.
77        let release = self.get(owner, repo, tag)?;
78        let id = release.id.ok_or_else(|| ToriiError::MalformedResponse {
79            provider: "gitea".into(),
80            message: "Gitea release missing id (cannot edit)".to_string(),
81        })?;
82
83        let mut body = serde_json::Map::new();
84        if let Some(n) = name {
85            body.insert("name".into(), serde_json::Value::String(n.to_string()));
86        }
87        if let Some(d) = description {
88            body.insert("body".into(), serde_json::Value::String(d.to_string()));
89        }
90        if body.is_empty() {
91            return Ok(());
92        }
93
94        let url = format!(
95            "{}/api/v1/repos/{}/{}/releases/{}",
96            self.base_url, owner, repo, id
97        );
98        let req = self
99            .client()
100            .patch(&url)
101            .header("Authorization", self.auth())
102            .json(&serde_json::Value::Object(body));
103        crate::http::send_empty(req, "Gitea edit release")
104    }
105
106    fn delete(&self, owner: &str, repo: &str, tag: &str) -> Result<()> {
107        let release = self.get(owner, repo, tag)?;
108        let id = release.id.ok_or_else(|| ToriiError::MalformedResponse {
109            provider: "gitea".into(),
110            message: "Gitea release missing id (cannot delete)".to_string(),
111        })?;
112        let url = format!(
113            "{}/api/v1/repos/{}/{}/releases/{}",
114            self.base_url, owner, repo, id
115        );
116        let req = self
117            .client()
118            .delete(&url)
119            .header("Authorization", self.auth());
120        crate::http::send_empty(req, "Gitea delete release")
121    }
122}
123
124fn parse_gitea_release(v: &serde_json::Value) -> Result<Release> {
125    let tag = v["tag_name"].as_str().unwrap_or("").to_string();
126    let id = v["id"].as_u64().map(|n| n.to_string());
127    Ok(Release {
128        tag: tag.clone(),
129        name: v["name"].as_str().unwrap_or(&tag).to_string(),
130        description: v["body"].as_str().unwrap_or("").to_string(),
131        created_at: v["created_at"].as_str().unwrap_or("").to_string(),
132        web_url: v["html_url"].as_str().unwrap_or("").to_string(),
133        id,
134    })
135}
136
137// ============================================================================
138// Sourcehut (no native release object)
139// ============================================================================
140//
141// Sourcehut doesn't expose "releases" as a first-class object the
142// way GitHub / GitLab / Gitea do — a release is just a git tag, and
143// binary distribution happens externally (project homepage, paste.sr.ht,
144// etc.). We return a clear error so the CLI surface remains honest.
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use httpmock::prelude::*;
150
151    fn client(server: &MockServer) -> GiteaReleaseClient {
152        GiteaReleaseClient {
153            token: "test-token".into(),
154            base_url: server.base_url(),
155        }
156    }
157
158    fn release_json(id: u64, tag: &str) -> serde_json::Value {
159        serde_json::json!({
160            "id": id,
161            "tag_name": tag,
162            "name": "Big Release",
163            "body": "changelog here",
164            "created_at": "2026-03-04T05:06:07Z",
165            "html_url": "https://codeberg.org/o/r/releases/tag/v1.0.0",
166        })
167    }
168
169    #[test]
170    fn parse_gitea_release_extracts_all_fields() {
171        let r = parse_gitea_release(&release_json(12, "v1.0.0")).unwrap();
172        assert_eq!(r.tag, "v1.0.0");
173        assert_eq!(r.name, "Big Release");
174        assert_eq!(r.description, "changelog here");
175        assert_eq!(r.created_at, "2026-03-04T05:06:07Z");
176        assert_eq!(r.web_url, "https://codeberg.org/o/r/releases/tag/v1.0.0");
177        assert_eq!(r.id.as_deref(), Some("12"));
178    }
179
180    #[test]
181    fn parse_gitea_release_falls_back_to_tag_when_name_missing() {
182        let r = parse_gitea_release(&serde_json::json!({ "tag_name": "v0.1.0" })).unwrap();
183        assert_eq!(r.tag, "v0.1.0");
184        assert_eq!(r.name, "v0.1.0");
185        assert_eq!(r.description, "");
186        assert_eq!(r.id, None);
187    }
188
189    #[test]
190    fn list_parses_releases_from_mocked_endpoint() {
191        let server = MockServer::start();
192        let mock = server.mock(|when, then| {
193            when.method(GET)
194                .path("/api/v1/repos/owner/repo/releases")
195                .query_param("limit", "5")
196                .header("Authorization", "token test-token");
197            then.status(200).json_body(serde_json::json!([
198                release_json(1, "v1.0.0"),
199                release_json(2, "v1.1.0"),
200            ]));
201        });
202        let releases = client(&server).list("owner", "repo", 5).unwrap();
203        mock.assert();
204        assert_eq!(releases.len(), 2);
205        assert_eq!(releases[0].tag, "v1.0.0");
206        assert_eq!(releases[1].id.as_deref(), Some("2"));
207    }
208
209    #[test]
210    fn delete_resolves_tag_to_id_then_deletes_with_token_auth() {
211        let server = MockServer::start();
212        let get_mock = server.mock(|when, then| {
213            when.method(GET)
214                .path("/api/v1/repos/owner/repo/releases/tags/v1.0.0")
215                .header("Authorization", "token test-token");
216            then.status(200).json_body(release_json(12, "v1.0.0"));
217        });
218        let delete_mock = server.mock(|when, then| {
219            when.method(DELETE)
220                .path("/api/v1/repos/owner/repo/releases/12")
221                .header("Authorization", "token test-token");
222            then.status(204);
223        });
224        client(&server).delete("owner", "repo", "v1.0.0").unwrap();
225        get_mock.assert();
226        delete_mock.assert();
227    }
228
229    #[test]
230    fn get_maps_non_2xx_to_platform_api_error() {
231        let server = MockServer::start();
232        server.mock(|when, then| {
233            when.method(GET)
234                .path("/api/v1/repos/owner/repo/releases/tags/v9.9.9");
235            then.status(404)
236                .json_body(serde_json::json!({ "message": "Not Found" }));
237        });
238        let err = client(&server).get("owner", "repo", "v9.9.9").unwrap_err();
239        assert!(
240            matches!(err, ToriiError::PlatformApi { status: 404, .. }),
241            "expected PlatformApi, got: {err:?}"
242        );
243    }
244}