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