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