Skip to main content

torii_lib/platforms/gitlab/
release.rs

1//! GitLab — release client.
2
3use 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, // GitLab uses the tag as the identifier
127    })
128}
129
130// ============================================================================
131// Gitea / Codeberg / Forgejo Releases
132// ============================================================================
133//
134// Gitea exposes a GitHub-shaped REST API at `/api/v1/...`. Forgejo (the
135// Codeberg fork) is API-compatible. Auth header is `token <token>`, same
136// as GitHub. Releases carry an integer `id` separate from the tag,
137// matching GitHub's model — `delete` requires the id, not the tag.
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use httpmock::prelude::*;
143
144    // ── parser ───────────────────────────────────────────────────────────
145
146    #[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    // ── client (httpmock) ────────────────────────────────────────────────
178
179    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}