1use std::{
4 env,
5 path::PathBuf,
6 sync::{Arc, Mutex},
7};
8
9use git_bot_feedback::{
10 AnnotationLevel, CommentKind, CommentPolicy, FileAnnotation, FileFilter, LinesChangedOnly,
11 OutputVariable, RestApiClient, ReviewAction, ReviewOptions, ThreadCommentOptions,
12 client::init_client,
13};
14
15use crate::{
16 clang_tools::{
17 ClangVersions, ReviewComments,
18 clang_format::{summarize_style, tally_format_advice},
19 clang_tidy::tally_tidy_advice,
20 },
21 cli::{ClangParams, FeedbackInput, ThreadComments},
22 common_fs::FileObj,
23 error::ClientError,
24};
25
26pub const COMMENT_MARKER: &str = "<!-- cpp linter action -->\n";
28
29pub const USER_AGENT: &str = concat!("cpp-linter/", env!("CARGO_PKG_VERSION"),);
31
32pub const USER_OUTREACH: &str = concat!(
34 "\n\nHave any feedback or feature suggestions? [Share it here.]",
35 "(https://github.com/cpp-linter/cpp-linter-action/issues)"
36);
37
38pub struct RestClient {
43 client: Box<dyn RestApiClient + Sync + Send>,
44}
45
46impl RestClient {
47 pub fn new() -> Result<Self, ClientError> {
52 let mut client = init_client()?;
53 client.set_user_agent(USER_AGENT)?;
54 Ok(Self { client })
55 }
56
57 pub fn client_kind(&self) -> String {
61 self.client.client_kind()
62 }
63
64 pub fn is_pr(&self) -> bool {
66 self.client.is_pr_event()
67 }
68
69 pub async fn get_list_of_changed_files(
74 &self,
75 file_filter: &FileFilter,
76 lines_changed_only: &LinesChangedOnly,
77 base_diff: &Option<String>,
78 ignore_index: bool,
79 ) -> Result<Vec<FileObj>, ClientError> {
80 let files = self
81 .client
82 .get_list_of_changed_files(
83 file_filter,
84 lines_changed_only,
85 base_diff.to_owned(),
86 ignore_index,
87 )
88 .await?;
89 Ok(files
90 .iter()
91 .map(|(file_name, diff_lines)| {
92 let diff_chunks = diff_lines
93 .diff_hunks
94 .iter()
95 .map(|hunk| hunk.start..=hunk.end)
96 .collect();
97 let file_path = PathBuf::from(file_name);
98 FileObj::from(file_path, diff_lines.added_lines.clone(), diff_chunks)
99 })
100 .collect())
101 }
102
103 pub fn is_debug_enabled(&self) -> bool {
105 self.client.is_debug_enabled()
106 }
107
108 pub fn start_log_group(&self, name: &str) {
110 self.client.start_log_group(name)
111 }
112
113 pub fn end_log_group(&self, name: &str) {
115 self.client.end_log_group(name)
116 }
117
118 pub async fn post_feedback(
123 &mut self,
124 files: &[Arc<Mutex<FileObj>>],
125 feedback_inputs: FeedbackInput,
126 clang_versions: ClangVersions,
127 ) -> Result<u64, ClientError> {
128 let tidy_checks_failed = tally_tidy_advice(files).map_err(ClientError::MutexPoisoned)?;
129 let format_checks_failed =
130 tally_format_advice(files).map_err(ClientError::MutexPoisoned)?;
131 let mut comment = None;
132
133 if feedback_inputs.file_annotations {
134 let annotations = Self::make_annotations(files, &feedback_inputs.style)?;
135 self.client.write_file_annotations(&annotations)?;
136 }
137 if feedback_inputs.step_summary || feedback_inputs.summary_output_file.is_some() {
138 let summary = Self::make_comment(
139 files,
140 format_checks_failed,
141 tidy_checks_failed,
142 &clang_versions,
143 None,
144 )?;
145 if feedback_inputs.step_summary {
146 self.client.append_step_summary(&summary)?;
147 }
148 if let Some(summary_output_file) = feedback_inputs.summary_output_file {
149 let output_file = if summary_output_file.is_absolute() {
150 summary_output_file
151 } else {
152 feedback_inputs.repo_root.join(&summary_output_file)
153 };
154 if let Some(parent) = output_file.parent() {
155 std::fs::create_dir_all(parent).map_err(|e| ClientError::MkDirFailed {
156 file_path: output_file.clone(),
157 source: e,
158 })?;
159 }
160 std::fs::write(&output_file, &summary).map_err(|e| {
161 ClientError::SummaryOutputFileWriteFailed {
162 file_path: output_file,
163 source: e,
164 }
165 })?;
166 }
167 comment = Some(summary);
168 }
169 let output_vars = [
170 OutputVariable {
171 name: "checks-failed".to_string(),
172 value: format!("{}", format_checks_failed + tidy_checks_failed),
173 },
174 OutputVariable {
175 name: "clang-format-checks-failed".to_string(),
176 value: format_checks_failed.to_string(),
177 },
178 OutputVariable {
179 name: "clang-tidy-checks-failed".to_string(),
180 value: tidy_checks_failed.to_string(),
181 },
182 ];
183 self.client.write_output_variables(&output_vars)?;
184
185 if feedback_inputs.thread_comments != ThreadComments::Off {
186 if comment.as_ref().is_none_or(|c| c.len() > u16::MAX as usize) {
188 comment = Some(Self::make_comment(
189 files,
190 format_checks_failed,
191 tidy_checks_failed,
192 &clang_versions,
193 Some(u16::MAX as u64),
194 )?);
195 }
196 let options = ThreadCommentOptions {
197 policy: if feedback_inputs.thread_comments == ThreadComments::Update {
198 CommentPolicy::Update
199 } else {
200 CommentPolicy::Anew
202 },
203 comment: comment.unwrap_or_default(),
204 kind: if format_checks_failed == 0 && tidy_checks_failed == 0 {
205 CommentKind::Lgtm
206 } else {
207 CommentKind::Concerns
208 },
209 marker: COMMENT_MARKER.to_string(),
210 no_lgtm: feedback_inputs.no_lgtm,
211 };
212 self.client.post_thread_comment(options).await?;
213 }
214 if self.client.is_pr_event() && feedback_inputs.pr_review {
215 let summary_only = ["true", "on", "1"].contains(
216 &env::var("CPP_LINTER_PR_REVIEW_SUMMARY_ONLY")
217 .unwrap_or("false".to_string())
218 .as_str(),
219 );
220 let mut review_comments = ReviewComments::default();
221 for file in files {
222 let file = file
223 .lock()
224 .map_err(|e| ClientError::MutexPoisoned(e.to_string()))?;
225 file.make_suggestions_from_patch(
226 &mut review_comments,
227 summary_only,
228 &feedback_inputs.repo_root,
229 )?;
230 }
231
232 let mut options = ReviewOptions {
233 marker: COMMENT_MARKER.to_string(),
234 comments: {
235 let mut comments = vec![];
236 for suggestion in &review_comments.comments {
237 comments.push(suggestion.as_review_comment());
238 }
239 comments
240 },
241 ..Default::default()
242 };
243
244 let total_review_comments = options.comments.len() as u32;
245 self.client.cull_pr_reviews(&mut options).await?;
246 let has_changes = review_comments.tool_total > 0;
247 options.action = if feedback_inputs.passive_reviews {
248 ReviewAction::Comment
249 } else if options.comments.is_empty() && !has_changes {
250 ReviewAction::Approve
251 } else {
252 ReviewAction::RequestChanges
253 };
254 options.summary = review_comments.summarize(
255 &clang_versions,
256 &options.comments,
257 total_review_comments,
258 summary_only,
259 );
260 self.client.post_pr_review(&options).await?;
261 } else {
262 for file in files {
263 let file = file
264 .lock()
265 .map_err(|e| ClientError::MutexPoisoned(e.to_string()))?;
266 file.maybe_append_patch(&feedback_inputs.repo_root)?;
267 }
268 }
269 let auto_fix_patch_path = feedback_inputs
270 .repo_root
271 .join(ClangParams::CACHE_DIR)
272 .join(ClangParams::AUTO_FIX_PATCH);
273 if auto_fix_patch_path.exists() {
274 self.client.write_output_variables(&[OutputVariable {
275 name: "fix-patch-path".to_string(),
276 value: auto_fix_patch_path.to_string_lossy().replace("\\", "/"),
277 }])?;
278 }
279 Ok(format_checks_failed + tidy_checks_failed)
280 }
281
282 pub fn make_annotations(
284 files: &[Arc<Mutex<FileObj>>],
285 style: &str,
286 ) -> Result<Vec<FileAnnotation>, ClientError> {
287 let style_guide = summarize_style(style);
288 let mut annotations = vec![];
289
290 for file in files {
292 let file = file
293 .lock()
294 .map_err(|e| ClientError::MutexPoisoned(e.to_string()))?;
295 if let Some(format_advice) = &file.format_advice {
296 let mut lines = Vec::new();
298 for replacement in &format_advice.replacements {
299 for i in replacement.clone() {
300 if !lines.contains(&i) {
301 lines.push(i);
302 }
303 }
304 }
305 if !lines.is_empty() {
307 let name = file.name.to_string_lossy().replace('\\', "/");
308 let title = format!("Run clang-format on {name}");
309 let message = format!(
310 "File {name} does not conform to {style_guide} style guidelines. (lines {line_set})",
311 line_set = lines
312 .iter()
313 .map(|val| val.to_string())
314 .collect::<Vec<_>>()
315 .join(","),
316 );
317 let annotation = FileAnnotation {
318 severity: AnnotationLevel::Notice,
319 path: name,
320 start_line: None,
321 end_line: None,
322 start_column: None,
323 end_column: None,
324 title: Some(title),
325 message,
326 };
327 annotations.push(annotation);
328 }
329 } if let Some(tidy_advice) = &file.tidy_advice {
335 for note in &tidy_advice.notes {
336 let path = file.name.to_string_lossy().replace('\\', "/");
337 if note.filename == path {
338 let title = format!("{}:{}:{}", note.filename, note.line, note.cols);
339 let annotation = FileAnnotation {
340 severity: match note.severity.as_str() {
341 "warning" => AnnotationLevel::Warning,
342 "error" => AnnotationLevel::Error,
343 _ => AnnotationLevel::Notice, },
345 path,
346 start_line: None,
347 end_line: Some(note.line as usize),
348 start_column: None,
349 end_column: Some(note.cols as usize),
350 title: Some(title),
351 message: note.rationale.clone(),
352 };
353 annotations.push(annotation);
354 }
355 }
356 }
357 }
358 Ok(annotations)
359 }
360
361 fn make_comment(
370 files: &[Arc<Mutex<FileObj>>],
371 format_checks_failed: u64,
372 tidy_checks_failed: u64,
373 clang_versions: &ClangVersions,
374 max_len: Option<u64>,
375 ) -> Result<String, ClientError> {
376 let mut comment = format!("{COMMENT_MARKER}# Cpp-Linter Report ");
377 let mut remaining_length =
378 max_len.unwrap_or(u64::MAX) - comment.len() as u64 - USER_OUTREACH.len() as u64;
379
380 if format_checks_failed > 0 || tidy_checks_failed > 0 {
381 let prompt = ":warning:\nSome files did not pass the configured checks!\n";
382 remaining_length -= prompt.len() as u64;
383 comment.push_str(prompt);
384 if format_checks_failed > 0
385 && let Some(format_version) = &clang_versions.format_version
386 {
387 make_format_comment(
388 files,
389 &mut comment,
390 format_checks_failed,
391 &format_version.to_string(),
392 &mut remaining_length,
393 )?;
394 }
395 if tidy_checks_failed > 0
396 && let Some(tidy_version) = &clang_versions.tidy_version
397 {
398 make_tidy_comment(
399 files,
400 &mut comment,
401 tidy_checks_failed,
402 &tidy_version.to_string(),
403 &mut remaining_length,
404 )?;
405 }
406 } else {
407 comment.push_str(":heavy_check_mark:\nNo problems need attention.");
408 }
409 comment.push_str(USER_OUTREACH);
410 Ok(comment)
411 }
412}
413
414const CLOSER: &str = "\n</details>";
416
417fn make_format_comment(
418 files: &[Arc<Mutex<FileObj>>],
419 comment: &mut String,
420 format_checks_failed: u64,
421 version_used: &String,
422 remaining_length: &mut u64,
423) -> Result<(), ClientError> {
424 let opener = format!(
425 "\n<details><summary>clang-format (v{version_used}) reports: <strong>{format_checks_failed} file(s) not formatted</strong></summary>\n\n",
426 );
427 let mut format_comment = String::new();
428 *remaining_length = remaining_length.saturating_sub(opener.len() as u64 + CLOSER.len() as u64);
429 for file in files {
430 let file = file
431 .lock()
432 .map_err(|e| ClientError::MutexPoisoned(e.to_string()))?;
433 if let Some(format_advice) = &file.format_advice
434 && !format_advice.replacements.is_empty()
435 && *remaining_length > 0
436 {
437 let line_count = format_advice
438 .replacements
439 .iter()
440 .fold(0, |acc, r| acc + r.clone().count());
441 let note = format!(
442 "- {} ({line_count} line{})\n",
443 file.name.to_string_lossy().replace('\\', "/"),
444 if line_count > 1 { "s" } else { "" }
445 );
446 if (note.len() as u64) < *remaining_length {
447 format_comment.push_str(¬e.to_string());
448 *remaining_length -= note.len() as u64;
449 }
450 }
451 }
452 comment.push_str(&opener);
453 comment.push_str(&format_comment);
454 comment.push_str(CLOSER);
455 Ok(())
456}
457
458fn make_tidy_comment(
459 files: &[Arc<Mutex<FileObj>>],
460 comment: &mut String,
461 tidy_checks_failed: u64,
462 version_used: &String,
463 remaining_length: &mut u64,
464) -> Result<(), ClientError> {
465 let opener = format!(
466 "\n<details><summary>clang-tidy (v{version_used}) reports: {tidy_checks_failed}<strong> concern(s)</strong></summary>\n\n"
467 );
468 let mut tidy_comment = String::new();
469 *remaining_length = remaining_length.saturating_sub(opener.len() as u64 + CLOSER.len() as u64);
470 for file in files {
471 let file = file
472 .lock()
473 .map_err(|e| ClientError::MutexPoisoned(e.to_string()))?;
474 if let Some(tidy_advice) = &file.tidy_advice {
475 for tidy_note in &tidy_advice.notes {
476 let file_path = PathBuf::from(&tidy_note.filename);
477 if file_path == file.name {
478 let mut tmp_note = format!("- {}\n\n", tidy_note.filename);
479 tmp_note.push_str(&format!(
480 " <strong>{filename}:{line}:{cols}:</strong> {severity}: [{diagnostic}]{auto_fixable}\n > {rationale}\n{concerned_code}",
481 filename = tidy_note.filename,
482 line = tidy_note.line,
483 cols = tidy_note.cols,
484 severity = tidy_note.severity,
485 diagnostic = tidy_note.diagnostic_link(),
486 auto_fixable = if tidy_note.fixed_lines.is_empty() {
487 ""
488 } else {
489 "\n :zap: auto-fix available"
490 },
491 rationale = tidy_note.rationale,
492 concerned_code = if tidy_note.suggestion.is_empty() {String::from("")} else {
493 format!("\n ```{ext}\n {suggestion}\n ```\n",
494 ext = file_path.extension().unwrap_or_default().to_string_lossy(),
495 suggestion = tidy_note.suggestion.join("\n "),
496 ).to_string()
497 },
498 ).to_string());
499
500 if (tmp_note.len() as u64) < *remaining_length {
501 tidy_comment.push_str(&tmp_note);
502 *remaining_length -= tmp_note.len() as u64;
503 }
504 }
505 }
506 }
507 }
508 comment.push_str(&opener);
509 comment.push_str(&tidy_comment);
510 comment.push_str(CLOSER);
511 Ok(())
512}
513
514#[cfg(all(test, feature = "bin"))]
515mod test {
516 #![allow(clippy::expect_used, clippy::unwrap_used)]
517
518 use std::{
519 default::Default,
520 env,
521 io::Read,
522 path::PathBuf,
523 sync::{Arc, Mutex},
524 };
525
526 use regex::Regex;
527 use semver::Version;
528 use tempfile::{NamedTempFile, tempdir};
529
530 use super::{RestClient, USER_OUTREACH};
531 use crate::{
532 clang_tools::{
533 ClangVersions,
534 clang_format::FormatAdvice,
535 clang_tidy::{TidyAdvice, TidyNotification},
536 },
537 cli::FeedbackInput,
538 common_fs::FileObj,
539 };
540 use clang_tools_manager::logger::try_init_logger;
541
542 async fn create_comment(is_lgtm: bool) -> (String, String) {
545 let tmp_dir = tempdir().unwrap();
546 unsafe {
547 env::set_var("GITHUB_ACTIONS", "true");
549 env::set_var("GITHUB_REPOSITORY", "cpp-linter/cpp-linter-rs");
550 env::set_var("GITHUB_SHA", "deadbeef123");
551 }
552 let mut rest_api_client = RestClient::new().unwrap();
553 #[cfg(feature = "bin")]
554 try_init_logger();
555 if env::var("ACTIONS_STEP_DEBUG").is_ok_and(|var| var == "true") {
556 log::set_max_level(log::LevelFilter::Debug);
558 }
559 let mut files = vec![];
560 if !is_lgtm {
561 for _i in 0..u16::MAX {
562 let filename = String::from("tests/demo/demo.cpp");
563 let mut file = FileObj::new(PathBuf::from(&filename));
564 let notes = vec![TidyNotification {
565 filename,
566 line: 0,
567 cols: 0,
568 severity: String::from("note"),
569 rationale: String::from("A test dummy rationale"),
570 diagnostic: String::from("clang-diagnostic-warning"),
571 suggestion: vec![],
572 fixed_lines: vec![],
573 }];
574 file.tidy_advice = Some(TidyAdvice { notes });
575 file.format_advice = Some(FormatAdvice {
576 #[allow(clippy::single_range_in_vec_init)]
577 replacements: vec![1..=2],
578 });
579 files.push(Arc::new(Mutex::new(file)));
580 }
581 }
582 let feedback_inputs = FeedbackInput {
583 style: if is_lgtm {
584 String::new()
585 } else {
586 String::from("file")
587 },
588 step_summary: true,
589 file_annotations: false,
590 ..Default::default()
591 };
592 let mut step_summary_path = NamedTempFile::new_in(tmp_dir.path()).unwrap();
593 let mut gh_out_path = NamedTempFile::new_in(tmp_dir.path()).unwrap();
594 unsafe {
595 env::set_var("GITHUB_STEP_SUMMARY", step_summary_path.path());
596 env::set_var("GITHUB_OUTPUT", gh_out_path.path());
597 }
598 let clang_versions = ClangVersions {
599 format_version: Some(Version::new(1, 2, 3)),
600 tidy_version: Some(Version::new(1, 2, 3)),
601 };
602 rest_api_client
603 .post_feedback(&files, feedback_inputs, clang_versions)
604 .await
605 .unwrap();
606 let mut step_summary_content = String::new();
607 step_summary_path
608 .read_to_string(&mut step_summary_content)
609 .unwrap();
610 assert!(&step_summary_content.contains(USER_OUTREACH));
611 let mut gh_out_content = String::new();
612 gh_out_path.read_to_string(&mut gh_out_content).unwrap();
613 assert!(gh_out_content.starts_with("checks-failed="));
614 (step_summary_content, gh_out_content)
615 }
616
617 #[tokio::test]
618 async fn check_comment_concerns() {
619 let (comment, gh_out) = create_comment(false).await;
620 assert!(&comment.contains(":warning:\nSome files did not pass the configured checks!\n"));
621 let fmt_pattern = Regex::new(r"clang-format-checks-failed=(\d+)\n").unwrap();
622 let tidy_pattern = Regex::new(r"clang-tidy-checks-failed=(\d+)\n").unwrap();
623 for pattern in [fmt_pattern, tidy_pattern] {
624 let number = pattern
625 .captures(&gh_out)
626 .expect("found no number of checks-failed")
627 .get(1)
628 .unwrap()
629 .as_str()
630 .parse::<u64>()
631 .unwrap();
632 assert!(number > 0);
633 }
634 }
635
636 #[tokio::test]
637 async fn check_comment_lgtm() {
638 unsafe {
639 env::set_var("ACTIONS_STEP_DEBUG", "true");
640 }
641 let (comment, gh_out) = create_comment(true).await;
642 assert!(comment.contains(":heavy_check_mark:\nNo problems need attention."));
643 assert_eq!(
644 gh_out,
645 "checks-failed=0\nclang-format-checks-failed=0\nclang-tidy-checks-failed=0\n"
646 );
647 }
648}