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 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/// Representation of a GitLab User.
143///
144/// <https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/openapi/openapi.yaml>
145#[derive(Debug, Default, Clone, Hash, Eq, PartialEq, Deserialize, Serialize)]
146pub struct GitLabUser {
147    /// Id
148    pub id: Option<i64>,
149    /// Name
150    pub name: Option<String>,
151    /// Username
152    pub username: Option<String>,
153    /// State of the User
154    pub state: Option<String>,
155    /// Url for avatar
156    pub avatar_url: Option<String>,
157    /// Web Url
158    pub web_url: Option<String>,
159}
160
161/// HTTP client for handling GitLab REST API requests.
162#[derive(Debug, Clone)]
163pub struct GitLabClient {
164    /// Remote.
165    remote: Remote,
166    /// HTTP client.
167    client: ClientWithMiddleware,
168}
169
170/// Constructs a GitLab client from the remote configuration.
171impl 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    /// Constructs the URL for GitLab project API.
196    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    /// Constructs the URL for GitLab commits API.
206    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    /// Constructs the URL for GitLab merge requests API.
219    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    /// Looks up the project details.
227    #[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    /// Fetches the complete list of commits.
235    /// This is inefficient for large repositories; consider using
236    /// `get_commit_stream` instead.
237    #[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    /// Fetches the complete list of pull requests.
251    /// This is inefficient for large repositories; consider using
252    /// `get_pull_request_stream` instead.
253    #[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                // GitLab pages are 1-indexed
271                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            // GitLab pages are 1-indexed
309            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}