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(
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}