torii_lib/platforms/gitea/
release.rs1use 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 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 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#[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}