Skip to main content

upstream_rs/providers/gitlab/
gitlab_adapter.rs

1use 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        // Convert asset links to Assets
118        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, // GitLab doesn't provide size in link metadata
128                created_at,
129            ));
130        }
131
132        // Convert source archives to Assets
133        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, // GitLab doesn't have numeric release IDs
151            tag: dto.tag_name,
152            name: dto.name,
153            body: dto.description,
154            is_draft: false, // GitLab doesn't have draft releases
155            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(
237            GitlabClient::new(None, None, Default::default()).expect("gitlab client"),
238        );
239        let dto = GitlabReleaseDto {
240            tag_name: "v1.9.0".to_string(),
241            name: "v1.9.0".to_string(),
242            description: "notes".to_string(),
243            created_at: "2026-02-21T00:00:00Z".to_string(),
244            released_at: None,
245            upcoming_release: Some(false),
246            assets: GitlabAssetsDto {
247                count: 2,
248                links: vec![GitlabLinkDto {
249                    id: 1,
250                    name: "tool-linux.tar.gz".to_string(),
251                    url: "https://example.invalid/tool-linux.tar.gz".to_string(),
252                    direct_asset_url: None,
253                    link_type: None,
254                }],
255                sources: vec![GitlabSourceDto {
256                    format: "tar.gz".to_string(),
257                    url: "https://example.invalid/source.tar.gz".to_string(),
258                }],
259            },
260        };
261
262        let release = adapter.convert_release(dto);
263        assert_eq!(release.version.to_string(), "1.9.0");
264        assert_eq!(release.assets.len(), 2);
265        assert_eq!(release.assets[1].name, "source.tar.gz");
266    }
267}