Skip to main content

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 std::{
3    env,
4    fmt::Debug,
5    path::{Component, Path, PathBuf},
6    time::Duration,
7};
8
9use async_trait::async_trait;
10use chrono::DateTime;
11use reqwest::{Client, Method, Request, Response, Url, header::HeaderMap};
12
13use crate::{FileAnnotation, OutputVariable, RestClientError, ReviewOptions, ThreadCommentOptions};
14
15#[cfg(feature = "gitea")]
16mod gitea;
17#[cfg(feature = "gitea")]
18pub use gitea::GiteaApiClient;
19
20#[cfg(feature = "github")]
21mod github;
22#[cfg(feature = "github")]
23pub use github::GithubApiClient;
24
25mod local;
26pub use local::LocalClient;
27
28mod common;
29
30#[cfg(not(any(
31    feature = "github",
32    feature = "gitea",
33    feature = "custom-git-server-impl",
34)))]
35compile_error!(
36    "At least one Git server implementation (eg. 'github') should be enabled via `features`"
37);
38
39#[cfg(feature = "file-changes")]
40use crate::{FileDiffLines, FileFilter, LinesChangedOnly};
41#[cfg(feature = "file-changes")]
42use std::collections::HashMap;
43
44/// The User-Agent header value included in all HTTP requests.
45pub static USER_AGENT: &str = concat!(env!("CARGO_CRATE_NAME"), "/", env!("CARGO_PKG_VERSION"));
46
47/// A structure to contain the different forms of headers that
48/// describe a REST API's rate limit status.
49#[derive(Debug, Clone)]
50pub struct RestApiRateLimitHeaders {
51    /// The header key of the rate limit's reset time.
52    pub reset: String,
53    /// The header key of the rate limit's remaining attempts.
54    pub remaining: String,
55    /// The header key of the rate limit's "backoff" time interval.
56    pub retry: String,
57}
58
59/// Find the closest folder, starting with `path` itself, that has a `.git` folder or file.
60///
61/// A git submodule has a `.git` file instead of a folder.
62/// A relative `path` is resolved against the working directory.
63/// The returned path is absolute, with any `.` and `..` components removed,
64/// but symbolic links are not resolved.
65///
66/// Returns [`None`] if no such folder is found.
67/// This does not invoke the git CLI.
68///
69/// ```rust
70/// use git_bot_feedback::client::nearest_git_root;
71/// use std::path::Path;
72///
73/// let root = nearest_git_root(Path::new("src")).unwrap();
74/// assert!(root.join(".git").exists());
75/// ```
76pub fn nearest_git_root(path: &Path) -> Option<PathBuf> {
77    let absolute = std::path::absolute(path).ok()?;
78    let mut normalized = PathBuf::new();
79    for component in absolute.components() {
80        match component {
81            Component::CurDir => {}
82            Component::ParentDir => {
83                normalized.pop();
84            }
85            _ => normalized.push(component),
86        }
87    }
88    normalized
89        .ancestors()
90        .find(|dir| dir.join(".git").exists())
91        .map(Path::to_path_buf)
92}
93
94/// The [`Result::Err`] type returned for fallible functions in this trait.
95pub(crate) type ClientError = RestClientError;
96
97/// The number of attempts made when contending a secondary rate limit in REST API requests.
98pub(crate) const MAX_RETRIES: u8 = 5;
99
100/// A custom trait that templates necessary functionality with a Git server's REST API.
101#[async_trait]
102pub trait RestApiClient {
103    /// This prints a line to indicate the beginning of a related group of [`log`] statements.
104    ///
105    /// For apps' [`log`] implementations, this function's [`log::info`] output needs to have
106    /// no prefixed data.
107    /// Such behavior can be identified by the log target `"CI_LOG_GROUPING"`.
108    ///
109    /// ```
110    /// # struct MyAppLogger;
111    /// impl log::Log for MyAppLogger {
112    /// #    fn enabled(&self, metadata: &log::Metadata) -> bool {
113    /// #        log::max_level() > metadata.level()
114    /// #    }
115    ///     fn log(&self, record: &log::Record) {
116    ///         if record.target() == "CI_LOG_GROUPING" {
117    ///             println!("{}", record.args());
118    ///         } else {
119    ///             println!(
120    ///                 "[{:>5}]{}: {}",
121    ///                 record.level().as_str(),
122    ///                 record.module_path().unwrap_or_default(),
123    ///                 record.args()
124    ///             );
125    ///         }
126    ///     }
127    /// #    fn flush(&self) {}
128    /// }
129    /// ```
130    fn start_log_group(&self, name: &str) {
131        log::info!(target: "CI_LOG_GROUPING", "start_log_group: {name}");
132    }
133
134    /// This prints a line to indicate the ending of a related group of [`log`] statements.
135    ///
136    /// See also [`RestApiClient::start_log_group`] about special handling of
137    /// the log target `"CI_LOG_GROUPING"`.
138    fn end_log_group(&self, name: &str) {
139        log::info!(target: "CI_LOG_GROUPING", "end_log_group: {name}");
140    }
141
142    /// Is the current CI event **trigger** a Pull Request?
143    ///
144    /// This **will not** check if a push event's instigating commit is part of any PR.
145    fn is_pr_event(&self) -> bool;
146
147    /// Is debug mode enabled?
148    ///
149    /// Typically, A CI platform will have a way to enable debug level logs for a job or workflow.
150    /// This method should be implemented to reflect the supported CI platform's implementation.
151    fn is_debug_enabled(&self) -> bool {
152        false
153    }
154
155    /// Get the name of the current CI event.
156    ///
157    /// This will return [`None`] if the event name is not known for the CI platform.
158    fn event_name(&self) -> Option<String> {
159        None
160    }
161
162    /// The root folder of the workspace, as named by the CI platform's environment
163    /// (for example, `GITHUB_WORKSPACE` on GitHub).
164    ///
165    /// Changed file names reported by the git server's REST API are relative to the
166    /// repository's top level, which is this folder unless the repository was checked
167    /// out into a sub-folder of it.
168    ///
169    /// Returns [`Ok(None)`](Option::None) for clients that are not run by a CI platform
170    /// (like [`LocalClient`]), and an error if the CI platform's environment variable
171    /// cannot be read. This does not fall back to the working directory or
172    /// [`nearest_git_root()`]; consumers can choose their own fallback.
173    fn workspace_root(&self) -> Result<Option<PathBuf>, ClientError> {
174        Ok(None)
175    }
176
177    /// Set the user agent for the underlying HTTP request client.
178    ///
179    /// By default the user agent is set to this lib's name and version.
180    /// See [`USER_AGENT`] for the default value.
181    fn set_user_agent(&mut self, user_agent: &str) -> Result<(), ClientError>;
182
183    /// A way to get the list of changed files in the context of the CI event.
184    ///
185    /// This method will parse diff blobs and return a list of changed files.
186    ///
187    /// The default implementation uses `git diff` to get the list of changed files.
188    /// So, the default implementation requires `git` installed and a non-shallow checkout.
189    ///
190    /// Other implementations use the Git server's REST API to get the list of changed files.
191    #[cfg(feature = "file-changes")]
192    #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
193    async fn get_list_of_changed_files(
194        &self,
195        file_filter: &FileFilter,
196        lines_changed_only: &LinesChangedOnly,
197        base_diff: Option<String>,
198        ignore_index: bool,
199    ) -> Result<HashMap<String, FileDiffLines>, ClientError>;
200
201    /// A way to post feedback to the Git server's GUI.
202    ///
203    /// The given [`ThreadCommentOptions::comment`] should be compliant with
204    /// the Git server's requirements (ie. the comment length is within acceptable limits).
205    async fn post_thread_comment(&self, options: ThreadCommentOptions) -> Result<(), ClientError>;
206
207    /// Appends a given comment to the CI workflow's summary page.
208    ///
209    /// This is the least obtrusive and recommended for push events.
210    /// Not all Git servers natively support this type of feedback.
211    /// GitHub and Gitea are known to support this.
212    /// For all other git servers, this is a non-op returning [`Ok`]
213    fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
214        let _ = comment;
215        Ok(())
216    }
217
218    /// Resolve outdated PR review comments and remove duplicate/reused comments.
219    ///
220    /// This should be used before [`Self::post_pr_review()`] to avoid posting duplicates of existing comments.
221    /// The [`ReviewOptions::comments`] will be modified to only include comments that should be posted for the current PR review.
222    /// After calling this function, the [`ReviewOptions::summary`] can be made to reflect the actual review being posted.
223    ///
224    /// The [`ReviewOptions::marker`] is used to identify comments from this software.
225    /// The [`ReviewOptions::delete_review_comments`] flag will delete outdated review comments.
226    /// The [`ReviewOptions::delete_review_comments`] flag does not apply to review summary comments nor
227    /// threads of discussion within a review.
228    /// A review summary comment will only be hidden/collapsed when all comments in the corresponding
229    /// review are resolved.
230    ///
231    /// This function does nothing for non-PR events.
232    async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError>;
233
234    /// Post a PR review based on the given options.
235    ///
236    /// This is expected to be used after calling [`Self::cull_pr_reviews()`] to
237    /// avoid posting duplicates of existing comments. Once the duplicates are filtered out,
238    /// the [`ReviewOptions::summary`] can be made to reflect the actual review being posted.
239    ///
240    /// This function does nothing for non-PR events.
241    async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError>;
242
243    /// Sets the given `vars` as output variables.
244    ///
245    /// These variables are designed to be consumed by other steps in the CI workflow.
246    fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError>;
247
248    /// Sets the given `annotations` as file annotations.
249    ///
250    /// Not all Git servers support this on their free tiers, namely GitLab.
251    fn write_file_annotations(&self, annotations: &[FileAnnotation]) -> Result<(), ClientError> {
252        for annotation in annotations {
253            log::info!("{annotation:#?}");
254        }
255        Ok(())
256    }
257
258    /// Construct a HTTP request to be sent.
259    ///
260    /// The idea here is that this method is called before [`Self::send_api_request()`].
261    /// ```ignore
262    /// let request = Self::make_api_request(
263    ///     &self.client,
264    ///     Url::parse("https://example.com").unwrap(),
265    ///     Method::GET,
266    ///     None,
267    ///     None,
268    /// ).unwrap();
269    /// let response = send_api_request(&self.client, request, &self.rest_api_headers);
270    /// match response.await {
271    ///     Ok(res) => todo!(handle response),
272    ///     Err(e) => todo!(handle failure),
273    /// }
274    /// ```
275    fn make_api_request(
276        &self,
277        client: &Client,
278        url: Url,
279        method: Method,
280        data: Option<String>,
281        headers: Option<HeaderMap>,
282    ) -> Result<Request, ClientError> {
283        let mut req = client.request(method, url);
284        if let Some(h) = headers {
285            req = req.headers(h);
286        }
287        if let Some(d) = data {
288            req = req.body(d);
289        }
290        req.build()
291            .map_err(|e| ClientError::add_request_context(ClientError::Request(e), "build request"))
292    }
293
294    /// A convenience function to send HTTP requests and respect a REST API rate limits.
295    ///
296    /// This method respects both primary and secondary rate limits.
297    /// In the event where the secondary rate limits is reached,
298    /// this function will wait for a time interval (if specified by the server) and retry afterward.
299    async fn send_api_request(
300        &self,
301        client: &Client,
302        request: Request,
303        rate_limit_headers: &RestApiRateLimitHeaders,
304    ) -> Result<Response, ClientError> {
305        for i in 0..MAX_RETRIES {
306            let response = client
307                .execute(request.try_clone().ok_or(ClientError::CannotCloneRequest)?)
308                .await?;
309            if [403u16, 429u16].contains(&response.status().as_u16()) {
310                // rate limit may have been exceeded
311
312                // check if primary rate limit was violated
313                let mut requests_remaining = None;
314                if let Some(remaining) = response.headers().get(&rate_limit_headers.remaining) {
315                    requests_remaining = Some(remaining.to_str()?.parse::<i64>()?);
316                } else {
317                    // NOTE: I guess it is sometimes valid for a response to
318                    // not include remaining rate limit attempts
319                    log::debug!("Response headers do not include remaining API usage count");
320                }
321                if requests_remaining.is_some_and(|v| v <= 0) {
322                    if let Some(reset_value) = response.headers().get(&rate_limit_headers.reset)
323                        && let Some(reset) =
324                            DateTime::from_timestamp(reset_value.to_str()?.parse::<i64>()?, 0)
325                    {
326                        return Err(ClientError::RateLimitPrimary(reset));
327                    }
328                    return Err(ClientError::RateLimitNoReset);
329                }
330
331                // check if secondary rate limit is violated. If so, then backoff and try again.
332                if let Some(retry_value) = response.headers().get(&rate_limit_headers.retry) {
333                    let interval = Duration::from_secs(
334                        retry_value.to_str()?.parse::<u64>()? + (i as u64).pow(2),
335                    );
336                    #[cfg(feature = "test-skip-wait-for-rate-limit")]
337                    {
338                        // Output a log statement to use the `interval` variable.
339                        log::warn!(
340                            "Skipped waiting {} seconds to expedite test",
341                            interval.as_secs()
342                        );
343                    }
344                    #[cfg(not(feature = "test-skip-wait-for-rate-limit"))]
345                    {
346                        tokio::time::sleep(interval).await;
347                    }
348                    continue;
349                }
350            }
351            return Ok(response);
352        }
353        Err(ClientError::RateLimitSecondary)
354    }
355
356    /// Gets the URL for the next page from the headers in a paginated response.
357    ///
358    /// Returns [`None`] if current response is the last page.
359    fn try_next_page(&self, headers: &HeaderMap) -> Option<Url> {
360        if let Some(links) = headers.get("link")
361            && let Ok(pg_str) = links.to_str()
362        {
363            let pages = pg_str.split(", ");
364            for page in pages {
365                if page.ends_with("; rel=\"next\"") {
366                    if let Some(link) = page.split_once(">;") {
367                        let url = link.0.trim_start_matches("<").to_string();
368                        if let Ok(next) = Url::parse(&url) {
369                            return Some(next);
370                        } else {
371                            log::debug!("Failed to parse next page link from response header");
372                        }
373                    } else {
374                        log::debug!("Response header link for pagination is malformed");
375                    }
376                }
377            }
378        }
379        None
380    }
381
382    /// A helper function to log the response of an API request with context.
383    ///
384    /// This also dumps the response body as text if possible.
385    async fn log_response(&self, response: Response, context: &str) {
386        if let Err(e) = response.error_for_status_ref() {
387            log::error!("{}: {e:?}", context.to_owned());
388            if let Ok(text) = response.text().await {
389                log::error!("{text}");
390            }
391        }
392    }
393
394    /// The name of git server implementation is being used.
395    fn client_kind(&self) -> String;
396}
397
398/// Instantiate an implementation of [`RestApiClient`] based on the environment.
399///
400/// This will fallback to an instance of [`LocalClient`] if
401///
402/// - the `GITHUB_ACTIONS` environment variable is not set
403pub fn init_client() -> Result<Box<dyn RestApiClient + Send + Sync>, ClientError> {
404    if cfg!(feature = "gitea")
405        && env::var("GITEA_ACTIONS").is_ok_and(|v| v.to_lowercase() == "true")
406    {
407        Ok(Box::new(GiteaApiClient::new()?))
408    } else if cfg!(feature = "github")
409        && env::var("GITHUB_ACTIONS").is_ok_and(|v| v.to_lowercase() == "true")
410    {
411        Ok(Box::new(GithubApiClient::new()?))
412    } else {
413        Ok(Box::new(LocalClient))
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    #![allow(clippy::unwrap_used)]
420
421    use super::nearest_git_root;
422    use std::fs;
423    use tempfile::tempdir;
424
425    #[test]
426    fn git_root_of_sub_folder() {
427        let tmp = tempdir().unwrap();
428        let repo = tmp.path().join("repo");
429        fs::create_dir_all(repo.join(".git")).unwrap();
430        fs::create_dir_all(repo.join("member/src")).unwrap();
431        let root = nearest_git_root(&repo.join("member/src")).unwrap();
432        assert_eq!(root, repo);
433        assert_eq!(nearest_git_root(&repo).unwrap(), repo);
434        // `.` and `..` are removed before searching
435        let indirect = repo.join("member/./src/../src/..");
436        assert_eq!(nearest_git_root(&indirect).unwrap(), repo);
437    }
438
439    #[test]
440    fn git_root_of_submodule() {
441        let tmp = tempdir().unwrap();
442        let repo = tmp.path().join("repo");
443        fs::create_dir_all(repo.join(".git")).unwrap();
444        let submodule = repo.join("libs/sub");
445        fs::create_dir_all(&submodule).unwrap();
446        // a submodule has a `.git` file
447        fs::write(submodule.join(".git"), "").unwrap();
448        assert_eq!(nearest_git_root(&submodule.join("src")).unwrap(), submodule);
449        assert_eq!(nearest_git_root(&repo.join("libs")).unwrap(), repo);
450    }
451
452    #[test]
453    fn no_git_root() {
454        let tmp = tempdir().unwrap();
455        let folder = tmp.path().join("not/a/repo");
456        fs::create_dir_all(&folder).unwrap();
457        let found = nearest_git_root(&folder);
458        // the temp dir is not expected to be inside a git repository
459        assert!(found.is_none_or(|root| !root.starts_with(tmp.path())));
460    }
461}