Skip to main content

git_cliff_core/remote/
github.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] = &["github", "commit.github", "commit.remote"];
12
13/// Representation of a single commit.
14#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct GitHubCommit {
16    /// SHA.
17    pub sha: String,
18    /// Author of the commit.
19    pub author: Option<GitHubCommitAuthor>,
20    /// Details of the commit
21    pub commit: Option<GitHubCommitDetails>,
22}
23
24/// Representation of subset of commit details
25#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct GitHubCommitDetails {
27    /// Author of the commit
28    pub author: GitHubCommitDetailsAuthor,
29}
30
31/// Representation of subset of commit author details
32#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct GitHubCommitDetailsAuthor {
34    /// Date of the commit
35    pub date: String,
36}
37
38impl RemoteCommit for GitHubCommit {
39    fn id(&self) -> String {
40        self.sha.clone()
41    }
42
43    fn username(&self) -> Option<String> {
44        self.author.clone().and_then(|v| v.login)
45    }
46
47    fn timestamp(&self) -> Option<i64> {
48        self.commit
49            .clone()
50            .map(|f| self.convert_to_unix_timestamp(f.author.date.clone().as_str()))
51    }
52}
53
54/// Author of the commit.
55#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct GitHubCommitAuthor {
57    /// Username.
58    pub login: Option<String>,
59}
60
61/// Label of the pull request.
62#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct PullRequestLabel {
65    /// Name of the label.
66    pub name: String,
67}
68
69/// Representation of a single pull request.
70#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct GitHubPullRequest {
72    /// Pull request number.
73    pub number: i64,
74    /// Pull request title.
75    pub title: Option<String>,
76    /// Account that opened the pull request.
77    pub user: Option<GitHubCommitAuthor>,
78    /// SHA of the merge commit.
79    pub merge_commit_sha: Option<String>,
80    /// Labels of the pull request.
81    pub labels: Vec<PullRequestLabel>,
82}
83
84impl RemotePullRequest for GitHubPullRequest {
85    fn number(&self) -> i64 {
86        self.number
87    }
88
89    fn title(&self) -> Option<String> {
90        self.title.clone()
91    }
92
93    fn author(&self) -> Option<String> {
94        self.user.clone().and_then(|v| v.login)
95    }
96
97    fn labels(&self) -> Vec<String> {
98        self.labels.iter().map(|v| v.name.clone()).collect()
99    }
100
101    fn merge_commit(&self) -> Option<String> {
102        self.merge_commit_sha.clone()
103    }
104}
105
106/// HTTP client for handling GitHub REST API requests.
107#[derive(Debug, Clone)]
108pub struct GitHubClient {
109    /// Remote.
110    remote: Remote,
111    /// HTTP client.
112    client: ClientWithMiddleware,
113}
114
115/// Constructs a GitHub client from the remote configuration.
116impl TryFrom<Remote> for GitHubClient {
117    type Error = Error;
118    fn try_from(remote: Remote) -> Result<Self> {
119        Ok(Self {
120            client: remote.create_client("application/vnd.github+json")?,
121            remote,
122        })
123    }
124}
125
126impl RemoteClient for GitHubClient {
127    const API_URL: &'static str = "https://api.github.com";
128    const API_URL_ENV: &'static str = "GITHUB_API_URL";
129
130    fn remote(&self) -> Remote {
131        self.remote.clone()
132    }
133
134    fn client(&self) -> ClientWithMiddleware {
135        self.client.clone()
136    }
137}
138
139impl GitHubClient {
140    /// Constructs the URL for GitHub commits API.
141    fn commits_url(api_url: &str, remote: &Remote, ref_name: Option<&str>, page: i32) -> String {
142        let mut url = format!(
143            "{}/repos/{}/{}/commits?per_page={MAX_PAGE_SIZE}&page={page}",
144            api_url, remote.owner, remote.repo
145        );
146
147        if let Some(ref_name) = ref_name {
148            url.push_str(&format!("&sha={ref_name}"));
149        }
150
151        url
152    }
153
154    /// Constructs the URL for GitHub pull requests API.
155    fn pull_requests_url(api_url: &str, remote: &Remote, page: i32) -> String {
156        format!(
157            "{}/repos/{}/{}/pulls?per_page={MAX_PAGE_SIZE}&page={page}&state=closed",
158            api_url, remote.owner, remote.repo
159        )
160    }
161
162    /// Fetches the complete list of commits.
163    /// This is inefficient for large repositories; consider using
164    /// `get_commit_stream` instead.
165    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
166    pub async fn get_commits(&self, ref_name: Option<&str>) -> Result<Vec<Box<dyn RemoteCommit>>> {
167        use futures::TryStreamExt;
168        crate::set_progress_message!("Fetching all commits from GitHub");
169        self.get_commit_stream(ref_name).try_collect().await
170    }
171
172    /// Fetches the complete list of pull requests.
173    /// This is inefficient for large repositories; consider using
174    /// `get_pull_request_stream` instead.
175    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
176    pub async fn get_pull_requests(&self) -> Result<Vec<Box<dyn RemotePullRequest>>> {
177        use futures::TryStreamExt;
178        crate::set_progress_message!("Fetching all pull requests from GitHub");
179        self.get_pull_request_stream().try_collect().await
180    }
181
182    fn get_commit_stream(
183        &self,
184        ref_name: Option<&str>,
185    ) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + '_ {
186        let ref_name = ref_name.map(ToString::to_string);
187        async_stream! {
188            let page_stream = stream::iter(0..)
189                .map(|page|
190                    {
191                    let ref_name = ref_name.clone();
192                    async move {
193                        let url = Self::commits_url(&self.api_url(), &self.remote(), ref_name.as_deref(), page);
194                        self.get_json::<Vec<GitHubCommit>>(&url).await
195                    }})
196                .buffered(10);
197
198            let mut page_stream = Box::pin(page_stream);
199
200            while let Some(page_result) = page_stream.next().await {
201                match page_result {
202                    Ok(commits) => {
203                        if commits.is_empty() {
204                            break;
205                        }
206
207                        for commit in commits {
208                            yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
209                        }
210                    }
211                    Err(e) => {
212                        yield Err(e);
213                        break;
214                    }
215                }
216            }
217        }
218    }
219
220    fn get_pull_request_stream(
221        &self,
222    ) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + '_ {
223        async_stream! {
224            let page_stream = stream::iter(0..)
225                .map(|page| async move {
226                    let url = Self::pull_requests_url(&self.api_url(), &self.remote(), page);
227                    self.get_json::<Vec<GitHubPullRequest>>(&url).await
228                })
229                .buffered(5);
230
231            let mut page_stream = Box::pin(page_stream);
232
233            while let Some(page_result) = page_stream.next().await {
234                match page_result {
235                    Ok(prs) => {
236                        if prs.is_empty() {
237                            break;
238                        }
239
240                        for pr in prs {
241                            yield Ok(Box::new(pr) as Box<dyn RemotePullRequest>);
242                        }
243                    }
244                    Err(e) => {
245                        yield Err(e);
246                        break;
247                    }
248                }
249            }
250        }
251    }
252}
253
254#[cfg(test)]
255mod test {
256    use pretty_assertions::assert_eq;
257
258    use super::*;
259    use crate::remote::{RemoteCommit, RemotePullRequest};
260
261    #[test]
262    fn timestamp() {
263        let remote_commit = GitHubCommit {
264            sha: String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071"),
265            author: Some(GitHubCommitAuthor {
266                login: Some(String::from("orhun")),
267            }),
268            commit: Some(GitHubCommitDetails {
269                author: GitHubCommitDetailsAuthor {
270                    date: String::from("2021-07-18T15:14:39+03:00"),
271                },
272            }),
273        };
274
275        assert_eq!(Some(1_626_610_479), remote_commit.timestamp());
276    }
277
278    #[test]
279    fn pull_request_author() {
280        let pull_request: GitHubPullRequest = serde_json::from_str(
281            r#"{
282                "number": 42,
283                "title": "feat: add pr_author",
284                "merge_commit_sha": "1d244937ee6ceb8e0314a4a201ba93a7a61f2071",
285                "labels": [],
286                "user": { "login": "contributor" }
287            }"#,
288        )
289        .expect("failed to deserialize pull request");
290
291        assert_eq!(Some(String::from("contributor")), pull_request.author());
292    }
293
294    #[test]
295    fn pull_request_author_missing() {
296        let pull_request: GitHubPullRequest = serde_json::from_str(
297            r#"{
298                "number": 42,
299                "title": "feat: add pr_author",
300                "merge_commit_sha": "1d244937ee6ceb8e0314a4a201ba93a7a61f2071",
301                "labels": [],
302                "user": null
303            }"#,
304        )
305        .expect("failed to deserialize pull request");
306
307        assert_eq!(None, pull_request.author());
308    }
309}