1use std::{
2 env,
3 path::PathBuf,
4 sync::{Arc, Mutex},
5};
6
7use git_bot_feedback::{
8 AnnotationLevel, CommentKind, CommentPolicy, FileAnnotation, FileFilter, LinesChangedOnly,
9 OutputVariable, RestApiClient, ReviewAction, ReviewOptions, ThreadCommentOptions,
10 client::init_client,
11};
12
13use crate::{
14 clang_tools::{
15 ClangVersions, ReviewComments,
16 clang_format::{summarize_style, tally_format_advice},
17 clang_tidy::tally_tidy_advice,
18 },
19 cli::{FeedbackInput, ThreadComments},
20 common_fs::FileObj,
21 error::ClientError,
22};
23
24pub const COMMENT_MARKER: &str = "<!-- cpp linter action -->\n";
26
27pub const USER_AGENT: &str = concat!("cpp-linter/", env!("CARGO_PKG_VERSION"),);
29
30pub const USER_OUTREACH: &str = concat!(
32 "\n\nHave any feedback or feature suggestions? [Share it here.]",
33 "(https://github.com/cpp-linter/cpp-linter-action/issues)"
34);
35
36pub struct RestClient {
37 client: Box<dyn RestApiClient + Sync + Send>,
38}
39
40impl RestClient {
41 pub fn new() -> Result<Self, ClientError> {
42 let mut client = init_client()?;
43 client.set_user_agent(USER_AGENT)?;
44 Ok(Self { client })
45 }
46
47 pub fn is_pr(&self) -> bool {
48 self.client.is_pr_event()
49 }
50
51 pub async fn get_list_of_changed_files(
52 &self,
53 file_filter: &FileFilter,
54 lines_changed_only: &LinesChangedOnly,
55 base_diff: &Option<String>,
56 ignore_index: bool,
57 ) -> Result<Vec<FileObj>, ClientError> {
58 let files = self
59 .client
60 .get_list_of_changed_files(
61 file_filter,
62 lines_changed_only,
63 base_diff.to_owned(),
64 ignore_index,
65 )
66 .await?;
67 Ok(files
68 .iter()
69 .map(|(file_name, diff_lines)| {
70 let diff_chunks = diff_lines
71 .diff_hunks
72 .iter()
73 .map(|hunk| hunk.start..=hunk.end)
74 .collect();
75 FileObj::from(
76 PathBuf::from(&file_name),
77 diff_lines.added_lines.clone(),
78 diff_chunks,
79 )
80 })
81 .collect())
82 }
83
84 pub fn start_log_group(&self, name: &str) {
85 self.client.start_log_group(name)
86 }
87
88 pub fn end_log_group(&self, name: &str) {
89 self.client.end_log_group(name)
90 }
91
92 pub async fn post_feedback(
93 &mut self,
94 files: &[Arc<Mutex<FileObj>>],
95 feedback_inputs: FeedbackInput,
96 clang_versions: ClangVersions,
97 ) -> Result<u64, ClientError> {
98 let tidy_checks_failed = tally_tidy_advice(files).map_err(ClientError::MutexPoisoned)?;
99 let format_checks_failed =
100 tally_format_advice(files).map_err(ClientError::MutexPoisoned)?;
101 let mut comment = None;
102
103 if feedback_inputs.file_annotations {
104 let annotations = Self::make_annotations(files, &feedback_inputs.style)?;
105 self.client.write_file_annotations(&annotations)?;
106 }
107 if feedback_inputs.step_summary {
108 comment = Some(Self::make_comment(
109 files,
110 format_checks_failed,
111 tidy_checks_failed,
112 &clang_versions,
113 None,
114 ));
115 self.client.append_step_summary(comment.as_ref().unwrap())?;
116 }
117 let output_vars = [
118 OutputVariable {
119 name: "checks-failed".to_string(),
120 value: format!("{}", format_checks_failed + tidy_checks_failed),
121 },
122 OutputVariable {
123 name: "format-checks-failed".to_string(),
124 value: format_checks_failed.to_string(),
125 },
126 OutputVariable {
127 name: "tidy-checks-failed".to_string(),
128 value: tidy_checks_failed.to_string(),
129 },
130 ];
131 self.client.write_output_variables(&output_vars)?;
132
133 if feedback_inputs.thread_comments != ThreadComments::Off {
134 if comment.as_ref().is_none_or(|c| c.len() > 65535) {
136 comment = Some(Self::make_comment(
137 files,
138 format_checks_failed,
139 tidy_checks_failed,
140 &clang_versions,
141 Some(65535),
142 ));
143 }
144 let options = ThreadCommentOptions {
145 policy: if feedback_inputs.thread_comments == ThreadComments::Update {
146 CommentPolicy::Update
147 } else {
148 CommentPolicy::Anew
150 },
151 comment: comment.unwrap_or_default(),
152 kind: if format_checks_failed == 0 && tidy_checks_failed == 0 {
153 CommentKind::Lgtm
154 } else {
155 CommentKind::Concerns
156 },
157 marker: COMMENT_MARKER.to_string(),
158 no_lgtm: feedback_inputs.no_lgtm,
159 };
160 self.client.post_thread_comment(options).await?;
161 }
162 if self.client.is_pr_event()
163 && (feedback_inputs.tidy_review || feedback_inputs.format_review)
164 {
165 let summary_only = ["true", "on", "1"].contains(
166 &env::var("CPP_LINTER_PR_REVIEW_SUMMARY_ONLY")
167 .unwrap_or("false".to_string())
168 .as_str(),
169 );
170 let mut review_comments = ReviewComments::default();
171 for file in files {
172 let file = file
173 .lock()
174 .map_err(|e| ClientError::MutexPoisoned(e.to_string()))?;
175 file.make_suggestions_from_patch(&mut review_comments, summary_only)?;
176 }
177
178 let mut options = ReviewOptions {
179 marker: COMMENT_MARKER.to_string(),
180 comments: {
181 let mut comments = vec![];
182 for suggestion in &review_comments.comments {
183 comments.push(suggestion.as_review_comment());
184 }
185 comments
186 },
187 ..Default::default()
188 };
189
190 self.client.cull_pr_reviews(&mut options).await?;
191 let has_changes = review_comments.full_patch.iter().any(|p| !p.is_empty());
192 options.action = if feedback_inputs.passive_reviews {
193 ReviewAction::Comment
194 } else if options.comments.is_empty() && !has_changes {
195 ReviewAction::Approve
196 } else {
197 ReviewAction::RequestChanges
198 };
199 options.summary = review_comments.summarize(&clang_versions, &options.comments);
200 self.client.post_pr_review(&options).await?;
201 }
202 Ok(format_checks_failed + tidy_checks_failed)
203 }
204
205 pub fn make_annotations(
207 files: &[Arc<Mutex<FileObj>>],
208 style: &str,
209 ) -> Result<Vec<FileAnnotation>, ClientError> {
210 let style_guide = summarize_style(style);
211 let mut annotations = vec![];
212
213 for file in files {
215 let file = file
216 .lock()
217 .map_err(|e| ClientError::MutexPoisoned(e.to_string()))?;
218 if let Some(format_advice) = &file.format_advice {
219 let mut lines = Vec::new();
221 for replacement in &format_advice.replacements {
222 if !lines.contains(&replacement.line) {
223 lines.push(replacement.line);
224 }
225 }
226 if !lines.is_empty() {
228 let name = file.name.to_string_lossy().replace('\\', "/");
229 let title = format!("Run clang-format on {name}");
230 let message = format!(
231 "File {name} does not conform to {style_guide} style guidelines. (lines {line_set})",
232 line_set = lines
233 .iter()
234 .map(|val| val.to_string())
235 .collect::<Vec<_>>()
236 .join(","),
237 );
238 let annotation = FileAnnotation {
239 severity: AnnotationLevel::Notice,
240 path: name,
241 start_line: None,
242 end_line: None,
243 start_column: None,
244 end_column: None,
245 title: Some(title),
246 message,
247 };
248 annotations.push(annotation);
249 }
250 } if let Some(tidy_advice) = &file.tidy_advice {
256 for note in &tidy_advice.notes {
257 let path = file.name.to_string_lossy().replace('\\', "/");
258 if note.filename == path {
259 let title = format!("{}:{}:{}", note.filename, note.line, note.cols);
260 let annotation = FileAnnotation {
261 severity: match note.severity.as_str() {
262 "warning" => AnnotationLevel::Warning,
263 "error" => AnnotationLevel::Error,
264 _ => AnnotationLevel::Notice, },
266 path,
267 start_line: None,
268 end_line: Some(note.line as usize),
269 start_column: None,
270 end_column: Some(note.cols as usize),
271 title: Some(title),
272 message: note.rationale.clone(),
273 };
274 annotations.push(annotation);
275 }
276 }
277 }
278 }
279 Ok(annotations)
280 }
281
282 fn make_comment(
291 files: &[Arc<Mutex<FileObj>>],
292 format_checks_failed: u64,
293 tidy_checks_failed: u64,
294 clang_versions: &ClangVersions,
295 max_len: Option<u64>,
296 ) -> String {
297 let mut comment = format!("{COMMENT_MARKER}# Cpp-Linter Report ");
298 let mut remaining_length =
299 max_len.unwrap_or(u64::MAX) - comment.len() as u64 - USER_OUTREACH.len() as u64;
300
301 if format_checks_failed > 0 || tidy_checks_failed > 0 {
302 let prompt = ":warning:\nSome files did not pass the configured checks!\n";
303 remaining_length -= prompt.len() as u64;
304 comment.push_str(prompt);
305 if format_checks_failed > 0 {
306 make_format_comment(
307 files,
308 &mut comment,
309 format_checks_failed,
310 &clang_versions.format_version.as_ref().unwrap().to_string(),
312 &mut remaining_length,
313 );
314 }
315 if tidy_checks_failed > 0 {
316 make_tidy_comment(
317 files,
318 &mut comment,
319 tidy_checks_failed,
320 &clang_versions.tidy_version.as_ref().unwrap().to_string(),
322 &mut remaining_length,
323 );
324 }
325 } else {
326 comment.push_str(":heavy_check_mark:\nNo problems need attention.");
327 }
328 comment.push_str(USER_OUTREACH);
329 comment
330 }
331}
332
333const CLOSER: &str = "\n</details>";
335
336fn make_format_comment(
337 files: &[Arc<Mutex<FileObj>>],
338 comment: &mut String,
339 format_checks_failed: u64,
340 version_used: &String,
341 remaining_length: &mut u64,
342) {
343 let opener = format!(
344 "\n<details><summary>clang-format (v{version_used}) reports: <strong>{format_checks_failed} file(s) not formatted</strong></summary>\n\n",
345 );
346 let mut format_comment = String::new();
347 *remaining_length = remaining_length.saturating_sub(opener.len() as u64 + CLOSER.len() as u64);
348 for file in files {
349 let file = file.lock().unwrap();
350 if let Some(format_advice) = &file.format_advice
351 && !format_advice.replacements.is_empty()
352 && *remaining_length > 0
353 {
354 let note = format!("- {}\n", file.name.to_string_lossy().replace('\\', "/"));
355 if (note.len() as u64) < *remaining_length {
356 format_comment.push_str(¬e.to_string());
357 *remaining_length -= note.len() as u64;
358 }
359 }
360 }
361 comment.push_str(&opener);
362 comment.push_str(&format_comment);
363 comment.push_str(CLOSER);
364}
365
366fn make_tidy_comment(
367 files: &[Arc<Mutex<FileObj>>],
368 comment: &mut String,
369 tidy_checks_failed: u64,
370 version_used: &String,
371 remaining_length: &mut u64,
372) {
373 let opener = format!(
374 "\n<details><summary>clang-tidy (v{version_used}) reports: {tidy_checks_failed}<strong> concern(s)</strong></summary>\n\n"
375 );
376 let mut tidy_comment = String::new();
377 *remaining_length = remaining_length.saturating_sub(opener.len() as u64 + CLOSER.len() as u64);
378 for file in files {
379 let file = file.lock().unwrap();
380 if let Some(tidy_advice) = &file.tidy_advice {
381 for tidy_note in &tidy_advice.notes {
382 let file_path = PathBuf::from(&tidy_note.filename);
383 if file_path == file.name {
384 let mut tmp_note = format!("- {}\n\n", tidy_note.filename);
385 tmp_note.push_str(&format!(
386 " <strong>{filename}:{line}:{cols}:</strong> {severity}: [{diagnostic}]\n > {rationale}\n{concerned_code}",
387 filename = tidy_note.filename,
388 line = tidy_note.line,
389 cols = tidy_note.cols,
390 severity = tidy_note.severity,
391 diagnostic = tidy_note.diagnostic_link(),
392 rationale = tidy_note.rationale,
393 concerned_code = if tidy_note.suggestion.is_empty() {String::from("")} else {
394 format!("\n ```{ext}\n {suggestion}\n ```\n",
395 ext = file_path.extension().unwrap_or_default().to_string_lossy(),
396 suggestion = tidy_note.suggestion.join("\n "),
397 ).to_string()
398 },
399 ).to_string());
400
401 if (tmp_note.len() as u64) < *remaining_length {
402 tidy_comment.push_str(&tmp_note);
403 *remaining_length -= tmp_note.len() as u64;
404 }
405 }
406 }
407 }
408 }
409 comment.push_str(&opener);
410 comment.push_str(&tidy_comment);
411 comment.push_str(CLOSER);
412}
413
414#[cfg(all(test, feature = "bin"))]
415mod test {
416 use std::{
417 default::Default,
418 env,
419 io::Read,
420 path::PathBuf,
421 sync::{Arc, Mutex},
422 };
423
424 use regex::Regex;
425 use semver::Version;
426 use tempfile::{NamedTempFile, tempdir};
427
428 use super::{RestClient, USER_OUTREACH};
429 use crate::{
430 clang_tools::{
431 ClangVersions,
432 clang_format::{FormatAdvice, Replacement},
433 clang_tidy::{TidyAdvice, TidyNotification},
434 },
435 cli::FeedbackInput,
436 common_fs::FileObj,
437 logger,
438 };
439
440 async fn create_comment(is_lgtm: bool) -> (String, String) {
443 let tmp_dir = tempdir().unwrap();
444 unsafe {
445 env::set_var("GITHUB_ACTIONS", "true");
447 env::set_var("GITHUB_REPOSITORY", "cpp-linter/cpp-linter-rs");
448 env::set_var("GITHUB_SHA", "deadbeef123");
449 }
450 let mut rest_api_client = RestClient::new().unwrap();
451 logger::try_init();
452 if env::var("ACTIONS_STEP_DEBUG").is_ok_and(|var| var == "true") {
453 log::set_max_level(log::LevelFilter::Debug);
455 }
456 let mut files = vec![];
457 if !is_lgtm {
458 for _i in 0..65535 {
459 let filename = String::from("tests/demo/demo.cpp");
460 let mut file = FileObj::new(PathBuf::from(&filename));
461 let notes = vec![TidyNotification {
462 filename,
463 line: 0,
464 cols: 0,
465 severity: String::from("note"),
466 rationale: String::from("A test dummy rationale"),
467 diagnostic: String::from("clang-diagnostic-warning"),
468 suggestion: vec![],
469 fixed_lines: vec![],
470 }];
471 file.tidy_advice = Some(TidyAdvice {
472 notes,
473 patched: None,
474 });
475 file.format_advice = Some(FormatAdvice {
476 replacements: vec![Replacement { offset: 0, line: 1 }],
477 patched: None,
478 });
479 files.push(Arc::new(Mutex::new(file)));
480 }
481 }
482 let feedback_inputs = FeedbackInput {
483 style: if is_lgtm {
484 String::new()
485 } else {
486 String::from("file")
487 },
488 step_summary: true,
489 file_annotations: false,
490 ..Default::default()
491 };
492 let mut step_summary_path = NamedTempFile::new_in(tmp_dir.path()).unwrap();
493 let mut gh_out_path = NamedTempFile::new_in(tmp_dir.path()).unwrap();
494 unsafe {
495 env::set_var("GITHUB_STEP_SUMMARY", step_summary_path.path());
496 env::set_var("GITHUB_OUTPUT", gh_out_path.path());
497 }
498 let clang_versions = ClangVersions {
499 format_version: Some(Version::new(1, 2, 3)),
500 tidy_version: Some(Version::new(1, 2, 3)),
501 };
502 rest_api_client
503 .post_feedback(&files, feedback_inputs, clang_versions)
504 .await
505 .unwrap();
506 let mut step_summary_content = String::new();
507 step_summary_path
508 .read_to_string(&mut step_summary_content)
509 .unwrap();
510 assert!(&step_summary_content.contains(USER_OUTREACH));
511 let mut gh_out_content = String::new();
512 gh_out_path.read_to_string(&mut gh_out_content).unwrap();
513 assert!(gh_out_content.starts_with("checks-failed="));
514 (step_summary_content, gh_out_content)
515 }
516
517 #[tokio::test]
518 async fn check_comment_concerns() {
519 let (comment, gh_out) = create_comment(false).await;
520 assert!(&comment.contains(":warning:\nSome files did not pass the configured checks!\n"));
521 let fmt_pattern = Regex::new(r"format-checks-failed=(\d+)\n").unwrap();
522 let tidy_pattern = Regex::new(r"tidy-checks-failed=(\d+)\n").unwrap();
523 for pattern in [fmt_pattern, tidy_pattern] {
524 let number = pattern
525 .captures(&gh_out)
526 .expect("found no number of checks-failed")
527 .get(1)
528 .unwrap()
529 .as_str()
530 .parse::<u64>()
531 .unwrap();
532 assert!(number > 0);
533 }
534 }
535
536 #[tokio::test]
537 async fn check_comment_lgtm() {
538 unsafe {
539 env::set_var("ACTIONS_STEP_DEBUG", "true");
540 }
541 let (comment, gh_out) = create_comment(true).await;
542 assert!(comment.contains(":heavy_check_mark:\nNo problems need attention."));
543 assert_eq!(
544 gh_out,
545 "checks-failed=0\nformat-checks-failed=0\ntidy-checks-failed=0\n"
546 );
547 }
548}