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}