Skip to main content

facto_core/forges/
gitlab.rs

1use std::future::Future;
2use std::pin::Pin;
3
4use crate::forges::{Forge, ForgeError, ForgeResult};
5use crate::models::*;
6
7pub struct GitLab {
8    client: reqwest::Client,
9    base_url: String,
10    platform_id: String,
11    platform_name: String,
12}
13
14impl GitLab {
15    pub fn new(
16        client: reqwest::Client,
17        base_url: &str,
18        platform_id: &str,
19        platform_name: &str,
20    ) -> Self {
21        Self {
22            client,
23            base_url: base_url.trim_end_matches('/').to_string(),
24            platform_id: platform_id.to_string(),
25            platform_name: platform_name.to_string(),
26        }
27    }
28}
29
30#[derive(serde::Deserialize)]
31struct GitLabRelease {
32    tag_name: String,
33    name: Option<String>,
34    released_at: Option<String>,
35    #[serde(default)]
36    upcoming_release: bool,
37    _links: Option<GitLabReleaseLinks>,
38    assets: Option<GitLabAssets>,
39}
40
41#[derive(serde::Deserialize)]
42struct GitLabReleaseLinks {
43    #[serde(rename = "self")]
44    self_url: Option<String>,
45}
46
47#[derive(serde::Deserialize)]
48struct GitLabAssets {
49    links: Option<Vec<GitLabAssetLink>>,
50}
51
52#[derive(serde::Deserialize)]
53struct GitLabAssetLink {
54    name: String,
55    url: String,
56}
57
58#[derive(serde::Deserialize)]
59struct GitLabProject {
60    name: String,
61    path_with_namespace: String,
62    namespace: GitLabNamespace,
63    description: Option<String>,
64    star_count: u64,
65    #[serde(default)]
66    topics: Vec<String>,
67    web_url: String,
68    last_activity_at: Option<String>,
69}
70
71#[derive(serde::Deserialize)]
72struct GitLabNamespace {
73    path: String,
74}
75
76impl Forge for GitLab {
77    fn id(&self) -> &str {
78        &self.platform_id
79    }
80
81    fn display_name(&self) -> &str {
82        &self.platform_name
83    }
84
85    fn supports_releases(&self) -> bool {
86        true
87    }
88
89    fn list_releases<'a>(
90        &'a self,
91        owner: &'a str,
92        repo: &'a str,
93        limit: usize,
94    ) -> Pin<Box<dyn Future<Output = ForgeResult<Vec<ReleaseInfo>>> + Send + 'a>> {
95        Box::pin(async move {
96            let project_path = format!("{}/{}", owner, repo);
97            let encoded = urlencoding::encode(&project_path);
98            let url = format!(
99                "{}/api/v4/projects/{}/releases?per_page={}",
100                self.base_url, encoded, limit
101            );
102            let resp = self.client.get(&url).send().await?;
103
104            match resp.status().as_u16() {
105                200 => {
106                    let releases: Vec<GitLabRelease> = crate::forges::bounded_json(resp).await?;
107                    Ok(releases
108                        .into_iter()
109                        .map(|r| {
110                            let html_url = r._links.and_then(|l| l.self_url).unwrap_or_default();
111                            let assets = r
112                                .assets
113                                .and_then(|a| a.links)
114                                .unwrap_or_default()
115                                .into_iter()
116                                .map(|a| ReleaseAsset {
117                                    name: a.name,
118                                    size: 0,
119                                    download_url: a.url,
120                                    download_count: 0,
121                                    content_type: String::new(),
122                                })
123                                .collect();
124                            ReleaseInfo {
125                                tag: r.tag_name,
126                                name: r.name,
127                                published_at: r
128                                    .released_at
129                                    .as_deref()
130                                    .and_then(|ts| ts.parse().ok()),
131                                prerelease: r.upcoming_release,
132                                draft: false,
133                                html_url,
134                                assets,
135                            }
136                        })
137                        .collect())
138                }
139                404 => Ok(Vec::new()),
140                status => Err(ForgeError::Parse(format!("unexpected status {}", status))),
141            }
142        })
143    }
144
145    fn supports_search(&self) -> bool {
146        true
147    }
148
149    fn search_repositories<'a>(
150        &'a self,
151        query: &'a str,
152        sort: RepoSort,
153        _language: Option<&'a str>,
154        limit: usize,
155    ) -> Pin<Box<dyn Future<Output = ForgeResult<Vec<RepositorySearchResult>>> + Send + 'a>> {
156        Box::pin(async move {
157            let order_by = match sort {
158                RepoSort::Stars => "star_count",
159                RepoSort::Updated => "last_activity_at",
160                RepoSort::Relevance => "similarity",
161            };
162            let url = format!(
163                "{}/api/v4/projects?search={}&order_by={}&sort=desc&per_page={}",
164                self.base_url,
165                urlencoding::encode(query),
166                order_by,
167                limit.min(100),
168            );
169            let resp = self.client.get(&url).send().await?;
170            match resp.status().as_u16() {
171                200 => {}
172                429 => return Err(ForgeError::RateLimited),
173                status => return Err(ForgeError::Parse(format!("unexpected status {status}"))),
174            }
175            let projects: Vec<GitLabProject> = crate::forges::bounded_json(resp).await?;
176            Ok(projects
177                .into_iter()
178                .map(|p| RepositorySearchResult {
179                    forge: self.platform_id.clone(),
180                    full_name: p.path_with_namespace,
181                    owner: p.namespace.path,
182                    name: p.name,
183                    description: p.description,
184                    stars: p.star_count,
185                    // GitLab's project-list API has no language field.
186                    language: None,
187                    topics: p.topics,
188                    url: p.web_url,
189                    updated_at: p.last_activity_at.as_deref().and_then(|ts| ts.parse().ok()),
190                })
191                .collect())
192        })
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[tokio::test]
201    #[ignore] // requires network
202    async fn test_gitlab_list_releases() {
203        let gl = GitLab::new(
204            crate::http::default_client().unwrap(),
205            "https://gitlab.com",
206            "gitlab",
207            "GitLab",
208        );
209        let releases = gl.list_releases("gitlab-org", "gitlab", 5).await.unwrap();
210        assert!(!releases.is_empty());
211        assert!(releases.len() <= 5);
212    }
213
214    #[tokio::test]
215    #[ignore] // requires network
216    async fn test_search_repositories() {
217        let gl = GitLab::new(
218            crate::http::default_client().unwrap(),
219            "https://gitlab.com",
220            "gitlab",
221            "GitLab",
222        );
223        let results = gl
224            .search_repositories("kubernetes", RepoSort::Stars, None, 5)
225            .await
226            .unwrap();
227        assert!(!results.is_empty());
228        assert!(results.len() <= 5);
229        assert_eq!(results[0].forge, "gitlab");
230    }
231}