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