Skip to main content

git_cliff_core/remote/
gitlab.rs

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
10/// Template variables related to this remote.
11pub(crate) const TEMPLATE_VARIABLES: &[&str] = &["gitlab", "commit.gitlab", "commit.remote"];
12
13/// Representation of a single GitLab Project.
14///
15/// <https://docs.gitlab.com/ee/api/projects.html#get-single-project>
16/// <https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/openapi/openapi.yaml>
17#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct GitLabProject {
19    /// GitLab id for project
20    pub id: Option<i64>,
21    /// Optional Description of project
22    pub description: Option<String>,
23    /// Name of project
24    pub name: Option<String>,
25    /// Name of project with namespace owner / repo
26    pub name_with_namespace: Option<String>,
27    /// Name of project with namespace owner/repo
28    pub path_with_namespace: Option<String>,
29    /// Project created at
30    pub created_at: Option<String>,
31    /// Default branch eg (main/master)
32    pub default_branch: Option<String>,
33}
34
35/// Representation of a single commit.
36///
37/// <https://docs.gitlab.com/ee/api/commits.html>
38/// <https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/openapi/openapi.yaml>
39#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct GitLabCommit {
41    /// Sha
42    pub id: Option<String>,
43    /// Short Sha
44    pub short_id: Option<String>,
45    /// Git message
46    pub title: Option<String>,
47    /// Author
48    pub author_name: Option<String>,
49    /// Author Email
50    pub author_email: Option<String>,
51    /// Authored Date
52    pub authored_date: Option<String>,
53    /// Committer Name
54    pub committer_name: Option<String>,
55    /// Committer Email
56    pub committer_email: Option<String>,
57    /// Committed Date
58    pub committed_date: Option<String>,
59    /// Created At
60    pub created_at: Option<String>,
61    /// Git Message
62    pub message: Option<String>,
63    /// Parent Ids
64    pub parent_ids: Vec<String>,
65    /// Web Url
66    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/// Representation of a single pull request.
88///
89/// <https://docs.gitlab.com/ee/api/merge_requests.html>
90/// <https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/openapi/openapi.yaml>
91#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct GitLabMergeRequest {
93    /// Id
94    pub id: Option<i64>,
95    /// Iid
96    pub iid: Option<i64>,
97    /// Project Id
98    pub project_id: Option<i64>,
99    /// Title
100    pub title: Option<String>,
101    /// Description
102    pub description: Option<String>,
103    /// State
104    pub state: Option<String>,
105    /// Created At
106    pub created_at: Option<String>,
107    /// Author
108    pub author: Option<GitLabUser>,
109    /// Commit Sha
110    pub sha: Option<String>,
111    /// Merge Commit Sha
112    pub merge_commit_sha: Option<String>,
113    /// Squash Commit Sha
114    pub squash_commit_sha: Option<String>,
115    /// Web Url
116    pub web_url: Option<String>,
117    /// Labels
118    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/// Representation of a GitLab User.
147///
148/// <https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/openapi/openapi.yaml>
149#[derive(Debug, Default, Clone, Hash, Eq, PartialEq, Deserialize, Serialize)]
150pub struct GitLabUser {
151    /// Id
152    pub id: Option<i64>,
153    /// Name
154    pub name: Option<String>,
155    /// Username
156    pub username: Option<String>,
157    /// State of the User
158    pub state: Option<String>,
159    /// Url for avatar
160    pub avatar_url: Option<String>,
161    /// Web Url
162    pub web_url: Option<String>,
163}
164
165/// HTTP client for handling GitLab REST API requests.
166#[derive(Debug, Clone)]
167pub struct GitLabClient {
168    /// Remote.
169    remote: Remote,
170    /// HTTP client.
171    client: ClientWithMiddleware,
172}
173
174/// Constructs a GitLab client from the remote configuration.
175impl 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    /// Constructs the URL for GitLab project API.
200    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    /// Constructs the URL for GitLab commits API.
210    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    /// Constructs the URL for GitLab merge requests API.
223    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    /// Looks up the project details.
231    #[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    /// Fetches the complete list of commits.
239    /// This is inefficient for large repositories; consider using
240    /// `get_commit_stream` instead.
241    #[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    /// Fetches the complete list of pull requests.
255    /// This is inefficient for large repositories; consider using
256    /// `get_pull_request_stream` instead.
257    #[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                // GitLab pages are 1-indexed
275                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            // GitLab pages are 1-indexed
313            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}