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