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, 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#[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(
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 req.build().map_err(Error::from)
99 }
100
101 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 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 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 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 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 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
219pub 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 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 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 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(¬e.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#[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 #[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 #[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 #[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}