1use std::fmt::Debug;
7use std::future::Future;
8use std::path::PathBuf;
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11
12use anyhow::{anyhow, Error, Result};
14use chrono::DateTime;
15use reqwest::header::{HeaderMap, HeaderValue};
16use reqwest::{Client, IntoUrl, Method, Request, Response, Url};
17
18pub 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#[derive(Debug, Clone)]
34pub struct RestApiRateLimitHeaders {
35 pub reset: String,
37 pub remaining: String,
39 pub retry: String,
41}
42
43pub trait RestApiClient {
45 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 fn start_log_group(&self, name: String);
55
56 fn end_log_group(&self);
58
59 fn make_headers() -> Result<HeaderMap<HeaderValue>>;
64
65 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 req.build().map_err(Error::from)
104 }
105
106 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 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 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 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 fn get_list_of_changed_files(
210 &self,
211 file_filter: &FileFilter,
212 ) -> impl Future<Output = Result<Vec<FileObj>>>;
213
214 fn get_changed_files_paginated(
219 &self,
220 url: Url,
221 file_filter: &FileFilter,
222 ) -> impl Future<Output = Result<Vec<FileObj>>>;
223
224 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 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 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 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 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(¬e.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#[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 #[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 #[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 #[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}