git-cliff-core 2.14.1

Core library of git-cliff
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
use async_stream::stream as async_stream;
use futures::{Stream, StreamExt, stream};
use reqwest_middleware::ClientWithMiddleware;
use serde::{Deserialize, Serialize};

use super::{Debug, MAX_PAGE_SIZE, RemoteClient, RemoteCommit, RemotePullRequest};
use crate::config::Remote;
use crate::error::{Error, Result};

/// Template variables related to this remote.
pub(crate) const TEMPLATE_VARIABLES: &[&str] = &["gitlab", "commit.gitlab", "commit.remote"];

/// Representation of a single GitLab Project.
///
/// <https://docs.gitlab.com/ee/api/projects.html#get-single-project>
/// <https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/openapi/openapi.yaml>
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitLabProject {
    /// GitLab id for project
    pub id: Option<i64>,
    /// Optional Description of project
    pub description: Option<String>,
    /// Name of project
    pub name: Option<String>,
    /// Name of project with namespace owner / repo
    pub name_with_namespace: Option<String>,
    /// Name of project with namespace owner/repo
    pub path_with_namespace: Option<String>,
    /// Project created at
    pub created_at: Option<String>,
    /// Default branch eg (main/master)
    pub default_branch: Option<String>,
}

/// Representation of a single commit.
///
/// <https://docs.gitlab.com/ee/api/commits.html>
/// <https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/openapi/openapi.yaml>
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitLabCommit {
    /// Sha
    pub id: Option<String>,
    /// Short Sha
    pub short_id: Option<String>,
    /// Git message
    pub title: Option<String>,
    /// Author
    pub author_name: Option<String>,
    /// Author Email
    pub author_email: Option<String>,
    /// Authored Date
    pub authored_date: Option<String>,
    /// Committer Name
    pub committer_name: Option<String>,
    /// Committer Email
    pub committer_email: Option<String>,
    /// Committed Date
    pub committed_date: Option<String>,
    /// Created At
    pub created_at: Option<String>,
    /// Git Message
    pub message: Option<String>,
    /// Parent Ids
    pub parent_ids: Vec<String>,
    /// Web Url
    pub web_url: Option<String>,
}

impl RemoteCommit for GitLabCommit {
    fn id(&self) -> String {
        self.id
            .clone()
            .expect("Commit id is required for git-cliff semantics")
    }

    fn username(&self) -> Option<String> {
        self.author_name.clone()
    }

    fn timestamp(&self) -> Option<i64> {
        self.committed_date
            .as_deref()
            .map(|d| self.convert_to_unix_timestamp(d))
    }
}

/// Representation of a single pull request.
///
/// <https://docs.gitlab.com/ee/api/merge_requests.html>
/// <https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/openapi/openapi.yaml>
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitLabMergeRequest {
    /// Id
    pub id: Option<i64>,
    /// Iid
    pub iid: Option<i64>,
    /// Project Id
    pub project_id: Option<i64>,
    /// Title
    pub title: Option<String>,
    /// Description
    pub description: Option<String>,
    /// State
    pub state: Option<String>,
    /// Created At
    pub created_at: Option<String>,
    /// Author
    pub author: Option<GitLabUser>,
    /// Commit Sha
    pub sha: Option<String>,
    /// Merge Commit Sha
    pub merge_commit_sha: Option<String>,
    /// Squash Commit Sha
    pub squash_commit_sha: Option<String>,
    /// Web Url
    pub web_url: Option<String>,
    /// Labels
    pub labels: Vec<String>,
}

impl RemotePullRequest for GitLabMergeRequest {
    fn number(&self) -> i64 {
        self.iid
            .expect("Merge request id is required for git-cliff semantics")
    }

    fn title(&self) -> Option<String> {
        self.title.clone()
    }

    fn author(&self) -> Option<String> {
        self.author.clone().and_then(|v| v.username)
    }

    fn labels(&self) -> Vec<String> {
        self.labels.clone()
    }

    fn merge_commit(&self) -> Option<String> {
        self.merge_commit_sha
            .clone()
            .or_else(|| self.squash_commit_sha.clone().or_else(|| self.sha.clone()))
    }
}

/// Representation of a GitLab User.
///
/// <https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/openapi/openapi.yaml>
#[derive(Debug, Default, Clone, Hash, Eq, PartialEq, Deserialize, Serialize)]
pub struct GitLabUser {
    /// Id
    pub id: Option<i64>,
    /// Name
    pub name: Option<String>,
    /// Username
    pub username: Option<String>,
    /// State of the User
    pub state: Option<String>,
    /// Url for avatar
    pub avatar_url: Option<String>,
    /// Web Url
    pub web_url: Option<String>,
}

/// HTTP client for handling GitLab REST API requests.
#[derive(Debug, Clone)]
pub struct GitLabClient {
    /// Remote.
    remote: Remote,
    /// HTTP client.
    client: ClientWithMiddleware,
}

/// Constructs a GitLab client from the remote configuration.
impl TryFrom<Remote> for GitLabClient {
    type Error = Error;
    fn try_from(remote: Remote) -> Result<Self> {
        Ok(Self {
            client: remote.create_client("application/json")?,
            remote,
        })
    }
}

impl RemoteClient for GitLabClient {
    const API_URL: &'static str = "https://gitlab.com/api/v4";
    const API_URL_ENV: &'static str = "GITLAB_API_URL";

    fn remote(&self) -> Remote {
        self.remote.clone()
    }

    fn client(&self) -> ClientWithMiddleware {
        self.client.clone()
    }
}

impl GitLabClient {
    /// Constructs the URL for GitLab project API.
    fn project_url(api_url: &str, remote: &Remote) -> String {
        format!(
            "{}/projects/{}%2F{}",
            api_url,
            urlencoding::encode(remote.owner.as_str()),
            remote.repo
        )
    }

    /// Constructs the URL for GitLab commits API.
    fn commits_url(project_id: i64, api_url: &str, ref_name: Option<&str>, page: i32) -> String {
        let mut url = format!(
            "{api_url}/projects/{project_id}/repository/commits?per_page={MAX_PAGE_SIZE}&\
             page={page}"
        );

        if let Some(ref_name) = ref_name {
            url.push_str(&format!("&ref_name={ref_name}"));
        }

        url
    }
    /// Constructs the URL for GitLab merge requests API.
    fn pull_requests_url(project_id: i64, api_url: &str, page: i32) -> String {
        format!(
            "{api_url}/projects/{project_id}/merge_requests?per_page={MAX_PAGE_SIZE}&page={page}&\
             state=merged"
        )
    }

    /// Looks up the project details.
    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
    pub async fn get_project(&self) -> Result<GitLabProject> {
        crate::set_progress_message!("Fetching the project details from GitLab");
        let url = Self::project_url(&self.api_url(), &self.remote());
        self.get_json::<GitLabProject>(&url).await
    }

    /// Fetches the complete list of commits.
    /// This is inefficient for large repositories; consider using
    /// `get_commit_stream` instead.
    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
    pub async fn get_commits(
        &self,
        project_id: i64,
        ref_name: Option<&str>,
    ) -> Result<Vec<Box<dyn RemoteCommit>>> {
        use futures::TryStreamExt;
        crate::set_progress_message!("Fetching all commits from GitLab");
        self.get_commit_stream(project_id, ref_name)
            .try_collect()
            .await
    }

    /// Fetches the complete list of pull requests.
    /// This is inefficient for large repositories; consider using
    /// `get_pull_request_stream` instead.
    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
    pub async fn get_pull_requests(
        &self,
        project_id: i64,
    ) -> Result<Vec<Box<dyn RemotePullRequest>>> {
        use futures::TryStreamExt;
        crate::set_progress_message!("Fetching all pull requests from GitLab");
        self.get_pull_request_stream(project_id).try_collect().await
    }

    fn get_commit_stream(
        &self,
        project_id: i64,
        ref_name: Option<&str>,
    ) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + '_ {
        let ref_name = ref_name.map(ToString::to_string);
        async_stream! {
                // GitLab pages are 1-indexed
                let page_stream = stream::iter(1..)
                    .map(move |page| {
                        let ref_name = ref_name.clone();
                        async move {
                            let url = Self::commits_url(project_id, &self.api_url(), ref_name.as_deref(), page);
                            self.get_json::<Vec<GitLabCommit>>(&url).await
                        }
                    })
                    .buffered(10);

                let mut page_stream = Box::pin(page_stream);

                while let Some(page_result) = page_stream.next().await {
                    match page_result {
                        Ok(commits) => {
                            if commits.is_empty() {
                                break;
                            }

                            for commit in commits {
                                yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
                            }
                        }
                        Err(e) => {
                            yield Err(e);
                            break;
                        }
                    }
                }
        }
    }

    fn get_pull_request_stream(
        &self,
        project_id: i64,
    ) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + '_ {
        async_stream! {
            // GitLab pages are 1-indexed
            let page_stream = stream::iter(1..)
                .map(move |page| async move {
                    let url = Self::pull_requests_url(project_id, &self.api_url(), page);
                    self.get_json::<Vec<GitLabMergeRequest>>(&url).await
                })
                .buffered(5);

            let mut page_stream = Box::pin(page_stream);

            while let Some(page_result) = page_stream.next().await {
                match page_result {
                    Ok(mrs) => {
                        if mrs.is_empty() {
                            break;
                        }

                        for mr in mrs {
                            yield Ok(Box::new(mr) as Box<dyn RemotePullRequest>);
                        }
                    }
                    Err(e) => {
                        yield Err(e);
                        break;
                    }
                }
            }
        }
    }
}

#[cfg(test)]
mod test {
    use pretty_assertions::assert_eq;

    use super::*;
    use crate::remote::RemotePullRequest;

    #[test]
    fn gitlab_project_url_encodes_owner() {
        let remote = Remote {
            owner: "abc/def".to_string(),
            repo: "xyz1".to_string(),
            ..Default::default()
        };
        let url = GitLabClient::project_url("https://gitlab.test.com/api/v4", &remote);
        assert_eq!(
            "https://gitlab.test.com/api/v4/projects/abc%2Fdef%2Fxyz1",
            url
        );
    }

    #[test]
    fn timestamp() {
        let remote_commit = GitLabCommit {
            id: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
            author_name: Some(String::from("orhun")),
            committed_date: Some(String::from("2021-07-18T15:14:39+03:00")),
            ..Default::default()
        };

        assert_eq!(Some(1_626_610_479), remote_commit.timestamp());
    }

    #[test]
    fn pull_request_no_merge_commit() {
        let mr = GitLabMergeRequest {
            sha: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
            ..Default::default()
        };
        assert!(mr.merge_commit().is_some());
    }

    #[test]
    fn pull_request_squash_commit() {
        let mr = GitLabMergeRequest {
            squash_commit_sha: Some(String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071")),
            ..Default::default()
        };
        assert!(mr.merge_commit().is_some());
    }

    #[test]
    fn pull_request_author() {
        let merge_request: GitLabMergeRequest = serde_json::from_str(
            r#"{
                "iid": 42,
                "title": "feat: add pr_author",
                "merge_commit_sha": "1d244937ee6ceb8e0314a4a201ba93a7a61f2071",
                "labels": [],
                "author": { "username": "contributor" }
            }"#,
        )
        .expect("failed to deserialize merge request");

        assert_eq!(Some(String::from("contributor")), merge_request.author());
    }

    #[test]
    fn merge_request_author_missing() {
        let merge_request: GitLabMergeRequest = serde_json::from_str(
            r#"{
                "iid": 42,
                "title": "feat: add pr_author",
                "merge_commit_sha": "1d244937ee6ceb8e0314a4a201ba93a7a61f2071",
                "labels": []
            }"#,
        )
        .expect("failed to deserialize merge request");

        assert_eq!(None, merge_request.author());
    }
}