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