1use async_stream::stream as async_stream;
2use futures::{Stream, StreamExt, stream};
3use reqwest_middleware::ClientWithMiddleware;
4use serde::{Deserialize, Serialize};
5
6use super::{Debug, MAX_PAGE_SIZE, RemoteClient, RemoteCommit, RemotePullRequest};
7use crate::config::Remote;
8use crate::error::{Error, Result};
9
10pub(crate) const TEMPLATE_VARIABLES: &[&str] = &["gitlab", "commit.gitlab", "commit.remote"];
12
13#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct GitLabProject {
19 pub id: Option<i64>,
21 pub description: Option<String>,
23 pub name: Option<String>,
25 pub name_with_namespace: Option<String>,
27 pub path_with_namespace: Option<String>,
29 pub created_at: Option<String>,
31 pub default_branch: Option<String>,
33}
34
35#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct GitLabCommit {
41 pub id: Option<String>,
43 pub short_id: Option<String>,
45 pub title: Option<String>,
47 pub author_name: Option<String>,
49 pub author_email: Option<String>,
51 pub authored_date: Option<String>,
53 pub committer_name: Option<String>,
55 pub committer_email: Option<String>,
57 pub committed_date: Option<String>,
59 pub created_at: Option<String>,
61 pub message: Option<String>,
63 pub parent_ids: Vec<String>,
65 pub web_url: Option<String>,
67}
68
69impl RemoteCommit for GitLabCommit {
70 fn id(&self) -> String {
71 self.id
72 .clone()
73 .expect("Commit id is required for git-cliff semantics")
74 }
75
76 fn username(&self) -> Option<String> {
77 self.author_name.clone()
78 }
79
80 fn timestamp(&self) -> Option<i64> {
81 self.committed_date
82 .as_deref()
83 .map(|d| self.convert_to_unix_timestamp(d))
84 }
85}
86
87#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct GitLabMergeRequest {
93 pub id: Option<i64>,
95 pub iid: Option<i64>,
97 pub project_id: Option<i64>,
99 pub title: Option<String>,
101 pub description: Option<String>,
103 pub state: Option<String>,
105 pub created_at: Option<String>,
107 pub author: Option<GitLabUser>,
109 pub sha: Option<String>,
111 pub merge_commit_sha: Option<String>,
113 pub squash_commit_sha: Option<String>,
115 pub web_url: Option<String>,
117 pub labels: Vec<String>,
119}
120
121impl RemotePullRequest for GitLabMergeRequest {
122 fn number(&self) -> i64 {
123 self.iid
124 .expect("Merge request id is required for git-cliff semantics")
125 }
126
127 fn title(&self) -> Option<String> {
128 self.title.clone()
129 }
130
131 fn labels(&self) -> Vec<String> {
132 self.labels.clone()
133 }
134
135 fn merge_commit(&self) -> Option<String> {
136 self.merge_commit_sha
137 .clone()
138 .or_else(|| self.squash_commit_sha.clone().or_else(|| self.sha.clone()))
139 }
140}
141
142#[derive(Debug, Default, Clone, Hash, Eq, PartialEq, Deserialize, Serialize)]
146pub struct GitLabUser {
147 pub id: Option<i64>,
149 pub name: Option<String>,
151 pub username: Option<String>,
153 pub state: Option<String>,
155 pub avatar_url: Option<String>,
157 pub web_url: Option<String>,
159}
160
161#[derive(Debug, Clone)]
163pub struct GitLabClient {
164 remote: Remote,
166 client: ClientWithMiddleware,
168}
169
170impl TryFrom<Remote> for GitLabClient {
172 type Error = Error;
173 fn try_from(remote: Remote) -> Result<Self> {
174 Ok(Self {
175 client: remote.create_client("application/json")?,
176 remote,
177 })
178 }
179}
180
181impl RemoteClient for GitLabClient {
182 const API_URL: &'static str = "https://gitlab.com/api/v4";
183 const API_URL_ENV: &'static str = "GITLAB_API_URL";
184
185 fn remote(&self) -> Remote {
186 self.remote.clone()
187 }
188
189 fn client(&self) -> ClientWithMiddleware {
190 self.client.clone()
191 }
192}
193
194impl GitLabClient {
195 fn project_url(api_url: &str, remote: &Remote) -> String {
197 format!(
198 "{}/projects/{}%2F{}",
199 api_url,
200 urlencoding::encode(remote.owner.as_str()),
201 remote.repo
202 )
203 }
204
205 fn commits_url(project_id: i64, api_url: &str, ref_name: Option<&str>, page: i32) -> String {
207 let mut url = format!(
208 "{api_url}/projects/{project_id}/repository/commits?per_page={MAX_PAGE_SIZE}&\
209 page={page}"
210 );
211
212 if let Some(ref_name) = ref_name {
213 url.push_str(&format!("&ref_name={ref_name}"));
214 }
215
216 url
217 }
218 fn pull_requests_url(project_id: i64, api_url: &str, page: i32) -> String {
220 format!(
221 "{api_url}/projects/{project_id}/merge_requests?per_page={MAX_PAGE_SIZE}&page={page}&\
222 state=merged"
223 )
224 }
225
226 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
228 pub async fn get_project(&self) -> Result<GitLabProject> {
229 crate::set_progress_message!("Fetching the project details from GitLab");
230 let url = Self::project_url(&self.api_url(), &self.remote());
231 self.get_json::<GitLabProject>(&url).await
232 }
233
234 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
238 pub async fn get_commits(
239 &self,
240 project_id: i64,
241 ref_name: Option<&str>,
242 ) -> Result<Vec<Box<dyn RemoteCommit>>> {
243 use futures::TryStreamExt;
244 crate::set_progress_message!("Fetching all commits from GitLab");
245 self.get_commit_stream(project_id, ref_name)
246 .try_collect()
247 .await
248 }
249
250 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
254 pub async fn get_pull_requests(
255 &self,
256 project_id: i64,
257 ) -> Result<Vec<Box<dyn RemotePullRequest>>> {
258 use futures::TryStreamExt;
259 crate::set_progress_message!("Fetching all pull requests from GitLab");
260 self.get_pull_request_stream(project_id).try_collect().await
261 }
262
263 fn get_commit_stream(
264 &self,
265 project_id: i64,
266 ref_name: Option<&str>,
267 ) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + '_ {
268 let ref_name = ref_name.map(ToString::to_string);
269 async_stream! {
270 let page_stream = stream::iter(1..)
272 .map(move |page| {
273 let ref_name = ref_name.clone();
274 async move {
275 let url = Self::commits_url(project_id, &self.api_url(), ref_name.as_deref(), page);
276 self.get_json::<Vec<GitLabCommit>>(&url).await
277 }
278 })
279 .buffered(10);
280
281 let mut page_stream = Box::pin(page_stream);
282
283 while let Some(page_result) = page_stream.next().await {
284 match page_result {
285 Ok(commits) => {
286 if commits.is_empty() {
287 break;
288 }
289
290 for commit in commits {
291 yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
292 }
293 }
294 Err(e) => {
295 yield Err(e);
296 break;
297 }
298 }
299 }
300 }
301 }
302
303 fn get_pull_request_stream(
304 &self,
305 project_id: i64,
306 ) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + '_ {
307 async_stream! {
308 let page_stream = stream::iter(1..)
310 .map(move |page| async move {
311 let url = Self::pull_requests_url(project_id, &self.api_url(), page);
312 self.get_json::<Vec<GitLabMergeRequest>>(&url).await
313 })
314 .buffered(5);
315
316 let mut page_stream = Box::pin(page_stream);
317
318 while let Some(page_result) = page_stream.next().await {
319 match page_result {
320 Ok(mrs) => {
321 if mrs.is_empty() {
322 break;
323 }
324
325 for mr in mrs {
326 yield Ok(Box::new(mr) as Box<dyn RemotePullRequest>);
327 }
328 }
329 Err(e) => {
330 yield Err(e);
331 break;
332 }
333 }
334 }
335 }
336 }
337}
338
339#[cfg(test)]
340mod test {
341 use pretty_assertions::assert_eq;
342
343 use super::*;
344
345 #[test]
346 fn gitlab_project_url_encodes_owner() {
347 let remote = Remote {
348 owner: "abc/def".to_string(),
349 repo: "xyz1".to_string(),
350 ..Default::default()
351 };
352 let url = GitLabClient::project_url("https://gitlab.test.com/api/v4", &remote);
353 assert_eq!(
354 "https://gitlab.test.com/api/v4/projects/abc%2Fdef%2Fxyz1",
355 url
356 );
357 }
358
359 #[test]
360 fn timestamp() {
361 let remote_commit = GitLabCommit {
362 id: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
363 author_name: Some(String::from("orhun")),
364 committed_date: Some(String::from("2021-07-18T15:14:39+03:00")),
365 ..Default::default()
366 };
367
368 assert_eq!(Some(1_626_610_479), remote_commit.timestamp());
369 }
370
371 #[test]
372 fn pull_request_no_merge_commit() {
373 let mr = GitLabMergeRequest {
374 sha: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
375 ..Default::default()
376 };
377 assert!(mr.merge_commit().is_some());
378 }
379
380 #[test]
381 fn pull_request_squash_commit() {
382 let mr = GitLabMergeRequest {
383 squash_commit_sha: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
384 ..Default::default()
385 };
386 assert!(mr.merge_commit().is_some());
387 }
388}