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
10pub(crate) const TEMPLATE_VARIABLES: &[&str] = &["github", "commit.github", "commit.remote"];
12
13#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct GitHubCommit {
16 pub sha: String,
18 pub author: Option<GitHubCommitAuthor>,
20 pub commit: Option<GitHubCommitDetails>,
22}
23
24#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct GitHubCommitDetails {
27 pub author: GitHubCommitDetailsAuthor,
29}
30
31#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct GitHubCommitDetailsAuthor {
34 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#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct GitHubCommitAuthor {
57 pub login: Option<String>,
59}
60
61#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct PullRequestLabel {
65 pub name: String,
67}
68
69#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct GitHubPullRequest {
72 pub number: i64,
74 pub title: Option<String>,
76 pub user: Option<GitHubCommitAuthor>,
78 pub merge_commit_sha: Option<String>,
80 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#[derive(Debug, Clone)]
108pub struct GitHubClient {
109 remote: Remote,
111 client: ClientWithMiddleware,
113}
114
115impl 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 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 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 #[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 #[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}