Skip to main content

cpp_linter/
rest_client.rs

1//! This module defines the struct that wraps employs a REST API client.
2
3use 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
26/// The comment marker used to identify bot comments from other comments (from users or other bots).
27pub const COMMENT_MARKER: &str = "<!-- cpp linter action -->\n";
28
29/// The UserAgent header value used in HTTP requests.
30pub const USER_AGENT: &str = concat!("cpp-linter/", env!("CARGO_PKG_VERSION"),);
31
32/// The user outreach message displayed in bot comments.
33pub 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
38/// A wrapper struct around a REST API client.
39///
40/// Underneath, the client may support various Git servers and CI platforms.
41/// Currently, the client supports GitHub and GitHub Actions.
42pub struct RestClient {
43    client: Box<dyn RestApiClient + Sync + Send>,
44}
45
46impl RestClient {
47    /// Initializes the REST API client and sets the UserAgent header.
48    ///
49    /// See [git_bot_feedback::client::init_client] for details on
50    /// possible errors during initialization.
51    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    /// Is this a pull request event?
58    pub fn is_pr(&self) -> bool {
59        self.client.is_pr_event()
60    }
61
62    /// Gets a list of changed files.
63    ///
64    /// Use the [`FileFilter`] and [`LinesChangedOnly`] to filter out files
65    /// that might be negligible.
66    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    /// Should debug logs be enabled?
97    pub fn is_debug_enabled(&self) -> bool {
98        self.client.is_debug_enabled()
99    }
100
101    /// Start a log group with the given `name`.
102    pub fn start_log_group(&self, name: &str) {
103        self.client.start_log_group(name)
104    }
105
106    /// End a log group with the given `name`.
107    pub fn end_log_group(&self, name: &str) {
108        self.client.end_log_group(name)
109    }
110
111    /// Post various forms of feedback.
112    ///
113    /// Use [`FeedbackInput`] to configure feedback behavior.
114    /// The given [`ClangVersions`] is used in thread comments, step summaries, and PR reviews' summary comment.
115    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            // post thread comment for PR or push event
180            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                    // feedback_inputs.thread_comments is not Off and not Update, so it must be just On.
194                    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    /// Post file annotations.
276    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        // iterate over clang-format advice and post annotations
284        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                // assemble a list of line numbers
290                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                // post annotation if any applicable lines were formatted
299                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            } // end format_advice iterations
323
324            // iterate over clang-tidy advice and post annotations
325            // The tidy_advice vector is parallel to the files vector; meaning it serves as a file filterer.
326            // lines are already filter as specified to clang-tidy CLI.
327            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, // default to notice for all else
337                            },
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    /// Makes a comment in MarkDown syntax based on the concerns in `format_advice` and
355    /// `tidy_advice` about the given set of `files`.
356    ///
357    /// This method has a default definition and should not need to be redefined by
358    /// implementors.
359    ///
360    /// Returns the markdown comment as a string as well as the total count of
361    /// `format_checks_failed` and `tidy_checks_failed` (in respective order).
362    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
407/// A closing tag for details blocks in markdown comments.
408const 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(&note.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    // ************************* tests for step-summary and output variables
536
537    async fn create_comment(is_lgtm: bool) -> (String, String) {
538        let tmp_dir = tempdir().unwrap();
539        unsafe {
540            // ensure we are mimicking a CI platform
541            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            // assert!(rest_api_client.debug_enabled);
550            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}