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 author(&self) -> Option<String> {
132 self.author.clone().and_then(|v| v.username)
133 }
134
135 fn labels(&self) -> Vec<String> {
136 self.labels.clone()
137 }
138
139 fn merge_commit(&self) -> Option<String> {
140 self.merge_commit_sha
141 .clone()
142 .or_else(|| self.squash_commit_sha.clone().or_else(|| self.sha.clone()))
143 }
144}
145
146#[derive(Debug, Default, Clone, Hash, Eq, PartialEq, Deserialize, Serialize)]
150pub struct GitLabUser {
151 pub id: Option<i64>,
153 pub name: Option<String>,
155 pub username: Option<String>,
157 pub state: Option<String>,
159 pub avatar_url: Option<String>,
161 pub web_url: Option<String>,
163}
164
165#[derive(Debug, Clone)]
167pub struct GitLabClient {
168 remote: Remote,
170 client: ClientWithMiddleware,
172}
173
174impl TryFrom<Remote> for GitLabClient {
176 type Error = Error;
177 fn try_from(remote: Remote) -> Result<Self> {
178 Ok(Self {
179 client: remote.create_client("application/json")?,
180 remote,
181 })
182 }
183}
184
185impl RemoteClient for GitLabClient {
186 const API_URL: &'static str = "https://gitlab.com/api/v4";
187 const API_URL_ENV: &'static str = "GITLAB_API_URL";
188
189 fn remote(&self) -> Remote {
190 self.remote.clone()
191 }
192
193 fn client(&self) -> ClientWithMiddleware {
194 self.client.clone()
195 }
196}
197
198impl GitLabClient {
199 fn project_url(api_url: &str, remote: &Remote) -> String {
201 format!(
202 "{}/projects/{}%2F{}",
203 api_url,
204 urlencoding::encode(remote.owner.as_str()),
205 remote.repo
206 )
207 }
208
209 fn commits_url(project_id: i64, api_url: &str, ref_name: Option<&str>, page: i32) -> String {
211 let mut url = format!(
212 "{api_url}/projects/{project_id}/repository/commits?per_page={MAX_PAGE_SIZE}&\
213 page={page}"
214 );
215
216 if let Some(ref_name) = ref_name {
217 url.push_str(&format!("&ref_name={ref_name}"));
218 }
219
220 url
221 }
222 fn pull_requests_url(project_id: i64, api_url: &str, page: i32) -> String {
224 format!(
225 "{api_url}/projects/{project_id}/merge_requests?per_page={MAX_PAGE_SIZE}&page={page}&\
226 state=merged"
227 )
228 }
229
230 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
232 pub async fn get_project(&self) -> Result<GitLabProject> {
233 crate::set_progress_message!("Fetching the project details from GitLab");
234 let url = Self::project_url(&self.api_url(), &self.remote());
235 self.get_json::<GitLabProject>(&url).await
236 }
237
238 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
242 pub async fn get_commits(
243 &self,
244 project_id: i64,
245 ref_name: Option<&str>,
246 ) -> Result<Vec<Box<dyn RemoteCommit>>> {
247 use futures::TryStreamExt;
248 crate::set_progress_message!("Fetching all commits from GitLab");
249 self.get_commit_stream(project_id, ref_name)
250 .try_collect()
251 .await
252 }
253
254 #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
258 pub async fn get_pull_requests(
259 &self,
260 project_id: i64,
261 ) -> Result<Vec<Box<dyn RemotePullRequest>>> {
262 use futures::TryStreamExt;
263 crate::set_progress_message!("Fetching all pull requests from GitLab");
264 self.get_pull_request_stream(project_id).try_collect().await
265 }
266
267 fn get_commit_stream(
268 &self,
269 project_id: i64,
270 ref_name: Option<&str>,
271 ) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + '_ {
272 let ref_name = ref_name.map(ToString::to_string);
273 async_stream! {
274 let page_stream = stream::iter(1..)
276 .map(move |page| {
277 let ref_name = ref_name.clone();
278 async move {
279 let url = Self::commits_url(project_id, &self.api_url(), ref_name.as_deref(), page);
280 self.get_json::<Vec<GitLabCommit>>(&url).await
281 }
282 })
283 .buffered(10);
284
285 let mut page_stream = Box::pin(page_stream);
286
287 while let Some(page_result) = page_stream.next().await {
288 match page_result {
289 Ok(commits) => {
290 if commits.is_empty() {
291 break;
292 }
293
294 for commit in commits {
295 yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
296 }
297 }
298 Err(e) => {
299 yield Err(e);
300 break;
301 }
302 }
303 }
304 }
305 }
306
307 fn get_pull_request_stream(
308 &self,
309 project_id: i64,
310 ) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + '_ {
311 async_stream! {
312 let page_stream = stream::iter(1..)
314 .map(move |page| async move {
315 let url = Self::pull_requests_url(project_id, &self.api_url(), page);
316 self.get_json::<Vec<GitLabMergeRequest>>(&url).await
317 })
318 .buffered(5);
319
320 let mut page_stream = Box::pin(page_stream);
321
322 while let Some(page_result) = page_stream.next().await {
323 match page_result {
324 Ok(mrs) => {
325 if mrs.is_empty() {
326 break;
327 }
328
329 for mr in mrs {
330 yield Ok(Box::new(mr) as Box<dyn RemotePullRequest>);
331 }
332 }
333 Err(e) => {
334 yield Err(e);
335 break;
336 }
337 }
338 }
339 }
340 }
341}
342
343#[cfg(test)]
344mod test {
345 use pretty_assertions::assert_eq;
346
347 use super::*;
348 use crate::remote::RemotePullRequest;
349
350 #[test]
351 fn gitlab_project_url_encodes_owner() {
352 let remote = Remote {
353 owner: "abc/def".to_string(),
354 repo: "xyz1".to_string(),
355 ..Default::default()
356 };
357 let url = GitLabClient::project_url("https://gitlab.test.com/api/v4", &remote);
358 assert_eq!(
359 "https://gitlab.test.com/api/v4/projects/abc%2Fdef%2Fxyz1",
360 url
361 );
362 }
363
364 #[test]
365 fn timestamp() {
366 let remote_commit = GitLabCommit {
367 id: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
368 author_name: Some(String::from("orhun")),
369 committed_date: Some(String::from("2021-07-18T15:14:39+03:00")),
370 ..Default::default()
371 };
372
373 assert_eq!(Some(1_626_610_479), remote_commit.timestamp());
374 }
375
376 #[test]
377 fn pull_request_no_merge_commit() {
378 let mr = GitLabMergeRequest {
379 sha: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
380 ..Default::default()
381 };
382 assert!(mr.merge_commit().is_some());
383 }
384
385 #[test]
386 fn pull_request_squash_commit() {
387 let mr = GitLabMergeRequest {
388 squash_commit_sha: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
389 ..Default::default()
390 };
391 assert!(mr.merge_commit().is_some());
392 }
393
394 #[test]
395 fn pull_request_author() {
396 let merge_request: GitLabMergeRequest = serde_json::from_str(
397 r#"{
398 "iid": 42,
399 "title": "feat: add pr_author",
400 "merge_commit_sha": "1d244937ee6ceb8e0314a4a201ba93a7a61f2071",
401 "labels": [],
402 "author": { "username": "contributor" }
403 }"#,
404 )
405 .expect("failed to deserialize merge request");
406
407 assert_eq!(Some(String::from("contributor")), merge_request.author());
408 }
409
410 #[test]
411 fn merge_request_author_missing() {
412 let merge_request: GitLabMergeRequest = serde_json::from_str(
413 r#"{
414 "iid": 42,
415 "title": "feat: add pr_author",
416 "merge_commit_sha": "1d244937ee6ceb8e0314a4a201ba93a7a61f2071",
417 "labels": []
418 }"#,
419 )
420 .expect("failed to deserialize merge request");
421
422 assert_eq!(None, merge_request.author());
423 }
424}