Skip to main content

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
17/// Azure DevOps client.
18#[cfg(feature = "azure_devops")]
19pub mod azure_devops;
20
21use std::env;
22use std::fmt::Debug;
23use std::time::Duration;
24
25use dyn_clone::DynClone;
26use etcetera::{BaseStrategy, choose_base_strategy};
27use http_cache_reqwest::{CACacheManager, Cache, CacheMode, HttpCache, HttpCacheOptions};
28use reqwest::Client;
29use reqwest::header::{HeaderMap, HeaderValue};
30use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
31use secrecy::ExposeSecret;
32use serde::de::DeserializeOwned;
33use serde::{Deserialize, Serialize};
34use time::OffsetDateTime;
35use time::format_description::well_known::Rfc3339;
36
37use crate::config::Remote;
38use crate::contributor::RemoteContributor;
39use crate::error::{Error, Result};
40
41/// User agent for interacting with the GitHub API.
42///
43/// This is needed since GitHub API does not accept empty user agent.
44pub(crate) const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
45
46/// Request timeout value in seconds.
47pub(crate) const REQUEST_TIMEOUT: u64 = 30;
48
49/// TCP keepalive value in seconds.
50pub(crate) const REQUEST_KEEP_ALIVE: u64 = 60;
51
52/// Maximum number of entries to fetch in a single page.
53pub(crate) const MAX_PAGE_SIZE: i32 = 100;
54
55/// Trait for handling remote commits.
56pub trait RemoteCommit: DynClone {
57    /// Commit SHA.
58    fn id(&self) -> String;
59    /// Commit author.
60    fn username(&self) -> Option<String>;
61    /// Timestamp.
62    fn timestamp(&self) -> Option<i64>;
63    /// Convert date in RFC3339 format to unix timestamp
64    fn convert_to_unix_timestamp(&self, date: &str) -> i64 {
65        OffsetDateTime::parse(date, &Rfc3339)
66            .expect("failed to parse date")
67            .unix_timestamp()
68    }
69}
70
71dyn_clone::clone_trait_object!(RemoteCommit);
72
73/// Trait for handling remote pull requests.
74pub trait RemotePullRequest: DynClone {
75    /// Number.
76    fn number(&self) -> i64;
77    /// Title.
78    fn title(&self) -> Option<String>;
79    /// Labels of the pull request.
80    fn labels(&self) -> Vec<String>;
81    /// Merge commit SHA.
82    fn merge_commit(&self) -> Option<String>;
83}
84
85dyn_clone::clone_trait_object!(RemotePullRequest);
86
87/// Result of a remote metadata.
88pub type RemoteMetadata = (Vec<Box<dyn RemoteCommit>>, Vec<Box<dyn RemotePullRequest>>);
89
90/// Metadata of a remote release.
91#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize, Serialize)]
92pub struct RemoteReleaseMetadata {
93    /// Contributors.
94    pub contributors: Vec<RemoteContributor>,
95}
96
97impl Remote {
98    /// Creates a HTTP client for the remote.
99    fn create_client(&self, accept_header: &str) -> Result<ClientWithMiddleware> {
100        if !self.is_set() {
101            return Err(Error::RemoteNotSetError);
102        }
103        // cannot panic - see https://github.com/lunacookies/etcetera/issues/42
104        let strategy = choose_base_strategy()
105            .expect("cannot determine current OS's default strategy (layout)");
106        let mut headers = HeaderMap::new();
107        headers.insert(
108            reqwest::header::ACCEPT,
109            HeaderValue::from_str(accept_header)?,
110        );
111        if let Some(token) = &self.token {
112            headers.insert(
113                reqwest::header::AUTHORIZATION,
114                format!("Bearer {}", token.expose_secret()).parse()?,
115            );
116        }
117        headers.insert(reqwest::header::USER_AGENT, USER_AGENT.parse()?);
118        let client_builder = Client::builder()
119            .timeout(Duration::from_secs(REQUEST_TIMEOUT))
120            .tcp_keepalive(Duration::from_secs(REQUEST_KEEP_ALIVE))
121            .default_headers(headers)
122            .tls_built_in_root_certs(false);
123        let client_builder = if self.native_tls.unwrap_or(false) {
124            client_builder.tls_built_in_native_certs(true)
125        } else {
126            client_builder.tls_built_in_webpki_certs(true)
127        };
128        let client = client_builder.build()?;
129        let client = ClientBuilder::new(client)
130            .with(Cache(HttpCache {
131                mode: CacheMode::Default,
132                manager: CACacheManager {
133                    path: strategy.cache_dir().join(env!("CARGO_PKG_NAME")),
134                },
135                options: HttpCacheOptions::default(),
136            }))
137            .build();
138        Ok(client)
139    }
140}
141
142/// Trait for handling the API connection and fetching.
143pub trait RemoteClient {
144    /// API URL for a particular client
145    const API_URL: &'static str;
146
147    /// Name of the environment variable used to set the API URL to a
148    /// self-hosted instance (if applicable).
149    const API_URL_ENV: &'static str;
150
151    /// Returns the API url.
152    fn api_url(&self) -> String {
153        env::var(Self::API_URL_ENV)
154            .ok()
155            .or(self.remote().api_url)
156            .unwrap_or_else(|| Self::API_URL.to_string())
157    }
158
159    /// Returns the remote repository information.
160    fn remote(&self) -> Remote;
161
162    /// Returns the HTTP client for making requests.
163    fn client(&self) -> ClientWithMiddleware;
164
165    /// Performs a HTTP GET request, deserializes the JSON response, and returns the result.
166    /// This is the core HTTP request and JSON parsing logic shared by all API methods.
167    /// Callers are responsible for any additional processing of the deserialized data.
168    async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
169        tracing::debug!("Sending request to: {url}");
170        let response = self.client().get(url).send().await?.error_for_status()?;
171        let response_text = if response.status().is_success() {
172            let text = response.text().await?;
173            tracing::trace!("Response: {text:?}");
174            text
175        } else {
176            let text = response.text().await?;
177            tracing::error!("Request error: {text}");
178            text
179        };
180        Ok(serde_json::from_str::<T>(&response_text)?)
181    }
182}
183
184/// Generates a function for updating the release metadata for a remote.
185#[doc(hidden)]
186#[macro_export]
187macro_rules! update_release_metadata {
188    ($remote: ident, $fn: ident) => {
189        impl<'a> Release<'a> {
190            /// Updates the remote metadata that is contained in the release.
191            ///
192            /// This function takes two arguments:
193            ///
194            /// - Commits: needed for associating the Git user with the GitHub username.
195            /// - Pull requests: needed for generating the contributor list for the release.
196            #[allow(deprecated)]
197            pub fn $fn(
198                &mut self,
199                mut commits: Vec<Box<dyn RemoteCommit>>,
200                pull_requests: Vec<Box<dyn RemotePullRequest>>,
201            ) -> Result<()> {
202                let mut contributors: Vec<RemoteContributor> = Vec::new();
203                let mut release_commit_timestamp: Option<i64> = None;
204                // retain the commits that are not a part of this release for later
205                // on checking the first contributors.
206                commits.retain(|v| {
207                    if let Some(commit) = self.commits.iter_mut().find(|commit| commit.id == v.id())
208                    {
209                        let sha_short = Some(v.id().clone().chars().take(12).collect());
210                        let pull_request = pull_requests.iter().find(|pr| {
211                            pr.merge_commit() == Some(v.id().clone()) ||
212                                pr.merge_commit() == sha_short
213                        });
214                        commit.$remote.username = v.username();
215                        commit.$remote.pr_number = pull_request.map(|v| v.number());
216                        commit.$remote.pr_title = pull_request.and_then(|v| v.title().clone());
217                        commit.$remote.pr_labels =
218                            pull_request.map(|v| v.labels().clone()).unwrap_or_default();
219                        if !contributors
220                            .iter()
221                            .any(|v| commit.$remote.username == v.username)
222                        {
223                            contributors.push(RemoteContributor {
224                                username: commit.$remote.username.clone(),
225                                pr_title: commit.$remote.pr_title.clone(),
226                                pr_number: commit.$remote.pr_number,
227                                pr_labels: commit.$remote.pr_labels.clone(),
228                                is_first_time: false,
229                            });
230                        }
231                        commit.remote = Some(commit.$remote.clone());
232                        // if remote commit is the release commit store timestamp for
233                        // use in calculation of first time
234                        if Some(v.id().clone()) == self.commit_id {
235                            release_commit_timestamp = v.timestamp().clone();
236                        }
237                        false
238                    } else {
239                        true
240                    }
241                });
242                // mark contributors as first-time
243                self.$remote.contributors = contributors
244                    .into_iter()
245                    .map(|mut v| {
246                        v.is_first_time = !commits
247                            .iter()
248                            .filter(|commit| {
249                                // If the current release is unreleased or we cannot
250                                // resolve the release commit timestamp, skip filtering
251                                // to avoid false positives.
252                                self.timestamp.is_none() ||
253                                    release_commit_timestamp.is_none() ||
254                                    commit.timestamp() < release_commit_timestamp
255                            })
256                            .map(|v| v.username())
257                            .any(|login| login == v.username);
258                        v
259                    })
260                    .collect();
261                Ok(())
262            }
263        }
264    };
265}