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