cpp_linter/rest_api/
mod.rs

1//! This module is the home of functionality that uses the REST API of various git-based
2//! servers.
3//!
4//! Currently, only Github is supported.
5
6use std::fmt::Debug;
7use std::future::Future;
8use std::path::PathBuf;
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11
12// non-std crates
13use anyhow::{anyhow, Error, Result};
14use chrono::DateTime;
15use reqwest::header::{HeaderMap, HeaderValue};
16use reqwest::{Client, IntoUrl, Method, Request, Response, Url};
17
18// project specific modules
19pub mod github;
20use crate::clang_tools::ClangVersions;
21use crate::cli::FeedbackInput;
22use crate::common_fs::{FileFilter, FileObj};
23
24pub static COMMENT_MARKER: &str = "<!-- cpp linter action -->\n";
25pub static USER_OUTREACH: &str = concat!(
26    "\n\nHave any feedback or feature suggestions? [Share it here.]",
27    "(https://github.com/cpp-linter/cpp-linter-action/issues)"
28);
29pub static USER_AGENT: &str = concat!("cpp-linter/", 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/// A custom trait that templates necessary functionality with a Git server's REST API.
44pub trait RestApiClient {
45    /// A way to set output variables specific to cpp_linter executions in CI.
46    fn set_exit_code(
47        &self,
48        checks_failed: u64,
49        format_checks_failed: Option<u64>,
50        tidy_checks_failed: Option<u64>,
51    ) -> u64;
52
53    /// This prints a line to indicate the beginning of a related group of log statements.
54    fn start_log_group(&self, name: String);
55
56    /// This prints a line to indicate the ending of a related group of log statements.
57    fn end_log_group(&self);
58
59    /// A convenience method to create the headers attached to all REST API calls.
60    ///
61    /// If an authentication token is provided (via environment variable),
62    /// this method shall include the relative information.
63    fn make_headers() -> Result<HeaderMap<HeaderValue>>;
64
65    /// Construct a HTTP request to be sent.
66    ///
67    /// The idea here is that this method is called before [`RestApiClient::send_api_request()`].
68    /// ```ignore
69    /// let request = Self::make_api_request(
70    ///     &self.client,
71    ///     "https://example.com",
72    ///     Method::GET,
73    ///     None,
74    ///     None
75    /// );
76    /// let response = Self::send_api_request(
77    ///     self.client.clone(),
78    ///     request,
79    ///     self.rest_api_headers.clone(),
80    ///     0, // start recursion count at 0 (max iterations is 4)
81    /// );
82    /// match response.await {
83    ///     Some(res) => {/* handle response */}
84    ///     None => {/* handle failure */}
85    /// }
86    /// ```
87    fn make_api_request(
88        client: &Client,
89        url: impl IntoUrl,
90        method: Method,
91        data: Option<String>,
92        headers: Option<HeaderMap>,
93    ) -> Result<Request> {
94        let mut req = client.request(method, url);
95        if let Some(h) = headers {
96            req = req.headers(h);
97        }
98        if let Some(d) = data {
99            req = req.body(d);
100        }
101        // RequestBuilder only fails to `build()` if there is a malformed `url`. We
102        // should be safe here because of this function's `url` parameter type.
103        req.build().map_err(Error::from)
104    }
105
106    /// A convenience function to send HTTP requests and respect a REST API rate limits.
107    ///
108    /// This method must own all the data passed to it because asynchronous recursion is used.
109    /// Recursion is needed when a secondary rate limit is hit. The server tells the client that
110    /// it should back off and retry after a specified time interval.
111    ///
112    /// Note, pass `0` to the `retries` parameter, which is used to count recursive iterations.
113    /// This function will recur a maximum of 4 times, and this only happens when the response's
114    /// headers includes a retry interval.
115    fn send_api_request(
116        client: Client,
117        request: Request,
118        rate_limit_headers: RestApiRateLimitHeaders,
119        retries: u8,
120    ) -> impl Future<Output = Result<Response>> + Send {
121        async move {
122            static MAX_RETRIES: u8 = 5;
123            for i in retries..MAX_RETRIES {
124                let result = client
125                    .execute(request.try_clone().ok_or(anyhow!(
126                        "Failed to clone request object for recursive behavior"
127                    ))?)
128                    .await;
129                if let Ok(response) = &result {
130                    if [403u16, 429u16].contains(&response.status().as_u16()) {
131                        // rate limit may have been exceeded
132
133                        // check if primary rate limit was violated; panic if so.
134                        let mut requests_remaining = None;
135                        if let Some(remaining) =
136                            response.headers().get(&rate_limit_headers.remaining)
137                        {
138                            if let Ok(count) = remaining.to_str() {
139                                if let Ok(value) = count.parse::<i64>() {
140                                    requests_remaining = Some(value);
141                                } else {
142                                    log::debug!(
143                                    "Failed to parse i64 from remaining attempts about rate limit: {count}"
144                                );
145                                }
146                            }
147                        } else {
148                            // NOTE: I guess it is sometimes valid for a request to
149                            // not include remaining rate limit attempts
150                            log::debug!(
151                                "Response headers do not include remaining API usage count"
152                            );
153                        }
154                        if requests_remaining.is_some_and(|v| v <= 0) {
155                            if let Some(reset_value) =
156                                response.headers().get(&rate_limit_headers.reset)
157                            {
158                                if let Ok(epoch) = reset_value.to_str() {
159                                    if let Ok(value) = epoch.parse::<i64>() {
160                                        if let Some(reset) = DateTime::from_timestamp(value, 0) {
161                                            return Err(anyhow!(
162                                                "REST API rate limit exceeded! Resets at {}",
163                                                reset
164                                            ));
165                                        }
166                                    } else {
167                                        log::debug!(
168                                        "Failed to parse i64 from reset time about rate limit: {epoch}"
169                                    );
170                                    }
171                                }
172                            } else {
173                                log::debug!("Response headers does not include a reset timestamp");
174                            }
175                            return Err(anyhow!("REST API rate limit exceeded!"));
176                        }
177
178                        // check if secondary rate limit is violated; backoff and try again.
179                        if let Some(retry_value) = response.headers().get(&rate_limit_headers.retry)
180                        {
181                            if let Ok(retry_str) = retry_value.to_str() {
182                                if let Ok(retry) = retry_str.parse::<u64>() {
183                                    let interval = Duration::from_secs(retry + (i as u64).pow(2));
184                                    tokio::time::sleep(interval).await;
185                                } else {
186                                    log::debug!(
187                                    "Failed to parse u64 from retry interval about rate limit: {retry_str}"
188                                );
189                                }
190                            }
191                            continue;
192                        }
193                    }
194                    return result.map_err(Error::from);
195                }
196                return result.map_err(Error::from);
197            }
198            Err(anyhow!(
199                "REST API secondary rate limit exceeded after {MAX_RETRIES} retries."
200            ))
201        }
202    }
203
204    /// A way to get the list of changed files using REST API calls. It is this method's
205    /// job to parse diff blobs and return a list of changed files.
206    ///
207    /// The context of the file changes are subject to the type of event in which
208    /// cpp_linter package is used.
209    fn get_list_of_changed_files(
210        &self,
211        file_filter: &FileFilter,
212    ) -> impl Future<Output = Result<Vec<FileObj>>>;
213
214    /// A way to get the list of changed files using REST API calls that employ a paginated response.
215    ///
216    /// This is a helper to [`RestApiClient::get_list_of_changed_files()`] but takes a formulated URL
217    /// endpoint based on the context of the triggering CI event.
218    fn get_changed_files_paginated(
219        &self,
220        url: Url,
221        file_filter: &FileFilter,
222    ) -> impl Future<Output = Result<Vec<FileObj>>>;
223
224    /// Makes a comment in MarkDown syntax based on the concerns in `format_advice` and
225    /// `tidy_advice` about the given set of `files`.
226    ///
227    /// This method has a default definition and should not need to be redefined by
228    /// implementors.
229    ///
230    /// Returns the markdown comment as a string as well as the total count of
231    /// `format_checks_failed` and `tidy_checks_failed` (in respective order).
232    fn make_comment(
233        &self,
234        files: &[Arc<Mutex<FileObj>>],
235        format_checks_failed: u64,
236        tidy_checks_failed: u64,
237        clang_versions: &ClangVersions,
238        max_len: Option<u64>,
239    ) -> String {
240        let mut comment = format!("{COMMENT_MARKER}# Cpp-Linter Report ");
241        let mut remaining_length =
242            max_len.unwrap_or(u64::MAX) - comment.len() as u64 - USER_OUTREACH.len() as u64;
243
244        if format_checks_failed > 0 || tidy_checks_failed > 0 {
245            let prompt = ":warning:\nSome files did not pass the configured checks!\n";
246            remaining_length -= prompt.len() as u64;
247            comment.push_str(prompt);
248            if format_checks_failed > 0 {
249                make_format_comment(
250                    files,
251                    &mut comment,
252                    format_checks_failed,
253                    // tidy_version should be `Some()` value at this point.
254                    clang_versions.tidy_version.as_ref().unwrap(),
255                    &mut remaining_length,
256                );
257            }
258            if tidy_checks_failed > 0 {
259                make_tidy_comment(
260                    files,
261                    &mut comment,
262                    tidy_checks_failed,
263                    // format_version should be `Some()` value at this point.
264                    clang_versions.format_version.as_ref().unwrap(),
265                    &mut remaining_length,
266                );
267            }
268        } else {
269            comment.push_str(":heavy_check_mark:\nNo problems need attention.");
270        }
271        comment.push_str(USER_OUTREACH);
272        comment
273    }
274
275    /// A way to post feedback in the form of `thread_comments`, `file_annotations`, and
276    /// `step_summary`.
277    ///
278    /// The given `files` should've been gathered from `get_list_of_changed_files()` or
279    /// `list_source_files()`.
280    ///
281    /// The `format_advice` and `tidy_advice` should be a result of parsing output from
282    /// clang-format and clang-tidy (see `capture_clang_tools_output()`).
283    ///
284    /// All other parameters correspond to CLI arguments.
285    fn post_feedback(
286        &self,
287        files: &[Arc<Mutex<FileObj>>],
288        user_inputs: FeedbackInput,
289        clang_versions: ClangVersions,
290    ) -> impl Future<Output = Result<u64>>;
291
292    /// Gets the URL for the next page in a paginated response.
293    ///
294    /// Returns [`None`] if current response is the last page.
295    fn try_next_page(headers: &HeaderMap) -> Option<Url> {
296        if let Some(links) = headers.get("link") {
297            if let Ok(pg_str) = links.to_str() {
298                let pages = pg_str.split(", ");
299                for page in pages {
300                    if page.ends_with("; rel=\"next\"") {
301                        if let Some(link) = page.split_once(">;") {
302                            let url = link.0.trim_start_matches("<").to_string();
303                            if let Ok(next) = Url::parse(&url) {
304                                return Some(next);
305                            } else {
306                                log::debug!("Failed to parse next page link from response header");
307                            }
308                        } else {
309                            log::debug!("Response header link for pagination is malformed");
310                        }
311                    }
312                }
313            }
314        }
315        None
316    }
317
318    fn log_response(response: Response, context: &str) -> impl Future<Output = ()> + Send {
319        async move {
320            if let Err(e) = response.error_for_status_ref() {
321                log::error!("{}: {e:?}", context.to_owned());
322                if let Ok(text) = response.text().await {
323                    log::error!("{text}");
324                }
325            }
326        }
327    }
328}
329
330fn make_format_comment(
331    files: &[Arc<Mutex<FileObj>>],
332    comment: &mut String,
333    format_checks_failed: u64,
334    version_used: &String,
335    remaining_length: &mut u64,
336) {
337    let opener = format!(
338        "\n<details><summary>clang-format (v{version_used}) reports: <strong>{format_checks_failed} file(s) not formatted</strong></summary>\n\n",
339    );
340    let closer = String::from("\n</details>");
341    let mut format_comment = String::new();
342    *remaining_length -= opener.len() as u64 + closer.len() as u64;
343    for file in files {
344        let file = file.lock().unwrap();
345        if let Some(format_advice) = &file.format_advice {
346            if !format_advice.replacements.is_empty() && *remaining_length > 0 {
347                let note = format!("- {}\n", file.name.to_string_lossy().replace('\\', "/"));
348                if (note.len() as u64) < *remaining_length {
349                    format_comment.push_str(&note.to_string());
350                    *remaining_length -= note.len() as u64;
351                }
352            }
353        }
354    }
355    comment.push_str(&opener);
356    comment.push_str(&format_comment);
357    comment.push_str(&closer);
358}
359
360fn make_tidy_comment(
361    files: &[Arc<Mutex<FileObj>>],
362    comment: &mut String,
363    tidy_checks_failed: u64,
364    version_used: &String,
365    remaining_length: &mut u64,
366) {
367    let opener = format!(
368        "\n<details><summary>clang-tidy (v{version_used}) reports: {tidy_checks_failed}<strong> concern(s)</strong></summary>\n\n"
369    );
370    let closer = String::from("\n</details>");
371    let mut tidy_comment = String::new();
372    *remaining_length -= opener.len() as u64 + closer.len() as u64;
373    for file in files {
374        let file = file.lock().unwrap();
375        if let Some(tidy_advice) = &file.tidy_advice {
376            for tidy_note in &tidy_advice.notes {
377                let file_path = PathBuf::from(&tidy_note.filename);
378                if file_path == file.name {
379                    let mut tmp_note = format!("- {}\n\n", tidy_note.filename);
380                    tmp_note.push_str(&format!(
381                        "   <strong>{filename}:{line}:{cols}:</strong> {severity}: [{diagnostic}]\n   > {rationale}\n{concerned_code}",
382                        filename = tidy_note.filename,
383                        line = tidy_note.line,
384                        cols = tidy_note.cols,
385                        severity = tidy_note.severity,
386                        diagnostic = tidy_note.diagnostic_link(),
387                        rationale = tidy_note.rationale,
388                        concerned_code = if tidy_note.suggestion.is_empty() {String::from("")} else {
389                            format!("\n   ```{ext}\n   {suggestion}\n   ```\n",
390                                ext = file_path.extension().unwrap_or_default().to_string_lossy(),
391                                suggestion = tidy_note.suggestion.join("\n   "),
392                            ).to_string()
393                        },
394                    ).to_string());
395
396                    if (tmp_note.len() as u64) < *remaining_length {
397                        tidy_comment.push_str(&tmp_note);
398                        *remaining_length -= tmp_note.len() as u64;
399                    }
400                }
401            }
402        }
403    }
404    comment.push_str(&opener);
405    comment.push_str(&tidy_comment);
406    comment.push_str(&closer);
407}
408
409/// This module tests the silent errors' debug logs
410/// from `try_next_page()` and `send_api_request()` functions.
411#[cfg(test)]
412mod test {
413    use std::sync::{Arc, Mutex};
414
415    use anyhow::{anyhow, Result};
416    use chrono::Utc;
417    use mockito::{Matcher, Server};
418    use reqwest::{
419        header::{HeaderMap, HeaderValue},
420        Client,
421    };
422    use reqwest::{Method, Url};
423
424    use crate::{
425        clang_tools::ClangVersions,
426        cli::FeedbackInput,
427        common_fs::{FileFilter, FileObj},
428        logger,
429    };
430
431    use super::{RestApiClient, RestApiRateLimitHeaders};
432
433    /// A dummy struct to impl RestApiClient
434    #[derive(Default)]
435    struct TestClient {}
436
437    impl RestApiClient for TestClient {
438        fn set_exit_code(
439            &self,
440            _checks_failed: u64,
441            _format_checks_failed: Option<u64>,
442            _tidy_checks_failed: Option<u64>,
443        ) -> u64 {
444            0
445        }
446
447        fn make_headers() -> Result<HeaderMap<HeaderValue>> {
448            Err(anyhow!("Not implemented"))
449        }
450
451        async fn get_list_of_changed_files(
452            &self,
453            _file_filter: &FileFilter,
454        ) -> Result<Vec<FileObj>> {
455            Err(anyhow!("Not implemented"))
456        }
457
458        async fn get_changed_files_paginated(
459            &self,
460            _url: reqwest::Url,
461            _file_filter: &FileFilter,
462        ) -> Result<Vec<FileObj>> {
463            Err(anyhow!("Not implemented"))
464        }
465
466        async fn post_feedback(
467            &self,
468            _files: &[Arc<Mutex<FileObj>>],
469            _user_inputs: FeedbackInput,
470            _clang_versions: ClangVersions,
471        ) -> Result<u64> {
472            Err(anyhow!("Not implemented"))
473        }
474
475        fn start_log_group(&self, name: String) {
476            log::info!(target: "CI_LOG_GROUPING", "start_log_group: {name}");
477        }
478
479        fn end_log_group(&self) {
480            log::info!(target: "CI_LOG_GROUPING", "end_log_group");
481        }
482    }
483
484    #[tokio::test]
485    async fn dummy_coverage() {
486        assert!(TestClient::make_headers().is_err());
487        let dummy = TestClient::default();
488        dummy.start_log_group("Dummy test".to_string());
489        assert_eq!(dummy.set_exit_code(1, None, None), 0);
490        assert!(dummy
491            .get_list_of_changed_files(&FileFilter::new(&[], vec![]))
492            .await
493            .is_err());
494        assert!(dummy
495            .get_changed_files_paginated(
496                Url::parse("https://example.net").unwrap(),
497                &FileFilter::new(&[], vec![])
498            )
499            .await
500            .is_err());
501        assert!(dummy
502            .post_feedback(&[], FeedbackInput::default(), ClangVersions::default())
503            .await
504            .is_err());
505        dummy.end_log_group();
506    }
507
508    // ************************************************* try_next_page() tests
509
510    #[test]
511    fn bad_link_header() {
512        let mut headers = HeaderMap::with_capacity(1);
513        assert!(headers
514            .insert("link", HeaderValue::from_str("; rel=\"next\"").unwrap())
515            .is_none());
516        logger::init().unwrap();
517        log::set_max_level(log::LevelFilter::Debug);
518        let result = TestClient::try_next_page(&headers);
519        assert!(result.is_none());
520    }
521
522    #[test]
523    fn bad_link_domain() {
524        let mut headers = HeaderMap::with_capacity(1);
525        assert!(headers
526            .insert(
527                "link",
528                HeaderValue::from_str("<not a domain>; rel=\"next\"").unwrap()
529            )
530            .is_none());
531        logger::init().unwrap();
532        log::set_max_level(log::LevelFilter::Debug);
533        let result = TestClient::try_next_page(&headers);
534        assert!(result.is_none());
535    }
536
537    // ************************************************* Rate Limit Tests
538
539    #[derive(Default)]
540    struct RateLimitTestParams {
541        secondary: bool,
542        has_remaining_count: bool,
543        bad_remaining_count: bool,
544        has_reset_timestamp: bool,
545        bad_reset_timestamp: bool,
546        has_retry_interval: bool,
547        bad_retry_interval: bool,
548    }
549
550    async fn simulate_rate_limit(test_params: &RateLimitTestParams) {
551        let rate_limit_headers = RestApiRateLimitHeaders {
552            reset: "reset".to_string(),
553            remaining: "remaining".to_string(),
554            retry: "retry".to_string(),
555        };
556        logger::init().unwrap();
557        log::set_max_level(log::LevelFilter::Debug);
558
559        let mut server = Server::new_async().await;
560        let client = Client::new();
561        let reset_timestamp = (Utc::now().timestamp() + 60).to_string();
562        let mut mock = server
563            .mock("GET", "/")
564            .match_body(Matcher::Any)
565            .expect_at_least(1)
566            .expect_at_most(5)
567            .with_status(429);
568        if test_params.has_remaining_count {
569            mock = mock.with_header(
570                &rate_limit_headers.remaining,
571                if test_params.secondary {
572                    "1"
573                } else if test_params.bad_remaining_count {
574                    "X"
575                } else {
576                    "0"
577                },
578            );
579        }
580        if test_params.has_reset_timestamp {
581            mock = mock.with_header(
582                &rate_limit_headers.reset,
583                if test_params.bad_reset_timestamp {
584                    "X"
585                } else {
586                    &reset_timestamp
587                },
588            );
589        }
590        if test_params.secondary && test_params.has_retry_interval {
591            mock.with_header(
592                &rate_limit_headers.retry,
593                if test_params.bad_retry_interval {
594                    "X"
595                } else {
596                    "0"
597                },
598            )
599            .create();
600        } else {
601            mock.create();
602        }
603        let request =
604            TestClient::make_api_request(&client, server.url(), Method::GET, None, None).unwrap();
605        TestClient::send_api_request(client.clone(), request, rate_limit_headers.clone(), 0)
606            .await
607            .unwrap();
608    }
609
610    #[tokio::test]
611    #[should_panic(expected = "REST API secondary rate limit exceeded")]
612    async fn rate_limit_secondary() {
613        simulate_rate_limit(&RateLimitTestParams {
614            secondary: true,
615            has_retry_interval: true,
616            ..Default::default()
617        })
618        .await;
619    }
620
621    #[tokio::test]
622    #[should_panic(expected = "REST API secondary rate limit exceeded")]
623    async fn rate_limit_bad_retry() {
624        simulate_rate_limit(&RateLimitTestParams {
625            secondary: true,
626            has_retry_interval: true,
627            bad_retry_interval: true,
628            ..Default::default()
629        })
630        .await;
631    }
632
633    #[tokio::test]
634    #[should_panic(expected = "REST API rate limit exceeded!")]
635    async fn rate_limit_primary() {
636        simulate_rate_limit(&RateLimitTestParams {
637            has_remaining_count: true,
638            has_reset_timestamp: true,
639            ..Default::default()
640        })
641        .await;
642    }
643
644    #[tokio::test]
645    #[should_panic(expected = "REST API rate limit exceeded!")]
646    async fn rate_limit_no_reset() {
647        simulate_rate_limit(&RateLimitTestParams {
648            has_remaining_count: true,
649            ..Default::default()
650        })
651        .await;
652    }
653
654    #[tokio::test]
655    #[should_panic(expected = "REST API rate limit exceeded!")]
656    async fn rate_limit_bad_reset() {
657        simulate_rate_limit(&RateLimitTestParams {
658            has_remaining_count: true,
659            has_reset_timestamp: true,
660            bad_reset_timestamp: true,
661            ..Default::default()
662        })
663        .await;
664    }
665
666    #[tokio::test]
667    async fn rate_limit_bad_count() {
668        simulate_rate_limit(&RateLimitTestParams {
669            has_remaining_count: true,
670            bad_remaining_count: true,
671            ..Default::default()
672        })
673        .await;
674    }
675}