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    /// SHA of the merge commit.
77    pub merge_commit_sha: Option<String>,
78    /// Labels of the pull request.
79    pub labels: Vec<PullRequestLabel>,
80}
81
82impl RemotePullRequest for GitHubPullRequest {
83    fn number(&self) -> i64 {
84        self.number
85    }
86
87    fn title(&self) -> Option<String> {
88        self.title.clone()
89    }
90
91    fn labels(&self) -> Vec<String> {
92        self.labels.iter().map(|v| v.name.clone()).collect()
93    }
94
95    fn merge_commit(&self) -> Option<String> {
96        self.merge_commit_sha.clone()
97    }
98}
99
100/// HTTP client for handling GitHub REST API requests.
101#[derive(Debug, Clone)]
102pub struct GitHubClient {
103    /// Remote.
104    remote: Remote,
105    /// HTTP client.
106    client: ClientWithMiddleware,
107}
108
109/// Constructs a GitHub client from the remote configuration.
110impl TryFrom<Remote> for GitHubClient {
111    type Error = Error;
112    fn try_from(remote: Remote) -> Result<Self> {
113        Ok(Self {
114            client: remote.create_client("application/vnd.github+json")?,
115            remote,
116        })
117    }
118}
119
120impl RemoteClient for GitHubClient {
121    const API_URL: &'static str = "https://api.github.com";
122    const API_URL_ENV: &'static str = "GITHUB_API_URL";
123
124    fn remote(&self) -> Remote {
125        self.remote.clone()
126    }
127
128    fn client(&self) -> ClientWithMiddleware {
129        self.client.clone()
130    }
131}
132
133impl GitHubClient {
134    /// Constructs the URL for GitHub commits API.
135    fn commits_url(api_url: &str, remote: &Remote, ref_name: Option<&str>, page: i32) -> String {
136        let mut url = format!(
137            "{}/repos/{}/{}/commits?per_page={MAX_PAGE_SIZE}&page={page}",
138            api_url, remote.owner, remote.repo
139        );
140
141        if let Some(ref_name) = ref_name {
142            url.push_str(&format!("&sha={ref_name}"));
143        }
144
145        url
146    }
147
148    /// Constructs the URL for GitHub pull requests API.
149    fn pull_requests_url(api_url: &str, remote: &Remote, page: i32) -> String {
150        format!(
151            "{}/repos/{}/{}/pulls?per_page={MAX_PAGE_SIZE}&page={page}&state=closed",
152            api_url, remote.owner, remote.repo
153        )
154    }
155
156    /// Fetches the complete list of commits.
157    /// This is inefficient for large repositories; consider using
158    /// `get_commit_stream` instead.
159    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
160    pub async fn get_commits(&self, ref_name: Option<&str>) -> Result<Vec<Box<dyn RemoteCommit>>> {
161        use futures::TryStreamExt;
162        crate::set_progress_message!("Fetching all commits from GitHub");
163        self.get_commit_stream(ref_name).try_collect().await
164    }
165
166    /// Fetches the complete list of pull requests.
167    /// This is inefficient for large repositories; consider using
168    /// `get_pull_request_stream` instead.
169    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
170    pub async fn get_pull_requests(&self) -> Result<Vec<Box<dyn RemotePullRequest>>> {
171        use futures::TryStreamExt;
172        crate::set_progress_message!("Fetching all pull requests from GitHub");
173        self.get_pull_request_stream().try_collect().await
174    }
175
176    fn get_commit_stream(
177        &self,
178        ref_name: Option<&str>,
179    ) -> impl Stream<Item = Result<Box<dyn RemoteCommit>>> + '_ {
180        let ref_name = ref_name.map(ToString::to_string);
181        async_stream! {
182            let page_stream = stream::iter(0..)
183                .map(|page|
184                    {
185                    let ref_name = ref_name.clone();
186                    async move {
187                        let url = Self::commits_url(&self.api_url(), &self.remote(), ref_name.as_deref(), page);
188                        self.get_json::<Vec<GitHubCommit>>(&url).await
189                    }})
190                .buffered(10);
191
192            let mut page_stream = Box::pin(page_stream);
193
194            while let Some(page_result) = page_stream.next().await {
195                match page_result {
196                    Ok(commits) => {
197                        if commits.is_empty() {
198                            break;
199                        }
200
201                        for commit in commits {
202                            yield Ok(Box::new(commit) as Box<dyn RemoteCommit>);
203                        }
204                    }
205                    Err(e) => {
206                        yield Err(e);
207                        break;
208                    }
209                }
210            }
211        }
212    }
213
214    fn get_pull_request_stream(
215        &self,
216    ) -> impl Stream<Item = Result<Box<dyn RemotePullRequest>>> + '_ {
217        async_stream! {
218            let page_stream = stream::iter(0..)
219                .map(|page| async move {
220                    let url = Self::pull_requests_url(&self.api_url(), &self.remote(), page);
221                    self.get_json::<Vec<GitHubPullRequest>>(&url).await
222                })
223                .buffered(5);
224
225            let mut page_stream = Box::pin(page_stream);
226
227            while let Some(page_result) = page_stream.next().await {
228                match page_result {
229                    Ok(prs) => {
230                        if prs.is_empty() {
231                            break;
232                        }
233
234                        for pr in prs {
235                            yield Ok(Box::new(pr) as Box<dyn RemotePullRequest>);
236                        }
237                    }
238                    Err(e) => {
239                        yield Err(e);
240                        break;
241                    }
242                }
243            }
244        }
245    }
246}
247
248#[cfg(test)]
249mod test {
250    use pretty_assertions::assert_eq;
251
252    use super::*;
253    use crate::remote::RemoteCommit;
254
255    #[test]
256    fn timestamp() {
257        let remote_commit = GitHubCommit {
258            sha: String::from("1d244937ee6ceb8e0314a4a201ba93a7a61f2071"),
259            author: Some(GitHubCommitAuthor {
260                login: Some(String::from("orhun")),
261            }),
262            commit: Some(GitHubCommitDetails {
263                author: GitHubCommitDetailsAuthor {
264                    date: String::from("2021-07-18T15:14:39+03:00"),
265                },
266            }),
267        };
268
269        assert_eq!(Some(1_626_610_479), remote_commit.timestamp());
270    }
271}