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::{env, fmt::Debug, time::Duration};
3
4use async_trait::async_trait;
5use chrono::DateTime;
6use reqwest::{Client, Method, Request, Response, Url, header::HeaderMap};
7
8use crate::{FileAnnotation, OutputVariable, RestClientError, ReviewOptions, ThreadCommentOptions};
9
10#[cfg(feature = "github")]
11mod github;
12#[cfg(feature = "github")]
13pub use github::GithubApiClient;
14
15mod local;
16pub use local::LocalClient;
17
18#[cfg(not(any(feature = "github", feature = "custom-git-server-impl")))]
19compile_error!(
20 "At least one Git server implementation (eg. 'github') should be enabled via `features`"
21);
22
23#[cfg(feature = "file-changes")]
24use crate::{FileDiffLines, FileFilter, LinesChangedOnly};
25#[cfg(feature = "file-changes")]
26use std::collections::HashMap;
27
28/// The User-Agent header value included in all HTTP requests.
29pub static USER_AGENT: &str = concat!(env!("CARGO_CRATE_NAME"), "/", env!("CARGO_PKG_VERSION"));
30
31/// A structure to contain the different forms of headers that
32/// describe a REST API's rate limit status.
33#[derive(Debug, Clone)]
34pub struct RestApiRateLimitHeaders {
35 /// The header key of the rate limit's reset time.
36 pub reset: String,
37 /// The header key of the rate limit's remaining attempts.
38 pub remaining: String,
39 /// The header key of the rate limit's "backoff" time interval.
40 pub retry: String,
41}
42
43/// The [`Result::Err`] type returned for fallible functions in this trait.
44pub(crate) type ClientError = RestClientError;
45
46/// The number of attempts made when contending a secondary rate limit in REST API requests.
47pub(crate) const MAX_RETRIES: u8 = 5;
48
49/// A custom trait that templates necessary functionality with a Git server's REST API.
50#[async_trait]
51pub trait RestApiClient {
52 /// This prints a line to indicate the beginning of a related group of log statements.
53 fn start_log_group(&self, name: &str) {
54 log::info!(target: "CI_LOG_GROUPING", "start_log_group: {name}");
55 }
56
57 /// This prints a line to indicate the ending of a related group of log statements.
58 fn end_log_group(&self, name: &str) {
59 log::info!(target: "CI_LOG_GROUPING", "end_log_group: {name}");
60 }
61
62 /// Is the current CI event **trigger** a Pull Request?
63 ///
64 /// This **will not** check if a push event's instigating commit is part of any PR.
65 fn is_pr_event(&self) -> bool;
66
67 /// Is debug mode enabled?
68 ///
69 /// Typically, A CI platform will have a way to enable debug level logs for a job or workflow.
70 /// This method should be implemented to reflect the supported CI platform's implementation.
71 fn is_debug_enabled(&self) -> bool {
72 false
73 }
74
75 /// Get the name of the current CI event.
76 ///
77 /// This will return [`None`] if the event name is not known for the CI platform.
78 fn event_name(&self) -> Option<String> {
79 None
80 }
81
82 /// Set the user agent for the underlying HTTP request client.
83 ///
84 /// By default the user agent is set to this lib's name and version.
85 /// See [`USER_AGENT`] for the default value.
86 fn set_user_agent(&mut self, user_agent: &str) -> Result<(), ClientError>;
87
88 /// A way to get the list of changed files in the context of the CI event.
89 ///
90 /// This method will parse diff blobs and return a list of changed files.
91 ///
92 /// The default implementation uses `git diff` to get the list of changed files.
93 /// So, the default implementation requires `git` installed and a non-shallow checkout.
94 ///
95 /// Other implementations use the Git server's REST API to get the list of changed files.
96 #[cfg(feature = "file-changes")]
97 #[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
98 async fn get_list_of_changed_files(
99 &self,
100 file_filter: &FileFilter,
101 lines_changed_only: &LinesChangedOnly,
102 base_diff: Option<String>,
103 ignore_index: bool,
104 ) -> Result<HashMap<String, FileDiffLines>, ClientError>;
105
106 /// A way to post feedback to the Git server's GUI.
107 ///
108 /// The given [`ThreadCommentOptions::comment`] should be compliant with
109 /// the Git server's requirements (ie. the comment length is within acceptable limits).
110 async fn post_thread_comment(&self, options: ThreadCommentOptions) -> Result<(), ClientError>;
111
112 /// Appends a given comment to the CI workflow's summary page.
113 ///
114 /// This is the least obtrusive and recommended for push events.
115 /// Not all Git servers natively support this type of feedback.
116 /// GitHub and Gitea are known to support this.
117 /// For all other git servers, this is a non-op returning [`Ok`]
118 fn append_step_summary(&self, comment: &str) -> Result<(), ClientError> {
119 let _ = comment;
120 Ok(())
121 }
122
123 /// Resolve outdated PR review comments and remove duplicate/reused comments.
124 ///
125 /// This should be used before [`Self::post_pr_review()`] to avoid posting duplicates of existing comments.
126 /// The [`ReviewOptions::comments`] will be modified to only include comments that should be posted for the current PR review.
127 /// After calling this function, the [`ReviewOptions::summary`] can be made to reflect the actual review being posted.
128 ///
129 /// The [`ReviewOptions::marker`] is used to identify comments from this software.
130 /// The [`ReviewOptions::delete_review_comments`] flag will delete outdated review comments.
131 /// The [`ReviewOptions::delete_review_comments`] flag does not apply to review summary comments nor
132 /// threads of discussion within a review.
133 /// A review summary comment will only be hidden/collapsed when all comments in the corresponding
134 /// review are resolved.
135 ///
136 /// This function does nothing for non-PR events.
137 async fn cull_pr_reviews(&mut self, options: &mut ReviewOptions) -> Result<(), ClientError>;
138
139 /// Post a PR review based on the given options.
140 ///
141 /// This is expected to be used after calling [`Self::cull_pr_reviews()`] to
142 /// avoid posting duplicates of existing comments. Once the duplicates are filtered out,
143 /// the [`ReviewOptions::summary`] can be made to reflect the actual review being posted.
144 ///
145 /// This function does nothing for non-PR events.
146 async fn post_pr_review(&mut self, options: &ReviewOptions) -> Result<(), ClientError>;
147
148 /// Sets the given `vars` as output variables.
149 ///
150 /// These variables are designed to be consumed by other steps in the CI workflow.
151 fn write_output_variables(&self, vars: &[OutputVariable]) -> Result<(), ClientError>;
152
153 /// Sets the given `annotations` as file annotations.
154 ///
155 /// Not all Git servers support this on their free tiers, namely GitLab.
156 fn write_file_annotations(&self, annotations: &[FileAnnotation]) -> Result<(), ClientError> {
157 for annotation in annotations {
158 log::info!("{annotation:#?}");
159 }
160 Ok(())
161 }
162
163 /// Construct a HTTP request to be sent.
164 ///
165 /// The idea here is that this method is called before [`Self::send_api_request()`].
166 /// ```ignore
167 /// let request = Self::make_api_request(
168 /// &self.client,
169 /// Url::parse("https://example.com").unwrap(),
170 /// Method::GET,
171 /// None,
172 /// None,
173 /// ).unwrap();
174 /// let response = send_api_request(&self.client, request, &self.rest_api_headers);
175 /// match response.await {
176 /// Ok(res) => todo!(handle response),
177 /// Err(e) => todo!(handle failure),
178 /// }
179 /// ```
180 fn make_api_request(
181 &self,
182 client: &Client,
183 url: Url,
184 method: Method,
185 data: Option<String>,
186 headers: Option<HeaderMap>,
187 ) -> Result<Request, ClientError> {
188 let mut req = client.request(method, url);
189 if let Some(h) = headers {
190 req = req.headers(h);
191 }
192 if let Some(d) = data {
193 req = req.body(d);
194 }
195 req.build()
196 .map_err(|e| ClientError::add_request_context(ClientError::Request(e), "build request"))
197 }
198
199 /// A convenience function to send HTTP requests and respect a REST API rate limits.
200 ///
201 /// This method respects both primary and secondary rate limits.
202 /// In the event where the secondary rate limits is reached,
203 /// this function will wait for a time interval (if specified by the server) and retry afterward.
204 async fn send_api_request(
205 &self,
206 client: &Client,
207 request: Request,
208 rate_limit_headers: &RestApiRateLimitHeaders,
209 ) -> Result<Response, ClientError> {
210 for i in 0..MAX_RETRIES {
211 let response = client
212 .execute(request.try_clone().ok_or(ClientError::CannotCloneRequest)?)
213 .await?;
214 if [403u16, 429u16].contains(&response.status().as_u16()) {
215 // rate limit may have been exceeded
216
217 // check if primary rate limit was violated
218 let mut requests_remaining = None;
219 if let Some(remaining) = response.headers().get(&rate_limit_headers.remaining) {
220 requests_remaining = Some(remaining.to_str()?.parse::<i64>()?);
221 } else {
222 // NOTE: I guess it is sometimes valid for a response to
223 // not include remaining rate limit attempts
224 log::debug!("Response headers do not include remaining API usage count");
225 }
226 if requests_remaining.is_some_and(|v| v <= 0) {
227 if let Some(reset_value) = response.headers().get(&rate_limit_headers.reset)
228 && let Some(reset) =
229 DateTime::from_timestamp(reset_value.to_str()?.parse::<i64>()?, 0)
230 {
231 return Err(ClientError::RateLimitPrimary(reset));
232 }
233 return Err(ClientError::RateLimitNoReset);
234 }
235
236 // check if secondary rate limit is violated. If so, then backoff and try again.
237 if let Some(retry_value) = response.headers().get(&rate_limit_headers.retry) {
238 let interval = Duration::from_secs(
239 retry_value.to_str()?.parse::<u64>()? + (i as u64).pow(2),
240 );
241 #[cfg(feature = "test-skip-wait-for-rate-limit")]
242 {
243 // Output a log statement to use the `interval` variable.
244 log::warn!(
245 "Skipped waiting {} seconds to expedite test",
246 interval.as_secs()
247 );
248 }
249 #[cfg(not(feature = "test-skip-wait-for-rate-limit"))]
250 {
251 tokio::time::sleep(interval).await;
252 }
253 continue;
254 }
255 }
256 return Ok(response);
257 }
258 Err(ClientError::RateLimitSecondary)
259 }
260
261 /// Gets the URL for the next page from the headers in a paginated response.
262 ///
263 /// Returns [`None`] if current response is the last page.
264 fn try_next_page(&self, headers: &HeaderMap) -> Option<Url> {
265 if let Some(links) = headers.get("link")
266 && let Ok(pg_str) = links.to_str()
267 {
268 let pages = pg_str.split(", ");
269 for page in pages {
270 if page.ends_with("; rel=\"next\"") {
271 if let Some(link) = page.split_once(">;") {
272 let url = link.0.trim_start_matches("<").to_string();
273 if let Ok(next) = Url::parse(&url) {
274 return Some(next);
275 } else {
276 log::debug!("Failed to parse next page link from response header");
277 }
278 } else {
279 log::debug!("Response header link for pagination is malformed");
280 }
281 }
282 }
283 }
284 None
285 }
286
287 async fn log_response(&self, response: Response, context: &str) {
288 if let Err(e) = response.error_for_status_ref() {
289 log::error!("{}: {e:?}", context.to_owned());
290 if let Ok(text) = response.text().await {
291 log::error!("{text}");
292 }
293 }
294 }
295}
296
297/// Instantiate an implementation of [`RestApiClient`] based on the environment.
298///
299/// This will fallback to an instance of [`LocalClient`] if
300///
301/// - the `GITHUB_ACTIONS` environment variable is not set
302pub fn init_client() -> Result<Box<dyn RestApiClient + Send + Sync>, ClientError> {
303 if env::var("GITHUB_ACTIONS").is_ok_and(|v| v.to_lowercase() == "true") {
304 Ok(Box::new(GithubApiClient::new()?))
305 } else {
306 Ok(Box::new(LocalClient))
307 }
308}