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