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