git_bot_feedback/client/mod.rs
1//! A module to contain traits and structs that are needed by the rest of the git-bot-feedback crate's API.
2use crate::{OutputVariable, RestClientError, ThreadCommentOptions};
3use chrono::DateTime;
4use reqwest::{
5 Client, IntoUrl, Method, Request, Response, Url,
6 header::{HeaderMap, HeaderValue},
7};
8use std::future::Future;
9use std::time::Duration;
10use std::{env, fmt::Debug};
11
12#[cfg(feature = "github")]
13mod github;
14#[cfg(feature = "github")]
15pub use github::GithubApiClient;
16
17#[cfg(not(any(feature = "github", feature = "custom-git-server-impl")))]
18compile_error!(
19 "At least one Git server implementation (eg. 'github') should be enabled via `features`"
20);
21
22#[cfg(feature = "file-changes")]
23use crate::{FileDiffLines, FileFilter, LinesChangedOnly, parse_diff};
24#[cfg(feature = "file-changes")]
25use std::collections::HashMap;
26#[cfg(feature = "file-changes")]
27use tokio::process::Command;
28
29/// The User-Agent header value included in all HTTP requests.
30pub static USER_AGENT: &str = concat!(env!("CARGO_CRATE_NAME"), "/", env!("CARGO_PKG_VERSION"));
31
32/// A structure to contain the different forms of headers that
33/// describe a REST API's rate limit status.
34#[derive(Debug, Clone)]
35pub struct RestApiRateLimitHeaders {
36 /// The header key of the rate limit's reset time.
37 pub reset: String,
38 /// The header key of the rate limit's remaining attempts.
39 pub remaining: String,
40 /// The header key of the rate limit's "backoff" time interval.
41 pub retry: String,
42}
43
44/// A custom trait that templates necessary functionality with a Git server's REST API.
45pub trait RestApiClient {
46 /// This prints a line to indicate the beginning of a related group of log statements.
47 fn start_log_group(name: &str);
48
49 /// This prints a line to indicate the ending of a related group of log statements.
50 fn end_log_group();
51
52 /// A convenience method to create the headers attached to all REST API calls.
53 ///
54 /// If an authentication token is provided (via environment variable),
55 /// this method shall include the relative information.
56 fn make_headers() -> Result<HeaderMap<HeaderValue>, RestClientError>;
57
58 /// Is the current CI event **trigger** a Pull Request?
59 ///
60 /// This **will not** check if a push event's instigating commit is part of any PR.
61 fn is_pr_event(&self) -> bool;
62
63 /// A way to get the list of changed files in the context of the CI event.
64 ///
65 /// This method will parse diff blobs and return a list of changed files.
66 ///
67 /// The default implementation uses `git diff` to get the list of changed files.
68 /// So, the default implementation requires `git` installed and a non-shallow checkout.
69 ///
70 /// Other implementations use the Git server's REST API to get the list of changed files.
71 #[cfg(feature = "file-changes")]
72 #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
73 fn get_list_of_changed_files(
74 &self,
75 file_filter: &FileFilter,
76 lines_changed_only: &LinesChangedOnly,
77 ) -> impl Future<Output = Result<HashMap<String, FileDiffLines>, RestClientError>> {
78 async move {
79 let git_status = Command::new("git")
80 .args(["status", "--short"])
81 .output()
82 .await
83 .map_err(RestClientError::Io)
84 .map(|output| {
85 if output.status.success() {
86 Ok(String::from_utf8_lossy(&output.stdout)
87 .to_string()
88 .trim_end_matches('\n')
89 .lines()
90 .count())
91 } else {
92 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
93 Err(RestClientError::GitCommandError(err_msg))
94 }
95 })??;
96 let mut diff_args = vec!["diff"];
97 if git_status == 0 {
98 log::debug!(
99 "No changes detected in the working directory; comparing last two commits."
100 );
101 // There are no changes in the working directory.
102 // So, compare the working directory with the last commit.
103 diff_args.extend(["HEAD~1", "HEAD"]);
104 }
105 Command::new("git")
106 .args(&diff_args)
107 .output()
108 .await
109 .map_err(RestClientError::Io)
110 .map(|output| {
111 if output.status.success() {
112 let diff_str = String::from_utf8_lossy(&output.stdout).to_string();
113 let files = parse_diff(&diff_str, file_filter, lines_changed_only);
114 Ok(files)
115 } else {
116 let err_msg = String::from_utf8_lossy(&output.stderr).to_string();
117 Err(RestClientError::GitCommandError(err_msg))
118 }
119 })?
120 }
121 }
122
123 /// A way to post feedback to the Git server's GUI.
124 ///
125 /// The given [`ThreadCommentOptions::comment`] should be compliant with
126 /// the Git server's requirements (ie. the comment length is within acceptable limits).
127 fn post_thread_comment(
128 &self,
129 options: ThreadCommentOptions,
130 ) -> impl Future<Output = Result<(), RestClientError>>;
131
132 /// Appends a given comment to the CI workflow's summary page.
133 ///
134 /// This is the least obtrusive and recommended for push events.
135 /// Not all Git servers natively support this type of feedback.
136 /// GitHub, and Gitea are known to support this.
137 /// For all other git servers, this is a non-op returning [`Ok`]
138 fn append_step_summary(comment: &str) -> Result<(), RestClientError> {
139 let _ = comment;
140 Ok(())
141 }
142
143 /// Sets the given `vars` as output variables.
144 ///
145 /// These variables are designed to be consumed by other steps in the CI workflow.
146 fn write_output_variables(vars: &[OutputVariable]) -> Result<(), RestClientError>;
147
148 /// Construct a HTTP request to be sent.
149 ///
150 /// The idea here is that this method is called before [`send_api_request()`].
151 /// ```ignore
152 /// let request = Self::make_api_request(
153 /// &self.client,
154 /// "https://example.com",
155 /// Method::GET,
156 /// None,
157 /// None,
158 /// ).unwrap();
159 /// let response = send_api_request(&self.client, request, &self.rest_api_headers);
160 /// match response.await {
161 /// Ok(res) => {/* handle response */}
162 /// Err(e) => {/* handle failure */}
163 /// }
164 /// ```
165 fn make_api_request(
166 client: &Client,
167 url: impl IntoUrl,
168 method: Method,
169 data: Option<String>,
170 headers: Option<HeaderMap>,
171 ) -> Result<Request, RestClientError> {
172 let mut req = client.request(method, url);
173 if let Some(h) = headers {
174 req = req.headers(h);
175 }
176 if let Some(d) = data {
177 req = req.body(d);
178 }
179 req.build().map_err(RestClientError::Request)
180 }
181
182 /// Gets the URL for the next page from the headers in a paginated response.
183 ///
184 /// Returns [`None`] if current response is the last page.
185 fn try_next_page(headers: &HeaderMap) -> Option<Url> {
186 if let Some(links) = headers.get("link")
187 && let Ok(pg_str) = links.to_str()
188 {
189 let pages = pg_str.split(", ");
190 for page in pages {
191 if page.ends_with("; rel=\"next\"") {
192 if let Some(link) = page.split_once(">;") {
193 let url = link.0.trim_start_matches("<").to_string();
194 if let Ok(next) = Url::parse(&url) {
195 return Some(next);
196 } else {
197 log::debug!("Failed to parse next page link from response header");
198 }
199 } else {
200 log::debug!("Response header link for pagination is malformed");
201 }
202 }
203 }
204 }
205 None
206 }
207
208 fn log_response(response: Response, context: &str) -> impl Future<Output = ()> + Send {
209 async move {
210 if let Err(e) = response.error_for_status_ref() {
211 log::error!("{}: {e:?}", context.to_owned());
212 if let Ok(text) = response.text().await {
213 log::error!("{text}");
214 }
215 }
216 }
217 }
218}
219
220const MAX_RETRIES: u8 = 5;
221
222/// A convenience function to send HTTP requests and respect a REST API rate limits.
223///
224/// This method respects both primary and secondary rate limits.
225/// In the event where the secondary rate limits is reached,
226/// this function will wait for a time interval specified the server and retry afterward.
227pub async fn send_api_request(
228 client: &Client,
229 request: Request,
230 rate_limit_headers: &RestApiRateLimitHeaders,
231) -> Result<Response, RestClientError> {
232 for i in 0..MAX_RETRIES {
233 let result = client
234 .execute(
235 request
236 .try_clone()
237 .ok_or(RestClientError::RequestCloneError)?,
238 )
239 .await
240 .map_err(RestClientError::Request);
241 match result {
242 Ok(response) => {
243 if [403u16, 429u16].contains(&response.status().as_u16()) {
244 // rate limit may have been exceeded
245
246 // check if primary rate limit was violated
247 let mut requests_remaining = None;
248 if let Some(remaining) = response.headers().get(&rate_limit_headers.remaining) {
249 if let Ok(count) = remaining.to_str() {
250 if let Ok(value) = count.parse::<i64>() {
251 requests_remaining = Some(value);
252 } else {
253 log::debug!(
254 "Failed to parse i64 from remaining attempts about rate limit: {count}"
255 );
256 }
257 }
258 } else {
259 // NOTE: I guess it is sometimes valid for a response to
260 // not include remaining rate limit attempts
261 log::debug!("Response headers do not include remaining API usage count");
262 }
263 if requests_remaining.is_some_and(|v| v <= 0) {
264 if let Some(reset_value) = response.headers().get(&rate_limit_headers.reset)
265 {
266 if let Ok(epoch) = reset_value.to_str() {
267 if let Ok(value) = epoch.parse::<i64>() {
268 if let Some(reset) = DateTime::from_timestamp(value, 0) {
269 log::error!(
270 "REST API rate limit exceeded! Resets at {reset}"
271 );
272 return Err(RestClientError::RateLimit);
273 }
274 } else {
275 log::debug!(
276 "Failed to parse i64 from reset time about rate limit: {epoch}"
277 );
278 }
279 }
280 } else {
281 log::debug!("Response headers does not include a reset timestamp");
282 }
283 return Err(RestClientError::RateLimit);
284 }
285
286 // check if secondary rate limit is violated. If so, then backoff and try again.
287 if let Some(retry_value) = response.headers().get(&rate_limit_headers.retry) {
288 if let Ok(retry_str) = retry_value.to_str() {
289 if let Ok(retry) = retry_str.parse::<u64>() {
290 let interval = Duration::from_secs(retry + (i as u64).pow(2));
291 #[cfg(feature = "test-skip-wait-for-rate-limit")]
292 {
293 // Output a log statement to use the `interval` variable.
294 log::warn!(
295 "Skipped waiting {} seconds to expedite test",
296 interval.as_secs()
297 );
298 }
299 #[cfg(not(feature = "test-skip-wait-for-rate-limit"))]
300 {
301 tokio::time::sleep(interval).await;
302 }
303 } else {
304 log::debug!(
305 "Failed to parse u64 from retry interval about rate limit: {retry_str}"
306 );
307 }
308 }
309 continue;
310 }
311 }
312 return Ok(response);
313 }
314 Err(e) => return Err(e),
315 }
316 }
317 log::error!("REST API secondary rate limit exceeded after {MAX_RETRIES} retries.");
318 Err(RestClientError::RateLimit)
319}