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