Skip to main content

upstream_rs/providers/gitlab/
gitlab_client.rs

1use anyhow::{Context, Result};
2use reqwest::{Client, header};
3use serde::Deserialize;
4use std::path::Path;
5
6use crate::providers::download_handler;
7
8use super::gitlab_dtos::GitlabReleaseDto;
9#[derive(Debug, Deserialize)]
10struct GitlabCommitRefDto {
11    id: String,
12}
13
14#[derive(Debug, Deserialize)]
15struct GitlabBranchDto {
16    commit: GitlabCommitRefDto,
17}
18
19#[derive(Debug, Clone)]
20pub struct GitlabClient {
21    client: Client,
22    base_url: String,
23}
24
25impl GitlabClient {
26    pub fn new(token: Option<&str>, base_url: Option<&str>) -> Result<Self> {
27        let mut base = base_url.unwrap_or("https://gitlab.com").to_string();
28
29        if !base.starts_with("http://") && !base.starts_with("https://") {
30            base = format!("https://{}", base);
31        }
32
33        let mut headers = header::HeaderMap::new();
34        let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
35        headers.insert(
36            header::USER_AGENT,
37            header::HeaderValue::from_str(&user_agent)
38                .context("Failed to create user agent header")?,
39        );
40
41        if let Some(token) = token {
42            headers.insert(
43                "PRIVATE-TOKEN",
44                header::HeaderValue::from_str(token)
45                    .context("Failed to create private token header")?,
46            );
47        }
48
49        let client = Client::builder()
50            .default_headers(headers)
51            .build()
52            .context("Failed to build HTTP client")?;
53
54        Ok(Self {
55            client,
56            base_url: base,
57        })
58    }
59
60    async fn get_json<T: for<'de> Deserialize<'de>>(&self, url: &str) -> Result<T> {
61        let response = self
62            .client
63            .get(url)
64            .send()
65            .await
66            .context(format!("Failed to send request to {}", url))?;
67
68        response
69            .error_for_status_ref()
70            .context(format!("GitLab API returned error for {}", url))?;
71
72        let data = response
73            .json::<T>()
74            .await
75            .context("Failed to parse JSON response")?;
76
77        Ok(data)
78    }
79
80    pub async fn download_file<F>(
81        &self,
82        url: &str,
83        destination: &Path,
84        progress: &mut Option<F>,
85    ) -> Result<()>
86    where
87        F: FnMut(u64, u64),
88    {
89        download_handler::download_file(&self.client, url, destination, progress).await
90    }
91
92    fn encode_project_path(project_path: &str) -> String {
93        project_path.replace('/', "%2F")
94    }
95
96    pub async fn get_release_by_tag(
97        &self,
98        project_path: &str,
99        tag: &str,
100    ) -> Result<GitlabReleaseDto> {
101        let encoded_path = Self::encode_project_path(project_path);
102        let url = format!(
103            "{}/api/v4/projects/{}/releases/{}",
104            self.base_url, encoded_path, tag
105        );
106        self.get_json(&url)
107            .await
108            .context(format!("Failed to get release for tag {}", tag))
109    }
110
111    pub async fn get_releases(
112        &self,
113        project_path: &str,
114        per_page: Option<u32>,
115        max_total: Option<u32>,
116    ) -> Result<Vec<GitlabReleaseDto>> {
117        let per_page = per_page.unwrap_or(20).min(100);
118        let encoded_path = Self::encode_project_path(project_path);
119        let mut page = 1;
120        let mut releases = Vec::new();
121
122        loop {
123            let url = format!(
124                "{}/api/v4/projects/{}/releases?per_page={}&page={}",
125                self.base_url, encoded_path, per_page, page
126            );
127            let batch: Vec<GitlabReleaseDto> = self
128                .get_json(&url)
129                .await
130                .context(format!("Failed to get releases page {}", page))?;
131
132            if batch.is_empty() {
133                break;
134            }
135
136            releases.extend(batch);
137
138            if let Some(max) = max_total
139                && releases.len() >= max as usize
140            {
141                releases.truncate(max as usize);
142                break;
143            }
144
145            if releases.len() % per_page as usize != 0 {
146                break;
147            }
148
149            page += 1;
150        }
151
152        Ok(releases)
153    }
154
155    pub async fn get_branch_head_sha(&self, project_path: &str, branch: &str) -> Result<String> {
156        let encoded_path = Self::encode_project_path(project_path);
157        let encoded_branch = Self::encode_project_path(branch);
158        let url = format!(
159            "{}/api/v4/projects/{}/repository/branches/{}",
160            self.base_url, encoded_path, encoded_branch
161        );
162        let dto: GitlabBranchDto = self.get_json(&url).await.context(format!(
163            "Failed to get branch head for {}/{}",
164            project_path, branch
165        ))?;
166        Ok(dto.commit.id)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::GitlabClient;
173    use crate::providers::gitlab::gitlab_dtos::GitlabReleaseDto;
174
175    #[test]
176    fn new_normalizes_base_url_without_scheme() {
177        let client = GitlabClient::new(None, Some("gitlab.example.com")).expect("client");
178        assert_eq!(client.base_url, "https://gitlab.example.com");
179    }
180
181    #[test]
182    fn encode_project_path_percent_encodes_slashes() {
183        assert_eq!(
184            GitlabClient::encode_project_path("group/subgroup/project"),
185            "group%2Fsubgroup%2Fproject"
186        );
187    }
188
189    #[test]
190    fn gitlab_release_dto_deserializes_minimal_valid_payload() {
191        let json = r#"
192            {
193              "tag_name": "v1.0.0",
194              "name": "v1.0.0",
195              "description": "notes",
196              "created_at": "2026-02-21T00:00:00Z",
197              "released_at": null,
198              "upcoming_release": false,
199              "assets": { "count": 0, "sources": [], "links": [] }
200            }
201            "#;
202
203        let parsed = serde_json::from_str::<GitlabReleaseDto>(json).expect("parse release");
204        assert_eq!(parsed.tag_name, "v1.0.0");
205        assert_eq!(parsed.assets.count, 0);
206        assert!(parsed.assets.links.is_empty());
207    }
208}