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