torii_lib/platforms/gitlab/
release.rs1use crate::error::{Result, ToriiError};
4use crate::platforms::release::*;
5use reqwest::blocking::Client;
6
7pub struct GitLabReleaseClient {
8 token: String,
9 base_url: String,
10}
11
12impl GitLabReleaseClient {
13 pub fn new() -> Result<Self> {
14 let token = crate::auth::resolve_token("gitlab", ".")
15 .value
16 .ok_or_else(|| ToriiError::Auth {
17 provider: "gitlab".into(),
18 message: "GitLab token not found. Run: torii auth set gitlab YOUR_TOKEN"
19 .to_string(),
20 })?;
21 let base_url =
22 std::env::var("GITLAB_URL").unwrap_or_else(|_| "https://gitlab.com/api/v4".to_string());
23 Ok(Self { token, base_url })
24 }
25
26 fn client(&self) -> Client {
27 crate::http::make_client()
28 }
29
30 fn project_path(owner: &str, repo: &str) -> String {
31 crate::url::encode(&format!("{}/{}", owner, repo))
32 }
33}
34
35impl ReleaseClient for GitLabReleaseClient {
36 fn list(&self, owner: &str, repo: &str, limit: usize) -> Result<Vec<Release>> {
37 let url = format!(
38 "{}/projects/{}/releases?per_page={}",
39 self.base_url,
40 Self::project_path(owner, repo),
41 limit.clamp(1, 100)
42 );
43 let req = self
44 .client()
45 .get(&url)
46 .header("Authorization", format!("Bearer {}", self.token));
47 let json = crate::http::send_json(req, &format!("GitLab (url: {})", url))?;
48 crate::http::extract_array(&json, &url)?
49 .iter()
50 .map(parse_gitlab_release)
51 .collect()
52 }
53
54 fn get(&self, owner: &str, repo: &str, tag: &str) -> Result<Release> {
55 let url = format!(
56 "{}/projects/{}/releases/{}",
57 self.base_url,
58 Self::project_path(owner, repo),
59 tag
60 );
61 let req = self
62 .client()
63 .get(&url)
64 .header("Authorization", format!("Bearer {}", self.token));
65 let json = crate::http::send_json(req, &format!("GitLab (tag: {})", tag))?;
66 parse_gitlab_release(&json)
67 }
68
69 fn edit(
70 &self,
71 owner: &str,
72 repo: &str,
73 tag: &str,
74 name: Option<&str>,
75 description: Option<&str>,
76 ) -> Result<()> {
77 let url = format!(
78 "{}/projects/{}/releases/{}",
79 self.base_url,
80 Self::project_path(owner, repo),
81 tag
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.into()));
86 }
87 if let Some(d) = description {
88 body.insert("description".into(), serde_json::Value::String(d.into()));
89 }
90 if body.is_empty() {
91 return Err(ToriiError::Usage(
92 "edit needs at least one of --name or --notes".to_string(),
93 ));
94 }
95 let req = self
96 .client()
97 .put(&url)
98 .header("Authorization", format!("Bearer {}", self.token))
99 .json(&serde_json::Value::Object(body));
100 crate::http::send_empty(req, "GitLab edit release")
101 }
102
103 fn delete(&self, owner: &str, repo: &str, tag: &str) -> Result<()> {
104 let url = format!(
105 "{}/projects/{}/releases/{}",
106 self.base_url,
107 Self::project_path(owner, repo),
108 tag
109 );
110 let req = self
111 .client()
112 .delete(&url)
113 .header("Authorization", format!("Bearer {}", self.token));
114 crate::http::send_empty(req, "GitLab delete release")
115 }
116}
117
118pub(crate) fn parse_gitlab_release(v: &serde_json::Value) -> Result<Release> {
119 let tag = v["tag_name"].as_str().unwrap_or("").to_string();
120 Ok(Release {
121 tag: tag.clone(),
122 name: v["name"].as_str().unwrap_or(&tag).to_string(),
123 description: v["description"].as_str().unwrap_or("").to_string(),
124 created_at: v["created_at"].as_str().unwrap_or("").to_string(),
125 web_url: v["_links"]["self"].as_str().unwrap_or("").to_string(),
126 id: None, })
128}
129
130#[cfg(test)]
140mod tests {
141 use super::*;
142 use httpmock::prelude::*;
143
144 #[test]
147 fn parse_gitlab_release_full() {
148 let json = serde_json::json!({
149 "tag_name": "v0.9.2",
150 "name": "Torii 0.9.2",
151 "description": "Bug fixes.",
152 "created_at": "2026-06-01T00:00:00Z",
153 "_links": { "self": "https://gitlab.com/acme/widget/-/releases/v0.9.2" }
154 });
155 let r = parse_gitlab_release(&json).unwrap();
156 assert_eq!(r.tag, "v0.9.2");
157 assert_eq!(r.name, "Torii 0.9.2");
158 assert_eq!(r.description, "Bug fixes.");
159 assert_eq!(r.created_at, "2026-06-01T00:00:00Z");
160 assert_eq!(
161 r.web_url,
162 "https://gitlab.com/acme/widget/-/releases/v0.9.2"
163 );
164 assert_eq!(r.id, None);
165 }
166
167 #[test]
168 fn parse_gitlab_release_name_falls_back_to_tag() {
169 let json = serde_json::json!({ "tag_name": "v1.0.0" });
170 let r = parse_gitlab_release(&json).unwrap();
171 assert_eq!(r.tag, "v1.0.0");
172 assert_eq!(r.name, "v1.0.0");
173 assert_eq!(r.description, "");
174 assert_eq!(r.web_url, "");
175 }
176
177 fn client(server: &MockServer) -> GitLabReleaseClient {
180 GitLabReleaseClient {
181 token: "test-token".into(),
182 base_url: server.base_url(),
183 }
184 }
185
186 #[test]
187 fn list_parses_releases() {
188 let server = MockServer::start();
189 let m = server.mock(|when, then| {
190 when.method(GET)
191 .path("/projects/acme%2Fwidget/releases")
192 .query_param("per_page", "10")
193 .header("Authorization", "Bearer test-token");
194 then.status(200).json_body(serde_json::json!([{
195 "tag_name": "v0.9.2", "name": "Torii 0.9.2",
196 "description": "", "created_at": "",
197 "_links": { "self": "https://x" }
198 }]));
199 });
200 let releases = client(&server).list("acme", "widget", 10).unwrap();
201 m.assert();
202 assert_eq!(releases.len(), 1);
203 assert_eq!(releases[0].tag, "v0.9.2");
204 }
205
206 #[test]
207 fn delete_sends_delete_with_bearer_auth() {
208 let server = MockServer::start();
209 let m = server.mock(|when, then| {
210 when.method(DELETE)
211 .path("/projects/acme%2Fwidget/releases/v1.0.0")
212 .header("Authorization", "Bearer test-token");
213 then.status(200);
214 });
215 client(&server).delete("acme", "widget", "v1.0.0").unwrap();
216 m.assert();
217 }
218
219 #[test]
220 fn edit_without_fields_is_usage_error() {
221 let server = MockServer::start();
222 let err = client(&server)
223 .edit("acme", "widget", "v1.0.0", None, None)
224 .unwrap_err();
225 assert!(
226 matches!(err, ToriiError::Usage(_)),
227 "expected Usage, got: {err:?}"
228 );
229 }
230
231 #[test]
232 fn get_non_2xx_maps_to_platform_api_error() {
233 let server = MockServer::start();
234 server.mock(|when, then| {
235 when.method(GET)
236 .path("/projects/acme%2Fwidget/releases/v9.9.9");
237 then.status(404)
238 .json_body(serde_json::json!({ "message": "404 Not Found" }));
239 });
240 let err = client(&server).get("acme", "widget", "v9.9.9").unwrap_err();
241 assert!(
242 matches!(err, ToriiError::PlatformApi { .. }),
243 "expected PlatformApi, got: {err:?}"
244 );
245 }
246}