upstream_rs/providers/gitlab/
gitlab_client.rs1use 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}