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