git_cliff_core/remote/
mod.rs

1/// GitHub client.
2#[cfg(feature = "github")]
3pub mod github;
4
5/// GitLab client.
6#[cfg(feature = "gitlab")]
7pub mod gitlab;
8
9/// Bitbucket client.
10#[cfg(feature = "bitbucket")]
11pub mod bitbucket;
12
13/// Gitea client.
14#[cfg(feature = "gitea")]
15pub mod gitea;
16
17use std::env;
18use std::fmt::Debug;
19use std::time::Duration;
20
21use dyn_clone::DynClone;
22use futures::{StreamExt, future, stream};
23use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions};
24use reqwest::Client;
25use reqwest::header::{HeaderMap, HeaderValue};
26use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
27use secrecy::ExposeSecret;
28use serde::de::DeserializeOwned;
29use serde::{Deserialize, Serialize};
30use time::OffsetDateTime;
31use time::format_description::well_known::Rfc3339;
32
33use crate::config::Remote;
34use crate::contributor::RemoteContributor;
35use crate::error::{Error, Result};
36
37/// User agent for interacting with the GitHub API.
38///
39/// This is needed since GitHub API does not accept empty user agent.
40pub(crate) const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
41
42/// Request timeout value in seconds.
43pub(crate) const REQUEST_TIMEOUT: u64 = 30;
44
45/// TCP keepalive value in seconds.
46pub(crate) const REQUEST_KEEP_ALIVE: u64 = 60;
47
48/// Maximum number of entries to fetch in a single page.
49pub(crate) const MAX_PAGE_SIZE: usize = 100;
50
51/// Trait for handling the different entries returned from the remote.
52pub trait RemoteEntry {
53    /// Returns the API URL for fetching the entries at the specified page.
54    fn url(
55        project_id: i64,
56        api_url: &str,
57        remote: &Remote,
58        ref_name: Option<&str>,
59        page: i32,
60    ) -> String;
61    /// Returns the request buffer size.
62    fn buffer_size() -> usize;
63    /// Whether if exit early.
64    fn early_exit(&self) -> bool;
65}
66
67/// Trait for handling remote commits.
68pub trait RemoteCommit: DynClone {
69    /// Commit SHA.
70    fn id(&self) -> String;
71    /// Commit author.
72    fn username(&self) -> Option<String>;
73    /// Timestamp.
74    fn timestamp(&self) -> Option<i64>;
75    /// Convert date in RFC3339 format to unix timestamp
76    fn convert_to_unix_timestamp(&self, date: &str) -> i64 {
77        OffsetDateTime::parse(date, &Rfc3339)
78            .expect("failed to parse date")
79            .unix_timestamp()
80    }
81}
82
83dyn_clone::clone_trait_object!(RemoteCommit);
84
85/// Trait for handling remote pull requests.
86pub trait RemotePullRequest: DynClone {
87    /// Number.
88    fn number(&self) -> i64;
89    /// Title.
90    fn title(&self) -> Option<String>;
91    /// Labels of the pull request.
92    fn labels(&self) -> Vec<String>;
93    /// Merge commit SHA.
94    fn merge_commit(&self) -> Option<String>;
95}
96
97dyn_clone::clone_trait_object!(RemotePullRequest);
98
99/// Result of a remote metadata.
100pub type RemoteMetadata = (Vec<Box<dyn RemoteCommit>>, Vec<Box<dyn RemotePullRequest>>);
101
102/// Metadata of a remote release.
103#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
104pub struct RemoteReleaseMetadata {
105    /// Contributors.
106    pub contributors: Vec<RemoteContributor>,
107}
108
109impl Remote {
110    /// Creates a HTTP client for the remote.
111    fn create_client(&self, accept_header: &str) -> Result<ClientWithMiddleware> {
112        if !self.is_set() {
113            return Err(Error::RemoteNotSetError);
114        }
115        let mut headers = HeaderMap::new();
116        headers.insert(
117            reqwest::header::ACCEPT,
118            HeaderValue::from_str(accept_header)?,
119        );
120        if let Some(token) = &self.token {
121            headers.insert(
122                reqwest::header::AUTHORIZATION,
123                format!("Bearer {}", token.expose_secret()).parse()?,
124            );
125        }
126        headers.insert(reqwest::header::USER_AGENT, USER_AGENT.parse()?);
127        let client_builder = Client::builder()
128            .timeout(Duration::from_secs(REQUEST_TIMEOUT))
129            .tcp_keepalive(Duration::from_secs(REQUEST_KEEP_ALIVE))
130            .default_headers(headers)
131            .tls_built_in_root_certs(false);
132        let client_builder = if self.native_tls.unwrap_or(false) {
133            client_builder.tls_built_in_native_certs(true)
134        } else {
135            client_builder.tls_built_in_webpki_certs(true)
136        };
137        let client = client_builder.build()?;
138        let client = ClientBuilder::new(client)
139            .with(Cache(HttpCache {
140                mode: CacheMode::Default,
141                manager: CACacheManager {
142                    path: dirs::cache_dir()
143                        .ok_or_else(|| {
144                            Error::DirsError(String::from(
145                                "failed to find the user's cache directory",
146                            ))
147                        })?
148                        .join(env!("CARGO_PKG_NAME")),
149                },
150                options: HttpCacheOptions::default(),
151            }))
152            .build();
153        Ok(client)
154    }
155}
156
157/// Trait for handling the API connection and fetching.
158pub trait RemoteClient {
159    /// API URL for a particular client
160    const API_URL: &'static str;
161
162    /// Name of the environment variable used to set the API URL to a
163    /// self-hosted instance (if applicable).
164    const API_URL_ENV: &'static str;
165
166    /// Returns the API url.
167    fn api_url(&self) -> String {
168        env::var(Self::API_URL_ENV)
169            .ok()
170            .or(self.remote().api_url)
171            .unwrap_or_else(|| Self::API_URL.to_string())
172    }
173
174    /// Returns the remote repository information.
175    fn remote(&self) -> Remote;
176
177    /// Returns the HTTP client for making requests.
178    fn client(&self) -> ClientWithMiddleware;
179
180    /// Returns true if the client should early exit.
181    fn early_exit<T: DeserializeOwned + RemoteEntry>(&self, page: &T) -> bool {
182        page.early_exit()
183    }
184
185    /// Retrieves a single object.
186    async fn get_entry<T: DeserializeOwned + RemoteEntry>(
187        &self,
188        project_id: i64,
189        ref_name: Option<&str>,
190        page: i32,
191    ) -> Result<T> {
192        let url = T::url(project_id, &self.api_url(), &self.remote(), ref_name, page);
193        debug!("Sending request to: {url}");
194        let response = self.client().get(&url).send().await?;
195        let response_text = if response.status().is_success() {
196            let text = response.text().await?;
197            trace!("Response: {:?}", text);
198            text
199        } else {
200            let text = response.text().await?;
201            error!("Request error: {}", text);
202            text
203        };
204        Ok(serde_json::from_str::<T>(&response_text)?)
205    }
206
207    /// Retrieves a single page of entries.
208    async fn get_entries_with_page<T: DeserializeOwned + RemoteEntry>(
209        &self,
210        project_id: i64,
211        ref_name: Option<&str>,
212        page: i32,
213    ) -> Result<Vec<T>> {
214        let url = T::url(project_id, &self.api_url(), &self.remote(), ref_name, page);
215        debug!("Sending request to: {url}");
216        let response = self.client().get(&url).send().await?;
217        let response_text = if response.status().is_success() {
218            let text = response.text().await?;
219            trace!("Response: {:?}", text);
220            text
221        } else {
222            let text = response.text().await?;
223            error!("Request error: {}", text);
224            text
225        };
226        let response = serde_json::from_str::<Vec<T>>(&response_text)?;
227        if response.is_empty() {
228            Err(Error::PaginationError(String::from("end of entries")))
229        } else {
230            Ok(response)
231        }
232    }
233
234    /// Fetches the remote API and returns the given entry.
235    ///
236    /// See `fetch_with_early_exit` for the early exit version of this method.
237    async fn fetch<T: DeserializeOwned + RemoteEntry>(
238        &self,
239        project_id: i64,
240        ref_name: Option<&str>,
241    ) -> Result<Vec<T>> {
242        let entries: Vec<Vec<T>> = stream::iter(0..)
243            .map(|i| self.get_entries_with_page(project_id, ref_name, i))
244            .buffered(T::buffer_size())
245            .take_while(|page| {
246                if let Err(e) = page {
247                    debug!("Error while fetching page: {:?}", e);
248                }
249                future::ready(page.is_ok())
250            })
251            .map(|page| match page {
252                Ok(v) => v,
253                Err(ref e) => {
254                    log::error!("{:#?}", e);
255                    page.expect("failed to fetch page: {}")
256                }
257            })
258            .collect()
259            .await;
260        Ok(entries.into_iter().flatten().collect())
261    }
262
263    /// Fetches the remote API and returns the given entry.
264    ///
265    /// Early exits based on the response.
266    async fn fetch_with_early_exit<T: DeserializeOwned + RemoteEntry>(
267        &self,
268        project_id: i64,
269        ref_name: Option<&str>,
270    ) -> Result<Vec<T>> {
271        let entries: Vec<T> = stream::iter(0..)
272            .map(|i| self.get_entry::<T>(project_id, ref_name, i))
273            .buffered(T::buffer_size())
274            .take_while(|page| {
275                let status = match page {
276                    Ok(v) => !self.early_exit(v),
277                    Err(e) => {
278                        debug!("Error while fetching page: {:?}", e);
279                        true
280                    }
281                };
282                future::ready(status && page.is_ok())
283            })
284            .map(|page| match page {
285                Ok(v) => v,
286                Err(ref e) => {
287                    log::error!("{:#?}", e);
288                    page.expect("failed to fetch page: {}")
289                }
290            })
291            .collect()
292            .await;
293        Ok(entries)
294    }
295}
296
297/// Generates a function for updating the release metadata for a remote.
298#[doc(hidden)]
299#[macro_export]
300macro_rules! update_release_metadata {
301    ($remote: ident, $fn: ident) => {
302        impl<'a> Release<'a> {
303            /// Updates the remote metadata that is contained in the release.
304            ///
305            /// This function takes two arguments:
306            ///
307            /// - Commits: needed for associating the Git user with the GitHub username.
308            /// - Pull requests: needed for generating the contributor list for the release.
309            #[allow(deprecated)]
310            pub fn $fn(
311                &mut self,
312                mut commits: Vec<Box<dyn RemoteCommit>>,
313                pull_requests: Vec<Box<dyn RemotePullRequest>>,
314            ) -> Result<()> {
315                let mut contributors: Vec<RemoteContributor> = Vec::new();
316                let mut release_commit_timestamp: Option<i64> = None;
317                // retain the commits that are not a part of this release for later
318                // on checking the first contributors.
319                commits.retain(|v| {
320                    if let Some(commit) = self.commits.iter_mut().find(|commit| commit.id == v.id())
321                    {
322                        let sha_short = Some(v.id().clone().chars().take(12).collect());
323                        let pull_request = pull_requests.iter().find(|pr| {
324                            pr.merge_commit() == Some(v.id().clone()) ||
325                                pr.merge_commit() == sha_short
326                        });
327                        commit.$remote.username = v.username();
328                        commit.$remote.pr_number = pull_request.map(|v| v.number());
329                        commit.$remote.pr_title = pull_request.and_then(|v| v.title().clone());
330                        commit.$remote.pr_labels =
331                            pull_request.map(|v| v.labels().clone()).unwrap_or_default();
332                        if !contributors
333                            .iter()
334                            .any(|v| commit.$remote.username == v.username)
335                        {
336                            contributors.push(RemoteContributor {
337                                username: commit.$remote.username.clone(),
338                                pr_title: commit.$remote.pr_title.clone(),
339                                pr_number: commit.$remote.pr_number,
340                                pr_labels: commit.$remote.pr_labels.clone(),
341                                is_first_time: false,
342                            });
343                        }
344                        commit.remote = Some(commit.$remote.clone());
345                        // if remote commit is the release commit store timestamp for
346                        // use in calculation of first time
347                        if Some(v.id().clone()) == self.commit_id {
348                            release_commit_timestamp = v.timestamp().clone();
349                        }
350                        false
351                    } else {
352                        true
353                    }
354                });
355                // mark contributors as first-time
356                self.$remote.contributors = contributors
357                    .into_iter()
358                    .map(|mut v| {
359                        v.is_first_time = !commits
360                            .iter()
361                            .filter(|commit| {
362                                // if current release is unreleased no need to filter
363                                // commits or filter commits that are from
364                                // newer releases
365                                self.timestamp == None ||
366                                    commit.timestamp() < release_commit_timestamp
367                            })
368                            .map(|v| v.username())
369                            .any(|login| login == v.username);
370                        v
371                    })
372                    .collect();
373                Ok(())
374            }
375        }
376    };
377}