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