Skip to main content

cpp_linter/rest_client/
mod.rs

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
24/// The comment marker used to identify bot comments from other comments (from users or other bots).
25pub const COMMENT_MARKER: &str = "<!-- cpp linter action -->\n";
26
27/// The UserAgent header value used in HTTP requests.
28pub const USER_AGENT: &str = concat!("cpp-linter/", env!("CARGO_PKG_VERSION"),);
29
30/// The user outreach message displayed in bot comments.
31pub 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            // post thread comment for PR or push event
135            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                    // feedback_inputs.thread_comments is not Off and not Update, so it must be just On.
149                    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    /// Post file annotations.
206    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        // iterate over clang-format advice and post annotations
214        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                // assemble a list of line numbers
220                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                // post annotation if any applicable lines were formatted
227                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            } // end format_advice iterations
251
252            // iterate over clang-tidy advice and post annotations
253            // The tidy_advice vector is parallel to the files vector; meaning it serves as a file filterer.
254            // lines are already filter as specified to clang-tidy CLI.
255            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, // default to notice for all else
265                            },
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    /// Makes a comment in MarkDown syntax based on the concerns in `format_advice` and
283    /// `tidy_advice` about the given set of `files`.
284    ///
285    /// This method has a default definition and should not need to be redefined by
286    /// implementors.
287    ///
288    /// Returns the markdown comment as a string as well as the total count of
289    /// `format_checks_failed` and `tidy_checks_failed` (in respective order).
290    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                    // format_version should be `Some()` value at this point.
311                    &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                    // tidy_version should be `Some()` value at this point.
321                    &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
333/// A closing tag for details blocks in markdown comments.
334const 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(&note.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    // ************************* tests for step-summary and output variables
441
442    async fn create_comment(is_lgtm: bool) -> (String, String) {
443        let tmp_dir = tempdir().unwrap();
444        unsafe {
445            // ensure we are mimicking a CI platform
446            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            // assert!(rest_api_client.debug_enabled);
454            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}