upstream_rs/providers/gitlab/
gitlab_adapter.rs1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use std::path::Path;
4
5use crate::models::common::Version;
6use crate::models::provider::{Asset, Release};
7use crate::providers::release_provider::ReleaseProvider;
8
9use super::gitlab_client::GitlabClient;
10use super::gitlab_dtos::GitlabReleaseDto;
11
12#[derive(Debug, Clone)]
13pub struct GitlabAdapter {
14 client: GitlabClient,
15}
16
17impl GitlabAdapter {
18 pub fn new(client: GitlabClient) -> Self {
19 Self { client }
20 }
21
22 pub async fn download_asset<F>(
23 &self,
24 asset: &Asset,
25 destination_path: &Path,
26 dl_callback: &mut Option<F>,
27 ) -> Result<()>
28 where
29 F: FnMut(u64, u64),
30 {
31 self.client
32 .download_file(&asset.download_url, destination_path, dl_callback)
33 .await
34 }
35
36 pub async fn get_release_by_tag(&self, project_path: &str, tag: &str) -> Result<Release> {
37 let dto = self.client.get_release_by_tag(project_path, tag).await?;
38 Ok(self.convert_release(dto))
39 }
40
41 pub async fn get_latest_release(&self, project_path: &str) -> Result<Release> {
42 let releases = self.get_releases(project_path, Some(1), Some(1)).await?;
43 releases
44 .into_iter()
45 .next()
46 .ok_or_else(|| anyhow::anyhow!("No releases found for project {}", project_path))
47 }
48
49 pub async fn get_releases(
50 &self,
51 project_path: &str,
52 per_page: Option<u32>,
53 max_total: Option<u32>,
54 ) -> Result<Vec<Release>> {
55 let dtos = self
56 .client
57 .get_releases(project_path, per_page, max_total)
58 .await?;
59 Ok(dtos
60 .into_iter()
61 .map(|dto| self.convert_release(dto))
62 .collect())
63 }
64
65 pub async fn get_releases_newer_than(
66 &self,
67 project_path: &str,
68 from_version: &Version,
69 per_page: Option<u32>,
70 ) -> Result<Vec<Release>> {
71 let per_page = per_page.unwrap_or(20).min(100);
72 let mut page = 1;
73 let mut releases = Vec::new();
74
75 loop {
76 let batch = self
77 .client
78 .get_releases_page(project_path, per_page, page)
79 .await?;
80 if batch.is_empty() {
81 break;
82 }
83
84 let partial_page = batch.len() < per_page as usize;
85 let mut reached_from_version = false;
86 for dto in batch {
87 let parsed_version = Version::from_tag(&dto.tag_name).ok();
88 let release = self.convert_release(dto);
89 if parsed_version
90 .as_ref()
91 .is_some_and(|version| version <= from_version)
92 {
93 reached_from_version = true;
94 continue;
95 }
96 releases.push(release);
97 }
98
99 if reached_from_version || partial_page {
100 break;
101 }
102
103 page += 1;
104 }
105
106 Ok(releases)
107 }
108
109 pub async fn get_branch_head_sha(&self, project_path: &str, branch: &str) -> Result<String> {
110 self.client.get_branch_head_sha(project_path, branch).await
111 }
112
113 fn convert_release(&self, dto: GitlabReleaseDto) -> Release {
114 let mut assets = Vec::new();
115 let mut asset_id: u64 = 0;
116
117 for link in dto.assets.links {
119 asset_id += 1;
120 let download_url = link.direct_asset_url.unwrap_or(link.url);
121 let created_at = Self::parse_timestamp(&dto.created_at);
122
123 assets.push(Asset::new(
124 download_url,
125 asset_id,
126 link.name,
127 0, created_at,
129 ));
130 }
131
132 for source in dto.assets.sources {
134 asset_id += 1;
135 let name = format!("source.{}", source.format);
136 let created_at = Self::parse_timestamp(&dto.created_at);
137
138 assets.push(Asset::new(source.url, asset_id, name, 0, created_at));
139 }
140
141 let version =
142 Version::from_tag(&dto.tag_name).unwrap_or_else(|_| Version::new(0, 0, 0, false));
143 let published_at = dto
144 .released_at
145 .as_ref()
146 .map(|s| Self::parse_timestamp(s))
147 .unwrap_or_else(|| Self::parse_timestamp(&dto.created_at));
148
149 Release {
150 id: asset_id, tag: dto.tag_name,
152 name: dto.name,
153 body: dto.description,
154 is_draft: false, is_prerelease: dto.upcoming_release.unwrap_or(false),
156 published_at,
157 assets,
158 version,
159 }
160 }
161
162 fn parse_timestamp(raw: &str) -> DateTime<Utc> {
163 if raw.trim().is_empty() {
164 return DateTime::<Utc>::MIN_UTC;
165 }
166 raw.parse::<DateTime<Utc>>()
167 .unwrap_or(DateTime::<Utc>::MIN_UTC)
168 }
169}
170
171#[async_trait::async_trait(?Send)]
172impl ReleaseProvider for GitlabAdapter {
173 async fn get_latest_release(&self, slug: &str) -> Result<Release> {
174 GitlabAdapter::get_latest_release(self, slug).await
175 }
176
177 async fn get_releases(
178 &self,
179 slug: &str,
180 per_page: Option<u32>,
181 max_total: Option<u32>,
182 ) -> Result<Vec<Release>> {
183 GitlabAdapter::get_releases(self, slug, per_page, max_total).await
184 }
185
186 async fn get_releases_newer_than(
187 &self,
188 slug: &str,
189 from_version: &Version,
190 per_page: Option<u32>,
191 ) -> Result<Vec<Release>> {
192 GitlabAdapter::get_releases_newer_than(self, slug, from_version, per_page).await
193 }
194
195 async fn get_release_by_tag(&self, slug: &str, tag: &str) -> Result<Release> {
196 GitlabAdapter::get_release_by_tag(self, slug, tag).await
197 }
198
199 async fn get_branch_head_sha(&self, slug: &str, branch: &str) -> Result<String> {
200 GitlabAdapter::get_branch_head_sha(self, slug, branch).await
201 }
202
203 async fn download_asset(
204 &self,
205 asset: &Asset,
206 destination_path: &Path,
207 dl_callback: Option<&mut (dyn FnMut(u64, u64) + '_)>,
208 ) -> Result<()> {
209 let mut forwarded = dl_callback;
210 GitlabAdapter::download_asset(self, asset, destination_path, &mut forwarded).await
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::GitlabAdapter;
217 use crate::providers::gitlab::gitlab_client::GitlabClient;
218 use crate::providers::gitlab::gitlab_dtos::{
219 GitlabAssetsDto, GitlabLinkDto, GitlabReleaseDto, GitlabSourceDto,
220 };
221
222 #[test]
223 fn parse_timestamp_handles_invalid_values() {
224 assert_eq!(
225 GitlabAdapter::parse_timestamp(""),
226 chrono::DateTime::<chrono::Utc>::MIN_UTC
227 );
228 assert_eq!(
229 GitlabAdapter::parse_timestamp("bad-date"),
230 chrono::DateTime::<chrono::Utc>::MIN_UTC
231 );
232 }
233
234 #[test]
235 fn convert_release_combines_links_and_sources_into_assets() {
236 let adapter = GitlabAdapter::new(GitlabClient::new(None, None).expect("gitlab client"));
237 let dto = GitlabReleaseDto {
238 tag_name: "v1.9.0".to_string(),
239 name: "v1.9.0".to_string(),
240 description: "notes".to_string(),
241 created_at: "2026-02-21T00:00:00Z".to_string(),
242 released_at: None,
243 upcoming_release: Some(false),
244 assets: GitlabAssetsDto {
245 count: 2,
246 links: vec![GitlabLinkDto {
247 id: 1,
248 name: "tool-linux.tar.gz".to_string(),
249 url: "https://example.invalid/tool-linux.tar.gz".to_string(),
250 direct_asset_url: None,
251 link_type: None,
252 }],
253 sources: vec![GitlabSourceDto {
254 format: "tar.gz".to_string(),
255 url: "https://example.invalid/source.tar.gz".to_string(),
256 }],
257 },
258 };
259
260 let release = adapter.convert_release(dto);
261 assert_eq!(release.version.to_string(), "1.9.0");
262 assert_eq!(release.assets.len(), 2);
263 assert_eq!(release.assets[1].name, "source.tar.gz");
264 }
265}