diff --git a/Cargo.lock b/Cargo.lock index 88d213a4..2201296e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -365,12 +376,14 @@ name = "cpp-linter" version = "2.0.0-rc15" dependencies = [ "anyhow", + "async-trait", "chrono", "clang-installer", "clap", "colored", "fast-glob", "futures", + "git-bot-feedback", "git2", "log", "mockito", @@ -749,6 +762,25 @@ dependencies = [ "wasip3", ] +[[package]] +name = "git-bot-feedback" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dde52ca9dc5b3d9227c79b910a804036f0599a563767015683d9b3fa6842874" +dependencies = [ + "async-trait", + "chrono", + "fast-glob", + "log", + "regex", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "url", +] + [[package]] name = "git2" version = "0.20.4" diff --git a/clang-installer/src/downloader/mod.rs b/clang-installer/src/downloader/mod.rs index 9427373f..78de368f 100644 --- a/clang-installer/src/downloader/mod.rs +++ b/clang-installer/src/downloader/mod.rs @@ -60,7 +60,17 @@ async fn download(url: &Url, cache_path: &Path, timeout: u64) -> Result<(), Down } let mut tmp_file = tempfile::NamedTempFile::new()?; let content_len = response.content_length().and_then(NonZero::new); - let mut progress_bar = ProgressBar::new(content_len, "Downloading"); + let mut progress_bar = ProgressBar::new( + content_len, + format!( + "Downloading {}", + cache_path + .file_name() + .map(|p| p.to_string_lossy()) + .unwrap_or_default() + ) + .as_str(), + ); progress_bar.render()?; while let Some(chunk) = response.chunk().await? { let chunk_len = chunk.len() as u64; diff --git a/clang-installer/src/main.rs b/clang-installer/src/main.rs index f0d92445..c32abb8e 100644 --- a/clang-installer/src/main.rs +++ b/clang-installer/src/main.rs @@ -132,7 +132,6 @@ pub struct CliOptions { async fn main() -> Result<()> { logging::initialize_logger(); let options = CliOptions::parse(); - log::debug!("{:?}", options); let tool = options .tool diff --git a/clang-installer/src/progress_bar.rs b/clang-installer/src/progress_bar.rs index 1a0c35b7..852f6d6a 100644 --- a/clang-installer/src/progress_bar.rs +++ b/clang-installer/src/progress_bar.rs @@ -59,7 +59,7 @@ impl ProgressBar { steps: 0, stdout_handle, is_interactive, - prompt: prompt.to_string(), + prompt: prompt.trim().to_string(), } } diff --git a/clang-installer/src/tool.rs b/clang-installer/src/tool.rs index ae93df06..88503a99 100644 --- a/clang-installer/src/tool.rs +++ b/clang-installer/src/tool.rs @@ -44,8 +44,8 @@ pub enum GetClangVersionError { RegexCompile(#[from] regex::Error), /// Failed to parse the version number from the output of `clang-tool --version`. - #[error("Failed to parse the version number from the `--version` output")] - VersionParse, + #[error("Failed to parse the version number from the `--version` output: {0}")] + VersionParse(String), /// Failed to parse the version number from the output of `clang-tool --version` into a [`semver::Version`]. #[error("Failed to parse the version number from the `--version` output: {0}")] @@ -149,12 +149,13 @@ impl ClangTool { .map_err(|e| GetClangVersionError::Command(path.to_path_buf(), e))?; let stdout = String::from_utf8_lossy(&output.stdout); let version_pattern = Regex::new(r"(?i)version[^\d]*([\d.]+)")?; - let captures = version_pattern - .captures(&stdout) - .ok_or(GetClangVersionError::VersionParse)?; - let result = captures.get(1).ok_or(GetClangVersionError::VersionParse)?; - let version = Version::parse(result.as_str())?; - Ok(version) + if let Some(captures) = version_pattern.captures(&stdout) + && let Some(result) = captures.get(1) + { + let version = Version::parse(result.as_str())?; + return Ok(version); + } + Err(GetClangVersionError::VersionParse(stdout.to_string())) } pub fn symlink_bin( diff --git a/clang-installer/src/version.rs b/clang-installer/src/version.rs index 5d2ea3c4..a603853e 100644 --- a/clang-installer/src/version.rs +++ b/clang-installer/src/version.rs @@ -56,7 +56,7 @@ pub enum GetToolError { ExecutablePathNoParent, /// Failed to capture the clang version from `--version` output. - #[error("Failed to capture the clang version from `--version` output: {0}")] + #[error(transparent)] GetClangVersion(#[from] GetClangVersionError), /// Failed to get the clang executable path. @@ -299,7 +299,7 @@ mod tests { // for this test we should use the oldest supported clang version // because that would be most likely to require downloading. let version_req = - VersionReq::parse(option_env!("MIN_CLANG_TOOLS_VERSION").unwrap_or("11")).unwrap(); + VersionReq::parse(option_env!("MIN_CLANG_TOOLS_VERSION").unwrap_or("16")).unwrap(); let downloaded_clang = RequestedVersion::Requirement(version_req.clone()) .eval_tool(&tool, false, Some(&PathBuf::from(tmp_cache_dir.path()))) .await diff --git a/cpp-linter/Cargo.toml b/cpp-linter/Cargo.toml index d684b287..c8363c82 100644 --- a/cpp-linter/Cargo.toml +++ b/cpp-linter/Cargo.toml @@ -15,12 +15,14 @@ license.workspace = true [dependencies] anyhow = { workspace = true } +async-trait = "0.1.89" chrono = "0.4.44" clang-installer = { path = "../clang-installer", version = "0.1.0" } clap = { workspace = true, optional = true } colored = { workspace = true, optional = true } fast-glob = "1.0.1" futures = "0.3.32" +git-bot-feedback = { version = "0.5.2", features = ["file-changes"] } git2 = "0.20.4" log = { workspace = true } quick-xml = { version = "0.39.2", features = ["serialize"] } diff --git a/cpp-linter/src/clang_tools/clang_format.rs b/cpp-linter/src/clang_tools/clang_format.rs index c4bb2c99..a9e0e7a5 100644 --- a/cpp-linter/src/clang_tools/clang_format.rs +++ b/cpp-linter/src/clang_tools/clang_format.rs @@ -68,12 +68,10 @@ pub fn summarize_style(style: &str) -> String { } /// Get a total count of clang-format advice from the given list of [FileObj]s. -pub fn tally_format_advice(files: &[Arc>]) -> Result { +pub fn tally_format_advice(files: &[Arc>]) -> Result { let mut total = 0; for file in files { - let file = file - .lock() - .map_err(|_| anyhow!("Failed to acquire lock on mutex for a source file"))?; + let file = file.lock().map_err(|e| e.to_string())?; if let Some(advice) = &file.format_advice && !advice.replacements.is_empty() { diff --git a/cpp-linter/src/clang_tools/clang_tidy.rs b/cpp-linter/src/clang_tools/clang_tidy.rs index a45abad8..5724f068 100644 --- a/cpp-linter/src/clang_tools/clang_tidy.rs +++ b/cpp-linter/src/clang_tools/clang_tidy.rs @@ -247,12 +247,10 @@ fn parse_tidy_output( } /// Get a total count of clang-tidy advice from the given list of [FileObj]s. -pub fn tally_tidy_advice(files: &[Arc>]) -> Result { +pub fn tally_tidy_advice(files: &[Arc>]) -> Result { let mut total = 0; for file in files { - let file = file - .lock() - .map_err(|_| anyhow!("Failed to acquire lock on mutex for a source file"))?; + let file = file.lock().map_err(|e| e.to_string())?; if let Some(advice) = &file.tidy_advice { for tidy_note in &advice.notes { let file_path = PathBuf::from(&tidy_note.filename); diff --git a/cpp-linter/src/clang_tools/mod.rs b/cpp-linter/src/clang_tools/mod.rs index ff152e3a..0ab217a2 100644 --- a/cpp-linter/src/clang_tools/mod.rs +++ b/cpp-linter/src/clang_tools/mod.rs @@ -11,15 +11,17 @@ use std::{ // non-std crates use anyhow::{Context, Result, anyhow}; use clang_installer::{ClangTool, RequestedVersion}; +use git_bot_feedback::ReviewComment; use git2::{DiffOptions, Patch}; use semver::Version; use tokio::task::JoinSet; // project-specific modules/crates use super::common_fs::FileObj; +use crate::error::SuggestionError; use crate::{ cli::ClangParams, - rest_api::{COMMENT_MARKER, RestApiClient, USER_OUTREACH}, + rest_client::{RestClient, USER_OUTREACH}, }; pub mod clang_format; use clang_format::run_clang_format; @@ -46,7 +48,7 @@ fn analyze_single_file( if clang_params .format_filter .as_ref() - .is_some_and(|f| f.is_source_or_ignored(file.name.as_path())) + .is_some_and(|f| f.is_qualified(file.name.as_path())) || clang_params.format_filter.is_none() { let format_result = run_clang_format(&mut file, &clang_params)?; @@ -65,7 +67,7 @@ fn analyze_single_file( if clang_params .tidy_filter .as_ref() - .is_some_and(|f| f.is_source_or_ignored(file.name.as_path())) + .is_some_and(|f| f.is_qualified(file.name.as_path())) || clang_params.tidy_filter.is_none() { let tidy_result = run_clang_tidy(&mut file, &clang_params)?; @@ -101,7 +103,7 @@ pub async fn capture_clang_tools_output( files: &[Arc>], version: &RequestedVersion, mut clang_params: ClangParams, - rest_api_client: &impl RestApiClient, + rest_api_client: &RestClient, ) -> Result { let mut clang_versions = ClangVersions::default(); // find the executable paths for clang-tidy and/or clang-format and show version @@ -148,11 +150,12 @@ pub async fn capture_clang_tools_output( // This includes any `spawn()` error and any `analyze_single_file()` error. // Any unresolved tasks are aborted and dropped when an error is returned here. let (file_name, logs) = output??; - rest_api_client.start_log_group(format!("Analyzing {}", file_name.to_string_lossy())); + let log_group_name = format!("Analyzing {}", file_name.to_string_lossy()); + rest_api_client.start_log_group(&log_group_name); for (level, msg) in logs { log::log!(level, "{}", msg); } - rest_api_client.end_log_group(); + rest_api_client.end_log_group(&log_group_name); } Ok(clang_versions) } @@ -169,6 +172,17 @@ pub struct Suggestion { pub path: String, } +impl Suggestion { + pub(crate) fn as_review_comment(&self) -> ReviewComment { + ReviewComment { + line_start: Some(self.line_start), + line_end: self.line_end, + comment: self.suggestion.clone(), + path: self.path.clone(), + } + } +} + /// A struct to describe the Pull Request review suggestions. #[derive(Default)] pub struct ReviewComments { @@ -189,8 +203,12 @@ pub struct ReviewComments { } impl ReviewComments { - pub fn summarize(&self, clang_versions: &ClangVersions) -> String { - let mut body = format!("{COMMENT_MARKER}## Cpp-linter Review\n"); + pub fn summarize( + &self, + clang_versions: &ClangVersions, + comments: &Vec, + ) -> String { + let mut body = String::from("## Cpp-linter Review\n"); for t in 0_usize..=1 { let mut total = 0; let (tool_name, tool_version) = if t == 0 { @@ -209,9 +227,9 @@ impl ReviewComments { if let Some(ver_str) = tool_version { body.push_str(format!("\n### Used {tool_name} v{ver_str}\n").as_str()); } - for comment in &self.comments { + for comment in comments { if comment - .suggestion + .comment .contains(format!("### {tool_name}").as_str()) { total += 1; @@ -266,24 +284,17 @@ pub fn make_patch<'buffer>( path: &Path, patched: &'buffer [u8], original_content: &'buffer [u8], -) -> Result> { +) -> Result, git2::Error> { let mut diff_opts = &mut DiffOptions::new(); diff_opts = diff_opts.indent_heuristic(true); diff_opts = diff_opts.context_lines(0); - let patch = Patch::from_buffers( + Patch::from_buffers( original_content, Some(path), patched, Some(path), Some(diff_opts), ) - .with_context(|| { - format!( - "Failed to create patch for file {}.", - path.to_string_lossy() - ) - })?; - Ok(patch) } /// A trait for generating suggestions from a [`FileObj`]'s advice's generated `patched` buffer. @@ -301,7 +312,7 @@ pub trait MakeSuggestions { file_obj: &FileObj, patch: &mut Patch, summary_only: bool, - ) -> Result<()> { + ) -> Result<(), SuggestionError> { let is_tidy_tool = (&self.get_tool_name() == "clang-tidy") as usize; let hunks_total = patch.num_hunks(); let mut hunks_in_patch = 0u32; @@ -313,11 +324,17 @@ pub trait MakeSuggestions { .to_owned(); let patch_buf = &patch .to_buf() - .with_context(|| "Failed to convert patch to byte array")? + .map_err(|e| SuggestionError::PatchIntoBytesFailed { + file_name: file_name.clone(), + source: e, + })? .to_vec(); review_comments.full_patch[is_tidy_tool].push_str( String::from_utf8(patch_buf.to_owned()) - .with_context(|| format!("Failed to convert patch to string: {file_name}"))? + .map_err(|e| SuggestionError::PatchIntoStringFailed { + file_name: file_name.clone(), + source: e, + })? .as_str(), ); if summary_only { @@ -325,9 +342,14 @@ pub trait MakeSuggestions { return Ok(()); } for hunk_id in 0..hunks_total { - let (hunk, line_count) = patch.hunk(hunk_id).with_context(|| { - format!("Failed to get hunk {hunk_id} from patch for {file_name}") - })?; + let (hunk, line_count) = + patch + .hunk(hunk_id) + .map_err(|e| SuggestionError::GetHunkFailed { + hunk_id, + file_name: file_name.clone(), + source: e, + })?; hunks_in_patch += 1; let hunk_range = file_obj.is_hunk_in_diff(&hunk); match hunk_range { @@ -337,11 +359,23 @@ pub trait MakeSuggestions { let suggestion_help = self.get_suggestion_help(start_line, end_line); let mut removed = vec![]; for line_index in 0..line_count { - let diff_line = patch - .line_in_hunk(hunk_id, line_index) - .with_context(|| format!("Failed to get line {line_index} in a hunk {hunk_id} of patch for {file_name}"))?; - let line = String::from_utf8(diff_line.content().to_owned()) - .with_context(|| format!("Failed to convert line {line_index} buffer to string in hunk {hunk_id} of patch for {file_name}"))?; + let diff_line = patch.line_in_hunk(hunk_id, line_index).map_err(|e| { + SuggestionError::GetHunkLineFailed { + line_index, + hunk_id, + file_name: file_name.clone(), + source: e, + } + })?; + let line = + String::from_utf8(diff_line.content().to_owned()).map_err(|e| { + SuggestionError::HunkLineIntoStringFailed { + line_index, + hunk_id, + file_name: file_name.clone(), + source: e, + } + })?; if ['+', ' '].contains(&diff_line.origin()) { suggestion.push_str(line.as_str()); } else { diff --git a/cpp-linter/src/cli/structs.rs b/cpp-linter/src/cli/structs.rs index 115a1c59..b51605db 100644 --- a/cpp-linter/src/cli/structs.rs +++ b/cpp-linter/src/cli/structs.rs @@ -5,7 +5,9 @@ use clap::{ValueEnum, builder::PossibleValue}; #[cfg(feature = "bin")] use super::Cli; -use crate::{clang_tools::clang_tidy::CompilationUnit, common_fs::FileFilter}; +use crate::clang_tools::clang_tidy::CompilationUnit; + +use git_bot_feedback::FileFilter; /// An enum to describe `--lines-changed-only` CLI option's behavior. #[derive(PartialEq, Clone, Debug, Default)] @@ -19,6 +21,16 @@ pub enum LinesChangedOnly { On, } +impl From for git_bot_feedback::LinesChangedOnly { + fn from(val: LinesChangedOnly) -> Self { + match val { + LinesChangedOnly::Off => git_bot_feedback::LinesChangedOnly::Off, + LinesChangedOnly::Diff => git_bot_feedback::LinesChangedOnly::Diff, + LinesChangedOnly::On => git_bot_feedback::LinesChangedOnly::On, + } + } +} + #[cfg(feature = "bin")] impl ValueEnum for LinesChangedOnly { /// Get a list possible value variants for display in `--help` output. @@ -172,6 +184,24 @@ pub struct ClangParams { impl From<&Cli> for ClangParams { /// Construct a [`ClangParams`] instance from a [`Cli`] instance. fn from(args: &Cli) -> Self { + let extensions: Vec<&str> = args + .source_options + .extensions + .iter() + .map(|ext| ext.as_str()) + .collect(); + let tidy_filter = args.tidy_options.ignore_tidy.as_ref().map(|ignore_tidy| { + let ignore_tidy: Vec<&str> = ignore_tidy.iter().map(|s| s.as_str()).collect(); + FileFilter::new(&ignore_tidy, &extensions.clone(), Some("clang-tidy")) + }); + let format_filter = args + .format_options + .ignore_format + .as_ref() + .map(|ignore_format| { + let ignore_format: Vec<&str> = ignore_format.iter().map(|s| s.as_str()).collect(); + FileFilter::new(&ignore_format, &extensions, Some("clang-format")) + }); ClangParams { tidy_checks: args.tidy_options.tidy_checks.clone(), lines_changed_only: args.source_options.lines_changed_only.clone(), @@ -181,16 +211,8 @@ impl From<&Cli> for ClangParams { style: args.format_options.style.clone(), clang_tidy_command: None, clang_format_command: None, - tidy_filter: args.tidy_options.ignore_tidy.as_ref().map(|ignore_tidy| { - FileFilter::new(ignore_tidy, args.source_options.extensions.clone()) - }), - format_filter: args - .format_options - .ignore_format - .as_ref() - .map(|ignore_format| { - FileFilter::new(ignore_format, args.source_options.extensions.clone()) - }), + tidy_filter, + format_filter, tidy_review: args.feedback_options.tidy_review, format_review: args.feedback_options.format_review, } @@ -243,17 +265,15 @@ impl Default for FeedbackInput { } } -#[cfg(test)] +#[cfg(all(test, feature = "bin"))] mod test { #![allow(clippy::unwrap_used)] - #[cfg(feature = "bin")] use clap::{Parser, ValueEnum}; use super::{Cli, LinesChangedOnly, ThreadComments}; #[test] - #[cfg(feature = "bin")] fn parse_positional() { let cli = Cli::parse_from(["cpp-linter", "file1.c", "file2.h"]); let not_ignored = cli.not_ignored.expect("failed to parse positional args"); diff --git a/cpp-linter/src/common_fs/file_filter.rs b/cpp-linter/src/common_fs/file_filter.rs deleted file mode 100644 index cb2dc84d..00000000 --- a/cpp-linter/src/common_fs/file_filter.rs +++ /dev/null @@ -1,281 +0,0 @@ -use anyhow::{Context, Result, anyhow}; -use fast_glob::glob_match; -use std::{ - fs, - path::{Path, PathBuf}, -}; - -use super::FileObj; - -/// A filter for files based on their path and extension. -#[derive(Debug, Clone)] -pub struct FileFilter { - pub ignored: Vec, - pub not_ignored: Vec, - pub extensions: Vec, -} -impl FileFilter { - /// Creates a new `FileFilter` from a list of ignore patterns and a list of extensions. - /// - /// The `ignore` parameter is a list of glob patterns that should be ignored. - /// These can be explicitly not ignored by prefixing them with `!`. - /// Hidden files/folders (patterns that start with a ".") cannot be explicitly not - /// ignored; they are always ignored. - /// - /// The `extensions` parameter is a list of file extensions that should be included. - pub fn new(ignore: &[String], extensions: Vec) -> Self { - let (ignored, not_ignored) = Self::parse_ignore(ignore); - Self { - ignored, - not_ignored, - extensions, - } - } - - /// This will parse the list of paths specified from the CLI using the `--ignore` - /// argument. - /// - /// It returns 2 lists (in order): - /// - /// - `ignored` paths - /// - `not_ignored` paths - fn parse_ignore(ignore: &[String]) -> (Vec, Vec) { - let mut ignored = vec![]; - let mut not_ignored = vec![]; - for pattern in ignore { - let as_posix = pattern.replace('\\', "/"); - let mut pat = as_posix.as_str().trim(); - let is_ignored = !pat.starts_with('!'); - if !is_ignored { - pat = pat[1..].trim_start(); - } - if pat.starts_with("./") { - pat = &pat[2..]; - } - let is_hidden = pat.starts_with('.'); - if is_hidden || is_ignored { - ignored.push(format!("./{pat}")); - } else { - not_ignored.push(format!("./{pat}")); - } - } - (ignored, not_ignored) - } - - /// This function will also read a .gitmodules file located in the working directory. - /// The named submodules' paths will be automatically added to the ignored list, - /// unless the submodule's path is already specified in the not_ignored list. - pub fn parse_submodules(&mut self) { - if let Ok(read_buf) = fs::read_to_string(".gitmodules") { - for line in read_buf.split('\n') { - if line.trim_start().starts_with("path") { - assert!(line.find('=').unwrap() > 0); - let submodule = - String::from("./") + line.split('=').next_back().unwrap().trim(); - log::debug!("Found submodule: {submodule}"); - let mut is_ignored = true; - for pat in &self.not_ignored { - if pat == &submodule { - is_ignored = false; - break; - } - } - if is_ignored && !self.ignored.contains(&submodule) { - self.ignored.push(submodule); - } - } - } - } - } - - /// Describes if a specified `file_name` is contained within the specified set of paths. - /// - /// The `is_ignored` flag describes which set of paths is used as domains. - /// The specified `file_name` can be a direct or distant descendant of any paths in - /// the set. - /// - /// Returns a `true` value of the the path/pattern that matches the given `file_name`. - /// If given `file_name` is not in the specified set, then `false` is returned. - pub fn is_file_in_list(&self, file_name: &Path, is_ignored: bool) -> bool { - let file_name = PathBuf::from(format!( - "./{}", - file_name - .as_os_str() - .to_string_lossy() - .to_string() - .replace("\\", "/") - .trim_start_matches("./") - )); - let set = if is_ignored { - &self.ignored - } else { - &self.not_ignored - }; - for pattern in set { - let glob_matched = - glob_match(pattern, file_name.to_string_lossy().to_string().as_str()); - let pat = PathBuf::from(&pattern); - if pattern.as_str() == "./" - || glob_matched - || (pat.is_file() && file_name == pat) - || (pat.is_dir() && file_name.starts_with(pat)) - { - log::debug!( - "file {file_name:?} is {}ignored with domain {pattern:?}.", - if is_ignored { "" } else { "not " } - ); - return true; - } - } - false - } - - /// A helper function that checks if `entry` satisfies the following conditions (in - /// ordered priority): - /// - /// - Does `entry`'s path use at least 1 of the listed file `extensions`? (takes - /// precedence) - /// - Is `entry` *not* specified in list of `ignored` paths? - /// - Is `entry` specified in the list of explicitly `not_ignored` paths? (supersedes - /// specified `ignored` paths) - pub fn is_source_or_ignored(&self, entry: &Path) -> bool { - let extension = entry - .extension() - .unwrap_or_default() // allow for matching files with no extension - .to_string_lossy() - .to_string(); - if !self.extensions.contains(&extension) { - return false; - } - let is_in_not_ignored = self.is_file_in_list(entry, false); - if is_in_not_ignored || !self.is_file_in_list(entry, true) { - return true; - } - false - } - - /// Walks a given `root_path` recursively and returns a [`Vec`] that - /// - /// - uses at least 1 of the given `extensions` - /// - is not specified in the internal list of `ignored` paths - /// - is specified in the internal list `not_ignored` paths (which supersedes `ignored` paths) - pub fn list_source_files(&self, root_path: &str) -> Result> { - let mut files: Vec = Vec::new(); - let entries = fs::read_dir(root_path) - .with_context(|| format!("Failed to read directory contents: {root_path}"))?; - for entry in entries.filter_map(|p| p.ok()) { - let path = entry.path(); - if path.is_dir() { - let mut is_hidden = false; - let parent = path - .components() - .next_back() - .ok_or(anyhow!("parent directory not known for {path:?}"))?; - if parent.as_os_str().to_str().unwrap().starts_with('.') { - is_hidden = true; - } - if !is_hidden { - files.extend(self.list_source_files(&path.to_string_lossy())?); - } - } else { - let is_valid_src = self.is_source_or_ignored(&path); - if is_valid_src { - files.push(FileObj::new( - path.clone().strip_prefix("./").unwrap().to_path_buf(), - )); - } - } - } - Ok(files) - } -} - -#[cfg(test)] -mod tests { - use clap::Parser; - - use super::FileFilter; - use crate::cli::Cli; - use std::{env::set_current_dir, path::PathBuf}; - - // ************* tests for ignored paths - - fn setup_ignore(input: &str, extension: Vec) -> FileFilter { - let args = Cli::parse_from(["cpp-linter", "-i", input]); - let ignore_arg = args.source_options.ignore; - let file_filter = FileFilter::new(&ignore_arg, extension); - println!("ignored = {:?}", file_filter.ignored); - println!("not ignored = {:?}", file_filter.not_ignored); - file_filter - } - - #[test] - fn ignore_src() { - let file_filter = setup_ignore("src", vec![]); - assert!(file_filter.is_file_in_list(&PathBuf::from("./src/lib.rs"), true)); - assert!(!file_filter.is_file_in_list(&PathBuf::from("./src/lib.rs"), false)); - } - - #[test] - fn ignore_root() { - let file_filter = setup_ignore("!src/lib.rs|./", vec![]); - assert!(file_filter.is_file_in_list(&PathBuf::from("./Cargo.toml"), true)); - assert!(file_filter.is_file_in_list(&PathBuf::from("./src/lib.rs"), false)); - } - - #[test] - fn ignore_root_implicit() { - let file_filter = setup_ignore("!src|", vec![]); - assert!(file_filter.is_file_in_list(&PathBuf::from("./Cargo.toml"), true)); - assert!(file_filter.is_file_in_list(&PathBuf::from("./src/lib.rs"), false)); - } - - #[test] - fn ignore_glob() { - let file_filter = setup_ignore("!src/**/*", vec![]); - assert!(file_filter.is_file_in_list(&PathBuf::from("./src/lib.rs"), false)); - assert!( - file_filter.is_file_in_list(&PathBuf::from("./src/common_fs/file_filter.rs"), false) - ); - } - - #[test] - fn ignore_submodules() { - set_current_dir("tests/ignored_paths").unwrap(); - let mut file_filter = setup_ignore("!pybind11", vec![]); - file_filter.parse_submodules(); - - // using Vec::contains() because these files don't actually exist in project files - for ignored_submodule in ["./RF24", "./RF24Network", "./RF24Mesh"] { - assert!(file_filter.ignored.contains(&ignored_submodule.to_string())); - assert!(!file_filter.is_file_in_list( - &PathBuf::from(ignored_submodule.to_string() + "/some_src.cpp"), - true - )); - } - assert!(file_filter.not_ignored.contains(&"./pybind11".to_string())); - assert!(!file_filter.is_file_in_list(&PathBuf::from("./pybind11/some_src.cpp"), false)); - } - - // *********************** tests for recursive path search - - #[test] - fn walk_dir_recursively() { - let extensions = vec!["cpp".to_string(), "hpp".to_string()]; - let file_filter = setup_ignore("target", extensions.clone()); - let files = file_filter.list_source_files(".").unwrap(); - assert!(!files.is_empty()); - for file in files { - assert!( - extensions.contains( - &file - .name - .extension() - .unwrap_or_default() - .to_string_lossy() - .to_string() - ) - ); - } - } -} diff --git a/cpp-linter/src/common_fs/mod.rs b/cpp-linter/src/common_fs/mod.rs index 0373b238..14fd3a17 100644 --- a/cpp-linter/src/common_fs/mod.rs +++ b/cpp-linter/src/common_fs/mod.rs @@ -1,18 +1,20 @@ //! A module to hold all common file system functionality. -use std::fmt::Debug; -use std::fs; -use std::path::Path; -use std::{ops::RangeInclusive, path::PathBuf}; - -use anyhow::{Context, Result}; - -use crate::clang_tools::clang_format::FormatAdvice; -use crate::clang_tools::clang_tidy::TidyAdvice; -use crate::clang_tools::{MakeSuggestions, ReviewComments, Suggestion, make_patch}; -use crate::cli::LinesChangedOnly; -mod file_filter; -pub use file_filter::FileFilter; +use std::{ + fmt::Debug, + fs, + ops::RangeInclusive, + path::{Path, PathBuf}, +}; + +use crate::{ + clang_tools::{ + MakeSuggestions, ReviewComments, Suggestion, clang_format::FormatAdvice, + clang_tidy::TidyAdvice, make_patch, + }, + cli::LinesChangedOnly, + error::FileObjError, +}; use git2::DiffHunk; /// A structure to represent a file's path and line changes. @@ -142,21 +144,22 @@ impl FileObj { &self, review_comments: &mut ReviewComments, summary_only: bool, - ) -> Result<()> { - let original_content = - fs::read(&self.name).with_context(|| "Failed to read original contents of file")?; + ) -> Result<(), FileObjError> { + let original_content = fs::read(&self.name).map_err(FileObjError::ReadFile)?; let file_name = self.name.to_str().unwrap_or_default().replace("\\", "/"); let file_path = Path::new(&file_name); if let Some(advice) = &self.format_advice && let Some(patched) = &advice.patched { - let mut patch = make_patch(file_path, patched, &original_content)?; + let mut patch = make_patch(file_path, patched, &original_content) + .map_err(|e| FileObjError::MakePatchFailed(file_name.clone(), e))?; advice.get_suggestions(review_comments, self, &mut patch, summary_only)?; } if let Some(advice) = &self.tidy_advice { if let Some(patched) = &advice.patched { - let mut patch = make_patch(file_path, patched, &original_content)?; + let mut patch = make_patch(file_path, patched, &original_content) + .map_err(|e| FileObjError::MakePatchFailed(file_name.clone(), e))?; advice.get_suggestions(review_comments, self, &mut patch, summary_only)?; } diff --git a/cpp-linter/src/error.rs b/cpp-linter/src/error.rs new file mode 100644 index 00000000..7c20d6aa --- /dev/null +++ b/cpp-linter/src/error.rs @@ -0,0 +1,66 @@ +use git_bot_feedback::RestClientError; + +#[derive(Debug, thiserror::Error)] +pub enum SuggestionError { + #[error("Failed to convert patch for '{file_name}' into bytes: {source}")] + PatchIntoBytesFailed { + file_name: String, + #[source] + source: git2::Error, + }, + #[error("Failed to convert patch for file '{file_name}' into string: {source}")] + PatchIntoStringFailed { + file_name: String, + #[source] + source: std::string::FromUtf8Error, + }, + #[error("Failed to get hunk {hunk_id} from patch for {file_name}: {source}")] + GetHunkFailed { + hunk_id: usize, + file_name: String, + #[source] + source: git2::Error, + }, + #[error( + "Failed to get line {line_index} in a hunk {hunk_id} of patch for {file_name}: {source}" + )] + GetHunkLineFailed { + line_index: usize, + hunk_id: usize, + file_name: String, + #[source] + source: git2::Error, + }, + #[error( + "Failed to convert line {line_index} buffer to string in hunk {hunk_id} of patch for {file_name}: {source}" + )] + HunkLineIntoStringFailed { + line_index: usize, + hunk_id: usize, + file_name: String, + #[source] + source: std::string::FromUtf8Error, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum FileObjError { + #[error("Failed to read file contents")] + ReadFile(std::io::Error), + #[error("Failed to create patch for file {0:?}: {1}")] + MakePatchFailed(String, #[source] git2::Error), + #[error(transparent)] + SuggestionError(#[from] SuggestionError), +} + +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error(transparent)] + RestClientError(#[from] RestClientError), + #[error("Unsupported Git server or CI platform")] + GitServerUnsupported, + #[error("Mutex lock poisoned for a source file: {0}")] + MutexPoisoned(String), + #[error(transparent)] + FileObjError(#[from] FileObjError), +} diff --git a/cpp-linter/src/git.rs b/cpp-linter/src/git.rs index 71cbea24..c93e1bf7 100644 --- a/cpp-linter/src/git.rs +++ b/cpp-linter/src/git.rs @@ -15,10 +15,8 @@ use anyhow::{Context, Result}; use git2::{Diff, Error, Patch, Repository}; // project specific modules/crates -use crate::{ - cli::LinesChangedOnly, - common_fs::{FileFilter, FileObj}, -}; +use crate::{cli::LinesChangedOnly, common_fs::FileObj}; +use git_bot_feedback::{FileFilter, error::DiffError}; /// This (re-)initializes the repository located in the specified `path`. /// @@ -174,7 +172,7 @@ pub fn parse_diff( if matches!( diff_delta.status(), git2::Delta::Added | git2::Delta::Modified | git2::Delta::Renamed, - ) && file_filter.is_source_or_ignored(&file_path) + ) && file_filter.is_qualified(&file_path) { let (added_lines, diff_chunks) = parse_patch(&Patch::from_diff(diff, file_idx).unwrap().unwrap()); @@ -198,246 +196,30 @@ pub fn parse_diff_from_buf( buff: &[u8], file_filter: &FileFilter, lines_changed_only: &LinesChangedOnly, -) -> Vec { +) -> Result, DiffError> { if let Ok(diff_obj) = &Diff::from_buffer(buff) { - parse_diff(diff_obj, file_filter, lines_changed_only) + Ok(parse_diff(diff_obj, file_filter, lines_changed_only)) } else { log::warn!("libgit2 failed to parse the diff"); - brute_force_parse_diff::parse_diff( + Ok(git_bot_feedback::parse_diff( &String::from_utf8_lossy(buff), file_filter, - lines_changed_only, - ) - } -} - -mod brute_force_parse_diff { - //! A private module to house the brute force algorithms of parsing a diff as a string. - //! This module is only intended as a fall back mechanism when [super::parse_diff_from_buf] - //! fails to use libgit2 C bindings. - //! - //! Since this is a fail safe, there are log messages that indicate when it is used. - //! Any instance where this mechanism is used should be reported as it is likely a bug - //! in libgit2 source. - - use regex::Regex; - use std::{ops::RangeInclusive, path::PathBuf}; - - use crate::{ - cli::LinesChangedOnly, - common_fs::{FileFilter, FileObj}, - }; - - fn get_filename_from_front_matter(front_matter: &str) -> Option<&str> { - let diff_file_name = Regex::new(r"(?m)^\+\+\+\sb?/(.*)$").unwrap(); - let diff_renamed_file = Regex::new(r"(?m)^rename to (.*)$").unwrap(); - let diff_binary_file = Regex::new(r"(?m)^Binary\sfiles\s").unwrap(); - if let Some(captures) = diff_file_name.captures(front_matter) { - return Some(captures.get(1).unwrap().as_str()); - } - if front_matter.trim_start().starts_with("similarity") - && let Some(captures) = diff_renamed_file.captures(front_matter) - { - return Some(captures.get(1).unwrap().as_str()); - } - if !diff_binary_file.is_match(front_matter) { - log::warn!("Unrecognized diff starting with:\n{}", front_matter); - } - None - } - - /// A regex pattern used in multiple functions - static HUNK_INFO_PATTERN: &str = r"(?m)@@\s\-\d+,\d+\s\+(\d+,\d+)\s@@"; - - /// Parses a single file's patch containing one or more hunks - /// Returns a 3-item tuple: - /// - the line numbers that contain additions - /// - the ranges of lines that span each hunk - fn parse_patch(patch: &str) -> (Vec, Vec>) { - let mut diff_chunks = Vec::new(); - let mut additions = Vec::new(); - - let hunk_info = Regex::new(HUNK_INFO_PATTERN).unwrap(); - if let Some(hunk_headers) = hunk_info.captures(patch) { - for (index, (hunk, header)) in - hunk_info.split(patch).zip(hunk_headers.iter()).enumerate() - { - if index == 0 { - continue; // we don't need the whole match, just the capture groups - } - let new_range: Vec = header - .unwrap() - .as_str() - .split(',') - .take(2) - .map(|val| val.parse::().unwrap()) - .collect(); - let start_line = new_range[0]; - let end_range = new_range[1]; - let mut line_numb_in_diff = start_line; - diff_chunks.push(RangeInclusive::new(start_line, start_line + end_range)); - for (line_index, line) in hunk.split('\n').enumerate() { - if line.starts_with('+') { - additions.push(line_numb_in_diff); - } - if line_index > 0 && !line.starts_with('-') { - line_numb_in_diff += 1; - } - } - } - } - (additions, diff_chunks) - } - - pub fn parse_diff( - diff: &str, - file_filter: &FileFilter, - lines_changed_only: &LinesChangedOnly, - ) -> Vec { - log::error!("Using brute force diff parsing!"); - let mut results = Vec::new(); - let diff_file_delimiter = Regex::new(r"(?m)^diff --git a/.*$").unwrap(); - let hunk_info = Regex::new(HUNK_INFO_PATTERN).unwrap(); - - let file_diffs = diff_file_delimiter.split(diff); - for file_diff in file_diffs { - if file_diff.is_empty() || file_diff.starts_with("deleted file") { - continue; - } - let hunk_start = if let Some(first_hunk) = hunk_info.find(file_diff) { - first_hunk.start() - } else { - file_diff.len() - }; - let front_matter = &file_diff[..hunk_start]; - if let Some(file_name) = get_filename_from_front_matter(front_matter) { - let file_path = PathBuf::from(file_name); - if file_filter.is_source_or_ignored(&file_path) { - let (added_lines, diff_chunks) = parse_patch(&file_diff[hunk_start..]); - if lines_changed_only - .is_change_valid(!added_lines.is_empty(), !diff_chunks.is_empty()) - { - results.push(FileObj::from(file_path, added_lines, diff_chunks)); - } - } - } - // } else { - // // file has no changed content. moving on - // continue; - // } - } - results - } - - // ******************* UNIT TESTS *********************** - #[cfg(test)] - mod test { - - use super::parse_diff; - use crate::{ - cli::LinesChangedOnly, - common_fs::{FileFilter, FileObj}, - git::parse_diff_from_buf, - }; - - static RENAMED_DIFF: &str = r#"diff --git a/tests/demo/some source.cpp b/tests/demo/some source.c -similarity index 100% -rename from /tests/demo/some source.cpp -rename to /tests/demo/some source.c -diff --git a/some picture.png b/some picture.png -new file mode 100644 -Binary files /dev/null and b/some picture.png differ -"#; - - static RENAMED_DIFF_WITH_CHANGES: &str = r#"diff --git a/tests/demo/some source.cpp b/tests/demo/some source.c -similarity index 99% -rename from /tests/demo/some source.cpp -rename to /tests/demo/some source.c -@@ -3,7 +3,7 @@ -\n \n \n-#include "iomanip" -+#include \n \n \n \n"#; - - #[test] - fn parse_renamed_diff() { - let diff_buf = RENAMED_DIFF.as_bytes(); - let files = parse_diff_from_buf( - diff_buf, - &FileFilter::new(&["target".to_string()], vec!["c".to_string()]), - &LinesChangedOnly::Off, - ); - assert!(!files.is_empty()); - assert!( - files - .first() - .unwrap() - .name - .ends_with("tests/demo/some source.c") - ); - } - - #[test] - fn parse_renamed_diff_with_patch() { - let diff_buf = RENAMED_DIFF_WITH_CHANGES.as_bytes(); - let files = parse_diff_from_buf( - diff_buf, - &FileFilter::new(&["target".to_string()], vec!["c".to_string()]), - &LinesChangedOnly::Off, - ); - assert!(!files.is_empty()); - } - - /// Used to parse the same string buffer using both libgit2 and brute force regex. - /// Returns 2 vectors of [FileObj] that should be equivalent. - fn setup_parsed(buf: &str, extensions: &[String]) -> (Vec, Vec) { - let ignore = ["target".to_string()]; - ( - parse_diff_from_buf( - buf.as_bytes(), - &FileFilter::new(&ignore, extensions.to_owned()), - &LinesChangedOnly::Off, - ), - parse_diff( - buf, - &FileFilter::new(&ignore, extensions.to_owned()), - &LinesChangedOnly::Off, - ), + &lines_changed_only.clone().into(), + )? + .iter() + .map(|(name, diff_lines)| { + let diff_chunks = diff_lines + .diff_hunks + .iter() + .map(|hunk| hunk.start..=hunk.end) + .collect(); + FileObj::from( + PathBuf::from(&name), + diff_lines.added_lines.clone(), + diff_chunks, ) - } - - fn assert_files_eq(files_from_a: &[FileObj], files_from_b: &[FileObj]) { - assert_eq!(files_from_a.len(), files_from_b.len()); - for (a, b) in files_from_a.iter().zip(files_from_b) { - assert_eq!(a.name, b.name); - assert_eq!(a.added_lines, b.added_lines); - assert_eq!(a.added_ranges, b.added_ranges); - assert_eq!(a.diff_chunks, b.diff_chunks); - } - } - - #[test] - fn parse_typical_diff() { - let diff_buf = "diff --git a/path/for/Some file.cpp b/path/to/Some file.cpp\n\ - --- a/path/for/Some file.cpp\n\ - +++ b/path/to/Some file.cpp\n\ - @@ -3,7 +3,7 @@\n \n \n \n\ - -#include \n\ - +#include \n \n \n \n"; - - let (files_from_buf, files_from_str) = setup_parsed(diff_buf, &[String::from("cpp")]); - assert!(!files_from_buf.is_empty()); - assert_files_eq(&files_from_buf, &files_from_str); - } - - #[test] - fn parse_binary_diff() { - let diff_buf = "diff --git a/some picture.png b/some picture.png\n\ - new file mode 100644\n\ - Binary files /dev/null and b/some picture.png differ\n"; - - let (files_from_buf, files_from_str) = setup_parsed(diff_buf, &[String::from("png")]); - assert!(files_from_buf.is_empty()); - assert_files_eq(&files_from_buf, &files_from_str); - } + }) + .collect()) } } @@ -452,11 +234,8 @@ mod test { use tempfile::{TempDir, tempdir}; use super::get_sha; - use crate::{ - cli::LinesChangedOnly, - common_fs::FileFilter, - rest_api::{RestApiClient, github::GithubApiClient}, - }; + use crate::{cli::LinesChangedOnly, rest_client::RestClient}; + use git_bot_feedback::FileFilter; const TEST_REPO_URL: &str = "https://github.com/cpp-linter/cpp-linter"; @@ -502,19 +281,28 @@ mod test { tmp.path().as_os_str().to_str().unwrap(), patch_path, ); - let rest_api_client = GithubApiClient::new(); - let file_filter = FileFilter::new(&["target".to_string()], extensions.to_owned()); - set_current_dir(tmp).unwrap(); // avoid use of REST API when testing in CI unsafe { + env::set_var("GITHUB_ACTIONS", "false"); env::set_var("CI", "false"); } + let rest_api_client = RestClient::new().unwrap(); + let file_filter = FileFilter::new( + &["target"], + &extensions.iter().map(|s| s.as_str()).collect::>(), + None, + ); + set_current_dir(tmp).unwrap(); + let base_diff = if ignore_staged { + Some("0".to_string()) + } else { + None:: + }; rest_api_client - .unwrap() .get_list_of_changed_files( &file_filter, - &LinesChangedOnly::Off, - if ignore_staged { &Some(0) } else { &None:: }, + &LinesChangedOnly::Off.into(), + &base_diff, ignore_staged, ) .await diff --git a/cpp-linter/src/lib.rs b/cpp-linter/src/lib.rs index 60bf0d6a..f7250a6c 100644 --- a/cpp-linter/src/lib.rs +++ b/cpp-linter/src/lib.rs @@ -10,7 +10,8 @@ pub mod clang_tools; pub mod cli; pub mod common_fs; +pub mod error; pub mod git; pub mod logger; -pub mod rest_api; +pub mod rest_client; pub mod run; diff --git a/cpp-linter/src/rest_api/github/mod.rs b/cpp-linter/src/rest_api/github/mod.rs deleted file mode 100644 index 47288961..00000000 --- a/cpp-linter/src/rest_api/github/mod.rs +++ /dev/null @@ -1,450 +0,0 @@ -//! This module holds functionality specific to using Github's REST API. -//! -//! In the root module, we just implement the RestApiClient trait. -//! In other (private) submodules we implement behavior specific to Github's REST API. - -use std::env; -use std::fmt::Display; -use std::fs::OpenOptions; -use std::io::Write; -use std::sync::{Arc, Mutex}; - -// non-std crates -use anyhow::{Context, Result}; -use reqwest::{ - Client, Method, Url, - header::{AUTHORIZATION, HeaderMap, HeaderValue}, -}; - -// project specific modules/crates -use super::{RestApiClient, RestApiRateLimitHeaders, send_api_request}; -use crate::clang_tools::ClangVersions; -use crate::clang_tools::clang_format::tally_format_advice; -use crate::clang_tools::clang_tidy::tally_tidy_advice; -use crate::cli::{FeedbackInput, LinesChangedOnly, ThreadComments}; -use crate::common_fs::{FileFilter, FileObj}; -use crate::git::{get_diff, open_repo, parse_diff, parse_diff_from_buf}; - -// private submodules. -mod serde_structs; -mod specific_api; - -/// A structure to work with Github REST API. -pub struct GithubApiClient { - /// The HTTP request client to be used for all REST API calls. - client: Client, - - /// The CI run's event payload from the webhook that triggered the workflow. - pull_request: i64, - - /// The name of the event that was triggered when running cpp_linter. - pub event_name: String, - - /// The value of the `GITHUB_API_URL` environment variable. - api_url: Url, - - /// The value of the `GITHUB_REPOSITORY` environment variable. - repo: Option, - - /// The value of the `GITHUB_SHA` environment variable. - sha: Option, - - /// The value of the `ACTIONS_STEP_DEBUG` environment variable. - pub debug_enabled: bool, - - /// The response header names that describe the rate limit status. - rate_limit_headers: RestApiRateLimitHeaders, -} - -// implement the RestApiClient trait for the GithubApiClient -impl RestApiClient for GithubApiClient { - fn set_exit_code( - &self, - checks_failed: u64, - format_checks_failed: Option, - tidy_checks_failed: Option, - ) -> u64 { - if let Ok(gh_out) = env::var("GITHUB_OUTPUT") { - if let Ok(mut gh_out_file) = OpenOptions::new().append(true).open(gh_out) { - for (prompt, value) in [ - ("checks-failed", Some(checks_failed)), - ("format-checks-failed", format_checks_failed), - ("tidy-checks-failed", tidy_checks_failed), - ] { - if let Err(e) = writeln!(gh_out_file, "{prompt}={}", value.unwrap_or(0),) { - log::error!("Could not write to GITHUB_OUTPUT file: {}", e); - break; - } - } - if let Err(e) = gh_out_file.flush() { - log::debug!("Failed to flush buffer to GITHUB_OUTPUT file: {e:?}"); - } - } else { - log::debug!("GITHUB_OUTPUT file could not be opened"); - } - } - log::info!( - "{} clang-format-checks-failed", - format_checks_failed.unwrap_or(0) - ); - log::info!( - "{} clang-tidy-checks-failed", - tidy_checks_failed.unwrap_or(0) - ); - log::info!("{checks_failed} checks-failed"); - checks_failed - } - - /// This prints a line to indicate the beginning of a related group of log statements. - fn start_log_group(&self, name: String) { - log::info!(target: "CI_LOG_GROUPING", "::group::{}", name); - } - - /// This prints a line to indicate the ending of a related group of log statements. - fn end_log_group(&self) { - log::info!(target: "CI_LOG_GROUPING", "::endgroup::"); - } - - fn make_headers() -> Result> { - let mut headers = HeaderMap::new(); - headers.insert( - "Accept", - HeaderValue::from_str("application/vnd.github.raw+json")?, - ); - if let Ok(token) = env::var("GITHUB_TOKEN") { - log::debug!("Using auth token from GITHUB_TOKEN environment variable"); - let mut val = HeaderValue::from_str(format!("token {token}").as_str())?; - val.set_sensitive(true); - headers.insert(AUTHORIZATION, val); - } - Ok(headers) - } - - async fn get_list_of_changed_files( - &self, - file_filter: &FileFilter, - lines_changed_only: &LinesChangedOnly, - diff_base: &Option, - ignore_index: bool, - ) -> Result> { - if env::var("CI").is_ok_and(|val| val.as_str() == "true") - && let Some(repo) = self.repo.as_ref() - { - // get diff from Github REST API - let is_pr = self.event_name == "pull_request"; - let pr = self.pull_request.to_string(); - let sha = self.sha.clone().unwrap_or_default(); - let url = self - .api_url - .join(format!("repos/{repo}/").as_str())? - .join(if is_pr { "pulls/" } else { "commits/" })? - .join(if is_pr { pr.as_str() } else { sha.as_str() })?; - let mut diff_header = HeaderMap::new(); - diff_header.insert("Accept", "application/vnd.github.diff".parse()?); - log::debug!("Getting file changes from {}", url.as_str()); - let request = Self::make_api_request( - &self.client, - url.as_str(), - Method::GET, - None, - Some(diff_header), - )?; - let response = send_api_request(&self.client, request, &self.rate_limit_headers) - .await - .with_context(|| "Failed to get list of changed files.")?; - if response.status().is_success() { - Ok(parse_diff_from_buf( - &response.bytes().await?, - file_filter, - lines_changed_only, - )) - } else { - let endpoint = if is_pr { - Url::parse(format!("{}/files", url.as_str()).as_str())? - } else { - url - }; - Self::log_response(response, "Failed to get full diff for event").await; - log::debug!("Trying paginated request to {}", endpoint.as_str()); - self.get_changed_files_paginated(endpoint, file_filter, lines_changed_only) - .await - } - } else { - // get diff from libgit2 API - let repo = open_repo(".").with_context( - || "Please ensure the repository is checked out before running cpp-linter.", - )?; - let list = parse_diff( - &get_diff(&repo, diff_base, ignore_index)?, - file_filter, - lines_changed_only, - ); - Ok(list) - } - } - - async fn post_feedback( - &self, - files: &[Arc>], - feedback_inputs: FeedbackInput, - clang_versions: ClangVersions, - ) -> Result { - let tidy_checks_failed = tally_tidy_advice(files)?; - let format_checks_failed = tally_format_advice(files)?; - let mut comment = None; - - if feedback_inputs.file_annotations { - self.post_annotations(files, feedback_inputs.style.as_str()); - } - if feedback_inputs.step_summary { - comment = Some(Self::make_comment( - files, - format_checks_failed, - tidy_checks_failed, - &clang_versions, - None, - )); - self.post_step_summary(comment.as_ref().unwrap()); - } - self.set_exit_code( - format_checks_failed + tidy_checks_failed, - Some(format_checks_failed), - Some(tidy_checks_failed), - ); - - if feedback_inputs.thread_comments != ThreadComments::Off { - // post thread comment for PR or push event - if comment.as_ref().is_some_and(|c| c.len() > 65535) || comment.is_none() { - comment = Some(Self::make_comment( - files, - format_checks_failed, - tidy_checks_failed, - &clang_versions, - Some(65535), - )); - } - if let Some(repo) = &self.repo { - let is_pr = self.event_name == "pull_request"; - let pr = self.pull_request.to_string() + "/"; - let sha = self.sha.clone().unwrap() + "/"; - let comments_url = self - .api_url - .join("repos/")? - .join(format!("{}/", repo).as_str())? - .join(if is_pr { "issues/" } else { "commits/" })? - .join(if is_pr { pr.as_str() } else { sha.as_str() })? - .join("comments")?; - - self.update_comment( - comments_url, - &comment.unwrap(), - feedback_inputs.no_lgtm, - format_checks_failed + tidy_checks_failed == 0, - feedback_inputs.thread_comments == ThreadComments::Update, - ) - .await?; - } - } - if self.event_name == "pull_request" - && (feedback_inputs.tidy_review || feedback_inputs.format_review) - { - self.post_review(files, &feedback_inputs, &clang_versions) - .await?; - } - Ok(format_checks_failed + tidy_checks_failed) - } -} - -#[cfg(test)] -mod test { - use std::{ - default::Default, - env, - io::Read, - path::{Path, PathBuf}, - sync::{Arc, Mutex}, - }; - - use regex::Regex; - use semver::Version; - use tempfile::{NamedTempFile, tempdir}; - - use super::GithubApiClient; - use crate::{ - clang_tools::{ - ClangVersions, - clang_format::{FormatAdvice, Replacement}, - clang_tidy::{TidyAdvice, TidyNotification}, - }, - cli::{FeedbackInput, LinesChangedOnly}, - common_fs::{FileFilter, FileObj}, - logger, - rest_api::{RestApiClient, USER_OUTREACH}, - }; - - // ************************* tests for step-summary and output variables - - async fn create_comment( - is_lgtm: bool, - fail_gh_out: bool, - fail_summary: bool, - ) -> (String, String) { - let tmp_dir = tempdir().unwrap(); - let rest_api_client = GithubApiClient::new().unwrap(); - logger::try_init(); - if env::var("ACTIONS_STEP_DEBUG").is_ok_and(|var| var == "true") { - assert!(rest_api_client.debug_enabled); - log::set_max_level(log::LevelFilter::Debug); - } - let mut files = vec![]; - if !is_lgtm { - for _i in 0..65535 { - let filename = String::from("tests/demo/demo.cpp"); - let mut file = FileObj::new(PathBuf::from(&filename)); - let notes = vec![TidyNotification { - filename, - line: 0, - cols: 0, - severity: String::from("note"), - rationale: String::from("A test dummy rationale"), - diagnostic: String::from("clang-diagnostic-warning"), - suggestion: vec![], - fixed_lines: vec![], - }]; - file.tidy_advice = Some(TidyAdvice { - notes, - patched: None, - }); - file.format_advice = Some(FormatAdvice { - replacements: vec![Replacement { offset: 0, line: 1 }], - patched: None, - }); - files.push(Arc::new(Mutex::new(file))); - } - } - let feedback_inputs = FeedbackInput { - style: if is_lgtm { - String::new() - } else { - String::from("file") - }, - step_summary: true, - ..Default::default() - }; - let mut step_summary_path = NamedTempFile::new_in(tmp_dir.path()).unwrap(); - let mut gh_out_path = NamedTempFile::new_in(tmp_dir.path()).unwrap(); - unsafe { - env::set_var( - "GITHUB_STEP_SUMMARY", - if fail_summary { - Path::new("not-a-file.txt") - } else { - step_summary_path.path() - }, - ); - env::set_var( - "GITHUB_OUTPUT", - if fail_gh_out { - Path::new("not-a-file.txt") - } else { - gh_out_path.path() - }, - ); - } - let clang_versions = ClangVersions { - format_version: Some(Version::new(1, 2, 3)), - tidy_version: Some(Version::new(1, 2, 3)), - }; - rest_api_client - .post_feedback(&files, feedback_inputs, clang_versions) - .await - .unwrap(); - let mut step_summary_content = String::new(); - step_summary_path - .read_to_string(&mut step_summary_content) - .unwrap(); - if !fail_summary { - assert!(&step_summary_content.contains(USER_OUTREACH)); - } - let mut gh_out_content = String::new(); - gh_out_path.read_to_string(&mut gh_out_content).unwrap(); - if !fail_gh_out { - assert!(gh_out_content.starts_with("checks-failed=")); - } - (step_summary_content, gh_out_content) - } - - #[tokio::test] - async fn check_comment_concerns() { - let (comment, gh_out) = create_comment(false, false, false).await; - assert!(&comment.contains(":warning:\nSome files did not pass the configured checks!\n")); - let fmt_pattern = Regex::new(r"format-checks-failed=(\d+)\n").unwrap(); - let tidy_pattern = Regex::new(r"tidy-checks-failed=(\d+)\n").unwrap(); - for pattern in [fmt_pattern, tidy_pattern] { - let number = pattern - .captures(&gh_out) - .expect("found no number of checks-failed") - .get(1) - .unwrap() - .as_str() - .parse::() - .unwrap(); - assert!(number > 0); - } - } - - #[tokio::test] - async fn check_comment_lgtm() { - unsafe { - env::set_var("ACTIONS_STEP_DEBUG", "true"); - } - let (comment, gh_out) = create_comment(true, false, false).await; - assert!(comment.contains(":heavy_check_mark:\nNo problems need attention.")); - assert_eq!( - gh_out, - "checks-failed=0\nformat-checks-failed=0\ntidy-checks-failed=0\n" - ); - } - - #[tokio::test] - async fn fail_gh_output() { - unsafe { - env::set_var("ACTIONS_STEP_DEBUG", "true"); - } - let (comment, gh_out) = create_comment(true, true, false).await; - assert!(&comment.contains(":heavy_check_mark:\nNo problems need attention.")); - assert!(gh_out.is_empty()); - } - - #[tokio::test] - async fn fail_gh_summary() { - unsafe { - env::set_var("ACTIONS_STEP_DEBUG", "true"); - } - let (comment, gh_out) = create_comment(true, false, true).await; - assert!(comment.is_empty()); - assert_eq!( - gh_out, - "checks-failed=0\nformat-checks-failed=0\ntidy-checks-failed=0\n" - ); - } - - #[tokio::test] - async fn fail_get_local_diff() { - unsafe { - env::set_var("CI", "false"); - } - let tmp_dir = tempdir().unwrap(); - env::set_current_dir(tmp_dir.path()).unwrap(); - let rest_client = GithubApiClient::new().unwrap(); - let files = rest_client - .get_list_of_changed_files( - &FileFilter::new(&[], vec![]), - &LinesChangedOnly::Off, - &None::, - false, - ) - .await; - assert!(files.is_err()) - } -} diff --git a/cpp-linter/src/rest_api/github/serde_structs.rs b/cpp-linter/src/rest_api/github/serde_structs.rs deleted file mode 100644 index e34e3a09..00000000 --- a/cpp-linter/src/rest_api/github/serde_structs.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! This submodule declares data structures used to -//! deserialize (and serializer) JSON payload data. - -use serde::{Deserialize, Serialize}; - -use crate::clang_tools::Suggestion; -use crate::rest_api::COMMENT_MARKER; - -#[derive(Debug, Serialize)] -pub struct FullReview { - pub event: String, - pub body: String, - pub comments: Vec, -} - -#[derive(Debug, Serialize)] -pub struct ReviewDiffComment { - pub body: String, - pub line: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub start_line: Option, - pub path: String, -} - -impl From for ReviewDiffComment { - fn from(value: Suggestion) -> Self { - Self { - body: format!("{COMMENT_MARKER}{}", value.suggestion), - line: value.line_end as i64, - start_line: if value.line_end != value.line_start { - Some(value.line_start as i64) - } else { - None - }, - path: value.path, - } - } -} - -/// A constant string used as a payload to dismiss PR reviews. -pub const REVIEW_DISMISSAL: &str = r#"{"event":"DISMISS","message":"outdated suggestion"}"#; - -/// A structure for deserializing a single changed file in a CI event. -#[derive(Debug, Deserialize, PartialEq, Clone)] -pub struct GithubChangedFile { - /// The file's name (including relative path to repo root) - pub filename: String, - /// If renamed, this will be the file's old name as a [`Some`], otherwise [`None`]. - pub previous_filename: Option, - /// The individual patch that describes the file's changes. - pub patch: Option, - /// The number of changes to the file contents. - pub changes: i64, -} - -/// A structure for deserializing a Push event's changed files. -#[derive(Debug, Deserialize, PartialEq, Clone)] -pub struct PushEventFiles { - /// The list of changed files. - pub files: Vec, -} - -/// A structure for deserializing a comment from a response's json. -#[derive(Debug, Deserialize, PartialEq, Clone)] -pub struct PullRequestInfo { - /// Is this PR a draft? - pub draft: bool, - /// What is current state of this PR? - /// - /// Here we only care if it is `"open"`. - pub state: String, -} - -/// A structure for deserializing a comment from a response's json. -#[derive(Debug, Deserialize, PartialEq, Clone)] -pub struct ReviewComment { - /// The content of the review's summary comment. - pub body: Option, - /// The review's ID. - pub id: i64, - /// The state of the review in question. - /// - /// This could be "PENDING", "DISMISSED", "APPROVED", or "COMMENT". - pub state: String, -} - -/// A structure for deserializing a comment from a response's json. -#[derive(Debug, Deserialize, PartialEq, Clone)] -pub struct ThreadComment { - /// The comment's ID number. - pub id: i64, - /// The comment's body number. - pub body: String, - /// The comment's user number. - /// - /// This is only used for debug output. - pub user: User, -} - -/// A structure for deserializing a comment's author from a response's json. -/// -/// This is only used for debug output. -#[derive(Debug, Deserialize, PartialEq, Clone)] -pub struct User { - pub login: String, - pub id: u64, -} diff --git a/cpp-linter/src/rest_api/github/specific_api.rs b/cpp-linter/src/rest_api/github/specific_api.rs deleted file mode 100644 index 4f7a17f7..00000000 --- a/cpp-linter/src/rest_api/github/specific_api.rs +++ /dev/null @@ -1,543 +0,0 @@ -//! This submodule implements functionality exclusively specific to Github's REST API. - -use std::{ - collections::HashMap, - env, - fs::OpenOptions, - io::{Read, Write}, - path::{Path, PathBuf}, - sync::{Arc, Mutex}, -}; - -use anyhow::{Context, Result, anyhow}; -use reqwest::{Client, Method, Url}; - -use crate::{ - clang_tools::{ClangVersions, ReviewComments, clang_format::summarize_style}, - cli::{FeedbackInput, LinesChangedOnly}, - common_fs::{FileFilter, FileObj}, - git::parse_diff_from_buf, - rest_api::{COMMENT_MARKER, RestApiRateLimitHeaders, USER_AGENT, send_api_request}, -}; - -use super::{ - GithubApiClient, RestApiClient, - serde_structs::{ - FullReview, GithubChangedFile, PullRequestInfo, PushEventFiles, REVIEW_DISMISSAL, - ReviewComment, ReviewDiffComment, ThreadComment, - }, -}; - -impl GithubApiClient { - /// Instantiate a [`GithubApiClient`] object. - pub fn new() -> Result { - let event_name = env::var("GITHUB_EVENT_NAME").unwrap_or(String::from("unknown")); - let pull_request = { - match event_name.as_str() { - "pull_request" => { - // GITHUB_*** env vars cannot be overwritten in CI runners on GitHub. - let event_payload_path = env::var("GITHUB_EVENT_PATH")?; - // event payload JSON file can be overwritten/removed in CI runners - let file_buf = &mut String::new(); - OpenOptions::new() - .read(true) - .open(event_payload_path.clone())? - .read_to_string(file_buf) - .with_context(|| { - format!("Failed to read event payload at {event_payload_path}") - })?; - let payload = - serde_json::from_str::>( - file_buf, - ) - .with_context(|| "Failed to deserialize event payload")?; - payload["number"].as_i64().unwrap_or(-1) - } - _ => -1, - } - }; - // GITHUB_*** env vars cannot be overwritten in CI runners on GitHub. - let gh_api_url = env::var("GITHUB_API_URL").unwrap_or("https://api.github.com".to_string()); - let api_url = Url::parse(gh_api_url.as_str())?; - - Ok(GithubApiClient { - client: Client::builder() - .default_headers(Self::make_headers()?) - .user_agent(USER_AGENT) - .build()?, - pull_request, - event_name, - api_url, - repo: env::var("GITHUB_REPOSITORY").ok(), - sha: env::var("GITHUB_SHA").ok(), - debug_enabled: env::var("ACTIONS_STEP_DEBUG").is_ok_and(|val| &val == "true"), - rate_limit_headers: RestApiRateLimitHeaders { - reset: "x-ratelimit-reset".to_string(), - remaining: "x-ratelimit-remaining".to_string(), - retry: "retry-after".to_string(), - }, - }) - } - - /// A way to get the list of changed files using REST API calls that employ a paginated response. - /// - /// This is a helper to [`Self::get_list_of_changed_files()`] but takes a formulated `url` - /// endpoint based on the context of the triggering CI event. - pub(super) async fn get_changed_files_paginated( - &self, - url: Url, - file_filter: &FileFilter, - lines_changed_only: &LinesChangedOnly, - ) -> Result> { - let mut url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?); - let mut files = vec![]; - while let Some(ref endpoint) = url { - let request = - Self::make_api_request(&self.client, endpoint.as_str(), Method::GET, None, None)?; - let response = send_api_request(&self.client, request, &self.rate_limit_headers) - .await - .with_context(|| "Failed to get paginated list of changed files")?; - url = Self::try_next_page(response.headers()); - let files_list = if self.event_name != "pull_request" { - let json_value: PushEventFiles = serde_json::from_str(&response.text().await?) - .with_context( - || "Failed to deserialize list of changed files from json response", - )?; - json_value.files - } else { - serde_json::from_str::>(&response.text().await?) - .with_context( - || "Failed to deserialize list of file changes from Pull Request event.", - )? - }; - for file in files_list { - let ext = Path::new(&file.filename).extension().unwrap_or_default(); - if !file_filter - .extensions - .contains(&ext.to_string_lossy().to_string()) - { - continue; - } - if let Some(patch) = file.patch { - let diff = format!( - "diff --git a/{old} b/{new}\n--- a/{old}\n+++ b/{new}\n{patch}\n", - old = file.previous_filename.unwrap_or(file.filename.clone()), - new = file.filename, - ); - if let Some(file_obj) = - parse_diff_from_buf(diff.as_bytes(), file_filter, lines_changed_only) - .first() - { - files.push(file_obj.to_owned()); - } - } else if file.changes == 0 { - // file may have been only renamed. - // include it in case files-changed-only is enabled. - files.push(FileObj::new(PathBuf::from(file.filename))); - } - // else changes are too big or we don't care - } - } - Ok(files) - } - - /// Append step summary to CI workflow's summary page. - pub fn post_step_summary(&self, comment: &String) { - if let Ok(gh_out) = env::var("GITHUB_STEP_SUMMARY") { - // step summary MD file can be overwritten/removed in CI runners - if let Ok(mut gh_out_file) = OpenOptions::new().append(true).open(gh_out) { - if let Err(e) = writeln!(gh_out_file, "\n{}\n", comment) { - log::error!("Could not write to GITHUB_STEP_SUMMARY file: {}", e); - } - } else { - log::error!("GITHUB_STEP_SUMMARY file could not be opened"); - } - } - } - - /// Post file annotations. - pub fn post_annotations(&self, files: &[Arc>], style: &str) { - let style_guide = summarize_style(style); - - // iterate over clang-format advice and post annotations - for file in files { - let file = file.lock().unwrap(); - if let Some(format_advice) = &file.format_advice { - // assemble a list of line numbers - let mut lines = Vec::new(); - for replacement in &format_advice.replacements { - if !lines.contains(&replacement.line) { - lines.push(replacement.line); - } - } - // post annotation if any applicable lines were formatted - if !lines.is_empty() { - println!( - "::notice file={name},title=Run clang-format on {name}::File {name} does not conform to {style_guide} style guidelines. (lines {line_set})", - name = &file.name.to_string_lossy().replace('\\', "/"), - line_set = lines - .iter() - .map(|val| val.to_string()) - .collect::>() - .join(","), - ); - } - } // end format_advice iterations - - // iterate over clang-tidy advice and post annotations - // The tidy_advice vector is parallel to the files vector; meaning it serves as a file filterer. - // lines are already filter as specified to clang-tidy CLI. - if let Some(tidy_advice) = &file.tidy_advice { - for note in &tidy_advice.notes { - if note.filename == file.name.to_string_lossy().replace('\\', "/") { - println!( - "::{severity} file={file},line={line},title={file}:{line}:{cols} [{diag}]::{info}", - severity = if note.severity == *"note" { - "notice".to_string() - } else { - note.severity.clone() - }, - file = note.filename, - line = note.line, - cols = note.cols, - diag = note.diagnostic, - info = note.rationale, - ); - } - } - } - } - } - - /// Update existing comment or remove old comment(s) and post a new comment - pub async fn update_comment( - &self, - url: Url, - comment: &String, - no_lgtm: bool, - is_lgtm: bool, - update_only: bool, - ) -> Result<()> { - let comment_url = self - .remove_bot_comments(&url, !update_only || (is_lgtm && no_lgtm)) - .await?; - if !is_lgtm || !no_lgtm { - let payload = HashMap::from([("body", comment)]); - // log::debug!("payload body:\n{:?}", payload); - let req_meth = if comment_url.is_some() { - Method::PATCH - } else { - Method::POST - }; - let request = Self::make_api_request( - &self.client, - comment_url.unwrap_or(url), - req_meth, - Some(serde_json::json!(&payload).to_string()), - None, - )?; - match send_api_request(&self.client, request, &self.rate_limit_headers).await { - Ok(response) => { - Self::log_response(response, "Failed to post thread comment").await; - } - Err(e) => { - log::error!("Failed to post thread comment: {e:?}"); - } - } - } - Ok(()) - } - - /// Remove thread comments previously posted by cpp-linter. - async fn remove_bot_comments(&self, url: &Url, delete: bool) -> Result> { - let mut comment_url = None; - let mut comments_url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?); - let repo = format!( - "repos/{}{}/comments", - // if we got here, then we know it is on a CI runner as self.repo should be known - self.repo.as_ref().expect("Repo name unknown."), - if self.event_name == "pull_request" { - "/issues" - } else { - "" - }, - ); - let base_comment_url = self.api_url.join(&repo).unwrap(); - while let Some(ref endpoint) = comments_url { - let request = - Self::make_api_request(&self.client, endpoint.as_str(), Method::GET, None, None)?; - let result = send_api_request(&self.client, request, &self.rate_limit_headers).await; - match result { - Err(e) => { - log::error!("Failed to get list of existing thread comments: {e:?}"); - return Ok(comment_url); - } - Ok(response) => { - if !response.status().is_success() { - Self::log_response( - response, - "Failed to get list of existing thread comments", - ) - .await; - return Ok(comment_url); - } - comments_url = Self::try_next_page(response.headers()); - let payload = - serde_json::from_str::>(&response.text().await?); - match payload { - Err(e) => { - log::error!( - "Failed to deserialize list of existing thread comments: {e:?}" - ); - continue; - } - Ok(payload) => { - for comment in payload { - if comment.body.starts_with(COMMENT_MARKER) { - log::debug!( - "Found cpp-linter comment id {} from user {} ({})", - comment.id, - comment.user.login, - comment.user.id, - ); - let this_comment_url = Url::parse( - format!("{base_comment_url}/{}", comment.id).as_str(), - )?; - if delete || comment_url.is_some() { - // if not updating: remove all outdated comments - // if updating: remove all outdated comments except the last one - - // use last saved comment_url (if not None) or current comment url - let del_url = if let Some(last_url) = &comment_url { - last_url - } else { - &this_comment_url - }; - let req = Self::make_api_request( - &self.client, - del_url.as_str(), - Method::DELETE, - None, - None, - )?; - match send_api_request( - &self.client, - req, - &self.rate_limit_headers, - ) - .await - { - Ok(result) => { - if !result.status().is_success() { - Self::log_response( - result, - "Failed to delete old thread comment", - ) - .await; - } - } - Err(e) => { - log::error!( - "Failed to delete old thread comment: {e:?}" - ) - } - } - } - if !delete { - comment_url = Some(this_comment_url) - } - } - } - } - } - } - } - } - Ok(comment_url) - } - - /// Post a PR review with code suggestions. - /// - /// Note: `--no-lgtm` is applied when nothing is suggested. - pub async fn post_review( - &self, - files: &[Arc>], - feedback_input: &FeedbackInput, - clang_versions: &ClangVersions, - ) -> Result<()> { - let url = self - .api_url - .join("repos/")? - .join( - format!( - "{}/", - // if we got here, then we know self.repo should be known - self.repo.as_ref().ok_or(anyhow!("Repo name unknown"))? - ) - .as_str(), - )? - .join("pulls/")? - // if we got here, then we know that it is a self.pull_request is a valid value - .join(self.pull_request.to_string().as_str())?; - let request = Self::make_api_request(&self.client, url.as_str(), Method::GET, None, None)?; - let response = send_api_request(&self.client, request, &self.rate_limit_headers); - - let url = Url::parse(format!("{}/", url).as_str())?.join("reviews")?; - let dismissal = self.dismiss_outdated_reviews(&url); - match response.await { - Ok(response) => { - match serde_json::from_str::(&response.text().await?) { - Err(e) => { - log::error!("Failed to deserialize PR info: {e:?}"); - return dismissal.await; - } - Ok(pr_info) => { - if pr_info.draft || pr_info.state != "open" { - return dismissal.await; - } - } - } - } - Err(e) => { - log::error!("Failed to get PR info from {e:?}"); - return dismissal.await; - } - } - - let summary_only = ["true", "on", "1"].contains( - &env::var("CPP_LINTER_PR_REVIEW_SUMMARY_ONLY") - .unwrap_or("false".to_string()) - .as_str(), - ); - - let mut review_comments = ReviewComments::default(); - for file in files { - let file = file.lock().unwrap(); - file.make_suggestions_from_patch(&mut review_comments, summary_only)?; - } - let has_no_changes = - review_comments.full_patch[0].is_empty() && review_comments.full_patch[1].is_empty(); - if has_no_changes && feedback_input.no_lgtm { - log::debug!("Not posting an approved review because `no-lgtm` is true"); - return dismissal.await; - } - let mut payload = FullReview { - event: if feedback_input.passive_reviews { - String::from("COMMENT") - } else if has_no_changes && review_comments.comments.is_empty() { - // if patches have no changes AND there are no comments about clang-tidy diagnostics - String::from("APPROVE") - } else { - String::from("REQUEST_CHANGES") - }, - body: String::new(), - comments: vec![], - }; - payload.body = review_comments.summarize(clang_versions); - if !summary_only { - payload.comments = { - let mut comments = vec![]; - for comment in review_comments.comments { - comments.push(ReviewDiffComment::from(comment)); - } - comments - }; - } - dismissal.await?; // free up the `url` variable - let request = Self::make_api_request( - &self.client, - url, - Method::POST, - Some( - serde_json::to_string(&payload) - .with_context(|| "Failed to serialize PR review to json string")?, - ), - None, - )?; - match send_api_request(&self.client, request, &self.rate_limit_headers).await { - Ok(response) => { - if !response.status().is_success() { - Self::log_response(response, "Failed to post a new PR review").await; - } - } - Err(e) => { - log::error!("Failed to post a new PR review: {e:?}"); - } - } - Ok(()) - } - - /// Dismiss any outdated reviews generated by cpp-linter. - async fn dismiss_outdated_reviews(&self, url: &Url) -> Result<()> { - let mut url_ = Some(Url::parse_with_params(url.as_str(), [("page", "1")])?); - while let Some(ref endpoint) = url_ { - let request = - Self::make_api_request(&self.client, endpoint.as_str(), Method::GET, None, None)?; - let result = send_api_request(&self.client, request, &self.rate_limit_headers).await; - match result { - Err(e) => { - log::error!("Failed to get a list of existing PR reviews: {e:?}"); - return Ok(()); - } - Ok(response) => { - if !response.status().is_success() { - Self::log_response(response, "Failed to get a list of existing PR reviews") - .await; - return Ok(()); - } - url_ = Self::try_next_page(response.headers()); - match serde_json::from_str::>(&response.text().await?) { - Err(e) => { - log::error!("Unable to serialize JSON about review comments: {e:?}"); - return Ok(()); - } - Ok(payload) => { - for review in payload { - if let Some(body) = &review.body - && body.starts_with(COMMENT_MARKER) - && !(["PENDING", "DISMISSED"].contains(&review.state.as_str())) - { - // dismiss outdated review - if let Ok(dismiss_url) = url - .join(format!("reviews/{}/dismissals", review.id).as_str()) - && let Ok(req) = Self::make_api_request( - &self.client, - dismiss_url, - Method::PUT, - Some(REVIEW_DISMISSAL.to_string()), - None, - ) - { - match send_api_request( - &self.client, - req, - &self.rate_limit_headers, - ) - .await - { - Ok(result) => { - if !result.status().is_success() { - Self::log_response( - result, - "Failed to dismiss outdated review", - ) - .await; - } - } - Err(e) => { - log::error!( - "Failed to dismiss outdated review: {e:}" - ); - } - } - } - } - } - } - } - } - } - } - Ok(()) - } -} diff --git a/cpp-linter/src/rest_api/mod.rs b/cpp-linter/src/rest_api/mod.rs deleted file mode 100644 index 9f1634e1..00000000 --- a/cpp-linter/src/rest_api/mod.rs +++ /dev/null @@ -1,662 +0,0 @@ -//! This module is the home of functionality that uses the REST API of various git-based -//! servers. -//! -//! Currently, only Github is supported. - -use std::fmt::{Debug, Display}; -use std::future::Future; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -// non-std crates -use anyhow::{Error, Result, anyhow}; -use chrono::DateTime; -use reqwest::header::{HeaderMap, HeaderValue}; -use reqwest::{Client, IntoUrl, Method, Request, Response, Url}; - -// project specific modules -pub mod github; -use crate::clang_tools::ClangVersions; -use crate::cli::{FeedbackInput, LinesChangedOnly}; -use crate::common_fs::{FileFilter, FileObj}; - -/// The comment marker used to identify bot comments from other comments (from users or other bots). -pub static COMMENT_MARKER: &str = "\n"; - -/// The user outreach message displayed in bot comments. -pub static USER_OUTREACH: &str = concat!( - "\n\nHave any feedback or feature suggestions? [Share it here.]", - "(https://github.com/cpp-linter/cpp-linter-action/issues)" -); - -/// The user agent string used for HTTP requests. -pub static USER_AGENT: &str = concat!("cpp-linter/", env!("CARGO_PKG_VERSION")); - -/// A structure to contain the different forms of headers that -/// describe a REST API's rate limit status. -#[derive(Debug, Clone)] -pub struct RestApiRateLimitHeaders { - /// The header key of the rate limit's reset time. - pub reset: String, - /// The header key of the rate limit's remaining attempts. - pub remaining: String, - /// The header key of the rate limit's "backoff" time interval. - pub retry: String, -} - -/// A custom trait that templates necessary functionality with a Git server's REST API. -pub trait RestApiClient { - /// A way to set output variables specific to cpp_linter executions in CI. - fn set_exit_code( - &self, - checks_failed: u64, - format_checks_failed: Option, - tidy_checks_failed: Option, - ) -> u64; - - /// This prints a line to indicate the beginning of a related group of log statements. - fn start_log_group(&self, name: String); - - /// This prints a line to indicate the ending of a related group of log statements. - fn end_log_group(&self); - - /// A convenience method to create the headers attached to all REST API calls. - /// - /// If an authentication token is provided (via environment variable), - /// this method shall include the relative information. - fn make_headers() -> Result>; - - /// Construct a HTTP request to be sent. - /// - /// The idea here is that this method is called before [`send_api_request()`]. - /// ```ignore - /// let request = Self::make_api_request( - /// &self.client, - /// "https://example.com", - /// Method::GET, - /// None, - /// None - /// ); - /// let response = send_api_request(&self.client, request, &self.rest_api_headers); - /// match response.await { - /// Ok(res) => {/* handle response */} - /// Err(e) => {/* handle failure */} - /// } - /// ``` - fn make_api_request( - client: &Client, - url: impl IntoUrl, - method: Method, - data: Option, - headers: Option, - ) -> Result { - let mut req = client.request(method, url); - if let Some(h) = headers { - req = req.headers(h); - } - if let Some(d) = data { - req = req.body(d); - } - // RequestBuilder only fails to `build()` if there is a malformed `url`. We - // should be safe here because of this function's `url` parameter type. - req.build().map_err(Error::from) - } - - /// A way to get the list of changed files using REST API calls. It is this method's - /// job to parse diff blobs and return a list of changed files. - /// - /// The context of the file changes are subject to the type of event in which - /// cpp_linter package is used. - /// - /// See [`get_diff()`](crate::git::get_diff()) for explanation of - /// `diff_base` and `ignore_index` parameters, which only applies to a - /// local (non-CI) environment. - fn get_list_of_changed_files( - &self, - file_filter: &FileFilter, - lines_changed_only: &LinesChangedOnly, - diff_base: &Option, - ignore_index: bool, - ) -> impl Future>>; - - /// Makes a comment in MarkDown syntax based on the concerns in `format_advice` and - /// `tidy_advice` about the given set of `files`. - /// - /// This method has a default definition and should not need to be redefined by - /// implementors. - /// - /// Returns the markdown comment as a string as well as the total count of - /// `format_checks_failed` and `tidy_checks_failed` (in respective order). - fn make_comment( - files: &[Arc>], - format_checks_failed: u64, - tidy_checks_failed: u64, - clang_versions: &ClangVersions, - max_len: Option, - ) -> String { - let mut comment = format!("{COMMENT_MARKER}# Cpp-Linter Report "); - let mut remaining_length = - max_len.unwrap_or(u64::MAX) - comment.len() as u64 - USER_OUTREACH.len() as u64; - - if format_checks_failed > 0 || tidy_checks_failed > 0 { - let prompt = ":warning:\nSome files did not pass the configured checks!\n"; - remaining_length -= prompt.len() as u64; - comment.push_str(prompt); - if format_checks_failed > 0 { - make_format_comment( - files, - &mut comment, - format_checks_failed, - // tidy_version should be `Some()` value at this point. - &clang_versions.tidy_version.as_ref().unwrap().to_string(), - &mut remaining_length, - ); - } - if tidy_checks_failed > 0 { - make_tidy_comment( - files, - &mut comment, - tidy_checks_failed, - // format_version should be `Some()` value at this point. - &clang_versions.format_version.as_ref().unwrap().to_string(), - &mut remaining_length, - ); - } - } else { - comment.push_str(":heavy_check_mark:\nNo problems need attention."); - } - comment.push_str(USER_OUTREACH); - comment - } - - /// A way to post feedback in the form of `thread_comments`, `file_annotations`, and - /// `step_summary`. - /// - /// The given `files` should've been gathered from `get_list_of_changed_files()` or - /// `list_source_files()`. - /// - /// The `format_advice` and `tidy_advice` should be a result of parsing output from - /// clang-format and clang-tidy (see `capture_clang_tools_output()`). - /// - /// All other parameters correspond to CLI arguments. - fn post_feedback( - &self, - files: &[Arc>], - user_inputs: FeedbackInput, - clang_versions: ClangVersions, - ) -> impl Future>; - - /// Gets the URL for the next page in a paginated response. - /// - /// Returns [`None`] if current response is the last page. - fn try_next_page(headers: &HeaderMap) -> Option { - if let Some(links) = headers.get("link") - && let Ok(pg_str) = links.to_str() - { - let pages = pg_str.split(", "); - for page in pages { - if page.ends_with("; rel=\"next\"") { - if let Some(link) = page.split_once(">;") { - let url = link.0.trim_start_matches("<").to_string(); - if let Ok(next) = Url::parse(&url) { - return Some(next); - } else { - log::debug!("Failed to parse next page link from response header"); - } - } else { - log::debug!("Response header link for pagination is malformed"); - } - } - } - } - None - } - - fn log_response(response: Response, context: &str) -> impl Future + Send { - async move { - if let Err(e) = response.error_for_status_ref() { - log::error!("{}: {e:?}", context.to_owned()); - if let Ok(text) = response.text().await { - log::error!("{text}"); - } - } - } - } -} - -const MAX_RETRIES: u8 = 5; - -/// A convenience function to send HTTP requests and respect a REST API rate limits. -/// -/// This method respects both primary and secondary rate limits. -/// In the event where the secondary rate limits is reached, -/// this function will wait for a time interval specified the server and retry afterward. -pub async fn send_api_request( - client: &Client, - request: Request, - rate_limit_headers: &RestApiRateLimitHeaders, -) -> Result { - for i in 0..MAX_RETRIES { - let result = client - .execute(request.try_clone().ok_or(anyhow!( - "Failed to clone request object for recursive behavior" - ))?) - .await; - if let Ok(response) = &result { - if [403u16, 429u16].contains(&response.status().as_u16()) { - // rate limit may have been exceeded - - // check if primary rate limit was violated; panic if so. - let mut requests_remaining = None; - if let Some(remaining) = response.headers().get(&rate_limit_headers.remaining) { - if let Ok(count) = remaining.to_str() { - if let Ok(value) = count.parse::() { - requests_remaining = Some(value); - } else { - log::debug!( - "Failed to parse i64 from remaining attempts about rate limit: {count}" - ); - } - } - } else { - // NOTE: I guess it is sometimes valid for a request to - // not include remaining rate limit attempts - log::debug!("Response headers do not include remaining API usage count"); - } - if requests_remaining.is_some_and(|v| v <= 0) { - if let Some(reset_value) = response.headers().get(&rate_limit_headers.reset) { - if let Ok(epoch) = reset_value.to_str() { - if let Ok(value) = epoch.parse::() { - if let Some(reset) = DateTime::from_timestamp(value, 0) { - return Err(anyhow!( - "REST API rate limit exceeded! Resets at {}", - reset - )); - } - } else { - log::debug!( - "Failed to parse i64 from reset time about rate limit: {epoch}" - ); - } - } - } else { - log::debug!("Response headers does not include a reset timestamp"); - } - return Err(anyhow!("REST API rate limit exceeded!")); - } - - // check if secondary rate limit is violated; backoff and try again. - if let Some(retry_value) = response.headers().get(&rate_limit_headers.retry) { - if let Ok(retry_str) = retry_value.to_str() { - if let Ok(retry) = retry_str.parse::() { - let interval = Duration::from_secs(retry + (i as u64).pow(2)); - tokio::time::sleep(interval).await; - } else { - log::debug!( - "Failed to parse u64 from retry interval about rate limit: {retry_str}" - ); - } - } - continue; - } - } - return result.map_err(Error::from); - } - return result.map_err(Error::from); - } - Err(anyhow!( - "REST API secondary rate limit exceeded after {MAX_RETRIES} retries." - )) -} - -fn make_format_comment( - files: &[Arc>], - comment: &mut String, - format_checks_failed: u64, - version_used: &String, - remaining_length: &mut u64, -) { - let opener = format!( - "\n
clang-format (v{version_used}) reports: {format_checks_failed} file(s) not formatted\n\n", - ); - let closer = String::from("\n
"); - let mut format_comment = String::new(); - *remaining_length -= opener.len() as u64 + closer.len() as u64; - for file in files { - let file = file.lock().unwrap(); - if let Some(format_advice) = &file.format_advice - && !format_advice.replacements.is_empty() - && *remaining_length > 0 - { - let note = format!("- {}\n", file.name.to_string_lossy().replace('\\', "/")); - if (note.len() as u64) < *remaining_length { - format_comment.push_str(¬e.to_string()); - *remaining_length -= note.len() as u64; - } - } - } - comment.push_str(&opener); - comment.push_str(&format_comment); - comment.push_str(&closer); -} - -fn make_tidy_comment( - files: &[Arc>], - comment: &mut String, - tidy_checks_failed: u64, - version_used: &String, - remaining_length: &mut u64, -) { - let opener = format!( - "\n
clang-tidy (v{version_used}) reports: {tidy_checks_failed} concern(s)\n\n" - ); - let closer = String::from("\n
"); - let mut tidy_comment = String::new(); - *remaining_length -= opener.len() as u64 + closer.len() as u64; - for file in files { - let file = file.lock().unwrap(); - if let Some(tidy_advice) = &file.tidy_advice { - for tidy_note in &tidy_advice.notes { - let file_path = PathBuf::from(&tidy_note.filename); - if file_path == file.name { - let mut tmp_note = format!("- {}\n\n", tidy_note.filename); - tmp_note.push_str(&format!( - " {filename}:{line}:{cols}: {severity}: [{diagnostic}]\n > {rationale}\n{concerned_code}", - filename = tidy_note.filename, - line = tidy_note.line, - cols = tidy_note.cols, - severity = tidy_note.severity, - diagnostic = tidy_note.diagnostic_link(), - rationale = tidy_note.rationale, - concerned_code = if tidy_note.suggestion.is_empty() {String::from("")} else { - format!("\n ```{ext}\n {suggestion}\n ```\n", - ext = file_path.extension().unwrap_or_default().to_string_lossy(), - suggestion = tidy_note.suggestion.join("\n "), - ).to_string() - }, - ).to_string()); - - if (tmp_note.len() as u64) < *remaining_length { - tidy_comment.push_str(&tmp_note); - *remaining_length -= tmp_note.len() as u64; - } - } - } - } - } - comment.push_str(&opener); - comment.push_str(&tidy_comment); - comment.push_str(&closer); -} - -/// This module tests the silent errors' debug logs -/// from `try_next_page()` and `send_api_request()` functions. -#[cfg(test)] -mod test { - use std::fmt::Display; - use std::sync::{Arc, Mutex}; - - use anyhow::{Result, anyhow}; - use chrono::Utc; - use mockito::{Matcher, Server}; - use reqwest::Method; - use reqwest::{ - Client, - header::{HeaderMap, HeaderValue}, - }; - - use crate::cli::LinesChangedOnly; - use crate::{ - clang_tools::ClangVersions, - cli::FeedbackInput, - common_fs::{FileFilter, FileObj}, - logger, - }; - - use super::{RestApiClient, RestApiRateLimitHeaders, send_api_request}; - - /// A dummy struct to impl RestApiClient - #[derive(Default)] - struct TestClient {} - - impl RestApiClient for TestClient { - fn set_exit_code( - &self, - _checks_failed: u64, - _format_checks_failed: Option, - _tidy_checks_failed: Option, - ) -> u64 { - 0 - } - - fn make_headers() -> Result> { - Err(anyhow!("Not implemented")) - } - - async fn get_list_of_changed_files( - &self, - _file_filter: &FileFilter, - _lines_changed_only: &LinesChangedOnly, - _diff_base: &Option, - _ignore_index: bool, - ) -> Result> { - Err(anyhow!("Not implemented")) - } - - async fn post_feedback( - &self, - _files: &[Arc>], - _user_inputs: FeedbackInput, - _clang_versions: ClangVersions, - ) -> Result { - Err(anyhow!("Not implemented")) - } - - fn start_log_group(&self, name: String) { - log::info!(target: "CI_LOG_GROUPING", "start_log_group: {name}"); - } - - fn end_log_group(&self) { - log::info!(target: "CI_LOG_GROUPING", "end_log_group"); - } - } - - #[tokio::test] - async fn dummy_coverage() { - assert!(TestClient::make_headers().is_err()); - let dummy = TestClient::default(); - dummy.start_log_group("Dummy test".to_string()); - assert_eq!(dummy.set_exit_code(1, None, None), 0); - assert!( - dummy - .get_list_of_changed_files( - &FileFilter::new(&[], vec![]), - &LinesChangedOnly::Off, - &None::, - false - ) - .await - .is_err() - ); - assert!( - dummy - .post_feedback(&[], FeedbackInput::default(), ClangVersions::default()) - .await - .is_err() - ); - dummy.end_log_group(); - } - - // ************************************************* try_next_page() tests - - #[test] - fn bad_link_header() { - let mut headers = HeaderMap::with_capacity(1); - assert!( - headers - .insert("link", HeaderValue::from_str("; rel=\"next\"").unwrap()) - .is_none() - ); - logger::try_init(); - log::set_max_level(log::LevelFilter::Debug); - let result = TestClient::try_next_page(&headers); - assert!(result.is_none()); - } - - #[test] - fn bad_link_domain() { - let mut headers = HeaderMap::with_capacity(1); - assert!( - headers - .insert( - "link", - HeaderValue::from_str("; rel=\"next\"").unwrap() - ) - .is_none() - ); - logger::try_init(); - log::set_max_level(log::LevelFilter::Debug); - let result = TestClient::try_next_page(&headers); - assert!(result.is_none()); - } - - // ************************************************* Rate Limit Tests - - #[derive(Default)] - struct RateLimitTestParams { - secondary: bool, - has_remaining_count: bool, - bad_remaining_count: bool, - has_reset_timestamp: bool, - bad_reset_timestamp: bool, - has_retry_interval: bool, - bad_retry_interval: bool, - } - - async fn simulate_rate_limit(test_params: &RateLimitTestParams) { - let rate_limit_headers = RestApiRateLimitHeaders { - reset: "reset".to_string(), - remaining: "remaining".to_string(), - retry: "retry".to_string(), - }; - logger::try_init(); - log::set_max_level(log::LevelFilter::Debug); - - let mut server = Server::new_async().await; - let client = Client::new(); - let reset_timestamp = (Utc::now().timestamp() + 60).to_string(); - let mut mock = server - .mock("GET", "/") - .match_body(Matcher::Any) - .expect_at_least(1) - .expect_at_most(5) - .with_status(429); - if test_params.has_remaining_count { - mock = mock.with_header( - &rate_limit_headers.remaining, - if test_params.secondary { - "1" - } else if test_params.bad_remaining_count { - "X" - } else { - "0" - }, - ); - } - if test_params.has_reset_timestamp { - mock = mock.with_header( - &rate_limit_headers.reset, - if test_params.bad_reset_timestamp { - "X" - } else { - &reset_timestamp - }, - ); - } - if test_params.secondary && test_params.has_retry_interval { - mock.with_header( - &rate_limit_headers.retry, - if test_params.bad_retry_interval { - "X" - } else { - "0" - }, - ) - .create(); - } else { - mock.create(); - } - let request = - TestClient::make_api_request(&client, server.url(), Method::GET, None, None).unwrap(); - send_api_request(&client, request, &rate_limit_headers) - .await - .unwrap(); - } - - #[tokio::test] - #[should_panic(expected = "REST API secondary rate limit exceeded")] - async fn rate_limit_secondary() { - simulate_rate_limit(&RateLimitTestParams { - secondary: true, - has_retry_interval: true, - ..Default::default() - }) - .await; - } - - #[tokio::test] - #[should_panic(expected = "REST API secondary rate limit exceeded")] - async fn rate_limit_bad_retry() { - simulate_rate_limit(&RateLimitTestParams { - secondary: true, - has_retry_interval: true, - bad_retry_interval: true, - ..Default::default() - }) - .await; - } - - #[tokio::test] - #[should_panic(expected = "REST API rate limit exceeded!")] - async fn rate_limit_primary() { - simulate_rate_limit(&RateLimitTestParams { - has_remaining_count: true, - has_reset_timestamp: true, - ..Default::default() - }) - .await; - } - - #[tokio::test] - #[should_panic(expected = "REST API rate limit exceeded!")] - async fn rate_limit_no_reset() { - simulate_rate_limit(&RateLimitTestParams { - has_remaining_count: true, - ..Default::default() - }) - .await; - } - - #[tokio::test] - #[should_panic(expected = "REST API rate limit exceeded!")] - async fn rate_limit_bad_reset() { - simulate_rate_limit(&RateLimitTestParams { - has_remaining_count: true, - has_reset_timestamp: true, - bad_reset_timestamp: true, - ..Default::default() - }) - .await; - } - - #[tokio::test] - async fn rate_limit_bad_count() { - simulate_rate_limit(&RateLimitTestParams { - has_remaining_count: true, - bad_remaining_count: true, - ..Default::default() - }) - .await; - } -} diff --git a/cpp-linter/src/rest_client/mod.rs b/cpp-linter/src/rest_client/mod.rs new file mode 100644 index 00000000..dc1f5c18 --- /dev/null +++ b/cpp-linter/src/rest_client/mod.rs @@ -0,0 +1,570 @@ +use std::{ + env, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use git_bot_feedback::{ + AnnotationLevel, CommentKind, CommentPolicy, FileAnnotation, FileFilter, LinesChangedOnly, + OutputVariable, RestApiClient, ReviewAction, ReviewOptions, ThreadCommentOptions, + client::init_client, +}; + +use crate::{ + clang_tools::{ + ClangVersions, ReviewComments, + clang_format::{summarize_style, tally_format_advice}, + clang_tidy::tally_tidy_advice, + }, + cli::{FeedbackInput, ThreadComments}, + common_fs::FileObj, + error::ClientError, +}; + +/// The comment marker used to identify bot comments from other comments (from users or other bots). +pub const COMMENT_MARKER: &str = "\n"; + +/// The UserAgent header value used in HTTP requests. +pub const USER_AGENT: &str = concat!("cpp-linter/", env!("CARGO_PKG_VERSION"),); + +/// The user outreach message displayed in bot comments. +pub const USER_OUTREACH: &str = concat!( + "\n\nHave any feedback or feature suggestions? [Share it here.]", + "(https://github.com/cpp-linter/cpp-linter-action/issues)" +); + +pub struct RestClient { + client: Box, +} + +impl RestClient { + pub fn new() -> Result { + let mut client = init_client()?; + client.set_user_agent(USER_AGENT)?; + Ok(Self { client }) + } + + pub fn is_pr(&self) -> bool { + self.client.is_pr_event() + } + + pub async fn get_list_of_changed_files( + &self, + file_filter: &FileFilter, + lines_changed_only: &LinesChangedOnly, + base_diff: &Option, + ignore_index: bool, + ) -> Result, ClientError> { + let files = self + .client + .get_list_of_changed_files( + file_filter, + lines_changed_only, + base_diff.to_owned(), + ignore_index, + ) + .await?; + Ok(files + .iter() + .map(|(file_name, diff_lines)| { + let diff_chunks = diff_lines + .diff_hunks + .iter() + .map(|hunk| hunk.start..=hunk.end) + .collect(); + FileObj::from( + PathBuf::from(&file_name), + diff_lines.added_lines.clone(), + diff_chunks, + ) + }) + .collect()) + } + + pub fn start_log_group(&self, name: &str) { + self.client.start_log_group(name) + } + + pub fn end_log_group(&self, name: &str) { + self.client.end_log_group(name) + } + + pub async fn post_feedback( + &mut self, + files: &[Arc>], + feedback_inputs: FeedbackInput, + clang_versions: ClangVersions, + ) -> Result { + let tidy_checks_failed = tally_tidy_advice(files).map_err(ClientError::MutexPoisoned)?; + let format_checks_failed = + tally_format_advice(files).map_err(ClientError::MutexPoisoned)?; + let mut comment = None; + + if feedback_inputs.file_annotations { + let annotations = Self::make_annotations(files, &feedback_inputs.style)?; + self.client.write_file_annotations(&annotations)?; + } + if feedback_inputs.step_summary { + comment = Some(Self::make_comment( + files, + format_checks_failed, + tidy_checks_failed, + &clang_versions, + None, + )); + self.client.append_step_summary(comment.as_ref().unwrap())?; + } + let output_vars = [ + OutputVariable { + name: "checks-failed".to_string(), + value: format!("{}", format_checks_failed + tidy_checks_failed), + }, + OutputVariable { + name: "format-checks-failed".to_string(), + value: format_checks_failed.to_string(), + }, + OutputVariable { + name: "tidy-checks-failed".to_string(), + value: tidy_checks_failed.to_string(), + }, + ]; + self.client.write_output_variables(&output_vars)?; + + if feedback_inputs.thread_comments != ThreadComments::Off { + // post thread comment for PR or push event + if comment.as_ref().is_none_or(|c| c.len() > 65535) { + comment = Some(Self::make_comment( + files, + format_checks_failed, + tidy_checks_failed, + &clang_versions, + Some(65535), + )); + } + let options = ThreadCommentOptions { + policy: match feedback_inputs.thread_comments { + ThreadComments::Update => CommentPolicy::Update, + ThreadComments::On => CommentPolicy::Anew, + ThreadComments::Off => unreachable!(), + }, + comment: comment.unwrap_or_default(), + kind: if format_checks_failed == 0 && tidy_checks_failed == 0 { + CommentKind::Lgtm + } else { + CommentKind::Concerns + }, + marker: COMMENT_MARKER.to_string(), + no_lgtm: feedback_inputs.no_lgtm, + }; + self.client.post_thread_comment(options).await?; + } + if self.client.is_pr_event() + && (feedback_inputs.tidy_review || feedback_inputs.format_review) + { + let summary_only = ["true", "on", "1"].contains( + &env::var("CPP_LINTER_PR_REVIEW_SUMMARY_ONLY") + .unwrap_or("false".to_string()) + .as_str(), + ); + let mut review_comments = ReviewComments::default(); + for file in files { + let file = file + .lock() + .map_err(|e| ClientError::MutexPoisoned(e.to_string()))?; + file.make_suggestions_from_patch(&mut review_comments, summary_only)?; + } + + let mut options = ReviewOptions { + marker: COMMENT_MARKER.to_string(), + comments: { + let mut comments = vec![]; + for suggestion in &review_comments.comments { + comments.push(suggestion.as_review_comment()); + } + comments + }, + ..Default::default() + }; + + self.client.cull_pr_reviews(&mut options).await?; + let has_changes = review_comments.full_patch.iter().any(|p| !p.is_empty()); + options.action = if feedback_inputs.passive_reviews { + ReviewAction::Comment + } else { + if options.comments.is_empty() && !has_changes { + ReviewAction::Approve + } else { + ReviewAction::RequestChanges + } + }; + options.summary = review_comments.summarize(&clang_versions, &options.comments); + self.client.post_pr_review(&options).await?; + } + Ok(format_checks_failed + tidy_checks_failed) + } + + /// Post file annotations. + pub fn make_annotations( + files: &[Arc>], + style: &str, + ) -> Result, ClientError> { + let style_guide = summarize_style(style); + let mut annotations = vec![]; + + // iterate over clang-format advice and post annotations + for file in files { + let file = file + .lock() + .map_err(|e| ClientError::MutexPoisoned(e.to_string()))?; + if let Some(format_advice) = &file.format_advice { + // assemble a list of line numbers + let mut lines = Vec::new(); + for replacement in &format_advice.replacements { + if !lines.contains(&replacement.line) { + lines.push(replacement.line); + } + } + // post annotation if any applicable lines were formatted + if !lines.is_empty() { + let name = file.name.to_string_lossy().replace('\\', "/"); + let title = format!("Run clang-format on {name}"); + let message = format!( + "File {name} does not conform to {style_guide} style guidelines. (lines {line_set})", + line_set = lines + .iter() + .map(|val| val.to_string()) + .collect::>() + .join(","), + ); + let annotation = FileAnnotation { + severity: AnnotationLevel::Notice, + path: name, + start_line: None, + end_line: None, + start_column: None, + end_column: None, + title: Some(title), + message, + }; + annotations.push(annotation); + } + } // end format_advice iterations + + // iterate over clang-tidy advice and post annotations + // The tidy_advice vector is parallel to the files vector; meaning it serves as a file filterer. + // lines are already filter as specified to clang-tidy CLI. + if let Some(tidy_advice) = &file.tidy_advice { + for note in &tidy_advice.notes { + let path = file.name.to_string_lossy().replace('\\', "/"); + if note.filename == path { + let title = format!("{}:{}:{}", note.filename, note.line, note.cols); + let annotation = FileAnnotation { + severity: match note.severity.as_str() { + "note" => AnnotationLevel::Notice, + "warning" => AnnotationLevel::Warning, + "error" => AnnotationLevel::Error, + _ => AnnotationLevel::Notice, // default to notice if severity is unrecognized + }, + path, + start_line: None, + end_line: Some(note.line as usize), + start_column: None, + end_column: Some(note.cols as usize), + title: Some(title), + message: note.rationale.clone(), + }; + annotations.push(annotation); + } + } + } + } + Ok(annotations) + } + + /// Makes a comment in MarkDown syntax based on the concerns in `format_advice` and + /// `tidy_advice` about the given set of `files`. + /// + /// This method has a default definition and should not need to be redefined by + /// implementors. + /// + /// Returns the markdown comment as a string as well as the total count of + /// `format_checks_failed` and `tidy_checks_failed` (in respective order). + fn make_comment( + files: &[Arc>], + format_checks_failed: u64, + tidy_checks_failed: u64, + clang_versions: &ClangVersions, + max_len: Option, + ) -> String { + let mut comment = format!("{COMMENT_MARKER}# Cpp-Linter Report "); + let mut remaining_length = + max_len.unwrap_or(u64::MAX) - comment.len() as u64 - USER_OUTREACH.len() as u64; + + if format_checks_failed > 0 || tidy_checks_failed > 0 { + let prompt = ":warning:\nSome files did not pass the configured checks!\n"; + remaining_length -= prompt.len() as u64; + comment.push_str(prompt); + if format_checks_failed > 0 { + make_format_comment( + files, + &mut comment, + format_checks_failed, + // tidy_version should be `Some()` value at this point. + &clang_versions.tidy_version.as_ref().unwrap().to_string(), + &mut remaining_length, + ); + } + if tidy_checks_failed > 0 { + make_tidy_comment( + files, + &mut comment, + tidy_checks_failed, + // format_version should be `Some()` value at this point. + &clang_versions.format_version.as_ref().unwrap().to_string(), + &mut remaining_length, + ); + } + } else { + comment.push_str(":heavy_check_mark:\nNo problems need attention."); + } + comment.push_str(USER_OUTREACH); + comment + } +} + +fn make_format_comment( + files: &[Arc>], + comment: &mut String, + format_checks_failed: u64, + version_used: &String, + remaining_length: &mut u64, +) { + let opener = format!( + "\n
clang-format (v{version_used}) reports: {format_checks_failed} file(s) not formatted\n\n", + ); + let closer = String::from("\n
"); + let mut format_comment = String::new(); + *remaining_length -= opener.len() as u64 + closer.len() as u64; + for file in files { + let file = file.lock().unwrap(); + if let Some(format_advice) = &file.format_advice + && !format_advice.replacements.is_empty() + && *remaining_length > 0 + { + let note = format!("- {}\n", file.name.to_string_lossy().replace('\\', "/")); + if (note.len() as u64) < *remaining_length { + format_comment.push_str(¬e.to_string()); + *remaining_length -= note.len() as u64; + } + } + } + comment.push_str(&opener); + comment.push_str(&format_comment); + comment.push_str(&closer); +} + +fn make_tidy_comment( + files: &[Arc>], + comment: &mut String, + tidy_checks_failed: u64, + version_used: &String, + remaining_length: &mut u64, +) { + let opener = format!( + "\n
clang-tidy (v{version_used}) reports: {tidy_checks_failed} concern(s)\n\n" + ); + let closer = String::from("\n
"); + let mut tidy_comment = String::new(); + *remaining_length -= opener.len() as u64 + closer.len() as u64; + for file in files { + let file = file.lock().unwrap(); + if let Some(tidy_advice) = &file.tidy_advice { + for tidy_note in &tidy_advice.notes { + let file_path = PathBuf::from(&tidy_note.filename); + if file_path == file.name { + let mut tmp_note = format!("- {}\n\n", tidy_note.filename); + tmp_note.push_str(&format!( + " {filename}:{line}:{cols}: {severity}: [{diagnostic}]\n > {rationale}\n{concerned_code}", + filename = tidy_note.filename, + line = tidy_note.line, + cols = tidy_note.cols, + severity = tidy_note.severity, + diagnostic = tidy_note.diagnostic_link(), + rationale = tidy_note.rationale, + concerned_code = if tidy_note.suggestion.is_empty() {String::from("")} else { + format!("\n ```{ext}\n {suggestion}\n ```\n", + ext = file_path.extension().unwrap_or_default().to_string_lossy(), + suggestion = tidy_note.suggestion.join("\n "), + ).to_string() + }, + ).to_string()); + + if (tmp_note.len() as u64) < *remaining_length { + tidy_comment.push_str(&tmp_note); + *remaining_length -= tmp_note.len() as u64; + } + } + } + } + } + comment.push_str(&opener); + comment.push_str(&tidy_comment); + comment.push_str(&closer); +} + +#[cfg(all(test, feature = "bin"))] +mod test { + use std::{ + default::Default, + env, + io::Read, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + }; + + use regex::Regex; + use semver::Version; + use tempfile::{NamedTempFile, tempdir}; + + use super::{RestClient, USER_OUTREACH}; + use crate::{ + clang_tools::{ + ClangVersions, + clang_format::{FormatAdvice, Replacement}, + clang_tidy::{TidyAdvice, TidyNotification}, + }, + cli::FeedbackInput, + common_fs::FileObj, + logger, + }; + + // ************************* tests for step-summary and output variables + + async fn create_comment( + is_lgtm: bool, + fail_gh_out: bool, + fail_summary: bool, + ) -> (String, String) { + let tmp_dir = tempdir().unwrap(); + unsafe { + // ensure we are mimicking a CI platform + env::set_var("GITHUB_ACTIONS", "true"); + env::set_var("GITHUB_REPOSITORY", "cpp-linter/cpp-linter-rs"); + env::set_var("GITHUB_SHA", "deadbeef123"); + } + let mut rest_api_client = RestClient::new().unwrap(); + logger::try_init(); + if env::var("ACTIONS_STEP_DEBUG").is_ok_and(|var| var == "true") { + // assert!(rest_api_client.debug_enabled); + log::set_max_level(log::LevelFilter::Debug); + } + let mut files = vec![]; + if !is_lgtm { + for _i in 0..65535 { + let filename = String::from("tests/demo/demo.cpp"); + let mut file = FileObj::new(PathBuf::from(&filename)); + let notes = vec![TidyNotification { + filename, + line: 0, + cols: 0, + severity: String::from("note"), + rationale: String::from("A test dummy rationale"), + diagnostic: String::from("clang-diagnostic-warning"), + suggestion: vec![], + fixed_lines: vec![], + }]; + file.tidy_advice = Some(TidyAdvice { + notes, + patched: None, + }); + file.format_advice = Some(FormatAdvice { + replacements: vec![Replacement { offset: 0, line: 1 }], + patched: None, + }); + files.push(Arc::new(Mutex::new(file))); + } + } + let feedback_inputs = FeedbackInput { + style: if is_lgtm { + String::new() + } else { + String::from("file") + }, + step_summary: true, + ..Default::default() + }; + let mut step_summary_path = NamedTempFile::new_in(tmp_dir.path()).unwrap(); + let mut gh_out_path = NamedTempFile::new_in(tmp_dir.path()).unwrap(); + unsafe { + env::set_var( + "GITHUB_STEP_SUMMARY", + if fail_summary { + Path::new("not-a-file.txt") + } else { + step_summary_path.path() + }, + ); + env::set_var( + "GITHUB_OUTPUT", + if fail_gh_out { + Path::new("not-a-file.txt") + } else { + gh_out_path.path() + }, + ); + } + let clang_versions = ClangVersions { + format_version: Some(Version::new(1, 2, 3)), + tidy_version: Some(Version::new(1, 2, 3)), + }; + rest_api_client + .post_feedback(&files, feedback_inputs, clang_versions) + .await + .unwrap(); + let mut step_summary_content = String::new(); + step_summary_path + .read_to_string(&mut step_summary_content) + .unwrap(); + if !fail_summary { + assert!(&step_summary_content.contains(USER_OUTREACH)); + } + let mut gh_out_content = String::new(); + gh_out_path.read_to_string(&mut gh_out_content).unwrap(); + if !fail_gh_out { + assert!(gh_out_content.starts_with("checks-failed=")); + } + (step_summary_content, gh_out_content) + } + + #[tokio::test] + async fn check_comment_concerns() { + let (comment, gh_out) = create_comment(false, false, false).await; + assert!(&comment.contains(":warning:\nSome files did not pass the configured checks!\n")); + let fmt_pattern = Regex::new(r"format-checks-failed=(\d+)\n").unwrap(); + let tidy_pattern = Regex::new(r"tidy-checks-failed=(\d+)\n").unwrap(); + for pattern in [fmt_pattern, tidy_pattern] { + let number = pattern + .captures(&gh_out) + .expect("found no number of checks-failed") + .get(1) + .unwrap() + .as_str() + .parse::() + .unwrap(); + assert!(number > 0); + } + } + + #[tokio::test] + async fn check_comment_lgtm() { + unsafe { + env::set_var("ACTIONS_STEP_DEBUG", "true"); + } + let (comment, gh_out) = create_comment(true, false, false).await; + assert!(comment.contains(":heavy_check_mark:\nNo problems need attention.")); + assert_eq!( + gh_out, + "checks-failed=0\nformat-checks-failed=0\ntidy-checks-failed=0\n" + ); + } +} diff --git a/cpp-linter/src/run.rs b/cpp-linter/src/run.rs index 6229acd5..0726d48d 100644 --- a/cpp-linter/src/run.rs +++ b/cpp-linter/src/run.rs @@ -6,7 +6,7 @@ #![cfg(feature = "bin")] use std::{ env, - path::Path, + path::{Path, PathBuf}, sync::{Arc, Mutex}, }; @@ -20,10 +20,11 @@ use log::{LevelFilter, set_max_level}; use crate::{ clang_tools::capture_clang_tools_output, cli::{ClangParams, Cli, CliCommand, FeedbackInput, LinesChangedOnly}, - common_fs::FileFilter, + common_fs::FileObj, logger, - rest_api::{RestApiClient, github::GithubApiClient}, + rest_client::RestClient, }; +use git_bot_feedback::FileFilter; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -67,20 +68,31 @@ pub async fn run_main(args: Vec) -> Result<()> { })?; } - let rest_api_client = GithubApiClient::new()?; + let mut rest_api_client = RestClient::new()?; set_max_level( - if cli.general_options.verbosity.is_debug() || rest_api_client.debug_enabled { + if cli.general_options.verbosity.is_debug() + /* || rest_api_client.debug_enabled */ + { LevelFilter::Debug } else { LevelFilter::Info }, ); - log::info!("Processing event {}", rest_api_client.event_name); - let is_pr = rest_api_client.event_name == "pull_request"; + // log::info!("Processing event {}", rest_api_client.event_name); + let is_pr = rest_api_client.is_pr(); let mut file_filter = FileFilter::new( - &cli.source_options.ignore, - cli.source_options.extensions.clone(), + &cli.source_options + .ignore + .iter() + .map(|s| s.as_str()) + .collect::>(), + &cli.source_options + .extensions + .iter() + .map(|s| s.as_str()) + .collect::>(), + None, ); file_filter.parse_submodules(); if let Some(files) = &cli.not_ignored { @@ -100,7 +112,7 @@ pub async fn run_main(args: Vec) -> Result<()> { } } - rest_api_client.start_log_group(String::from("Get list of specified source files")); + rest_api_client.start_log_group("Get list of specified source files"); let files = if !matches!(cli.source_options.lines_changed_only, LinesChangedOnly::Off) || cli.source_options.files_changed_only { @@ -108,19 +120,23 @@ pub async fn run_main(args: Vec) -> Result<()> { rest_api_client .get_list_of_changed_files( &file_filter, - &cli.source_options.lines_changed_only, + &cli.source_options.lines_changed_only.clone().into(), &cli.source_options.diff_base, cli.source_options.ignore_index, ) .await? } else { // walk the folder and look for files with specified extensions according to ignore values. - let mut all_files = file_filter.list_source_files(".")?; + let mut all_files: Vec = file_filter + .walk_dir(".")? + .into_iter() + .map(|file_name| FileObj::new(PathBuf::from(&file_name))) + .collect(); if is_pr && (cli.feedback_options.tidy_review || cli.feedback_options.format_review) { let changed_files = rest_api_client .get_list_of_changed_files( &file_filter, - &LinesChangedOnly::Off, + &LinesChangedOnly::Off.into(), &cli.source_options.diff_base, cli.source_options.ignore_index, ) @@ -143,7 +159,7 @@ pub async fn run_main(args: Vec) -> Result<()> { log::info!(" ./{}", file.name.to_string_lossy().replace('\\', "/")); arc_files.push(Arc::new(Mutex::new(file))); } - rest_api_client.end_log_group(); + rest_api_client.end_log_group("Get list of specified source files"); let mut clang_params = ClangParams::from(&cli); clang_params.format_review &= is_pr; @@ -156,11 +172,11 @@ pub async fn run_main(args: Vec) -> Result<()> { &rest_api_client, ) .await?; - rest_api_client.start_log_group(String::from("Posting feedback")); + rest_api_client.start_log_group("Posting feedback"); let checks_failed = rest_api_client .post_feedback(&arc_files, user_inputs, clang_versions) .await?; - rest_api_client.end_log_group(); + rest_api_client.end_log_group("Posting feedback"); if env::var("PRE_COMMIT").is_ok_and(|v| v == "1") && checks_failed > 1 { return Err(anyhow!("Some checks did not pass")); } diff --git a/cpp-linter/tests/.hidden/test_asset.txt b/cpp-linter/tests/.hidden/test_asset.txt deleted file mode 100644 index 83788f1a..00000000 --- a/cpp-linter/tests/.hidden/test_asset.txt +++ /dev/null @@ -1 +0,0 @@ -This file is here for completeness when testing file filters. diff --git a/cpp-linter/tests/comment_test_assets/patch.diff b/cpp-linter/tests/comment_test_assets/patch.diff deleted file mode 100644 index 3c5dd0b5..00000000 --- a/cpp-linter/tests/comment_test_assets/patch.diff +++ /dev/null @@ -1,108 +0,0 @@ -diff --git a/.github/workflows/cpp-lint-package.yml b/.github/workflows/cpp-lint-package.yml -index 0418957..3b8c454 100644 ---- a/.github/workflows/cpp-lint-package.yml -+++ b/.github/workflows/cpp-lint-package.yml -@@ -7,6 +7,7 @@ on: - description: 'which branch to test' - default: 'main' - required: true -+ pull_request: - - jobs: - cpp-linter: -@@ -14,9 +15,9 @@ jobs: - - strategy: - matrix: -- clang-version: ['7', '8', '9','10', '11', '12', '13', '14', '15', '16', '17'] -+ clang-version: ['10', '11', '12', '13', '14', '15', '16', '17'] - repo: ['cpp-linter/cpp-linter'] -- branch: ['${{ inputs.branch }}'] -+ branch: ['pr-review-suggestions'] - fail-fast: false - - steps: -@@ -62,10 +63,12 @@ jobs: - -i=build - -p=build - -V=${{ runner.temp }}/llvm -- -f=false - --extra-arg="-std=c++14 -Wall" -- --thread-comments=${{ matrix.clang-version == '12' }} -- -a=${{ matrix.clang-version == '12' }} -+ --file-annotations=false -+ --lines-changed-only=true -+ --thread-comments=${{ matrix.clang-version == '16' }} -+ --tidy-review=${{ matrix.clang-version == '16' }} -+ --format-review=${{ matrix.clang-version == '16' }} - - - name: Fail fast?! - if: steps.linter.outputs.checks-failed > 0 -diff --git a/src/demo.cpp b/src/demo.cpp -index 0c1db60..1bf553e 100644 ---- a/src/demo.cpp -+++ b/src/demo.cpp -@@ -1,17 +1,18 @@ - /** This is a very ugly test code (doomed to fail linting) */ - #include "demo.hpp" --#include --#include -+#include - --// using size_t from cstddef --size_t dummyFunc(size_t i) { return i; } - --int main() --{ -- for (;;) -- break; -+ -+ -+int main(){ -+ -+ for (;;) break; -+ - - printf("Hello world!\n"); - -- return 0; --} -+ -+ -+ -+ return 0;} -diff --git a/src/demo.hpp b/src/demo.hpp -index 2695731..f93d012 100644 ---- a/src/demo.hpp -+++ b/src/demo.hpp -@@ -5,12 +5,10 @@ - class Dummy { - char* useless; - int numb; -+ Dummy() :numb(0), useless("\0"){} - - public: -- void *not_usefull(char *str){ -- useless = str; -- return 0; -- } -+ void *not_useful(char *str){useless = str;} - }; - - -@@ -28,14 +26,11 @@ class Dummy { - - - -- -- -- -- - - - struct LongDiff - { -+ - long diff; - - }; diff --git a/cpp-linter/tests/comment_test_assets/pr_comments_pg1.json b/cpp-linter/tests/comment_test_assets/pr_comments_pg1.json index 72b8cfbc..33608181 100644 --- a/cpp-linter/tests/comment_test_assets/pr_comments_pg1.json +++ b/cpp-linter/tests/comment_test_assets/pr_comments_pg1.json @@ -2,7 +2,7 @@ { "url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/issues/comments/1782261434", "html_url": "https://github.com/cpp-linter/test-cpp-linter-action/pull/22#issuecomment-1782261434", - "issue_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/issues/22", + "issue_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/issues/42", "id": 1782261434, "node_id": "IC_kwDOFY2uzM5qOya6", "user": { diff --git a/cpp-linter/tests/comment_test_assets/pr_comments_pg2.json b/cpp-linter/tests/comment_test_assets/pr_comments_pg2.json index 72b8cfbc..33608181 100644 --- a/cpp-linter/tests/comment_test_assets/pr_comments_pg2.json +++ b/cpp-linter/tests/comment_test_assets/pr_comments_pg2.json @@ -2,7 +2,7 @@ { "url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/issues/comments/1782261434", "html_url": "https://github.com/cpp-linter/test-cpp-linter-action/pull/22#issuecomment-1782261434", - "issue_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/issues/22", + "issue_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/issues/42", "id": 1782261434, "node_id": "IC_kwDOFY2uzM5qOya6", "user": { diff --git a/cpp-linter/tests/comment_test_assets/pr_diff.json b/cpp-linter/tests/comment_test_assets/pr_diff.json new file mode 100644 index 00000000..11538a6b --- /dev/null +++ b/cpp-linter/tests/comment_test_assets/pr_diff.json @@ -0,0 +1,74 @@ +[ + { + "sha": "1dd236cb113f5e05df15f34dfd338b3dd8b164cd", + "filename": ".clang-format", + "status": "modified", + "additions": 1, + "deletions": 1, + "changes": 2, + "blob_url": "https://github.com/cpp-linter/test-cpp-linter-action/blob/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/.clang-format", + "raw_url": "https://github.com/cpp-linter/test-cpp-linter-action/raw/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/.clang-format", + "contents_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/contents/.clang-format?ref=01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "patch": "@@ -1,3 +1,3 @@\n ---\n Language: Cpp\n-BasedOnStyle: WebKit\n\\ No newline at end of file\n+BasedOnStyle: WebKit" + }, + { + "sha": "d3865adefcf84328aa3e1015942d8a60680f726a", + "filename": ".clang-tidy", + "status": "modified", + "additions": 1, + "deletions": 0, + "changes": 1, + "blob_url": "https://github.com/cpp-linter/test-cpp-linter-action/blob/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/.clang-tidy", + "raw_url": "https://github.com/cpp-linter/test-cpp-linter-action/raw/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/.clang-tidy", + "contents_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/contents/.clang-tidy?ref=01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "patch": "@@ -183,3 +183,4 @@ CheckOptions:\n value: '1'\n - key: readability-uppercase-literal-suffix.NewSuffixes\n value: ''\n+..." + }, + { + "sha": "7c6ca7ac47687ea5caae171ccd04525c08312bc6", + "filename": ".github/workflows/cpp-lint-action.yml", + "status": "modified", + "additions": 7, + "deletions": 7, + "changes": 14, + "blob_url": "https://github.com/cpp-linter/test-cpp-linter-action/blob/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/.github%2Fworkflows%2Fcpp-lint-action.yml", + "raw_url": "https://github.com/cpp-linter/test-cpp-linter-action/raw/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/.github%2Fworkflows%2Fcpp-lint-action.yml", + "contents_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/contents/.github%2Fworkflows%2Fcpp-lint-action.yml?ref=01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "patch": "@@ -32,7 +32,7 @@ jobs:\n run: mkdir build && cmake -Bbuild src\n \n - name: Run linter as action\n- uses: cpp-linter/cpp-linter-action@latest\n+ uses: cpp-linter/cpp-linter-action@main\n id: linter\n env:\n GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n@@ -42,17 +42,17 @@ jobs:\n # to ignore all build folder contents\n ignore: build\n database: build\n- verbosity: 9\n+ verbosity: debug\n version: ${{ matrix.clang-version }}\n- thread-comments: ${{ matrix.clang-version == '12' }}\n- file-annotations: ${{ matrix.clang-version == '12' }}\n+ thread-comments: ${{ matrix.clang-version == '16' }}\n+ file-annotations: ${{ matrix.clang-version == '16' }}\n extra-args: -std=c++14 -Wall\n \n - name: Fail fast?!\n # if: steps.linter.outputs.checks-failed > 0\n run: | \n- echo \"some linter checks failed\"\n- echo \"${{ steps.linter.outputs.checks-failed }}\"\n- echo \"${{ env.checks-failed }}\"\n+ echo \"checks-failed: ${{ steps.linter.outputs.checks-failed }}\"\n+ echo \"clang-tidy-checks-failed: ${{ steps.linter.outputs.clang-tidy-checks-failed }}\"\n+ echo \"clang-format-checks-failed: ${{ steps.linter.outputs.clang-format-checks-failed }}\"\n # for actual deployment\n # run: exit 1" + }, + { + "sha": "18b133e7997cdace197d391cc01e9b192b446df0", + "filename": ".github/workflows/cpp-lint-package.yml", + "status": "modified", + "additions": 10, + "deletions": 3, + "changes": 13, + "blob_url": "https://github.com/cpp-linter/test-cpp-linter-action/blob/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/.github%2Fworkflows%2Fcpp-lint-package.yml", + "raw_url": "https://github.com/cpp-linter/test-cpp-linter-action/raw/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/.github%2Fworkflows%2Fcpp-lint-package.yml", + "contents_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/contents/.github%2Fworkflows%2Fcpp-lint-package.yml?ref=01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "patch": "@@ -5,8 +5,9 @@ on:\n inputs:\n branch:\n description: 'which branch to test'\n- default: 'main'\n+ default: 'fix-test-coverage'\n required: true\n+ pull_request:\n \n jobs:\n cpp-linter:\n@@ -16,7 +17,8 @@ jobs:\n matrix:\n clang-version: ['7', '8', '9','10', '11', '12', '13', '14', '15', '16', '17']\n repo: ['cpp-linter/cpp-linter']\n- branch: ['${{ inputs.branch }}']\n+ branch:\n+ - ${{ github.event_name == 'workflow_dispatch' && inputs.branch || 'fix-test-coverage' }}\n fail-fast: false\n \n steps:\n@@ -66,9 +68,14 @@ jobs:\n --extra-arg=\"-std=c++14 -Wall\"\n --thread-comments=${{ matrix.clang-version == '17' && 'update' }}\n -a=${{ matrix.clang-version == '17' }}\n+ --tidy-review=${{ matrix.clang-version == '17' }}\n+ --format-review=${{ matrix.clang-version == '17' }}\n \n - name: Fail fast?!\n if: steps.linter.outputs.checks-failed > 0\n- run: echo \"Some files failed the linting checks!\"\n+ run: |\n+ echo \"checks-failed: ${{ steps.linter.outputs.checks-failed }}\"\n+ echo \"clang-tidy-checks-failed: ${{ steps.linter.outputs.clang-tidy-checks-failed }}\"\n+ echo \"clang-format-checks-failed: ${{ steps.linter.outputs.clang-format-checks-failed }}\"\n # for actual deployment\n # run: exit 1" + }, + { + "sha": "1bf553e06e4b7c6c9a9be5da4845acbdeb04f6a5", + "filename": "src/demo.cpp", + "status": "modified", + "additions": 11, + "deletions": 10, + "changes": 21, + "blob_url": "https://github.com/cpp-linter/test-cpp-linter-action/blob/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/src%2Fdemo.cpp", + "raw_url": "https://github.com/cpp-linter/test-cpp-linter-action/raw/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/src%2Fdemo.cpp", + "contents_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/contents/src%2Fdemo.cpp?ref=01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "patch": "@@ -1,17 +1,18 @@\n /** This is a very ugly test code (doomed to fail linting) */\n #include \"demo.hpp\"\n-#include \n-#include \n+#include \n \n-// using size_t from cstddef\n-size_t dummyFunc(size_t i) { return i; }\n \n-int main()\n-{\n- for (;;)\n- break;\n+\n+\n+int main(){\n+\n+ for (;;) break;\n+\n \n printf(\"Hello world!\\n\");\n \n- return 0;\n-}\n+\n+\n+\n+ return 0;}" + }, + { + "sha": "f93d0122ae2e3c1952c795837d71c432036b55eb", + "filename": "src/demo.hpp", + "status": "modified", + "additions": 3, + "deletions": 8, + "changes": 11, + "blob_url": "https://github.com/cpp-linter/test-cpp-linter-action/blob/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/src%2Fdemo.hpp", + "raw_url": "https://github.com/cpp-linter/test-cpp-linter-action/raw/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/src%2Fdemo.hpp", + "contents_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/contents/src%2Fdemo.hpp?ref=01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "patch": "@@ -5,12 +5,10 @@\n class Dummy {\n char* useless;\n int numb;\n+ Dummy() :numb(0), useless(\"\\0\"){}\n \n public:\n- void *not_usefull(char *str){\n- useless = str;\n- return 0;\n- }\n+ void *not_useful(char *str){useless = str;}\n };\n \n \n@@ -28,14 +26,11 @@ class Dummy {\n \n \n \n-\n-\n-\n-\n \n \n struct LongDiff\n {\n+\n long diff;\n \n };" + } +] diff --git a/cpp-linter/tests/comment_test_assets/push_comments_8d68756375e0483c7ac2b4d6bbbece420dbbb495.json b/cpp-linter/tests/comment_test_assets/push_comments.json similarity index 97% rename from cpp-linter/tests/comment_test_assets/push_comments_8d68756375e0483c7ac2b4d6bbbece420dbbb495.json rename to cpp-linter/tests/comment_test_assets/push_comments.json index 13843925..603d5d3a 100644 --- a/cpp-linter/tests/comment_test_assets/push_comments_8d68756375e0483c7ac2b4d6bbbece420dbbb495.json +++ b/cpp-linter/tests/comment_test_assets/push_comments.json @@ -1,7 +1,7 @@ [ { "url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/comments/76453652", - "html_url": "https://github.com/cpp-linter/test-cpp-linter-action/commit/8d68756375e0483c7ac2b4d6bbbece420dbbb495#commitcomment-76453652", + "html_url": "https://github.com/cpp-linter/test-cpp-linter-action/commit/01e2ebf010b949986fa3e6d6e81e7f7c8da90001#commitcomment-76453652", "id": 76453652, "node_id": "CC_kwDOFY2uzM4EjpcU", "user": { @@ -27,7 +27,7 @@ "position": null, "line": null, "path": null, - "commit_id": "8d68756375e0483c7ac2b4d6bbbece420dbbb495", + "commit_id": "01e2ebf010b949986fa3e6d6e81e7f7c8da90001", "created_at": "2022-06-19T12:17:04Z", "updated_at": "2022-06-19T12:17:04Z", "author_association": "NONE", @@ -47,7 +47,7 @@ }, { "url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/comments/76453652", - "html_url": "https://github.com/cpp-linter/test-cpp-linter-action/commit/8d68756375e0483c7ac2b4d6bbbece420dbbb495#commitcomment-76453652", + "html_url": "https://github.com/cpp-linter/test-cpp-linter-action/commit/01e2ebf010b949986fa3e6d6e81e7f7c8da90001#commitcomment-76453652", "id": 76453653, "node_id": "CC_kwDOFY2uzM4EjpcU", "user": { @@ -73,7 +73,7 @@ "position": null, "line": null, "path": null, - "commit_id": "8d68756375e0483c7ac2b4d6bbbece420dbbb495", + "commit_id": "01e2ebf010b949986fa3e6d6e81e7f7c8da90001", "created_at": "2022-06-19T12:17:04Z", "updated_at": "2022-06-19T12:17:04Z", "author_association": "NONE", @@ -93,7 +93,7 @@ }, { "url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/comments/76453652", - "html_url": "https://github.com/cpp-linter/test-cpp-linter-action/commit/8d68756375e0483c7ac2b4d6bbbece420dbbb495#commitcomment-76453652", + "html_url": "https://github.com/cpp-linter/test-cpp-linter-action/commit/01e2ebf010b949986fa3e6d6e81e7f7c8da90001#commitcomment-76453652", "id": 76453652, "node_id": "CC_kwDOFY2uzM4EjpcU", "user": { @@ -119,7 +119,7 @@ "position": null, "line": null, "path": null, - "commit_id": "8d68756375e0483c7ac2b4d6bbbece420dbbb495", + "commit_id": "01e2ebf010b949986fa3e6d6e81e7f7c8da90001", "created_at": "2022-06-19T12:17:04Z", "updated_at": "2022-06-19T12:17:04Z", "author_association": "NONE", diff --git a/cpp-linter/tests/comment_test_assets/push_diff.json b/cpp-linter/tests/comment_test_assets/push_diff.json new file mode 100644 index 00000000..bb6014cb --- /dev/null +++ b/cpp-linter/tests/comment_test_assets/push_diff.json @@ -0,0 +1,113 @@ +{ + "sha": "01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "node_id": "C_kwDOFY2uzNoAKDAxZTJlYmYwMTBiOTQ5OTg2ZmEzZTZkNmU4MWU3ZjdjOGRhOTAwMDE", + "commit": { + "author": { + "name": "Brendan", + "email": "2bndy5@gmail.com", + "date": "2024-12-12T16:04:15Z" + }, + "committer": { + "name": "Brendan", + "email": "2bndy5@gmail.com", + "date": "2024-12-12T16:04:15Z" + }, + "message": "make source changes", + "tree": { + "sha": "c2ce8a3ed0d96bf72e5c4e34857349f0e97f3fb4", + "url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/git/trees/c2ce8a3ed0d96bf72e5c4e34857349f0e97f3fb4" + }, + "url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/git/commits/01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "comment_count": 0, + "verification": { + "verified": false, + "reason": "unsigned", + "signature": null, + "payload": null, + "verified_at": null + } + }, + "url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/commits/01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "html_url": "https://github.com/cpp-linter/test-cpp-linter-action/commit/01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "comments_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/commits/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/comments", + "author": { + "login": "2bndy5", + "id": 14963867, + "node_id": "MDQ6VXNlcjE0OTYzODY3", + "avatar_url": "https://avatars.githubusercontent.com/u/14963867?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/2bndy5", + "html_url": "https://github.com/2bndy5", + "followers_url": "https://api.github.com/users/2bndy5/followers", + "following_url": "https://api.github.com/users/2bndy5/following{/other_user}", + "gists_url": "https://api.github.com/users/2bndy5/gists{/gist_id}", + "starred_url": "https://api.github.com/users/2bndy5/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/2bndy5/subscriptions", + "organizations_url": "https://api.github.com/users/2bndy5/orgs", + "repos_url": "https://api.github.com/users/2bndy5/repos", + "events_url": "https://api.github.com/users/2bndy5/events{/privacy}", + "received_events_url": "https://api.github.com/users/2bndy5/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "committer": { + "login": "2bndy5", + "id": 14963867, + "node_id": "MDQ6VXNlcjE0OTYzODY3", + "avatar_url": "https://avatars.githubusercontent.com/u/14963867?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/2bndy5", + "html_url": "https://github.com/2bndy5", + "followers_url": "https://api.github.com/users/2bndy5/followers", + "following_url": "https://api.github.com/users/2bndy5/following{/other_user}", + "gists_url": "https://api.github.com/users/2bndy5/gists{/gist_id}", + "starred_url": "https://api.github.com/users/2bndy5/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/2bndy5/subscriptions", + "organizations_url": "https://api.github.com/users/2bndy5/orgs", + "repos_url": "https://api.github.com/users/2bndy5/repos", + "events_url": "https://api.github.com/users/2bndy5/events{/privacy}", + "received_events_url": "https://api.github.com/users/2bndy5/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "parents": [ + { + "sha": "d6831a093b4a6f828e49573e8b048729841cd5c7", + "url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/commits/d6831a093b4a6f828e49573e8b048729841cd5c7", + "html_url": "https://github.com/cpp-linter/test-cpp-linter-action/commit/d6831a093b4a6f828e49573e8b048729841cd5c7" + } + ], + "stats": { + "total": 32, + "additions": 14, + "deletions": 18 + }, + "files": [ + { + "sha": "1bf553e06e4b7c6c9a9be5da4845acbdeb04f6a5", + "filename": "src/demo.cpp", + "status": "modified", + "additions": 11, + "deletions": 10, + "changes": 21, + "blob_url": "https://github.com/cpp-linter/test-cpp-linter-action/blob/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/src%2Fdemo.cpp", + "raw_url": "https://github.com/cpp-linter/test-cpp-linter-action/raw/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/src%2Fdemo.cpp", + "contents_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/contents/src%2Fdemo.cpp?ref=01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "patch": "@@ -1,17 +1,18 @@\n /** This is a very ugly test code (doomed to fail linting) */\n #include \"demo.hpp\"\n-#include \n-#include \n+#include \n \n-// using size_t from cstddef\n-size_t dummyFunc(size_t i) { return i; }\n \n-int main()\n-{\n- for (;;)\n- break;\n+\n+\n+int main(){\n+\n+ for (;;) break;\n+\n \n printf(\"Hello world!\\n\");\n \n- return 0;\n-}\n+\n+\n+\n+ return 0;}" + }, + { + "sha": "f93d0122ae2e3c1952c795837d71c432036b55eb", + "filename": "src/demo.hpp", + "status": "modified", + "additions": 3, + "deletions": 8, + "changes": 11, + "blob_url": "https://github.com/cpp-linter/test-cpp-linter-action/blob/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/src%2Fdemo.hpp", + "raw_url": "https://github.com/cpp-linter/test-cpp-linter-action/raw/01e2ebf010b949986fa3e6d6e81e7f7c8da90001/src%2Fdemo.hpp", + "contents_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/contents/src%2Fdemo.hpp?ref=01e2ebf010b949986fa3e6d6e81e7f7c8da90001", + "patch": "@@ -5,12 +5,10 @@\n class Dummy {\n char* useless;\n int numb;\n+ Dummy() :numb(0), useless(\"\\0\"){}\n \n public:\n- void *not_usefull(char *str){\n- useless = str;\n- return 0;\n- }\n+ void *not_useful(char *str){useless = str;}\n };\n \n \n@@ -28,14 +26,11 @@ class Dummy {\n \n \n \n-\n-\n-\n-\n \n \n struct LongDiff\n {\n+\n long diff;\n \n };" + } + ] +} diff --git a/cpp-linter/tests/comments.rs b/cpp-linter/tests/comments.rs index 628278ef..b77aa13d 100644 --- a/cpp-linter/tests/comments.rs +++ b/cpp-linter/tests/comments.rs @@ -1,6 +1,8 @@ +#![cfg(feature = "bin")] use chrono::Utc; -use cpp_linter::cli::{LinesChangedOnly, ThreadComments}; +use cpp_linter::cli::ThreadComments; use cpp_linter::run::run_main; +use git_bot_feedback::LinesChangedOnly; use mockito::Matcher; use std::{env, fmt::Display, io::Write, path::Path}; use tempfile::NamedTempFile; @@ -8,12 +10,11 @@ use tempfile::NamedTempFile; mod common; use common::{create_test_space, mock_server}; -const SHA: &str = "8d68756375e0483c7ac2b4d6bbbece420dbbb495"; +const SHA: &str = "01e2ebf010b949986fa3e6d6e81e7f7c8da90001"; const REPO: &str = "cpp-linter/test-cpp-linter-action"; -const PR: i64 = 22; +const PR: i64 = 42; const TOKEN: &str = "123456"; const MOCK_ASSETS_PATH: &str = "tests/comment_test_assets/"; -const EVENT_PAYLOAD: &str = "{\"number\": 22}"; const RESET_RATE_LIMIT_HEADER: &str = "x-ratelimit-reset"; const REMAINING_RATE_LIMIT_HEADER: &str = "x-ratelimit-remaining"; @@ -63,19 +64,30 @@ impl Default for TestParams { async fn setup(lib_root: &Path, test_params: &TestParams) { let mut event_payload_path = NamedTempFile::new_in("./").unwrap(); + let tmp_out_file = NamedTempFile::new().unwrap(); unsafe { + env::set_var("GITHUB_ACTIONS", "true"); env::set_var( "GITHUB_EVENT_NAME", test_params.event_t.to_string().as_str(), ); - env::remove_var("GITHUB_OUTPUT"); // avoid writing to GH_OUT in parallel-running tests + env::set_var("GITHUB_OUTPUT", tmp_out_file.path()); env::set_var("GITHUB_REPOSITORY", REPO); env::set_var("GITHUB_SHA", SHA); env::set_var("GITHUB_TOKEN", TOKEN); env::set_var("CI", "true"); if test_params.event_t == EventType::PullRequest { + let event_payload = serde_json::json!({ + "pull_request": { + "draft": false, + "state": "open", + "number": PR, + "locked": false, + } + }) + .to_string(); event_payload_path - .write_all(EVENT_PAYLOAD.as_bytes()) + .write_all(event_payload.as_bytes()) .expect("Failed to create mock event payload."); env::set_var("GITHUB_EVENT_PATH", event_payload_path.path()); } @@ -91,17 +103,18 @@ async fn setup(lib_root: &Path, test_params: &TestParams) { let mut mocks = vec![]; if test_params.lines_changed_only != LinesChangedOnly::Off { - let diff_end_point = if test_params.event_t == EventType::PullRequest { - format!("pulls/{PR}") + let (asset_prefix, diff_end_point) = if test_params.event_t == EventType::PullRequest { + ("pr".to_string(), format!("pulls/{PR}/files")) } else { - format!("commits/{SHA}") + ("push".to_string(), format!("commits/{SHA}")) }; mocks.push( server .mock("GET", format!("/repos/{REPO}/{diff_end_point}").as_str()) - .match_header("Accept", "application/vnd.github.diff") + .match_header("Accept", "application/vnd.github.raw+json") .match_header("Authorization", format!("token {TOKEN}").as_str()) - .with_body_from_file(format!("{asset_path}patch.diff")) + .match_query(Matcher::Any) + .with_body_from_file(format!("{asset_path}{asset_prefix}_diff.json")) .with_header(REMAINING_RATE_LIMIT_HEADER, "50") .with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str()) .create(), @@ -127,7 +140,7 @@ async fn setup(lib_root: &Path, test_params: &TestParams) { if test_params.bad_existing_comments { mock = mock.with_body(String::new()); } else { - mock = mock.with_body_from_file(format!("{asset_path}push_comments_{SHA}.json")); + mock = mock.with_body_from_file(format!("{asset_path}push_comments.json")); } mock = mock.create(); mocks.push(mock); @@ -232,7 +245,7 @@ async fn setup(lib_root: &Path, test_params: &TestParams) { let mut args = vec![ "cpp-linter".to_string(), "-v=debug".to_string(), - format!("-V={}", env::var("CLANG_VERSION").unwrap_or("".to_string())), + format!("-V={}", env::var("CLANG_VERSION").unwrap_or_default()), format!("-l={}", test_params.lines_changed_only), "--ignore-tidy=src/some source.c".to_string(), "--ignore-format=src/some source.c".to_string(), @@ -244,7 +257,19 @@ async fn setup(lib_root: &Path, test_params: &TestParams) { if test_params.force_lgtm { args.push("-e=c".to_string()); } - run_main(args).await.unwrap(); + let result = run_main(args).await; + match result { + Ok(_) => { + if test_params.bad_existing_comments { + panic!("Expected failure but got success"); + } + } + Err(e) => { + if !test_params.bad_existing_comments { + panic!("Expected success but got failure: {e}"); + } + } + } for mock in mocks { mock.assert(); } diff --git a/cpp-linter/tests/git_status_test_assets/cpp-linter/cpp-linter/950ff0b690e1903797c303c5fc8d9f3b52f1d3c5.diff b/cpp-linter/tests/git_status_test_assets/cpp-linter/cpp-linter/950ff0b690e1903797c303c5fc8d9f3b52f1d3c5.diff deleted file mode 100644 index 6544d4ce..00000000 --- a/cpp-linter/tests/git_status_test_assets/cpp-linter/cpp-linter/950ff0b690e1903797c303c5fc8d9f3b52f1d3c5.diff +++ /dev/null @@ -1,5208 +0,0 @@ -diff --git a/.gitattributes b/.gitattributes -new file mode 100644 -index 0000000..f7c5d6a ---- /dev/null -+++ b/.gitattributes -@@ -0,0 +1,10 @@ -+# Set the default behavior, in case people don't have core.autocrlf set. -+* text=auto -+ -+# Explicitly declare text files you want to always be normalized and converted -+# to native line endings on checkout. -+*.py text eol=lf -+*.rst text eol=lf -+*.sh text eol=lf -+*.cpp text eol=lf -+*.hpp text eol=lf -diff --git a/.github/stale.yml b/.github/stale.yml -new file mode 100644 -index 0000000..0d0b1c9 ---- /dev/null -+++ b/.github/stale.yml -@@ -0,0 +1 @@ -+_extends: .github -diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml -new file mode 100644 -index 0000000..33f08a3 ---- /dev/null -+++ b/.github/workflows/build-docs.yml -@@ -0,0 +1,33 @@ -+name: Docs -+ -+on: [push, workflow_dispatch] -+ -+jobs: -+ build: -+ runs-on: ubuntu-latest -+ steps: -+ - uses: actions/checkout@v3 -+ -+ - uses: actions/setup-python@v4 -+ with: -+ python-version: 3.x -+ -+ - name: Install docs dependencies -+ run: pip install . -r docs/requirements.txt -+ -+ - name: Build docs -+ run: sphinx-build docs docs/_build/html -+ -+ - name: Deploy to gh-pages -+ uses: actions/upload-artifact@v3 -+ with: -+ name: "cpp-linter_docs" -+ path: ${{ github.workspace }}/docs/_build/html -+ -+ - name: upload to github pages -+ # only publish doc changes from main branch -+ if: github.ref == 'refs/heads/main' -+ uses: peaceiris/actions-gh-pages@v3 -+ with: -+ github_token: ${{ secrets.GITHUB_TOKEN }} -+ publish_dir: ./docs/_build/html -diff --git a/.github/workflows/pre-commit-hooks.yml b/.github/workflows/pre-commit-hooks.yml -new file mode 100644 -index 0000000..e9426fc ---- /dev/null -+++ b/.github/workflows/pre-commit-hooks.yml -@@ -0,0 +1,17 @@ -+name: Pre-commit -+ -+on: -+ push: -+ pull_request: -+ types: opened -+ -+jobs: -+ check-source-files: -+ runs-on: ubuntu-latest -+ steps: -+ - uses: actions/checkout@v3 -+ - uses: actions/setup-python@v4 -+ with: -+ python-version: '3.x' -+ - run: python3 -m pip install pre-commit -+ - run: pre-commit run --all-files -diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml -new file mode 100644 -index 0000000..278717b ---- /dev/null -+++ b/.github/workflows/publish-pypi.yml -@@ -0,0 +1,51 @@ -+# This workflow will upload a Python Package using Twine when a release is created -+# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries -+ -+# This workflow uses actions that are not certified by GitHub. -+# They are provided by a third-party and are governed by -+# separate terms of service, privacy policy, and support -+# documentation. -+ -+name: Upload Python Package -+ -+on: -+ release: -+ branches: [master] -+ types: [published] -+ workflow_dispatch: -+ -+permissions: -+ contents: read -+ -+jobs: -+ deploy: -+ -+ runs-on: ubuntu-latest -+ -+ steps: -+ - uses: actions/checkout@v3 -+ # use fetch --all for setuptools_scm to work -+ with: -+ fetch-depth: 0 -+ - name: Set up Python -+ uses: actions/setup-python@v4 -+ with: -+ python-version: '3.x' -+ - name: Install dependencies -+ run: python -m pip install --upgrade pip twine -+ - name: Build wheel -+ run: python -m pip wheel -w dist --no-deps . -+ - name: Check distribution -+ run: twine check dist/* -+ - name: Publish package (to TestPyPI) -+ if: github.event_name == 'workflow_dispatch' && github.repository == 'cpp-linter/cpp-linter' -+ env: -+ TWINE_USERNAME: __token__ -+ TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} -+ run: twine upload --repository testpypi dist/* -+ - name: Publish package (to PyPI) -+ if: github.event_name != 'workflow_dispatch' && github.repository == 'cpp-linter/cpp-linter' -+ env: -+ TWINE_USERNAME: __token__ -+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} -+ run: twine upload dist/* -diff --git a/.github/workflows/run-dev-tests.yml b/.github/workflows/run-dev-tests.yml -new file mode 100644 -index 0000000..6c2dd88 ---- /dev/null -+++ b/.github/workflows/run-dev-tests.yml -@@ -0,0 +1,111 @@ -+name: "Check python code" -+ -+on: -+ push: -+ paths: -+ - "**.py" -+ - pyproject.toml -+ - ".github/workflows/run-dev-tests.yml" -+ pull_request: -+ types: opened -+ paths: -+ - "**.py" -+ - "**requirements*.txt" -+ - pyproject.toml -+ - ".github/workflows/run-dev-tests.yml" -+ -+jobs: -+ build: -+ runs-on: ubuntu-latest -+ steps: -+ - uses: actions/checkout@v3 -+ - uses: actions/setup-python@v4 -+ with: -+ python-version: '3.x' -+ - name: Build wheel -+ run: python3 -m pip wheel --no-deps -w dist . -+ - name: Upload wheel as artifact -+ uses: actions/upload-artifact@v3 -+ with: -+ name: cpp-linter_wheel -+ path: ${{ github.workspace }}/dist/*.whl -+ -+ test: -+ needs: [build] -+ strategy: -+ fail-fast: false -+ matrix: -+ py: ['3.7', '3.8', '3.9', '3.10'] -+ os: ['windows-latest', ubuntu-latest] -+ version: ['13', '12', '11', '10', '9', '8', '7'] -+ include: -+ - tools_dir: 'N/A' -+ - os: 'windows-latest' -+ version: '10' -+ tools_dir: temp -+ - os: 'windows-latest' -+ version: '10' -+ tools_dir: temp -+ - os: 'windows-latest' -+ version: '11' -+ tools_dir: temp -+ - os: 'windows-latest' -+ version: '12' -+ tools_dir: temp -+ - os: 'ubuntu-latest' -+ version: '13' -+ tools_dir: temp -+ # - version: '14' -+ # tools_dir: temp -+ - version: '7' -+ tools_dir: temp -+ - version: '8' -+ tools_dir: temp -+ - version: '9' -+ tools_dir: temp -+ -+ runs-on: ${{ matrix.os }} -+ steps: -+ - uses: actions/checkout@v3 -+ -+ - uses: actions/setup-python@v4 -+ with: -+ python-version: ${{ matrix.py }} -+ -+ - name: download wheel artifact -+ uses: actions/download-artifact@v3 -+ with: -+ name: cpp-linter_wheel -+ path: dist -+ -+ - name: Install workflow deps -+ # using a wildcard as filename on Windows requires a bash shell -+ shell: bash -+ run: python3 -m pip install pytest coverage[toml] dist/*.whl -+ -+ - name: Install clang-tools -+ if: matrix.tools_dir == 'temp' -+ run: | -+ python -m pip install clang-tools -+ clang-tools --install ${{ matrix.version }} --directory ${{ runner.temp }}/clang-tools -+ -+ - name: Collect Coverage (native clang install) -+ if: matrix.tools_dir == 'N/A' -+ env: -+ CLANG_VERSION: ${{ matrix.version }} -+ run: coverage run -m pytest -+ -+ - name: Collect Coverage (non-native clang install) -+ if: matrix.tools_dir == 'temp' -+ env: -+ CLANG_VERSION: ${{ runner.temp }}/clang-tools -+ run: coverage run -m pytest -+ -+ - run: coverage report && coverage xml -+ -+ - uses: codecov/codecov-action@v3 -+ if: matrix.os == 'ubuntu-latest' && matrix.version == '12' && matrix.py == '3.10' -+ with: -+ files: ./coverage.xml -+ fail_ci_if_error: true # optional (default = false) -+ verbose: true # optional (default = false) -diff --git a/.gitignore b/.gitignore -new file mode 100644 -index 0000000..3065187 ---- /dev/null -+++ b/.gitignore -@@ -0,0 +1,188 @@ -+# local demo specific -+.changed_files.json -+clang_format*.txt -+clang_tidy*.txt -+act.exe -+clang_tidy_output.yml -+clang_format_output.xml -+event_payload.json -+comments.json -+ -+#### ignores for Python -+# Byte-compiled / optimized / DLL files -+__pycache__/ -+*.py[cod] -+*$py.class -+ -+# Distribution / packaging -+.Python -+build/ -+develop-eggs/ -+dist/ -+downloads/ -+eggs/ -+.eggs/ -+lib/ -+lib64/ -+parts/ -+sdist/ -+var/ -+wheels/ -+pip-wheel-metadata/ -+share/python-wheels/ -+*.egg-info/ -+.installed.cfg -+*.egg -+MANIFEST -+ -+# PyInstaller -+# Usually these files are written by a python script from a template -+# before PyInstaller builds the exe, so as to inject date/other infos into it. -+*.manifest -+*.spec -+ -+# Installer logs -+pip-log.txt -+pip-delete-this-directory.txt -+ -+# Unit test / coverage reports -+htmlcov/ -+.tox/ -+.nox/ -+.coverage -+.coverage.* -+.cache -+nosetests.xml -+coverage.xml -+*.cover -+*.py,cover -+.hypothesis/ -+.pytest_cache/ -+tests/*/test*.json -+tests/**/*.c -+ -+# Translations -+*.mo -+*.pot -+ -+# Django stuff: -+*.log -+local_settings.py -+db.sqlite3 -+db.sqlite3-journal -+ -+# Flask stuff: -+instance/ -+.webassets-cache -+ -+# Scrapy stuff: -+.scrapy -+ -+# Sphinx documentation -+docs/_build/ -+ -+# PyBuilder -+target/ -+ -+# Jupyter Notebook -+.ipynb_checkpoints -+ -+# IPython -+profile_default/ -+ipython_config.py -+ -+# pyenv -+.python-version -+ -+# pipenv -+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -+# However, in case of collaboration, if having platform-specific dependencies or dependencies -+# having no cross-platform support, pipenv may install dependencies that don't work, or not -+# install all needed dependencies. -+#Pipfile.lock -+ -+# PEP 582; used by e.g. github.com/David-OConnor/pyflow -+__pypackages__/ -+ -+# Celery stuff -+celerybeat-schedule -+celerybeat.pid -+ -+# SageMath parsed files -+*.sage.py -+ -+# Environments -+.env -+.venv -+env/ -+venv/ -+ENV/ -+env.bak/ -+venv.bak/ -+ -+# Spyder project settings -+.spyderproject -+.spyproject -+ -+# Rope project settings -+.ropeproject -+ -+# mkdocs documentation -+/site -+ -+# mypy -+.mypy_cache/ -+.dmypy.json -+dmypy.json -+ -+# Pyre type checker -+.pyre/ -+ -+# exclude local VSCode's settings folder -+.vscode/ -+ -+#### ignores for C++ -+# Prerequisites -+*.d -+ -+# Compiled Object files -+*.slo -+*.lo -+*.o -+*.obj -+ -+# Precompiled Headers -+*.gch -+*.pch -+ -+# Compiled Dynamic libraries -+*.so -+*.dylib -+*.dll -+ -+# Fortran module files -+*.mod -+*.smod -+ -+# Compiled Static libraries -+*.lai -+*.la -+*.a -+*.lib -+ -+# Executables -+*.exe -+*.out -+*.app -+ -+# Cmake build-in-source generated stuff -+CMakeUserPresets.json -+CMakeCache.txt -+CPackConfig.cmake -+CPackSourceConfig.cmake -+CMakeFiles -+cmake_install.cmake -+ -+ -+# generated doc pages -+docs/cli_args.rst -diff --git a/.gitpod.yml b/.gitpod.yml -new file mode 100644 -index 0000000..c21ea2f ---- /dev/null -+++ b/.gitpod.yml -@@ -0,0 +1,2 @@ -+tasks: -+ - init: pip install -r requirements.txt -diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml -new file mode 100644 -index 0000000..4ccf7ab ---- /dev/null -+++ b/.pre-commit-config.yaml -@@ -0,0 +1,38 @@ -+repos: -+ - repo: https://github.com/pre-commit/pre-commit-hooks -+ rev: v4.3.0 -+ hooks: -+ - id: trailing-whitespace -+ - id: end-of-file-fixer -+ - id: check-docstring-first -+ - id: check-added-large-files -+ - id: check-yaml -+ - id: check-toml -+ - id: requirements-txt-fixer -+ - id: mixed-line-ending -+ args: ["--fix=lf"] -+ - repo: https://github.com/python/black -+ rev: '22.6.0' -+ hooks: -+ - id: black -+ args: ["--diff"] -+ - repo: https://github.com/pycqa/pylint -+ rev: v2.14.5 -+ hooks: -+ - id: pylint -+ name: pylint (action code) -+ types: [python] -+ exclude: "^(docs/|tests/|setup.py$)" -+ additional_dependencies: [pyyaml, requests] -+ - repo: local -+ # this is a "local" hook to run mypy (see https://pre-commit.com/#repository-local-hooks) -+ # because the mypy project doesn't seem to be compatible with pre-commit hooks -+ hooks: -+ - id: mypy -+ name: mypy -+ description: type checking with mypy tool -+ language: python -+ types: [python] -+ entry: mypy -+ exclude: "^(docs/|setup.py$)" -+ additional_dependencies: [mypy, types-pyyaml, types-requests, rich, requests, pytest, pyyaml, '.'] -diff --git a/README.rst b/README.rst -new file mode 100644 -index 0000000..c0b5ae3 ---- /dev/null -+++ b/README.rst -@@ -0,0 +1,34 @@ -+C/C++ Linting Package -+===================== -+ -+.. image:: https://img.shields.io/github/v/release/cpp-linter/cpp-linter?style=plastic -+ :alt: Latest Version -+ :target: https://github.com/cpp-linter/cpp-linter/releases -+.. image:: https://img.shields.io/github/license/cpp-linter/cpp-linter?label=license&logo=github&style=plastic -+ :alt: License -+ :target: https://github.com/cpp-linter/cpp-linter/blob/main/LICENSE -+.. image:: https://codecov.io/gh/cpp-linter/cpp-linter/branch/main/graph/badge.svg?token=0814O9WHQU -+ :alt: CodeCov -+ :target: https://codecov.io/gh/cpp-linter/cpp-linter -+.. image:: https://github.com/cpp-linter/cpp-linter/actions/workflows/build-docs.yml/badge.svg -+ :alt: Docs -+ :target: https://cpp-linter.github.io/cpp-linter -+ -+A Python package for linting C/C++ code with clang-tidy and/or clang-format to collect feedback provided in the form of thread comments and/or file annotations. -+ -+Usage -+----- -+ -+For usage in a CI workflow, see `the cpp-linter/cpp-linter-action repository `_ -+ -+For the description of supported Command Line Interface options, see `the CLI documentation `_ -+ -+Have question or feedback? -+-------------------------- -+ -+To provide feedback (requesting a feature or reporting a bug) please post to `issues `_. -+ -+License -+------- -+ -+The scripts and documentation in this project are released under the `MIT License `_. -diff --git a/cpp_linter/__init__.py b/cpp_linter/__init__.py -new file mode 100644 -index 0000000..5921623 ---- /dev/null -+++ b/cpp_linter/__init__.py -@@ -0,0 +1,157 @@ -+"""The Base module of the :mod:`cpp_linter` package. This holds the objects shared by -+multiple modules.""" -+import os -+from pathlib import Path -+import platform -+import logging -+from typing import TYPE_CHECKING, List, Dict, Tuple, Any -+from requests import Response -+ -+if TYPE_CHECKING: # Used to avoid circular imports -+ from cpp_linter.clang_format_xml import XMLFixit -+ from cpp_linter.clang_tidy_yml import YMLFixit -+ from cpp_linter.clang_tidy import TidyNotification -+ -+FOUND_RICH_LIB = False -+try: -+ from rich.logging import RichHandler -+ -+ FOUND_RICH_LIB = True -+ -+ logging.basicConfig( -+ format="%(name)s: %(message)s", -+ handlers=[RichHandler(show_time=False)], -+ ) -+ -+except ImportError: # pragma: no cover -+ logging.basicConfig() -+ -+#: The logging.Logger object used for outputting data. -+logger = logging.getLogger("CPP Linter") -+if not FOUND_RICH_LIB: -+ logger.debug("rich module not found") -+ -+# global constant variables -+IS_ON_RUNNER = bool(os.getenv("CI")) -+GITHUB_SHA = os.getenv("GITHUB_SHA", "") -+GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", os.getenv("GIT_REST_API", "")) -+API_HEADERS = { -+ "Accept": "application/vnd.github.v3.text+json", -+} -+if GITHUB_TOKEN: -+ API_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}" -+IS_ON_WINDOWS = platform.system().lower() == "windows" -+ -+ -+class Globals: -+ """Global variables for re-use (non-constant).""" -+ -+ PAYLOAD_TIDY: str = "" -+ """The accumulated output of clang-tidy (gets appended to OUTPUT)""" -+ OUTPUT: str = "" -+ """The accumulated body of the resulting comment that gets posted.""" -+ FILES: List[Dict[str, Any]] = [] -+ """The responding payload containing info about changed files.""" -+ EVENT_PAYLOAD: Dict[str, Any] = {} -+ """The parsed JSON of the event payload.""" -+ response_buffer: Response = Response() -+ """A shared response object for `requests` module.""" -+ -+ -+class GlobalParser: -+ """Global variables specific to output parsers. Each element in each of the -+ following attributes represents a clang-tool's output for 1 source file. -+ """ -+ -+ tidy_notes = [] # type: List[TidyNotification] -+ """This can only be a `list` of type -+ :class:`~cpp_linter.clang_tidy.TidyNotification`.""" -+ tidy_advice = [] # type: List[YMLFixit] -+ """This can only be a `list` of type :class:`~cpp_linter.clang_tidy_yml.YMLFixit`. -+ """ -+ format_advice = [] # type: List[XMLFixit] -+ """This can only be a `list` of type :class:`~cpp_linter.clang_format_xml.XMLFixit`. -+ """ -+ -+ -+def get_line_cnt_from_cols(file_path: str, offset: int) -> Tuple[int, int]: -+ """Gets a line count and columns offset from a file's absolute offset. -+ -+ :param file_path: Path to file. -+ :param offset: The byte offset to translate -+ -+ :returns: -+ A `tuple` of 2 `int` numbers: -+ -+ - Index 0 is the line number for the given offset. -+ - Index 1 is the column number for the given offset on the line. -+ """ -+ # logger.debug("Getting line count from %s at offset %d", file_path, offset) -+ contents = Path(file_path).read_bytes()[:offset] -+ return (contents.count(b"\n") + 1, offset - contents.rfind(b"\n")) -+ -+ -+def range_of_changed_lines( -+ file_obj: Dict[str, Any], lines_changed_only: int -+) -> List[int]: -+ """Assemble a list of lines changed. -+ -+ :param file_obj: The file's JSON object. -+ :param lines_changed_only: A flag to indicate the focus of certain lines. -+ -+ - ``0``: focuses on all lines in file. -+ - ``1``: focuses on any lines shown in the event's diff (may include -+ unchanged lines). -+ - ``2``: focuses strictly on lines in the diff that contain additions. -+ :returns: -+ A list of line numbers for which to give attention. -+ """ -+ if lines_changed_only: -+ ranges = file_obj["line_filter"][ -+ "diff_chunks" if lines_changed_only == 1 else "lines_added" -+ ] -+ return [l for r in ranges for l in range(r[0], r[1])] -+ return [] -+ -+ -+def log_response_msg() -> bool: -+ """Output the response buffer's message on a failed request. -+ -+ :returns: -+ A bool describing if response's status code was less than 400. -+ """ -+ if Globals.response_buffer.status_code >= 400: -+ logger.error( -+ "response returned %d message: %s", -+ Globals.response_buffer.status_code, -+ Globals.response_buffer.text, -+ ) -+ return False -+ return True -+ -+ -+def assemble_version_exec(tool_name: str, specified_version: str) -> str: -+ """Assembles the command to the executable of the given clang tool based on given -+ version information. -+ -+ :param tool_name: The name of the clang tool to be executed. -+ :param specified_version: The version number or the installed path to a version of -+ the tool's executable. -+ """ -+ suffix = ".exe" if IS_ON_WINDOWS else "" -+ if specified_version.isdigit(): # version info is not a path -+ # let's assume the exe is in the PATH env var -+ if IS_ON_WINDOWS: -+ # installs don't usually append version number to exe name on Windows -+ return f"{tool_name}{suffix}" # omit version number -+ return f"{tool_name}-{specified_version}{suffix}" -+ version_path = Path(specified_version).resolve() # make absolute -+ for path in [ -+ # if installed via KyleMayes/install-llvm-action using the `directory` option -+ version_path / "bin" / (tool_name + suffix), -+ # if installed via clang-tools-pip pkg using the `-d` option -+ version_path / (tool_name + suffix), -+ ]: -+ if path.exists(): -+ return str(path) -+ return tool_name + suffix -diff --git a/cpp_linter/clang_format_xml.py b/cpp_linter/clang_format_xml.py -new file mode 100644 -index 0000000..0554264 ---- /dev/null -+++ b/cpp_linter/clang_format_xml.py -@@ -0,0 +1,137 @@ -+"""Parse output from clang-format's XML suggestions.""" -+from pathlib import PurePath -+from typing import List, Optional -+import xml.etree.ElementTree as ET -+from . import GlobalParser, get_line_cnt_from_cols -+ -+ -+class FormatReplacement: -+ """An object representing a single replacement. -+ -+ :param cols: The columns number of where the suggestion starts on the line -+ :param null_len: The number of bytes removed by suggestion -+ :param text: The `bytearray` of the suggestion -+ """ -+ -+ def __init__(self, cols: int, null_len: int, text: str) -> None: -+ #: The columns number of where the suggestion starts on the line -+ self.cols = cols -+ #: The number of bytes removed by suggestion -+ self.null_len = null_len -+ #: The `bytearray` of the suggestion -+ self.text = text -+ -+ def __repr__(self) -> str: -+ return ( -+ f"" -+ ) -+ -+ -+class FormatReplacementLine: -+ """An object that represents a replacement(s) for a single line. -+ -+ :param line_numb: The line number of about the replacements -+ """ -+ -+ def __init__(self, line_numb: int): -+ #: The line number of where the suggestion starts -+ self.line = line_numb -+ -+ #: A list of `FormatReplacement` object(s) representing suggestions. -+ self.replacements: List[FormatReplacement] = [] -+ -+ def __repr__(self): -+ return ( -+ f"" -+ ) -+ -+ -+class XMLFixit: -+ """A single object to represent each suggestion. -+ -+ :param filename: The source file's name for which the contents of the xml -+ file exported by clang-tidy. -+ """ -+ -+ def __init__(self, filename: str): -+ """ """ -+ #: The source file that the suggestion concerns. -+ self.filename = PurePath(filename).as_posix() -+ -+ self.replaced_lines: List[FormatReplacementLine] = [] -+ """A list of `FormatReplacementLine` representing replacement(s) -+ on a single line.""" -+ -+ def __repr__(self) -> str: -+ return ( -+ f"" -+ ) -+ -+ def log_command(self, style: str, line_filter: List[int]) -> Optional[str]: -+ """Output a notification as a github log command. -+ -+ .. seealso:: -+ -+ - `An error message `_ -+ - `A warning message `_ -+ - `A notice message `_ -+ -+ :param style: The chosen code style guidelines. -+ :param line_filter: A list of lines numbers used to narrow notifications. -+ """ -+ if style not in ("llvm", "google", "webkit", "mozilla", "gnu"): -+ # potentially the style parameter could be a str of JSON/YML syntax -+ style = "Custom" -+ else: -+ if style.startswith("llvm") or style.startswith("gnu"): -+ style = style.upper() -+ else: -+ style = style.title() -+ line_list = [] -+ for fix in self.replaced_lines: -+ if not line_filter or (line_filter and fix.line in line_filter): -+ line_list.append(str(fix.line)) -+ if not line_list: -+ return None -+ return ( -+ "::notice file={name},title=Run clang-format on {name}::" -+ "File {name} (lines {lines}): Code does not conform to {style_guide} " -+ "style guidelines.".format( -+ name=self.filename, -+ lines=", ".join(line_list), -+ style_guide=style, -+ ) -+ ) -+ -+ -+def parse_format_replacements_xml(src_filename: str): -+ """Parse XML output of replacements from clang-format. Output is saved to -+ :attr:`~cpp_linter.GlobalParser.format_advice`. -+ -+ :param src_filename: The source file's name for which the contents of the xml -+ file exported by clang-tidy. -+ """ -+ tree = ET.parse("clang_format_output.xml") -+ fixit = XMLFixit(src_filename) -+ for child in tree.getroot(): -+ if child.tag == "replacement": -+ offset = int(child.attrib["offset"]) -+ line, cols = get_line_cnt_from_cols(src_filename, offset) -+ null_len = int(child.attrib["length"]) -+ text = "" if child.text is None else child.text -+ fix = FormatReplacement(cols, null_len, text) -+ if not fixit.replaced_lines or ( -+ fixit.replaced_lines and line != fixit.replaced_lines[-1].line -+ ): -+ line_fix = FormatReplacementLine(line) -+ line_fix.replacements.append(fix) -+ fixit.replaced_lines.append(line_fix) -+ elif fixit.replaced_lines and line == fixit.replaced_lines[-1].line: -+ fixit.replaced_lines[-1].replacements.append(fix) -+ GlobalParser.format_advice.append(fixit) -diff --git a/cpp_linter/clang_tidy.py b/cpp_linter/clang_tidy.py -new file mode 100644 -index 0000000..11c1ae8 ---- /dev/null -+++ b/cpp_linter/clang_tidy.py -@@ -0,0 +1,116 @@ -+"""Parse output from clang-tidy's stdout""" -+from pathlib import Path, PurePath -+import re -+from typing import Tuple, Union, List, cast -+from . import GlobalParser -+ -+NOTE_HEADER = re.compile(r"^(.*):(\d+):(\d+):\s(\w+):(.*)\[(.*)\]$") -+ -+ -+class TidyNotification: -+ """Create a object that decodes info from the clang-tidy output's initial line that -+ details a specific notification. -+ -+ :param notification_line: The first line in the notification parsed into a -+ `tuple` of `str` that represent the different components of the -+ notification's details. -+ """ -+ -+ def __init__( -+ self, -+ notification_line: Tuple[str, Union[int, str], Union[int, str], str, str, str], -+ ): -+ # logger.debug("Creating tidy note from line %s", notification_line) -+ ( -+ self.filename, -+ self.line, -+ #: The columns of the line that triggered the notification. -+ self.cols, -+ self.note_type, -+ self.note_info, -+ #: The clang-tidy check that enabled the notification. -+ self.diagnostic, -+ ) = notification_line -+ -+ #: The rationale of the notification. -+ self.note_info = self.note_info.strip() -+ #: The priority level of notification (warning/error). -+ self.note_type = self.note_type.strip() -+ #: The line number of the source file. -+ self.line = int(self.line) -+ self.cols = int(self.cols) -+ #: The source filename concerning the notification. -+ self.filename = ( -+ PurePath(self.filename).as_posix().replace(Path.cwd().as_posix(), "") -+ ) -+ #: A `list` of lines for the code-block in the notification. -+ self.fixit_lines: List[str] = [] -+ -+ def __repr__(self) -> str: -+ concerned_code = "" -+ if self.fixit_lines: -+ if not self.fixit_lines[-1].endswith("\n"): -+ # some notifications' code-blocks don't end in a LF -+ self.fixit_lines[-1] += "\n" # and they should for us -+ concerned_code = "```{}\n{}```\n".format( -+ PurePath(self.filename).suffix.lstrip("."), -+ "\n".join(self.fixit_lines), -+ ) -+ return ( -+ "
\n{}:{}:{}: {}: [{}]" -+ "\n\n> {}\n

\n\n{}

\n
\n\n".format( -+ self.filename, -+ self.line, -+ self.cols, -+ self.note_type, -+ self.diagnostic, -+ self.note_info, -+ concerned_code, -+ ) -+ ) -+ -+ def log_command(self) -> str: -+ """Output the notification as a github log command. -+ -+ .. seealso:: -+ -+ - `An error message `_ -+ - `A warning message `_ -+ - `A notice message `_ -+ """ -+ filename = self.filename.replace("\\", "/") -+ return ( -+ "::{} file={file},line={line},title={file}:{line}:{cols} [{diag}]::" -+ "{info}".format( -+ "notice" if self.note_type.startswith("note") else self.note_type, -+ file=filename, -+ line=self.line, -+ cols=self.cols, -+ diag=self.diagnostic, -+ info=self.note_info, -+ ) -+ ) -+ -+ -+def parse_tidy_output() -> None: -+ """Parse clang-tidy output in a file created from stdout. The results are -+ saved to :attr:`~cpp_linter.GlobalParser.tidy_notes`.""" -+ notification = None -+ tidy_out = Path("clang_tidy_report.txt").read_text(encoding="utf-8") -+ for line in tidy_out.splitlines(): -+ match = re.match(NOTE_HEADER, line) -+ if match is not None: -+ notification = TidyNotification( -+ cast( -+ Tuple[str, Union[int, str], Union[int, str], str, str, str], -+ match.groups(), -+ ) -+ ) -+ GlobalParser.tidy_notes.append(notification) -+ elif notification is not None: -+ # append lines of code that are part of -+ # the previous line's notification -+ notification.fixit_lines.append(line) -diff --git a/cpp_linter/clang_tidy_yml.py b/cpp_linter/clang_tidy_yml.py -new file mode 100644 -index 0000000..21b38a0 ---- /dev/null -+++ b/cpp_linter/clang_tidy_yml.py -@@ -0,0 +1,120 @@ -+"""Parse output from clang-tidy's YML format""" -+from pathlib import Path, PurePath -+from typing import List, cast, Dict, Any -+import yaml -+from . import GlobalParser, get_line_cnt_from_cols, logger -+ -+ -+CWD_HEADER_GUARD = bytes( -+ "_".join([p.upper().replace("-", "_") for p in Path.cwd().parts]), encoding="utf-8" -+) #: The constant used to trim absolute paths from header guard suggestions. -+ -+ -+class TidyDiagnostic: -+ """Create an object that represents a diagnostic output found in the -+ YAML exported from clang-tidy. -+ -+ :param diagnostic_name: The name of the check that got triggered. -+ """ -+ -+ def __init__(self, diagnostic_name: str): -+ #: The diagnostic name -+ self.name = diagnostic_name -+ #: The diagnostic message -+ self.message = "" -+ #: The line number that triggered the diagnostic -+ self.line = 0 -+ #: The columns of the `line` that triggered the diagnostic -+ self.cols = 0 -+ #: The number of bytes replaced by suggestions -+ self.null_len = 0 -+ #: The `list` of `TidyReplacement` objects. -+ self.replacements: List["TidyReplacement"] = [] -+ -+ def __repr__(self): -+ """A str representation of all attributes.""" -+ return ( -+ f"" -+ ) -+ -+ -+class TidyReplacement: -+ """Create an object representing a clang-tidy suggested replacement. -+ -+ :param line_cnt: The replacement content's starting line -+ :param cols: The replacement content's starting columns -+ :param length: The number of bytes discarded from `cols` -+ """ -+ -+ def __init__(self, line_cnt: int, cols: int, length: int): -+ #: The replacement content's starting line -+ self.line = line_cnt -+ #: The replacement content's starting columns -+ self.cols = cols -+ #: The number of bytes discarded from `cols` -+ self.null_len = length -+ #: The replacement content's text. -+ self.text: bytes = b"" -+ -+ def __repr__(self) -> str: -+ return ( -+ f"" -+ ) -+ -+ -+class YMLFixit: -+ """A single object to represent each suggestion. -+ -+ :param filename: The source file's name (with path) concerning the suggestion. -+ """ -+ -+ def __init__(self, filename: str) -> None: -+ #: The source file's name concerning the suggestion. -+ self.filename = PurePath(filename).relative_to(Path.cwd()).as_posix() -+ #: The `list` of `TidyDiagnostic` objects. -+ self.diagnostics: List[TidyDiagnostic] = [] -+ -+ def __repr__(self) -> str: -+ return ( -+ f"" -+ ) -+ -+ -+def parse_tidy_suggestions_yml(): -+ """Read a YAML file from clang-tidy and create a list of suggestions from it. -+ Output is saved to :attr:`~cpp_linter.GlobalParser.tidy_advice`. -+ """ -+ yml_file = Path("clang_tidy_output.yml").read_text(encoding="utf-8") -+ yml = yaml.safe_load(yml_file) -+ fixit = YMLFixit(yml["MainSourceFile"]) -+ -+ for diag_results in yml["Diagnostics"]: -+ diag = TidyDiagnostic(diag_results["DiagnosticName"]) -+ if "DiagnosticMessage" in cast(Dict[str, Any], diag_results).keys(): -+ msg = diag_results["DiagnosticMessage"]["Message"] -+ offset = diag_results["DiagnosticMessage"]["FileOffset"] -+ replacements = diag_results["DiagnosticMessage"]["Replacements"] -+ else: # prior to clang-tidy v9, the YML output was structured differently -+ msg = diag_results["Message"] -+ offset = diag_results["FileOffset"] -+ replacements = diag_results["Replacements"] -+ diag.message = msg -+ diag.line, diag.cols = get_line_cnt_from_cols(yml["MainSourceFile"], offset) -+ for replacement in [] if replacements is None else replacements: -+ line_cnt, cols = get_line_cnt_from_cols( -+ yml["MainSourceFile"], replacement["Offset"] -+ ) -+ fix = TidyReplacement(line_cnt, cols, replacement["Length"]) -+ fix.text = bytes(replacement["ReplacementText"], encoding="utf-8") -+ if fix.text.startswith(b"header is missing header guard"): -+ logger.debug( -+ "filtering header guard suggestion (making relative to repo root)" -+ ) -+ fix.text = fix.text.replace(CWD_HEADER_GUARD, b"") -+ diag.replacements.append(fix) -+ fixit.diagnostics.append(diag) -+ # filter out absolute header guards -+ GlobalParser.tidy_advice.append(fixit) -diff --git a/cpp_linter/run.py b/cpp_linter/run.py -new file mode 100644 -index 0000000..d98281e ---- /dev/null -+++ b/cpp_linter/run.py -@@ -0,0 +1,984 @@ -+"""Run clang-tidy and clang-format on a list of changed files provided by GitHub's -+REST API. If executed from command-line, then `main()` is the entrypoint. -+ -+.. seealso:: -+ -+ - `github rest API reference for pulls -+ `_ -+ - `github rest API reference for repos -+ `_ -+ - `github rest API reference for issues -+ `_ -+""" -+import subprocess -+from pathlib import Path, PurePath -+import os -+import sys -+import argparse -+import configparser -+import json -+from typing import cast, List, Dict, Any, Tuple -+import requests -+from . import ( -+ Globals, -+ GlobalParser, -+ logging, -+ logger, -+ GITHUB_TOKEN, -+ GITHUB_SHA, -+ API_HEADERS, -+ IS_ON_RUNNER, -+ log_response_msg, -+ range_of_changed_lines, -+ assemble_version_exec, -+) -+from .clang_tidy_yml import parse_tidy_suggestions_yml -+from .clang_format_xml import parse_format_replacements_xml -+from .clang_tidy import parse_tidy_output, TidyNotification -+from .thread_comments import remove_bot_comments, list_diff_comments # , get_review_id -+ -+ -+# global constant variables -+GITHUB_EVENT_PATH = os.getenv("GITHUB_EVENT_PATH", "") -+GITHUB_API_URL = os.getenv("GITHUB_API_URL", "https://api.github.com") -+GITHUB_REPOSITORY = os.getenv("GITHUB_REPOSITORY", "") -+GITHUB_EVENT_NAME = os.getenv("GITHUB_EVENT_NAME", "unknown") -+GITHUB_WORKSPACE = os.getenv("GITHUB_WORKSPACE", "") -+IS_USING_DOCKER = os.getenv("USING_CLANG_TOOLS_DOCKER", os.getenv("CLANG_VERSIONS")) -+RUNNER_WORKSPACE = "/github/workspace" if IS_USING_DOCKER else GITHUB_WORKSPACE -+ -+# setup CLI args -+cli_arg_parser = argparse.ArgumentParser( -+ description=__doc__[: __doc__.find("If executed from")], -+ formatter_class=argparse.RawTextHelpFormatter, -+) -+arg = cli_arg_parser.add_argument( -+ "-v", -+ "--verbosity", -+ type=int, -+ default=10, -+ help="""This controls the action's verbosity in the workflow's logs. -+Supported options are defined by the `logging-level `_. -+This option does not affect the verbosity of resulting -+thread comments or file annotations. -+ -+Defaults to level ``%(default)s`` (aka """, -+) -+assert arg.help is not None -+arg.help += f"``logging.{logging.getLevelName(arg.default)}``)." -+cli_arg_parser.add_argument( -+ "-p", -+ "--database", -+ default="", -+ help="""The path that is used to read a compile command database. -+For example, it can be a CMake build directory in which a file named -+compile_commands.json exists (set ``CMAKE_EXPORT_COMPILE_COMMANDS`` to ``ON``). -+When no build path is specified, a search for compile_commands.json will be -+attempted through all parent paths of the first input file. See -+https://clang.llvm.org/docs/HowToSetupToolingForLLVM.html for an -+example of setting up Clang Tooling on a source tree.""", -+) -+cli_arg_parser.add_argument( -+ "-s", -+ "--style", -+ default="llvm", -+ help="""The style rules to use (defaults to ``%(default)s``). -+ -+- Set this to ``file`` to have clang-format use the closest relative -+ .clang-format file. -+- Set this to a blank string (``""``) to disable using clang-format -+ entirely.""", -+) -+cli_arg_parser.add_argument( -+ "-c", -+ "--tidy-checks", -+ default="boost-*,bugprone-*,performance-*,readability-*,portability-*,modernize-*," -+ "clang-analyzer-*,cppcoreguidelines-*", -+ help="""A comma-separated list of globs with optional ``-`` prefix. -+Globs are processed in order of appearance in the list. -+Globs without ``-`` prefix add checks with matching names to the set, -+globs with the ``-`` prefix remove checks with matching names from the set of -+enabled checks. This option's value is appended to the value of the 'Checks' -+option in a .clang-tidy file (if any). -+ -+- It is possible to disable clang-tidy entirely by setting this option to ``'-*'``. -+- It is also possible to rely solely on a .clang-tidy config file by -+ specifying this option as a blank string (``''``). -+ -+The defaults is:: -+ -+ %(default)s -+ -+See also clang-tidy docs for more info.""", -+) -+arg = cli_arg_parser.add_argument( -+ "-V", -+ "--version", -+ default="", -+ help="""The desired version of the clang tools to use. Accepted options are -+strings which can be 8, 9, 10, 11, 12, 13, 14. -+ -+- Set this option to a blank string (``''``) to use the -+ platform's default installed version. -+- This value can also be a path to where the clang tools are -+ installed (if using a custom install location). All paths specified -+ here are converted to absolute. -+ -+Default is """ -+) -+assert arg.help is not None -+arg.help += "a blank string." if not arg.default else f"``{arg.default}``." -+arg = cli_arg_parser.add_argument( -+ "-e", -+ "--extensions", -+ default=["c", "h", "C", "H", "cpp", "hpp", "cc", "hh", "c++", "h++", "cxx", "hxx"], -+ type=lambda i: [ext.strip().lstrip(".") for ext in i.split(",")], -+ help="""The file extensions to analyze. -+This comma-separated string defaults to:: -+ -+ """, -+) -+assert arg.help is not None -+arg.help += ",".join(arg.default) + "\n" -+cli_arg_parser.add_argument( -+ "-r", -+ "--repo-root", -+ default=".", -+ help="""The relative path to the repository root directory. This path is -+relative to the runner's ``GITHUB_WORKSPACE`` environment variable (or -+the current working directory if not using a CI runner). -+ -+The default value is ``%(default)s``""", -+) -+cli_arg_parser.add_argument( -+ "-i", -+ "--ignore", -+ default=".github", -+ help="""Set this option with path(s) to ignore (or not ignore). -+ -+- In the case of multiple paths, you can use ``|`` to separate each path. -+- There is no need to use ``./`` for each entry; a blank string (``''``) -+ represents the repo-root path. -+- This can also have files, but the file's path (relative to -+ the :cli-opt:`repo-root`) has to be specified with the filename. -+- Submodules are automatically ignored. Hidden directories (beginning -+ with a ``.``) are also ignored automatically. -+- Prefix a path with ``!`` to explicitly not ignore it. This can be -+ applied to a submodule's path (if desired) but not hidden directories. -+- Glob patterns are not supported here. All asterisk characters (``*``) -+ are literal.""", -+) -+arg = cli_arg_parser.add_argument( -+ "-l", -+ "--lines-changed-only", -+ default=0, -+ type=lambda a: 2 if a.lower() == "true" else (1 if a.lower() == "diff" else 0), -+ help="""This controls what part of the files are analyzed. -+The following values are accepted: -+ -+- false: All lines in a file are analyzed. -+- true: Only lines in the diff that contain additions are analyzed. -+- diff: All lines in the diff are analyzed (including unchanged -+ lines but not subtractions). -+ -+Defaults to """, -+) -+assert arg.help is not None -+arg.help += f"``{str(bool(arg.default)).lower()}``." -+cli_arg_parser.add_argument( -+ "-f", -+ "--files-changed-only", -+ default="false", -+ type=lambda input: input.lower() == "true", -+ help="""Set this option to false to analyze any source files in the repo. -+This is automatically enabled if -+:cli-opt:`lines-changed-only` is enabled. -+ -+.. note:: -+ The ``GITHUB_TOKEN`` should be supplied when running on a -+ private repository with this option enabled, otherwise the runner -+ does not not have the privilege to list the changed files for an event. -+ -+ See `Authenticating with the GITHUB_TOKEN -+ `_ -+ -+Defaults to ``%(default)s``.""", -+) -+cli_arg_parser.add_argument( -+ "-t", -+ "--thread-comments", -+ default="false", -+ type=lambda input: input.lower() == "true", -+ help="""Set this option to false to disable the use of -+thread comments as feedback. -+ -+.. note:: -+ To use thread comments, the ``GITHUB_TOKEN`` (provided by -+ Github to each repository) must be declared as an environment -+ variable. -+ -+ See `Authenticating with the GITHUB_TOKEN -+ `_ -+ -+.. hint:: -+ If run on a private repository, then this feature is -+ disabled because the GitHub REST API behaves -+ differently for thread comments on a private repository. -+ -+Defaults to ``%(default)s``.""", -+) -+cli_arg_parser.add_argument( -+ "-a", -+ "--file-annotations", -+ default="true", -+ type=lambda input: input.lower() == "true", -+ help="""Set this option to false to disable the use of -+file annotations as feedback. -+ -+Defaults to ``%(default)s``.""", -+) -+ -+ -+def set_exit_code(override: int = None) -> int: -+ """Set the action's exit code. -+ -+ :param override: The number to use when overriding the action's logic. -+ -+ :returns: -+ The exit code that was used. If the ``override`` parameter was not passed, -+ then this value will describe (like a bool value) if any checks failed. -+ """ -+ exit_code = override if override is not None else bool(Globals.OUTPUT) -+ print(f"::set-output name=checks-failed::{exit_code}") -+ return exit_code -+ -+ -+# setup a separate logger for using github log commands -+log_commander = logging.getLogger("LOG COMMANDER") # create a child of our logger obj -+log_commander.setLevel(logging.DEBUG) # be sure that log commands are output -+console_handler = logging.StreamHandler() # Create special stdout stream handler -+console_handler.setFormatter(logging.Formatter("%(message)s")) # no formatted log cmds -+log_commander.addHandler(console_handler) # Use special handler for log_commander -+log_commander.propagate = False -+ -+ -+def start_log_group(name: str) -> None: -+ """Begin a collapsable group of log statements. -+ -+ :param name: The name of the collapsable group -+ """ -+ log_commander.fatal("::group::%s", name) -+ -+ -+def end_log_group() -> None: -+ """End a collapsable group of log statements.""" -+ log_commander.fatal("::endgroup::") -+ -+ -+def is_file_in_list(paths: List[str], file_name: str, prompt: str) -> bool: -+ """Determine if a file is specified in a list of paths and/or filenames. -+ -+ :param paths: A list of specified paths to compare with. This list can contain a -+ specified file, but the file's path must be included as part of the -+ filename. -+ :param file_name: The file's path & name being sought in the ``paths`` list. -+ :param prompt: A debugging prompt to use when the path is found in the list. -+ -+ :returns: -+ -+ - True if ``file_name`` is in the ``paths`` list. -+ - False if ``file_name`` is not in the ``paths`` list. -+ """ -+ for path in paths: -+ result = os.path.commonpath( -+ [PurePath(path).as_posix(), PurePath(file_name).as_posix()] -+ ) -+ if result == path: -+ logger.debug( -+ '"./%s" is %s as specified in the domain "./%s"', -+ file_name, -+ prompt, -+ path, -+ ) -+ return True -+ return False -+ -+ -+def get_list_of_changed_files() -> None: -+ """Fetch the JSON payload of the event's changed files. Sets the -+ :attr:`~cpp_linter.Globals.FILES` attribute.""" -+ start_log_group("Get list of specified source files") -+ files_link = f"{GITHUB_API_URL}/repos/{GITHUB_REPOSITORY}/" -+ if GITHUB_EVENT_NAME == "pull_request": -+ files_link += f"pulls/{Globals.EVENT_PAYLOAD['number']}/files" -+ else: -+ if GITHUB_EVENT_NAME != "push": -+ logger.warning( -+ "Triggered on unsupported event '%s'. Behaving like a push event.", -+ GITHUB_EVENT_NAME, -+ ) -+ files_link += f"commits/{GITHUB_SHA}" -+ logger.info("Fetching files list from url: %s", files_link) -+ Globals.response_buffer = requests.get(files_link, headers=API_HEADERS) -+ log_response_msg() -+ if GITHUB_EVENT_NAME == "pull_request": -+ Globals.FILES = Globals.response_buffer.json() -+ else: -+ Globals.FILES = Globals.response_buffer.json()["files"] -+ -+ -+def consolidate_list_to_ranges(just_numbers: List[int]) -> List[List[int]]: -+ """A helper function to `filter_out_non_source_files()` that is only used when -+ extracting the lines from a diff that contain additions.""" -+ result: List[List[int]] = [] -+ for i, n in enumerate(just_numbers): -+ if not i: -+ result.append([n]) -+ elif n - 1 != just_numbers[i - 1]: -+ result[-1].append(just_numbers[i - 1] + 1) -+ result.append([n]) -+ if i == len(just_numbers) - 1: -+ result[-1].append(n + 1) -+ return result -+ -+ -+def filter_out_non_source_files( -+ ext_list: List[str], -+ ignored: List[str], -+ not_ignored: List[str], -+) -> bool: -+ """Exclude undesired files (specified by user input 'extensions'). This filter -+ applies to the event's :attr:`~cpp_linter.Globals.FILES` attribute. -+ -+ :param ext_list: A list of file extensions that are to be examined. -+ :param ignored: A list of paths to explicitly ignore. -+ :param not_ignored: A list of paths to explicitly not ignore. -+ -+ :returns: -+ True if there are files to check. False will invoke a early exit (in -+ `main()`) when no files to be checked. -+ """ -+ files = [] -+ for file in Globals.FILES: -+ if ( -+ PurePath(file["filename"]).suffix.lstrip(".") in ext_list -+ and not file["status"].endswith("removed") -+ and ( -+ not is_file_in_list(ignored, file["filename"], "ignored") -+ or is_file_in_list(not_ignored, file["filename"], "not ignored") -+ ) -+ ): -+ if "patch" in file.keys(): -+ # get diff details for the file's changes -+ # ranges is a list of start/end line numbers shown in the diff -+ ranges: List[List[int]] = [] -+ # additions is a list line numbers in the diff containing additions -+ additions: List[int] = [] -+ line_numb_in_diff: int = 0 -+ for line in cast(str, file["patch"]).splitlines(): -+ if line.startswith("+"): -+ additions.append(line_numb_in_diff) -+ if line.startswith("@@ -"): -+ hunk = line[line.find(" +") + 2 : line.find(" @@")].split(",") -+ start_line, hunk_length = [int(x) for x in hunk] -+ ranges.append([start_line, hunk_length + start_line]) -+ line_numb_in_diff = start_line -+ elif not line.startswith("-"): -+ line_numb_in_diff += 1 -+ file["line_filter"] = dict( -+ diff_chunks=ranges, -+ lines_added=consolidate_list_to_ranges(additions), -+ ) -+ files.append(file) -+ -+ if files: -+ logger.info( -+ "Giving attention to the following files:\n\t%s", -+ "\n\t".join([f["filename"] for f in files]), -+ ) -+ Globals.FILES = files -+ if not IS_ON_RUNNER: # if not executed on a github runner -+ # dump altered json of changed files -+ Path(".changed_files.json").write_text( -+ json.dumps(Globals.FILES, indent=2), -+ encoding="utf-8", -+ ) -+ else: -+ logger.info("No source files need checking!") -+ return False -+ return True -+ -+ -+def verify_files_are_present() -> None: -+ """Download the files if not present. -+ -+ .. hint:: -+ This function assumes the working directory is the root of the invoking -+ repository. If files are not found, then they are downloaded to the working -+ directory. This is bad for files with the same name from different folders. -+ """ -+ for file in Globals.FILES: -+ file_name = Path(file["filename"]) -+ if not file_name.exists(): -+ logger.warning("Could not find %s! Did you checkout the repo?", file_name) -+ logger.info("Downloading file from url: %s", file["raw_url"]) -+ Globals.response_buffer = requests.get(file["raw_url"]) -+ # retain the repo's original structure -+ Path.mkdir(file_name.parent, parents=True, exist_ok=True) -+ file_name.write_text(Globals.response_buffer.text, encoding="utf-8") -+ -+ -+def list_source_files( -+ ext_list: List[str], ignored_paths: List[str], not_ignored: List[str] -+) -> bool: -+ """Make a list of source files to be checked. The resulting list is stored in -+ :attr:`~cpp_linter.Globals.FILES`. -+ -+ :param ext_list: A list of file extensions that should by attended. -+ :param ignored_paths: A list of paths to explicitly ignore. -+ :param not_ignored: A list of paths to explicitly not ignore. -+ -+ :returns: -+ True if there are files to check. False will invoke a early exit (in -+ `main()` when no files to be checked. -+ """ -+ start_log_group("Get list of specified source files") -+ -+ root_path = Path(".") -+ for ext in ext_list: -+ for rel_path in root_path.rglob(f"*.{ext}"): -+ for parent in rel_path.parts[:-1]: -+ if parent.startswith("."): -+ break -+ else: -+ file_path = rel_path.as_posix() -+ logger.debug('"./%s" is a source code file', file_path) -+ if not is_file_in_list( -+ ignored_paths, file_path, "ignored" -+ ) or is_file_in_list(not_ignored, file_path, "not ignored"): -+ Globals.FILES.append(dict(filename=file_path)) -+ -+ if Globals.FILES: -+ logger.info( -+ "Giving attention to the following files:\n\t%s", -+ "\n\t".join([f["filename"] for f in Globals.FILES]), -+ ) -+ else: -+ logger.info("No source files found.") # this might need to be warning -+ return False -+ return True -+ -+ -+def run_clang_tidy( -+ filename: str, -+ file_obj: Dict[str, Any], -+ version: str, -+ checks: str, -+ lines_changed_only: int, -+ database: str, -+ repo_root: str, -+) -> None: -+ """Run clang-tidy on a certain file. -+ -+ :param filename: The name of the local file to run clang-tidy on. -+ :param file_obj: JSON info about the file. -+ :param version: The version of clang-tidy to run. -+ :param checks: The `str` of comma-separated regulate expressions that describe -+ the desired clang-tidy checks to be enabled/configured. -+ :param lines_changed_only: A flag that forces focus on only changes in the event's -+ diff info. -+ :param database: The path to the compilation database. -+ :param repo_root: The path to the repository root folder. -+ """ -+ if checks == "-*": # if all checks are disabled, then clang-tidy is skipped -+ # clear the clang-tidy output file and exit function -+ Path("clang_tidy_report.txt").write_bytes(b"") -+ return -+ filename = PurePath(filename).as_posix() -+ cmds = [ -+ assemble_version_exec("clang-tidy", version), -+ "--export-fixes=clang_tidy_output.yml", -+ ] -+ if checks: -+ cmds.append(f"-checks={checks}") -+ if database: -+ cmds.append("-p") -+ if not PurePath(database).is_absolute(): -+ database = str(Path(RUNNER_WORKSPACE, repo_root, database).resolve()) -+ cmds.append(database) -+ if lines_changed_only: -+ ranges = "diff_chunks" if lines_changed_only == 1 else "lines_added" -+ line_ranges = dict(name=filename, lines=file_obj["line_filter"][ranges]) -+ logger.info("line_filter = %s", json.dumps([line_ranges])) -+ cmds.append(f"--line-filter={json.dumps([line_ranges])}") -+ cmds.append(filename) -+ # clear yml file's content before running clang-tidy -+ Path("clang_tidy_output.yml").write_bytes(b"") -+ logger.info('Running "%s"', " ".join(cmds)) -+ results = subprocess.run(cmds, capture_output=True) -+ Path("clang_tidy_report.txt").write_bytes(results.stdout) -+ logger.debug("Output from clang-tidy:\n%s", results.stdout.decode()) -+ if Path("clang_tidy_output.yml").stat().st_size: -+ parse_tidy_suggestions_yml() # get clang-tidy fixes from yml -+ if results.stderr: -+ logger.debug( -+ "clang-tidy made the following summary:\n%s", results.stderr.decode() -+ ) -+ -+ -+def run_clang_format( -+ filename: str, -+ file_obj: Dict[str, Any], -+ version: str, -+ style: str, -+ lines_changed_only: int, -+) -> None: -+ """Run clang-format on a certain file -+ -+ :param filename: The name of the local file to run clang-format on. -+ :param file_obj: JSON info about the file. -+ :param version: The version of clang-format to run. -+ :param style: The clang-format style rules to adhere. Set this to 'file' to -+ use the relative-most .clang-format configuration file. -+ :param lines_changed_only: A flag that forces focus on only changes in the event's -+ diff info. -+ """ -+ if not style: # if `style` == "" -+ Path("clang_format_output.xml").write_bytes(b"") -+ return # clear any previous output and exit -+ cmds = [ -+ assemble_version_exec("clang-format", version), -+ f"-style={style}", -+ "--output-replacements-xml", -+ ] -+ if lines_changed_only: -+ ranges = "diff_chunks" if lines_changed_only == 1 else "lines_added" -+ for line_range in file_obj["line_filter"][ranges]: -+ cmds.append(f"--lines={line_range[0]}:{line_range[1]}") -+ cmds.append(PurePath(filename).as_posix()) -+ logger.info('Running "%s"', " ".join(cmds)) -+ results = subprocess.run(cmds, capture_output=True) -+ Path("clang_format_output.xml").write_bytes(results.stdout) -+ if results.returncode: -+ logger.debug( -+ "%s raised the following error(s):\n%s", cmds[0], results.stderr.decode() -+ ) -+ -+ -+def create_comment_body( -+ filename: str, -+ file_obj: Dict[str, Any], -+ lines_changed_only: int, -+ tidy_notes: List[TidyNotification], -+): -+ """Create the content for a thread comment about a certain file. -+ This is a helper function to `capture_clang_tools_output()`. -+ -+ :param filename: The file's name (& path). -+ :param file_obj: The file's JSON `dict`. -+ :param lines_changed_only: A flag used to filter the comment based on line changes. -+ :param tidy_notes: A list of cached notifications from clang-tidy. This is used to -+ avoid duplicated content in comment, and it is later used again by -+ `make_annotations()` after `capture_clang_tools_output()` is finished. -+ """ -+ ranges = range_of_changed_lines(file_obj, lines_changed_only) -+ if Path("clang_tidy_report.txt").stat().st_size: -+ parse_tidy_output() # get clang-tidy fixes from stdout -+ comment_output = "" -+ if Globals.PAYLOAD_TIDY: -+ Globals.PAYLOAD_TIDY += "
" -+ for fix in GlobalParser.tidy_notes: -+ if lines_changed_only and fix.line not in ranges: -+ continue -+ comment_output += repr(fix) -+ tidy_notes.append(fix) -+ if comment_output: -+ Globals.PAYLOAD_TIDY += f"
{filename}
\n" -+ Globals.PAYLOAD_TIDY += comment_output -+ GlobalParser.tidy_notes.clear() # empty list to avoid duplicated output -+ -+ if Path("clang_format_output.xml").stat().st_size: -+ parse_format_replacements_xml(PurePath(filename).as_posix()) -+ if GlobalParser.format_advice and GlobalParser.format_advice[-1].replaced_lines: -+ should_comment = lines_changed_only == 0 -+ if not should_comment: -+ for line in [ -+ replacement.line -+ for replacement in GlobalParser.format_advice[-1].replaced_lines -+ ]: -+ if line in ranges: -+ should_comment = True -+ break -+ if should_comment: -+ if not Globals.OUTPUT: -+ Globals.OUTPUT = "\n## :scroll: " -+ Globals.OUTPUT += "Run `clang-format` on the following files\n" -+ Globals.OUTPUT += f"- [ ] {file_obj['filename']}\n" -+ -+ -+def capture_clang_tools_output( -+ version: str, -+ checks: str, -+ style: str, -+ lines_changed_only: int, -+ database: str, -+ repo_root: str, -+): -+ """Execute and capture all output from clang-tidy and clang-format. This aggregates -+ results in the :attr:`~cpp_linter.Globals.OUTPUT`. -+ -+ :param version: The version of clang-tidy to run. -+ :param checks: The `str` of comma-separated regulate expressions that describe -+ the desired clang-tidy checks to be enabled/configured. -+ :param style: The clang-format style rules to adhere. Set this to 'file' to -+ use the relative-most .clang-format configuration file. -+ :param lines_changed_only: A flag that forces focus on only changes in the event's -+ diff info. -+ :param database: The path to the compilation database. -+ :param repo_root: The path to the repository root folder. -+ """ -+ # temporary cache of parsed notifications for use in log commands -+ tidy_notes: List[TidyNotification] = [] -+ for file in Globals.FILES: -+ filename = cast(str, file["filename"]) -+ start_log_group(f"Performing checkup on {filename}") -+ run_clang_tidy( -+ filename, file, version, checks, lines_changed_only, database, repo_root -+ ) -+ run_clang_format(filename, file, version, style, lines_changed_only) -+ end_log_group() -+ -+ create_comment_body(filename, file, lines_changed_only, tidy_notes) -+ -+ if Globals.PAYLOAD_TIDY: -+ if not Globals.OUTPUT: -+ Globals.OUTPUT = "\n" -+ else: -+ Globals.OUTPUT += "\n---\n" -+ Globals.OUTPUT += "## :speech_balloon: Output from `clang-tidy`\n" -+ Globals.OUTPUT += Globals.PAYLOAD_TIDY -+ GlobalParser.tidy_notes = tidy_notes[:] # restore cache of notifications -+ -+ -+def post_push_comment(base_url: str, user_id: int) -> bool: -+ """POST action's results for a push event. -+ -+ :param base_url: The root of the url used to interact with the REST API via -+ `requests`. -+ :param user_id: The user's account ID number. -+ -+ :returns: -+ A bool describing if the linter checks passed. This is used as the action's -+ output value (a soft exit code). -+ """ -+ comments_url = base_url + f"commits/{GITHUB_SHA}/comments" -+ remove_bot_comments(comments_url, user_id) -+ -+ if Globals.OUTPUT: # diff comments are not supported for push events (yet) -+ payload = json.dumps({"body": Globals.OUTPUT}) -+ logger.debug("payload body:\n%s", json.dumps({"body": Globals.OUTPUT})) -+ Globals.response_buffer = requests.post( -+ comments_url, headers=API_HEADERS, data=payload -+ ) -+ logger.info( -+ "Got %d response from POSTing comment", Globals.response_buffer.status_code -+ ) -+ log_response_msg() -+ return bool(Globals.OUTPUT) -+ -+ -+def post_diff_comments(base_url: str, user_id: int) -> bool: -+ """Post comments inside a unified diff (only PRs are supported). -+ -+ :param base_url: The root of the url used to interact with the REST API via -+ `requests`. -+ :param user_id: The user's account ID number. -+ -+ :returns: -+ A bool describing if the linter checks passed. This is used as the action's -+ output value (a soft exit code). -+ """ -+ comments_url = base_url + "pulls/comments/" # for use with comment_id -+ payload = list_diff_comments(2) # only focus on additions in diff -+ logger.info("Posting %d comments", len(payload)) -+ -+ # uncomment the next 3 lines for debug output without posting a comment -+ # for i, comment in enumerate(payload): -+ # logger.debug("comments %d: %s", i, json.dumps(comment, indent=2)) -+ # return -+ -+ # get existing review comments -+ reviews_url = base_url + f'pulls/{Globals.EVENT_PAYLOAD["number"]}/' -+ Globals.response_buffer = requests.get(reviews_url + "comments") -+ existing_comments = json.loads(Globals.response_buffer.text) -+ # filter out comments not made by our bot -+ for index, comment in enumerate(existing_comments): -+ if not comment["body"].startswith(""): -+ del existing_comments[index] -+ -+ # conditionally post comments in the diff -+ for i, body in enumerate(payload): -+ # check if comment is already there -+ already_posted = False -+ comment_id = None -+ for comment in existing_comments: -+ if ( -+ int(comment["user"]["id"]) == user_id -+ and comment["line"] == body["line"] -+ and comment["path"] == body["path"] -+ ): -+ already_posted = True -+ if comment["body"] != body["body"]: -+ comment_id = str(comment["id"]) # use this to update comment -+ else: -+ break -+ if already_posted and comment_id is None: -+ logger.info("comment %d already posted", i) -+ continue # don't bother re-posting the same comment -+ -+ # update ot create a review comment (in the diff) -+ logger.debug("Payload %d body = %s", i, json.dumps(body)) -+ if comment_id is not None: -+ Globals.response_buffer = requests.patch( -+ comments_url + comment_id, -+ headers=API_HEADERS, -+ data=json.dumps({"body": body["body"]}), -+ ) -+ logger.info( -+ "Got %d from PATCHing comment %d (%d)", -+ Globals.response_buffer.status_code, -+ i, -+ comment_id, -+ ) -+ log_response_msg() -+ else: -+ Globals.response_buffer = requests.post( -+ reviews_url + "comments", headers=API_HEADERS, data=json.dumps(body) -+ ) -+ logger.info( -+ "Got %d from POSTing review comment %d", -+ Globals.response_buffer.status_code, -+ i, -+ ) -+ log_response_msg() -+ return bool(payload) -+ -+ -+def post_pr_comment(base_url: str, user_id: int) -> bool: -+ """POST action's results for a push event. -+ -+ :param base_url: The root of the url used to interact with the REST API via -+ `requests`. -+ :param user_id: The user's account ID number. -+ -+ :returns: -+ A bool describing if the linter checks passed. This is used as the action's -+ output value (a soft exit code). -+ """ -+ comments_url = base_url + f'issues/{Globals.EVENT_PAYLOAD["number"]}/comments' -+ remove_bot_comments(comments_url, user_id) -+ payload = "" -+ if Globals.OUTPUT: -+ payload = json.dumps({"body": Globals.OUTPUT}) -+ logger.debug( -+ "payload body:\n%s", json.dumps({"body": Globals.OUTPUT}, indent=2) -+ ) -+ Globals.response_buffer = requests.post( -+ comments_url, headers=API_HEADERS, data=payload -+ ) -+ logger.info("Got %d from POSTing comment", Globals.response_buffer.status_code) -+ log_response_msg() -+ return bool(payload) -+ -+ -+def post_results(use_diff_comments: bool, user_id: int = 41898282): -+ """Post action's results using REST API. -+ -+ :param use_diff_comments: This flag enables making/updating comments in the PR's -+ diff info. -+ :param user_id: The user's account ID number. Defaults to the generic bot's ID. -+ """ -+ if not GITHUB_TOKEN: -+ logger.error("The GITHUB_TOKEN is required!") -+ sys.exit(set_exit_code(1)) -+ -+ base_url = f"{GITHUB_API_URL}/repos/{GITHUB_REPOSITORY}/" -+ checks_passed = True -+ if GITHUB_EVENT_NAME == "pull_request": -+ checks_passed = post_pr_comment(base_url, user_id) -+ if use_diff_comments: -+ checks_passed = post_diff_comments(base_url, user_id) -+ elif GITHUB_EVENT_NAME == "push": -+ checks_passed = post_push_comment(base_url, user_id) -+ set_exit_code(1 if checks_passed else 0) -+ -+ -+def make_annotations( -+ style: str, file_annotations: bool, lines_changed_only: int -+) -> bool: -+ """Use github log commands to make annotations from clang-format and -+ clang-tidy output. -+ -+ :param style: The chosen code style guidelines. The value 'file' is replaced with -+ 'custom style'. -+ -+ :returns: -+ A boolean describing if any annotations were made. -+ """ -+ count = 0 -+ files = ( -+ Globals.FILES -+ if GITHUB_EVENT_NAME == "pull_request" or isinstance(Globals.FILES, list) -+ else cast(Dict[str, Any], Globals.FILES)["files"] -+ ) -+ for advice, file in zip(GlobalParser.format_advice, files): -+ line_filter = range_of_changed_lines(file, lines_changed_only) -+ if advice.replaced_lines: -+ if file_annotations: -+ output = advice.log_command(style, line_filter) -+ if output is not None: -+ log_commander.info(output) -+ count += 1 -+ for note in GlobalParser.tidy_notes: -+ if lines_changed_only: -+ filename = note.filename.replace("\\", "/") -+ line_filter = [] -+ for file in files: -+ if filename == file["filename"]: -+ line_filter = range_of_changed_lines(file, lines_changed_only) -+ break -+ else: -+ continue -+ if note.line in line_filter: -+ count += 1 -+ log_commander.info(note.log_command()) -+ else: -+ count += 1 -+ log_commander.info(note.log_command()) -+ logger.info("Created %d annotations", count) -+ return bool(count) -+ -+ -+def parse_ignore_option(paths: str) -> Tuple[List[str], List[str]]: -+ """Parse a given string of paths (separated by a ``|``) into ``ignored`` and -+ ``not_ignored`` lists of strings. -+ -+ :param paths: This argument conforms to the input value of CLI arg -+ :cli-opt:`ignore`. -+ -+ :returns: -+ Returns a tuple of lists in which each list is a set of strings. -+ -+ - index 0 is the ``ignored`` list -+ - index 1 is the ``not_ignored`` list -+ """ -+ ignored, not_ignored = ([], []) -+ -+ for path in paths.split("|"): -+ is_included = path.startswith("!") -+ if path.startswith("!./" if is_included else "./"): -+ path = path.replace("./", "", 1) # relative dir is assumed -+ path = path.strip() # strip leading/trailing spaces -+ if is_included: -+ not_ignored.append(path[1:]) # strip leading `!` -+ else: -+ ignored.append(path) -+ -+ # auto detect submodules -+ gitmodules = Path(".gitmodules") -+ if gitmodules.exists(): -+ submodules = configparser.ConfigParser() -+ submodules.read(gitmodules.resolve().as_posix()) -+ for module in submodules.sections(): -+ path = submodules[module]["path"] -+ if path not in not_ignored: -+ logger.info("Appending submodule to ignored paths: %s", path) -+ ignored.append(path) -+ -+ if ignored: -+ logger.info( -+ "Ignoring the following paths/files:\n\t./%s", -+ "\n\t./".join(f for f in ignored), -+ ) -+ if not_ignored: -+ logger.info( -+ "Not ignoring the following paths/files:\n\t./%s", -+ "\n\t./".join(f for f in not_ignored), -+ ) -+ return (ignored, not_ignored) -+ -+ -+def main(): -+ """The main script.""" -+ -+ # The parsed CLI args -+ args = cli_arg_parser.parse_args() -+ -+ # force files-changed-only to reflect value of lines-changed-only -+ if args.lines_changed_only: -+ args.files_changed_only = True -+ -+ # set logging verbosity -+ logger.setLevel(int(args.verbosity)) -+ -+ # prepare ignored paths list -+ ignored, not_ignored = parse_ignore_option(args.ignore) -+ -+ logger.info("processing %s event", GITHUB_EVENT_NAME) -+ -+ # change working directory -+ os.chdir(args.repo_root) -+ -+ if GITHUB_EVENT_PATH: -+ # load event's json info about the workflow run -+ Globals.EVENT_PAYLOAD = json.loads( -+ Path(GITHUB_EVENT_PATH).read_text(encoding="utf-8") -+ ) -+ if logger.getEffectiveLevel() <= logging.DEBUG: -+ start_log_group("Event json from the runner") -+ logger.debug(json.dumps(Globals.EVENT_PAYLOAD)) -+ end_log_group() -+ -+ exit_early = False -+ if args.files_changed_only: -+ get_list_of_changed_files() -+ exit_early = not filter_out_non_source_files( -+ args.extensions, -+ ignored, -+ not_ignored, -+ ) -+ if not exit_early: -+ verify_files_are_present() -+ else: -+ exit_early = not list_source_files(args.extensions, ignored, not_ignored) -+ end_log_group() -+ if exit_early: -+ sys.exit(set_exit_code(0)) -+ -+ capture_clang_tools_output( -+ args.version, -+ args.tidy_checks, -+ args.style, -+ args.lines_changed_only, -+ args.database, -+ args.repo_root, -+ ) -+ -+ start_log_group("Posting comment(s)") -+ thread_comments_allowed = True -+ if GITHUB_EVENT_PATH and "private" in Globals.EVENT_PAYLOAD["repository"]: -+ thread_comments_allowed = ( -+ Globals.EVENT_PAYLOAD["repository"]["private"] is not True -+ ) -+ if args.thread_comments and thread_comments_allowed: -+ post_results(False) # False is hard-coded to disable diff comments. -+ set_exit_code( -+ int( -+ make_annotations(args.style, args.file_annotations, args.lines_changed_only) -+ ) -+ ) -+ end_log_group() -+ -+ -+if __name__ == "__main__": -+ main() -diff --git a/cpp_linter/thread_comments.py b/cpp_linter/thread_comments.py -new file mode 100644 -index 0000000..1d0699a ---- /dev/null -+++ b/cpp_linter/thread_comments.py -@@ -0,0 +1,269 @@ -+"""A module to house the various functions for traversing/adjusting comments""" -+from typing import Union, cast, List, Optional, Dict, Any -+import json -+from pathlib import Path -+import requests -+from . import ( -+ Globals, -+ GlobalParser, -+ logger, -+ API_HEADERS, -+ GITHUB_SHA, -+ log_response_msg, -+ range_of_changed_lines, -+) -+ -+ -+def remove_bot_comments(comments_url: str, user_id: int): -+ """Traverse the list of comments made by a specific user -+ and remove all. -+ -+ :param comments_url: The URL used to fetch the comments. -+ :param user_id: The user's account id number. -+ """ -+ logger.info("comments_url: %s", comments_url) -+ Globals.response_buffer = requests.get(comments_url) -+ if not log_response_msg(): -+ return # error getting comments for the thread; stop here -+ comments = Globals.response_buffer.json() -+ for comment in comments: -+ # only search for comments from the user's ID and -+ # whose comment body begins with a specific html comment -+ if ( -+ int(comment["user"]["id"]) == user_id -+ # the specific html comment is our action's name -+ and comment["body"].startswith("") -+ ): -+ # remove other outdated comments but don't remove the last comment -+ Globals.response_buffer = requests.delete( -+ comment["url"], -+ headers=API_HEADERS, -+ ) -+ logger.info( -+ "Got %d from DELETE %s", -+ Globals.response_buffer.status_code, -+ comment["url"][comment["url"].find(".com") + 4 :], -+ ) -+ log_response_msg() -+ logger.debug( -+ "comment id %d from user %s (%d)", -+ comment["id"], -+ comment["user"]["login"], -+ comment["user"]["id"], -+ ) -+ with open("comments.json", "w", encoding="utf-8") as json_comments: -+ json.dump(comments, json_comments, indent=4) -+ -+ -+def aggregate_tidy_advice(lines_changed_only: int) -> List[Dict[str, Any]]: -+ """Aggregate a list of json contents representing advice from clang-tidy -+ suggestions. -+ -+ :param lines_changed_only: A flag indicating the focus of the advice that -+ should be headed. -+ """ -+ results = [] -+ for fixit, file in zip(GlobalParser.tidy_advice, Globals.FILES): -+ for diag in fixit.diagnostics: -+ ranges = range_of_changed_lines(file, lines_changed_only) -+ if lines_changed_only and diag.line not in ranges: -+ continue -+ -+ # base body of comment -+ body = "\n## :speech_balloon: Clang-tidy\n**" -+ body += diag.name + "**\n>" + diag.message -+ -+ # get original code -+ filename = Path(cast(str, file["filename"])) -+ # the list of lines in a file -+ lines = filename.read_text(encoding="utf-8").splitlines() -+ -+ # aggregate clang-tidy advice -+ suggestion = "\n```suggestion\n" -+ is_multiline_fix = False -+ fix_lines: List[int] = [] # a list of line numbers for the suggested fixes -+ line = "" # the line that concerns the fix/comment -+ for i, tidy_fix in enumerate(diag.replacements): -+ line = lines[tidy_fix.line - 1] -+ if not fix_lines: -+ fix_lines.append(tidy_fix.line) -+ elif tidy_fix.line not in fix_lines: -+ is_multiline_fix = True -+ break -+ if i: # if this isn't the first tidy_fix for the same line -+ last_fix = diag.replacements[i - 1] -+ suggestion += ( -+ line[last_fix.cols + last_fix.null_len - 1 : tidy_fix.cols - 1] -+ + tidy_fix.text.decode() -+ ) -+ else: -+ suggestion += line[: tidy_fix.cols - 1] + tidy_fix.text.decode() -+ if not is_multiline_fix and diag.replacements: -+ # complete suggestion with original src code and closing md fence -+ last_fix = diag.replacements[len(diag.replacements) - 1] -+ suggestion += line[last_fix.cols + last_fix.null_len - 1 : -1] + "\n```" -+ body += suggestion -+ -+ results.append( -+ dict( -+ body=body, -+ commit_id=GITHUB_SHA, -+ line=diag.line, -+ path=fixit.filename, -+ side="RIGHT", -+ ) -+ ) -+ return results -+ -+ -+def aggregate_format_advice(lines_changed_only: int) -> List[Dict[str, Any]]: -+ """Aggregate a list of json contents representing advice from clang-format -+ suggestions.""" -+ results = [] -+ for fmt_advice, file in zip(GlobalParser.format_advice, Globals.FILES): -+ -+ # get original code -+ filename = Path(file["filename"]) -+ # the list of lines from the src file -+ lines = filename.read_text(encoding="utf-8").splitlines() -+ -+ # aggregate clang-format suggestion -+ line = "" # the line that concerns the fix -+ for fixed_line in fmt_advice.replaced_lines: -+ # clang-format can include advice that starts/ends outside the diff's domain -+ ranges = range_of_changed_lines(file, lines_changed_only) -+ if lines_changed_only and fixed_line.line not in ranges: -+ continue # line is out of scope for diff, so skip this fix -+ -+ # assemble the suggestion -+ body = "## :scroll: clang-format advice\n```suggestion\n" -+ line = lines[fixed_line.line - 1] -+ # logger.debug("%d >>> %s", fixed_line.line, line[:-1]) -+ for fix_index, line_fix in enumerate(fixed_line.replacements): -+ # logger.debug( -+ # "%s >>> %s", repr(line_fix), line_fix.text.encode("utf-8") -+ # ) -+ if fix_index: -+ last_fix = fixed_line.replacements[fix_index - 1] -+ body += line[ -+ last_fix.cols + last_fix.null_len - 1 : line_fix.cols - 1 -+ ] -+ body += line_fix.text -+ else: -+ body += line[: line_fix.cols - 1] + line_fix.text -+ # complete suggestion with original src code and closing md fence -+ last_fix = fixed_line.replacements[-1] -+ body += line[last_fix.cols + last_fix.null_len - 1 : -1] + "\n```" -+ # logger.debug("body <<< %s", body) -+ -+ # create a suggestion from clang-format advice -+ results.append( -+ dict( -+ body=body, -+ commit_id=GITHUB_SHA, -+ line=fixed_line.line, -+ path=fmt_advice.filename, -+ side="RIGHT", -+ ) -+ ) -+ return results -+ -+ -+def concatenate_comments( -+ tidy_advice: list, format_advice: list -+) -> List[Dict[str, Union[str, int]]]: -+ """Concatenate comments made to the same line of the same file. -+ -+ :param tidy_advice: Pass the output from `aggregate_tidy_advice()` here. -+ :param format_advice: Pass the output from `aggregate_format_advice()` here. -+ """ -+ # traverse comments from clang-format -+ for index, comment_body in enumerate(format_advice): -+ # check for comments from clang-tidy on the same line -+ comment_index = None -+ for i, payload in enumerate(tidy_advice): -+ if ( -+ payload["line"] == comment_body["line"] -+ and payload["path"] == comment_body["path"] -+ ): -+ comment_index = i # mark this comment for concatenation -+ break -+ if comment_index is not None: -+ # append clang-format advice to clang-tidy output/suggestion -+ tidy_advice[comment_index]["body"] += "\n" + comment_body["body"] -+ del format_advice[index] # remove duplicate comment -+ return tidy_advice + format_advice -+ -+ -+def list_diff_comments(lines_changed_only: int) -> List[Dict[str, Union[str, int]]]: -+ """Aggregate list of comments for use in the event's diff. This function assumes -+ that the CLI option ``--lines_changed_only`` is set to True. -+ -+ :param lines_changed_only: A flag indicating the focus of the advice that -+ should be headed. -+ -+ :returns: -+ A list of comments (each element as json content). -+ """ -+ return concatenate_comments( -+ aggregate_tidy_advice(lines_changed_only), -+ aggregate_format_advice(lines_changed_only), -+ ) -+ -+ -+def get_review_id(reviews_url: str, user_id: int) -> Optional[int]: -+ """Dismiss all stale reviews (only the ones made by our bot). -+ -+ :param reviews_url: The URL used to fetch the review comments. -+ :param user_id: The user's account id number. -+ -+ :returns: -+ The ID number of the review created by the action's generic bot. -+ """ -+ logger.info(" review_url: %s", reviews_url) -+ Globals.response_buffer = requests.get(reviews_url) -+ review_id = find_review(json.loads(Globals.response_buffer.text), user_id) -+ if review_id is None: # create a PR review -+ Globals.response_buffer = requests.post( -+ reviews_url, -+ headers=API_HEADERS, -+ data=json.dumps( -+ { -+ "body": "\n" -+ "CPP Linter Action found no problems", -+ "event": "COMMENTED", -+ } -+ ), -+ ) -+ logger.info( -+ "Got %d from POSTing new(/temp) PR review", -+ Globals.response_buffer.status_code, -+ ) -+ Globals.response_buffer = requests.get(reviews_url) -+ if Globals.response_buffer.status_code != 200 and log_response_msg(): -+ raise RuntimeError("could not create a review for comments") -+ reviews = json.loads(Globals.response_buffer.text) -+ reviews.reverse() # traverse the list in reverse -+ review_id = find_review(reviews, user_id) -+ return review_id -+ -+ -+def find_review(reviews: dict, user_id: int) -> Optional[int]: -+ """Find a review created by a certain user ID. -+ -+ :param reviews: the JSON object fetched via GIT REST API. -+ :param user_id: The user account's ID number -+ -+ :returns: -+ An ID that corresponds to the specified ``user_id``. -+ """ -+ review_id = None -+ for review in reviews: -+ if int(review["user"]["id"]) == user_id and review["body"].startswith( -+ "" -+ ): -+ review_id = int(review["id"]) -+ break # there will only be 1 review from this action, so break when found -+ -+ logger.info(" review_id: %d", review_id) -+ return review_id -diff --git a/docs/API-Reference/cpp_linter.clang_format_xml.rst b/docs/API-Reference/cpp_linter.clang_format_xml.rst -new file mode 100644 -index 0000000..6011fdf ---- /dev/null -+++ b/docs/API-Reference/cpp_linter.clang_format_xml.rst -@@ -0,0 +1,10 @@ -+clang_format_xml module -+======================= -+ -+.. admonition:: Info -+ :class: info -+ -+ This API is experimental and not actually used in production. -+ -+.. automodule:: cpp_linter.clang_format_xml -+ :members: -diff --git a/docs/API-Reference/cpp_linter.clang_tidy.rst b/docs/API-Reference/cpp_linter.clang_tidy.rst -new file mode 100644 -index 0000000..9aecade ---- /dev/null -+++ b/docs/API-Reference/cpp_linter.clang_tidy.rst -@@ -0,0 +1,5 @@ -+clang_tidy module -+================= -+ -+.. automodule:: cpp_linter.clang_tidy -+ :members: -diff --git a/docs/API-Reference/cpp_linter.clang_tidy_yml.rst b/docs/API-Reference/cpp_linter.clang_tidy_yml.rst -new file mode 100644 -index 0000000..695a7d0 ---- /dev/null -+++ b/docs/API-Reference/cpp_linter.clang_tidy_yml.rst -@@ -0,0 +1,10 @@ -+clang_tidy_yml module -+===================== -+ -+.. admonition:: Info -+ :class: info -+ -+ This API is experimental and not actually used in production. -+ -+.. automodule:: cpp_linter.clang_tidy_yml -+ :members: -diff --git a/docs/API-Reference/cpp_linter.rst b/docs/API-Reference/cpp_linter.rst -new file mode 100644 -index 0000000..a2ba1f3 ---- /dev/null -+++ b/docs/API-Reference/cpp_linter.rst -@@ -0,0 +1,5 @@ -+Base module -+=========== -+ -+.. automodule:: cpp_linter -+ :members: -diff --git a/docs/API-Reference/cpp_linter.run.rst b/docs/API-Reference/cpp_linter.run.rst -new file mode 100644 -index 0000000..b2c8159 ---- /dev/null -+++ b/docs/API-Reference/cpp_linter.run.rst -@@ -0,0 +1,5 @@ -+Run module -+========== -+ -+.. automodule:: cpp_linter.run -+ :members: -diff --git a/docs/API-Reference/cpp_linter.thread_comments.rst b/docs/API-Reference/cpp_linter.thread_comments.rst -new file mode 100644 -index 0000000..ceb3005 ---- /dev/null -+++ b/docs/API-Reference/cpp_linter.thread_comments.rst -@@ -0,0 +1,5 @@ -+thread_comments module -+====================== -+ -+.. automodule:: cpp_linter.thread_comments -+ :members: -diff --git a/docs/_static/extra_css.css b/docs/_static/extra_css.css -new file mode 100644 -index 0000000..8e2a15e ---- /dev/null -+++ b/docs/_static/extra_css.css -@@ -0,0 +1,10 @@ -+tbody .stub, -+thead { -+ background-color: var(--md-accent-bg-color--light); -+ color: var(--md-default-bg-color); -+} -+ -+.md-header, -+.md-nav--primary .md-nav__title[for="__drawer"] { -+ background-color: #4051b5; -+} -diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico -new file mode 100644 -index 0000000..c5de55c -Binary files /dev/null and b/docs/_static/favicon.ico differ -diff --git a/docs/_static/logo.png b/docs/_static/logo.png -new file mode 100644 -index 0000000..9c3e4db -Binary files /dev/null and b/docs/_static/logo.png differ -diff --git a/docs/building_docs.rst b/docs/building_docs.rst -new file mode 100644 -index 0000000..069f108 ---- /dev/null -+++ b/docs/building_docs.rst -@@ -0,0 +1,21 @@ -+How to build the docs -+===================== -+ -+From the root directory of the repository, do the following to steps -+ -+1. Install docs' dependencies -+ -+ .. code-block:: text -+ -+ pip install -r docs/requirements.txt -+ -+ On Linux, you may need to use ``pip3`` instead. -+ -+2. Build the docs -+ -+ .. code-block:: text -+ -+ sphinx-build docs docs/_build/html -+ -+ Browse the files in docs/_build/html with your internet browser to see the rendered -+ output. -diff --git a/docs/conf.py b/docs/conf.py -new file mode 100644 -index 0000000..b6d772e ---- /dev/null -+++ b/docs/conf.py -@@ -0,0 +1,138 @@ -+# pylint: disable=all -+# Configuration file for the Sphinx documentation builder. -+# -+# For the full list of built-in configuration values, see the documentation: -+# https://www.sphinx-doc.org/en/master/usage/configuration.html -+ -+import re -+from pathlib import Path -+import io -+from docutils.nodes import Node -+from sphinx import addnodes -+from sphinx.application import Sphinx -+from sphinx.environment import BuildEnvironment -+from cpp_linter.run import cli_arg_parser -+ -+# -- Project information ----------------------------------------------------- -+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -+project = "cpp-linter" -+copyright = "2022, 2bndy5" -+author = "2bndy5" -+release = "2.0.0" -+ -+# -- General configuration --------------------------------------------------- -+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -+extensions = [ -+ "sphinx_immaterial", -+ "sphinx.ext.autodoc", -+ "sphinx.ext.intersphinx", -+ "sphinx.ext.viewcode", -+] -+ -+intersphinx_mapping = { -+ "python": ("https://docs.python.org/3", None), -+ "requests": ("https://requests.readthedocs.io/en/latest/", None), -+} -+ -+templates_path = ["_templates"] -+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] -+ -+default_role = "any" -+ -+# -- Options for HTML output ------------------------------------------------- -+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -+ -+html_theme = "sphinx_immaterial" -+html_static_path = ["_static"] -+html_logo = "_static/logo.png" -+html_favicon = "_static/favicon.ico" -+html_css_files = ["extra_css.css"] -+html_title = "cpp-linter" -+ -+html_theme_options = { -+ "repo_url": "https://github.com/cpp-linter/cpp-linter", -+ "repo_name": "cpp-linter", -+ "repo_type": "github", -+ "palette": [ -+ { -+ "media": "(prefers-color-scheme: light)", -+ "scheme": "default", -+ "primary": "light-blue", -+ "accent": "deep-purple", -+ "toggle": { -+ "icon": "material/lightbulb-outline", -+ "name": "Switch to dark mode", -+ }, -+ }, -+ { -+ "media": "(prefers-color-scheme: dark)", -+ "scheme": "slate", -+ "primary": "light-blue", -+ "accent": "deep-purple", -+ "toggle": { -+ "icon": "material/lightbulb", -+ "name": "Switch to light mode", -+ }, -+ }, -+ ], -+ "features": [ -+ "navigation.top", -+ "navigation.tabs", -+ "navigation.tabs.sticky", -+ "toc.sticky", -+ "toc.follow", -+ "search.share", -+ ], -+} -+ -+object_description_options = [ -+ ("py:parameter", dict(include_in_toc=False)), -+] -+ -+# -- Parse CLI args from `-h` output ------------------------------------- -+ -+ -+def parse_cli_option(env: BuildEnvironment, sig: str, sig_node: Node): -+ """parse the given signature of a CLI option and -+ return the docutil nodes accordingly.""" -+ opt_names = sig.split(", ") -+ sig_node["is_multiline"] = True -+ for i, opt_name in enumerate(opt_names): -+ name = addnodes.desc_signature_line("", "--" if i else opt_name) -+ if not i: -+ name["add_permalink"] = True -+ else: -+ name += addnodes.desc_name(opt_name, opt_name.lstrip("-")) -+ sig_node += name -+ # print(sig_node.pformat()) -+ return opt_names[-1].lstrip("-") -+ -+ -+def setup(app: Sphinx): -+ """Generate a doc from the executable script's ``--help`` output.""" -+ app.add_object_type( -+ "cli-opt", -+ "cli-opt", -+ objname="Command Line Interface option", -+ indextemplate="pair: %s; Command Line Interface option", -+ parse_node=parse_cli_option, -+ ) -+ -+ with io.StringIO() as help_out: -+ cli_arg_parser.print_help(help_out) -+ output = help_out.getvalue() -+ first_line = re.search(r"^options:\s*\n", output, re.MULTILINE) -+ if first_line is None: -+ raise OSError("unrecognized output from `cpp-linter -h`") -+ output = output[first_line.end(0) :] -+ doc = "Command Line Interface Options\n==============================\n\n" -+ CLI_OPT_NAME = re.compile(r"^\s*(\-\w)\s?[A-Z_]*,\s(\-\-.*?)\s") -+ for line in output.splitlines(): -+ match = CLI_OPT_NAME.search(line) -+ if match is not None: -+ # print(match.groups()) -+ doc += "\n.. cli-opt:: " + ", ".join(match.groups()) + "\n\n" -+ doc += line + "\n" -+ cli_doc = Path(app.srcdir, "cli_args.rst") -+ cli_doc.unlink(missing_ok=True) -+ cli_doc.write_text(doc) -diff --git a/docs/index.rst b/docs/index.rst -new file mode 100644 -index 0000000..f723b71 ---- /dev/null -+++ b/docs/index.rst -@@ -0,0 +1,29 @@ -+.. include:: ../README.rst -+ -+.. toctree:: -+ :hidden: -+ -+ self -+ building_docs -+ -+.. toctree:: -+ :hidden: -+ -+ cli_args -+ -+.. toctree:: -+ :hidden: -+ :caption: API Reference -+ -+ API-Reference/cpp_linter -+ API-Reference/cpp_linter.run -+ API-Reference/cpp_linter.clang_tidy -+ API-Reference/cpp_linter.clang_tidy_yml -+ API-Reference/cpp_linter.clang_format_xml -+ API-Reference/cpp_linter.thread_comments -+ -+Indices and tables -+================== -+ -+* :ref:`genindex` -+* :ref:`modindex` -diff --git a/docs/requirements.txt b/docs/requirements.txt -new file mode 100644 -index 0000000..d078f24 ---- /dev/null -+++ b/docs/requirements.txt -@@ -0,0 +1 @@ -+sphinx-immaterial -diff --git a/pyproject.toml b/pyproject.toml -new file mode 100644 -index 0000000..981b64c ---- /dev/null -+++ b/pyproject.toml -@@ -0,0 +1,616 @@ -+[build-system] -+requires = ["setuptools>=45", "setuptools-scm"] -+build-backend = "setuptools.build_meta" -+ -+[project] -+name = "cpp-linter" -+description = "Run clang-format and clang-tidy on a batch of files." -+readme = "README.md" -+keywords = ["clang", "clang-tools", "linter", "clang-tidy", "clang-format"] -+license = {text = "MIT License"} -+authors = [ -+ { name = "Brendan Doherty", email = "2bndy5@gmail.com" }, -+ { name = "Peter Shen", email = "xianpeng.shen@gmail.com" }, -+] -+dependencies = [ -+ "requests", -+ "pyyaml", -+] -+classifiers = [ -+ # https://pypi.org/pypi?%3Aaction=list_classifiers -+ "Development Status :: 5 - Production/Stable", -+ "License :: OSI Approved :: MIT License", -+ "Intended Audience :: Developers", -+ "Intended Audience :: System Administrators", -+ "Intended Audience :: Information Technology", -+ "Natural Language :: English", -+ "Operating System :: Microsoft :: Windows", -+ "Operating System :: POSIX :: Linux", -+ "Operating System :: MacOS", -+ "Programming Language :: Python :: 3", -+ "Topic :: Software Development :: Build Tools", -+] -+dynamic = ["version"] -+ -+[project.scripts] -+cpp-linter = "cpp_linter.run:main" -+ -+[project.urls] -+source = "https://github.com/cpp-linter/cpp-linter" -+tracker = "https://github.com/cpp-linter/cpp-linter/issues" -+ -+# ... other project metadata fields as specified in: -+# https://packaging.python.org/en/latest/specifications/declaring-project-metadata/ -+ -+[tool.setuptools] -+zip-safe = false -+packages = ["cpp_linter"] -+ -+[tool.setuptools_scm] -+# It would be nice to include the commit hash in the version, but that -+# can't be done in a PEP 440-compatible way. -+version_scheme= "no-guess-dev" -+# Test PyPI does not support local versions. -+local_scheme = "no-local-version" -+fallback_version = "0.0.0" -+ -+[tool.mypy] -+show_error_codes = true -+show_column_numbers = true -+ -+[tool.pytest.ini_options] -+minversion = "6.0" -+addopts = "-vv" -+testpaths = ["tests"] -+ -+[tool.coverage] -+[tool.coverage.run] -+dynamic_context = "test_function" -+omit = [ -+ # don't include tests in coverage -+ "tests/*", -+] -+ -+[tool.coverage.json] -+pretty_print = true -+ -+[tool.coverage.html] -+show_contexts = true -+ -+[tool.coverage.report] -+# Regexes for lines to exclude from consideration -+exclude_lines = [ -+ # Have to re-enable the standard pragma -+ "pragma: no cover", -+ # Don\'t complain about missing debug-only code: -+ "def __repr__", -+ # the point of unit tests is to test parts of main() -+ "def main", -+ # ignore any branch that makes the module executable -+ 'if __name__ == "__main__"', -+ # ignore branches specific to type checking -+ "if TYPE_CHECKING", -+ # ignore the local secific debug statement related to not having rich installed -+ "if not FOUND_RICH_LIB", -+] -+ -+[tool.pylint.main] -+# Analyse import fallback blocks. This can be used to support both Python 2 and 3 -+# compatible code, which means that the block might have code that exists only in -+# one or another interpreter, leading to false positives when analysed. -+# analyse-fallback-blocks = -+ -+# Always return a 0 (non-error) status code, even if lint errors are found. This -+# is primarily useful in continuous integration scripts. -+# exit-zero = -+ -+# A comma-separated list of package or module names from where C extensions may -+# be loaded. Extensions are loading into the active Python interpreter and may -+# run arbitrary code. -+# extension-pkg-allow-list = -+ -+# A comma-separated list of package or module names from where C extensions may -+# be loaded. Extensions are loading into the active Python interpreter and may -+# run arbitrary code. (This is an alternative name to extension-pkg-allow-list -+# for backward compatibility.) -+# extension-pkg-whitelist = -+ -+# Return non-zero exit code if any of these messages/categories are detected, -+# even if score is above --fail-under value. Syntax same as enable. Messages -+# specified are enabled, while categories only check already-enabled messages. -+# fail-on = -+ -+# Specify a score threshold to be exceeded before program exits with error. -+fail-under = 10 -+ -+# Interpret the stdin as a python script, whose filename needs to be passed as -+# the module_or_package argument. -+# from-stdin = -+ -+# Files or directories to be skipped. They should be base names, not paths. -+ignore = ["CVS"] -+ -+# Add files or directories matching the regex patterns to the ignore-list. The -+# regex matches against paths and can be in Posix or Windows format. -+# ignore-paths = -+ -+# Files or directories matching the regex patterns are skipped. The regex matches -+# against base names, not paths. The default value ignores Emacs file locks -+# ignore-patterns = -+ -+# List of module names for which member attributes should not be checked (useful -+# for modules/projects where namespaces are manipulated during runtime and thus -+# existing member attributes cannot be deduced by static analysis). It supports -+# qualified module names, as well as Unix pattern matching. -+# ignored-modules = -+ -+# Python code to execute, usually for sys.path manipulation such as -+# pygtk.require(). -+# init-hook = -+ -+# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -+# number of processors available to use. -+jobs = 2 -+ -+# Control the amount of potential inferred values when inferring a single object. -+# This can help the performance when dealing with large functions or complex, -+# nested conditions. -+limit-inference-results = 100 -+ -+# List of plugins (as comma separated values of python module names) to load, -+# usually to register additional checkers. -+# load-plugins = -+ -+# Pickle collected data for later comparisons. -+persistent = true -+ -+# Minimum Python version to use for version dependent checks. Will default to the -+# version used to run pylint. -+py-version = "3.10" -+ -+# Discover python modules and packages in the file system subtree. -+# recursive = -+ -+# When enabled, pylint would attempt to guess common misconfiguration and emit -+# user-friendly hints instead of false-positive error messages. -+suggestion-mode = true -+ -+# Allow loading of arbitrary C extensions. Extensions are imported into the -+# active Python interpreter and may run arbitrary code. -+# unsafe-load-any-extension = -+ -+[tool.pylint.basic] -+# Naming style matching correct argument names. -+argument-naming-style = "snake_case" -+ -+# Regular expression matching correct argument names. Overrides argument-naming- -+# style. If left empty, argument names will be checked with the set naming style. -+argument-rgx = "(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$" -+ -+# Naming style matching correct attribute names. -+attr-naming-style = "snake_case" -+ -+# Regular expression matching correct attribute names. Overrides attr-naming- -+# style. If left empty, attribute names will be checked with the set naming -+# style. -+attr-rgx = "(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$" -+ -+# Bad variable names which should always be refused, separated by a comma. -+bad-names = ["foo", "bar", "baz", "toto", "tutu", "tata"] -+ -+# Bad variable names regexes, separated by a comma. If names match any regex, -+# they will always be refused -+# bad-names-rgxs = -+ -+# Naming style matching correct class attribute names. -+class-attribute-naming-style = "any" -+ -+# Regular expression matching correct class attribute names. Overrides class- -+# attribute-naming-style. If left empty, class attribute names will be checked -+# with the set naming style. -+class-attribute-rgx = "([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$" -+ -+# Naming style matching correct class constant names. -+class-const-naming-style = "UPPER_CASE" -+ -+# Regular expression matching correct class constant names. Overrides class- -+# const-naming-style. If left empty, class constant names will be checked with -+# the set naming style. -+# class-const-rgx = -+ -+# Naming style matching correct class names. -+class-naming-style = "PascalCase" -+ -+# Regular expression matching correct class names. Overrides class-naming-style. -+# If left empty, class names will be checked with the set naming style. -+class-rgx = "[A-Z_][a-zA-Z0-9_]+$" -+ -+# Naming style matching correct constant names. -+const-naming-style = "UPPER_CASE" -+ -+# Regular expression matching correct constant names. Overrides const-naming- -+# style. If left empty, constant names will be checked with the set naming style. -+const-rgx = "(([A-Z_][A-Z0-9_]*)|(__.*__))$" -+ -+# Minimum line length for functions/classes that require docstrings, shorter ones -+# are exempt. -+docstring-min-length = -1 -+ -+# Naming style matching correct function names. -+function-naming-style = "snake_case" -+ -+# Regular expression matching correct function names. Overrides function-naming- -+# style. If left empty, function names will be checked with the set naming style. -+function-rgx = "(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$" -+ -+# Good variable names which should always be accepted, separated by a comma. -+good-names = ["r", "g", "b", "w", "i", "j", "k", "n", "x", "y", "z", "ex", "ok", "Run", "_"] -+ -+# Good variable names regexes, separated by a comma. If names match any regex, -+# they will always be accepted -+# good-names-rgxs = -+ -+# Include a hint for the correct naming format with invalid-name. -+# include-naming-hint = -+ -+# Naming style matching correct inline iteration names. -+inlinevar-naming-style = "any" -+ -+# Regular expression matching correct inline iteration names. Overrides -+# inlinevar-naming-style. If left empty, inline iteration names will be checked -+# with the set naming style. -+inlinevar-rgx = "[A-Za-z_][A-Za-z0-9_]*$" -+ -+# Naming style matching correct method names. -+method-naming-style = "snake_case" -+ -+# Regular expression matching correct method names. Overrides method-naming- -+# style. If left empty, method names will be checked with the set naming style. -+method-rgx = "(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$" -+ -+# Naming style matching correct module names. -+module-naming-style = "snake_case" -+ -+# Regular expression matching correct module names. Overrides module-naming- -+# style. If left empty, module names will be checked with the set naming style. -+module-rgx = "(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$" -+ -+# Colon-delimited sets of names that determine each other's naming style when the -+# name regexes allow several styles. -+# name-group = -+ -+# Regular expression which should only match function or class names that do not -+# require a docstring. -+no-docstring-rgx = "^_" -+ -+# List of decorators that produce properties, such as abc.abstractproperty. Add -+# to this list to register other decorators that produce valid properties. These -+# decorators are taken in consideration only for invalid-name. -+property-classes = ["abc.abstractproperty"] -+ -+# Regular expression matching correct type variable names. If left empty, type -+# variable names will be checked with the set naming style. -+# typevar-rgx = -+ -+# Naming style matching correct variable names. -+variable-naming-style = "snake_case" -+ -+# Regular expression matching correct variable names. Overrides variable-naming- -+# style. If left empty, variable names will be checked with the set naming style. -+variable-rgx = "(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$" -+ -+[tool.pylint.classes] -+# Warn about protected attribute access inside special methods -+# check-protected-access-in-special-methods = -+ -+# List of method names used to declare (i.e. assign) instance attributes. -+defining-attr-methods = ["__init__", "__new__", "setUp"] -+ -+# List of member names, which should be excluded from the protected access -+# warning. -+exclude-protected = ["_asdict", "_fields", "_replace", "_source", "_make"] -+ -+# List of valid names for the first argument in a class method. -+valid-classmethod-first-arg = ["cls"] -+ -+# List of valid names for the first argument in a metaclass class method. -+valid-metaclass-classmethod-first-arg = ["mcs"] -+ -+[tool.pylint.design] -+# List of regular expressions of class ancestor names to ignore when counting -+# public methods (see R0903) -+# exclude-too-few-public-methods = -+ -+# List of qualified class names to ignore when counting class parents (see R0901) -+# ignored-parents = -+ -+# Maximum number of arguments for function / method. -+max-args = 8 -+ -+# Maximum number of attributes for a class (see R0902). -+max-attributes = 11 -+ -+# Maximum number of boolean expressions in an if statement (see R0916). -+max-bool-expr = 5 -+ -+# Maximum number of branch for function / method body. -+max-branches = 12 -+ -+# Maximum number of locals for function / method body. -+max-locals = 18 -+ -+# Maximum number of parents for a class (see R0901). -+max-parents = 7 -+ -+# Maximum number of public methods for a class (see R0904). -+max-public-methods = 20 -+ -+# Maximum number of return / yield for function / method body. -+max-returns = 6 -+ -+# Maximum number of statements in function / method body. -+max-statements = 50 -+ -+# Minimum number of public methods for a class (see R0903). -+min-public-methods = 1 -+ -+[tool.pylint.exceptions] -+# Exceptions that will emit a warning when caught. -+overgeneral-exceptions = ["Exception"] -+ -+[tool.pylint.format] -+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -+expected-line-ending-format = "LF" -+ -+# Regexp for a line that is allowed to be longer than the limit. -+ignore-long-lines = "^\\s*(# )??$" -+ -+# Number of spaces of indent required inside a hanging or continued line. -+indent-after-paren = 4 -+ -+# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -+# tab). -+indent-string = " " -+ -+# Maximum number of characters on a single line. -+max-line-length = 88 -+ -+# Maximum number of lines in a module. -+max-module-lines = 1000 -+ -+# Allow the body of a class to be on the same line as the declaration if body -+# contains single statement. -+# single-line-class-stmt = -+ -+# Allow the body of an if to be on the same line as the test if there is no else. -+# single-line-if-stmt = -+ -+[tool.pylint.imports] -+# List of modules that can be imported at any level, not just the top level one. -+# allow-any-import-level = -+ -+# Allow wildcard imports from modules that define __all__. -+# allow-wildcard-with-all = -+ -+# Deprecated modules which should not be used, separated by a comma. -+deprecated-modules = ["optparse", "tkinter.tix"] -+ -+# Output a graph (.gv or any supported image format) of external dependencies to -+# the given file (report RP0402 must not be disabled). -+# ext-import-graph = -+ -+# Output a graph (.gv or any supported image format) of all (i.e. internal and -+# external) dependencies to the given file (report RP0402 must not be disabled). -+# import-graph = -+ -+# Output a graph (.gv or any supported image format) of internal dependencies to -+# the given file (report RP0402 must not be disabled). -+# int-import-graph = -+ -+# Force import order to recognize a module as part of the standard compatibility -+# libraries. -+# known-standard-library = -+ -+# Force import order to recognize a module as part of a third party library. -+known-third-party = ["enchant"] -+ -+# Couples of modules and preferred modules, separated by a comma. -+# preferred-modules = -+ -+[tool.pylint.logging] -+# The type of string formatting that logging methods do. `old` means using % -+# formatting, `new` is for `{}` formatting. -+logging-format-style = "old" -+ -+# Logging modules to check that the string format arguments are in logging -+# function parameter format. -+logging-modules = ["logging"] -+ -+[tool.pylint."messages control"] -+# Only show warnings with the listed confidence levels. Leave empty to show all. -+# Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -+confidence = ["HIGH", "CONTROL_FLOW", "INFERENCE", "INFERENCE_FAILURE", "UNDEFINED"] -+ -+# Disable the message, report, category or checker with the given id(s). You can -+# either give multiple identifiers separated by comma (,) or put this option -+# multiple times (only on the command line, not in the configuration file where -+# it should appear only once). You can also use "--disable=all" to disable -+# everything first and then re-enable specific checks. For example, if you want -+# to run only the similarities checker, you can use "--disable=all -+# --enable=similarities". If you want to run only the classes checker, but have -+# no Warning level messages displayed, use "--disable=all --enable=classes -+# --disable=W". -+disable = ["raw-checker-failed", "bad-inline-option", "locally-disabled", "file-ignored", "suppressed-message", "useless-suppression", "deprecated-pragma", "use-symbolic-message-instead", "invalid-sequence-index", "anomalous-backslash-in-string", "too-few-public-methods", "consider-using-f-string", "subprocess-run-check"] -+ -+# Enable the message, report, category or checker with the given id(s). You can -+# either give multiple identifier separated by comma (,) or put this option -+# multiple time (only on the command line, not in the configuration file where it -+# should appear only once). See also the "--disable" option for examples. -+enable = ["c-extension-no-member"] -+ -+[tool.pylint.miscellaneous] -+# List of note tags to take in consideration, separated by a comma. -+notes = ["FIXME", "XXX"] -+ -+# Regular expression of note tags to take in consideration. -+# notes-rgx = -+ -+[tool.pylint.refactoring] -+# Maximum number of nested blocks for function / method body -+max-nested-blocks = 5 -+ -+# Complete name of functions that never returns. When checking for inconsistent- -+# return-statements if a never returning function is called then it will be -+# considered as an explicit return statement and no message will be printed. -+never-returning-functions = ["sys.exit", "argparse.parse_error"] -+ -+[tool.pylint.reports] -+# Python expression which should return a score less than or equal to 10. You -+# have access to the variables 'fatal', 'error', 'warning', 'refactor', -+# 'convention', and 'info' which contain the number of messages in each category, -+# as well as 'statement' which is the total number of statements analyzed. This -+# score is used by the global evaluation report (RP0004). -+evaluation = "10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)" -+ -+# Template used to display messages. This is a python new-style format string -+# used to format the message information. See doc for all details. -+# msg-template = -+ -+# Set the output format. Available formats are text, parseable, colorized, json -+# and msvs (visual studio). You can also give a reporter class, e.g. -+# mypackage.mymodule.MyReporterClass. -+# output-format = -+ -+# Tells whether to display a full report or only the messages. -+# reports = -+ -+# Activate the evaluation score. -+score = true -+ -+[tool.pylint.similarities] -+# Comments are removed from the similarity computation -+ignore-comments = true -+ -+# Docstrings are removed from the similarity computation -+ignore-docstrings = true -+ -+# Imports are removed from the similarity computation -+# ignore-imports = -+ -+# Signatures are removed from the similarity computation -+ignore-signatures = true -+ -+# Minimum lines number of a similarity. -+min-similarity-lines = 4 -+ -+[tool.pylint.spelling] -+# Limits count of emitted suggestions for spelling mistakes. -+max-spelling-suggestions = 4 -+ -+# Spelling dictionary name. Available dictionaries: none. To make it work, -+# install the 'python-enchant' package. -+# spelling-dict = -+ -+# List of comma separated words that should be considered directives if they -+# appear at the beginning of a comment and should not be checked. -+spelling-ignore-comment-directives = "fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:" -+ -+# List of comma separated words that should not be checked. -+# spelling-ignore-words = -+ -+# A path to a file that contains the private dictionary; one word per line. -+# spelling-private-dict-file = -+ -+# Tells whether to store unknown words to the private dictionary (see the -+# --spelling-private-dict-file option) instead of raising a message. -+# spelling-store-unknown-words = -+ -+[tool.pylint.string] -+# This flag controls whether inconsistent-quotes generates a warning when the -+# character used as a quote delimiter is used inconsistently within a module. -+# check-quote-consistency = -+ -+# This flag controls whether the implicit-str-concat should generate a warning on -+# implicit string concatenation in sequences defined over several lines. -+# check-str-concat-over-line-jumps = -+ -+[tool.pylint.typecheck] -+# List of decorators that produce context managers, such as -+# contextlib.contextmanager. Add to this list to register other decorators that -+# produce valid context managers. -+contextmanager-decorators = ["contextlib.contextmanager"] -+ -+# List of members which are set dynamically and missed by pylint inference -+# system, and so shouldn't trigger E1101 when accessed. Python regular -+# expressions are accepted. -+# generated-members = -+ -+# Tells whether missing members accessed in mixin class should be ignored. A -+# class is considered mixin if its name matches the mixin-class-rgx option. -+# Tells whether to warn about missing members when the owner of the attribute is -+# inferred to be None. -+ignore-none = true -+ -+# This flag controls whether pylint should warn about no-member and similar -+# checks whenever an opaque object is returned when inferring. The inference can -+# return multiple potential results while evaluating a Python object, but some -+# branches might not be evaluated, which results in partial inference. In that -+# case, it might be useful to still emit no-member and other checks for the rest -+# of the inferred objects. -+ignore-on-opaque-inference = true -+ -+# List of symbolic message names to ignore for Mixin members. -+ignored-checks-for-mixins = ["no-member", "not-async-context-manager", "not-context-manager", "attribute-defined-outside-init"] -+ -+# List of class names for which member attributes should not be checked (useful -+# for classes with dynamically set attributes). This supports the use of -+# qualified names. -+ignored-classes = ["optparse.Values", "thread._local", "_thread._local"] -+ -+# Show a hint with possible names when a member name was not found. The aspect of -+# finding the hint is based on edit distance. -+missing-member-hint = true -+ -+# The minimum edit distance a name should have in order to be considered a -+# similar match for a missing member name. -+missing-member-hint-distance = 1 -+ -+# The total number of similar names that should be taken in consideration when -+# showing a hint for a missing member. -+missing-member-max-choices = 1 -+ -+# Regex pattern to define which classes are considered mixins. -+mixin-class-rgx = ".*[Mm]ixin" -+ -+# List of decorators that change the signature of a decorated function. -+# signature-mutators = -+ -+[tool.pylint.variables] -+# List of additional names supposed to be defined in builtins. Remember that you -+# should avoid defining new builtins when possible. -+# additional-builtins = -+ -+# Tells whether unused global variables should be treated as a violation. -+allow-global-unused-variables = true -+ -+# List of names allowed to shadow builtins -+# allowed-redefined-builtins = -+ -+# List of strings which can identify a callback function by name. A callback name -+# must start or end with one of those strings. -+callbacks = ["cb_", "_cb", "_callback"] -+ -+# A regular expression matching the name of dummy variables (i.e. expected to not -+# be used). -+dummy-variables-rgx = "_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_" -+ -+# Argument names that match this expression will be ignored. Default to name with -+# leading underscore. -+ignored-argument-names = "_.*|^ignored_|^unused_" -+ -+# Tells whether we should check for unused import in __init__ files. -+# init-import = -+ -+# List of qualified module names which can have objects that can redefine -+# builtins. -+redefining-builtins-modules = ["six.moves", "future.builtins"] -diff --git a/requirements-dev.txt b/requirements-dev.txt -new file mode 100644 -index 0000000..fe21d76 ---- /dev/null -+++ b/requirements-dev.txt -@@ -0,0 +1,7 @@ -+coverage[toml] -+mypy -+pylint -+pytest -+rich -+types-PyYAML -+types-requests -diff --git a/requirements.txt b/requirements.txt -new file mode 100644 -index 0000000..1c6d8b4 ---- /dev/null -+++ b/requirements.txt -@@ -0,0 +1,2 @@ -+pyyaml -+requests -diff --git a/setup.py b/setup.py -new file mode 100644 -index 0000000..1698e52 ---- /dev/null -+++ b/setup.py -@@ -0,0 +1,11 @@ -+#!/usr/bin/env python -+"""Bootstrapper for docker's ENTRYPOINT executable. -+ -+Since using setup.py is no longer std convention, -+all install information is located in pyproject.toml -+""" -+ -+import setuptools -+ -+ -+setuptools.setup() -diff --git a/tests/capture_tools_output/.clang-format b/tests/capture_tools_output/.clang-format -new file mode 100644 -index 0000000..6997192 ---- /dev/null -+++ b/tests/capture_tools_output/.clang-format -@@ -0,0 +1,148 @@ -+--- -+Language: Cpp -+# BasedOnStyle: LLVM -+AccessModifierOffset: -2 -+AlignAfterOpenBracket: Align -+AlignConsecutiveMacros: true -+AlignConsecutiveAssignments: false -+AlignConsecutiveBitFields: false -+AlignConsecutiveDeclarations: false -+AlignEscapedNewlines: Right -+AlignOperands: Align -+AlignTrailingComments: true -+AllowAllArgumentsOnNextLine: true -+AllowAllConstructorInitializersOnNextLine: true -+AllowAllParametersOfDeclarationOnNextLine: true -+AllowShortEnumsOnASingleLine: true -+AllowShortBlocksOnASingleLine: Never -+AllowShortCaseLabelsOnASingleLine: false -+AllowShortFunctionsOnASingleLine: None -+AllowShortLambdasOnASingleLine: All -+AllowShortIfStatementsOnASingleLine: Never -+AllowShortLoopsOnASingleLine: false -+AlwaysBreakAfterDefinitionReturnType: None -+AlwaysBreakAfterReturnType: None -+AlwaysBreakBeforeMultilineStrings: false -+AlwaysBreakTemplateDeclarations: MultiLine -+BinPackArguments: true -+BinPackParameters: true -+BraceWrapping: -+ AfterCaseLabel: false -+ AfterClass: true -+ AfterControlStatement: Always -+ AfterEnum: true -+ AfterFunction: true -+ AfterNamespace: true -+ AfterObjCDeclaration: false -+ AfterStruct: true -+ AfterUnion: true -+ AfterExternBlock: false -+ BeforeCatch: false -+ BeforeElse: true -+ BeforeLambdaBody: false -+ BeforeWhile: false -+ IndentBraces: false -+ SplitEmptyFunction: true -+ SplitEmptyRecord: true -+ SplitEmptyNamespace: true -+BreakBeforeBinaryOperators: None -+BreakBeforeBraces: Custom -+BreakBeforeInheritanceComma: false -+BreakInheritanceList: BeforeColon -+BreakBeforeTernaryOperators: true -+BreakConstructorInitializersBeforeComma: false -+BreakConstructorInitializers: BeforeColon -+BreakAfterJavaFieldAnnotations: false -+BreakStringLiterals: true -+ColumnLimit: 80 -+CommentPragmas: '^ IWYU pragma:' -+CompactNamespaces: false -+ConstructorInitializerAllOnOneLineOrOnePerLine: false -+ConstructorInitializerIndentWidth: 4 -+ContinuationIndentWidth: 4 -+Cpp11BracedListStyle: true -+DeriveLineEnding: true -+DerivePointerAlignment: false -+DisableFormat: false -+ExperimentalAutoDetectBinPacking: false -+FixNamespaceComments: true -+ForEachMacros: -+ - foreach -+ - Q_FOREACH -+ - BOOST_FOREACH -+IncludeBlocks: Preserve -+IncludeCategories: -+ - Regex: '^"(llvm|llvm-c|clang|clang-c)/' -+ Priority: 2 -+ SortPriority: 0 -+ - Regex: '^(<|"(gtest|gmock|isl|json)/)' -+ Priority: 3 -+ SortPriority: 0 -+ - Regex: '.*' -+ Priority: 1 -+ SortPriority: 0 -+IncludeIsMainRegex: '(Test)?$' -+IncludeIsMainSourceRegex: '' -+IndentCaseLabels: false -+IndentCaseBlocks: false -+IndentGotoLabels: true -+IndentPPDirectives: None -+IndentExternBlock: AfterExternBlock -+IndentWidth: 4 -+IndentWrappedFunctionNames: false -+InsertTrailingCommas: None -+JavaScriptQuotes: Leave -+JavaScriptWrapImports: true -+KeepEmptyLinesAtTheStartOfBlocks: true -+MacroBlockBegin: '' -+MacroBlockEnd: '' -+MaxEmptyLinesToKeep: 2 -+NamespaceIndentation: None -+ObjCBinPackProtocolList: Auto -+ObjCBlockIndentWidth: 2 -+ObjCBreakBeforeNestedBlockParam: true -+ObjCSpaceAfterProperty: false -+ObjCSpaceBeforeProtocolList: true -+PenaltyBreakAssignment: 2 -+PenaltyBreakBeforeFirstCallParameter: 19 -+PenaltyBreakComment: 300 -+PenaltyBreakFirstLessLess: 120 -+PenaltyBreakString: 1000 -+PenaltyBreakTemplateDeclaration: 10 -+PenaltyExcessCharacter: 1000000 -+PenaltyReturnTypeOnItsOwnLine: 60 -+PointerAlignment: Right -+ReflowComments: false -+SortIncludes: false -+SortUsingDeclarations: true -+SpaceAfterCStyleCast: false -+SpaceAfterLogicalNot: false -+SpaceAfterTemplateKeyword: true -+SpaceBeforeAssignmentOperators: true -+SpaceBeforeCpp11BracedList: false -+SpaceBeforeCtorInitializerColon: true -+SpaceBeforeInheritanceColon: true -+SpaceBeforeParens: ControlStatements -+SpaceBeforeRangeBasedForLoopColon: true -+SpaceInEmptyBlock: false -+SpaceInEmptyParentheses: false -+SpacesBeforeTrailingComments: 1 -+SpacesInAngles: false -+SpacesInConditionalStatement: false -+SpacesInContainerLiterals: true -+SpacesInCStyleCastParentheses: false -+SpacesInParentheses: false -+SpacesInSquareBrackets: false -+SpaceBeforeSquareBrackets: false -+Standard: Latest -+StatementMacros: -+ - Q_UNUSED -+ - QT_REQUIRE_VERSION -+TabWidth: 8 -+UseCRLF: false -+UseTab: Never -+WhitespaceSensitiveMacros: -+ - STRINGIZE -+ - PP_STRINGIZE -+ - BOOST_PP_STRINGIZE -+... -diff --git a/tests/capture_tools_output/.clang-tidy b/tests/capture_tools_output/.clang-tidy -new file mode 100644 -index 0000000..19dc3ea ---- /dev/null -+++ b/tests/capture_tools_output/.clang-tidy -@@ -0,0 +1,349 @@ -+--- -+Checks: 'clang-diagnostic-*,clang-analyzer-*,-boost-*,bugprone-*,performance-*,readability-*,portability-*,modernize-*,clang-analyzer-*,cppcoreguidelines-*,-cppcoreguidelines-avoid-magic-numbers,-readability-magic-numbers' -+WarningsAsErrors: '' -+HeaderFilterRegex: '' -+AnalyzeTemporaryDtors: false -+FormatStyle: none -+User: ytreh -+CheckOptions: -+ - key: modernize-replace-auto-ptr.IncludeStyle -+ value: llvm -+ - key: cppcoreguidelines-no-malloc.Reallocations -+ value: '::realloc' -+ - key: cppcoreguidelines-owning-memory.LegacyResourceConsumers -+ value: '::free;::realloc;::freopen;::fclose' -+ - key: readability-static-accessed-through-instance.NameSpecifierNestingThreshold -+ value: '3' -+ - key: readability-function-size.VariableThreshold -+ value: '4294967295' -+ - key: modernize-use-auto.MinTypeNameLength -+ value: '5' -+ - key: bugprone-reserved-identifier.Invert -+ value: 'false' -+ - key: performance-move-const-arg.CheckTriviallyCopyableMove -+ value: 'true' -+ - key: cert-dcl16-c.NewSuffixes -+ value: 'L;LL;LU;LLU' -+ - key: bugprone-narrowing-conversions.WarnOnFloatingPointNarrowingConversion -+ value: 'true' -+ - key: readability-identifier-naming.GetConfigPerFile -+ value: 'true' -+ - key: bugprone-narrowing-conversions.PedanticMode -+ value: 'false' -+ - key: readability-inconsistent-declaration-parameter-name.Strict -+ value: 'false' -+ - key: cppcoreguidelines-macro-usage.CheckCapsOnly -+ value: 'false' -+ - key: bugprone-unused-return-value.CheckedFunctions -+ value: '::std::async;::std::launder;::std::remove;::std::remove_if;::std::unique;::std::unique_ptr::release;::std::basic_string::empty;::std::vector::empty;::std::back_inserter;::std::distance;::std::find;::std::find_if;::std::inserter;::std::lower_bound;::std::make_pair;::std::map::count;::std::map::find;::std::map::lower_bound;::std::multimap::equal_range;::std::multimap::upper_bound;::std::set::count;::std::set::find;::std::setfill;::std::setprecision;::std::setw;::std::upper_bound;::std::vector::at;::bsearch;::ferror;::feof;::isalnum;::isalpha;::isblank;::iscntrl;::isdigit;::isgraph;::islower;::isprint;::ispunct;::isspace;::isupper;::iswalnum;::iswprint;::iswspace;::isxdigit;::memchr;::memcmp;::strcmp;::strcoll;::strncmp;::strpbrk;::strrchr;::strspn;::strstr;::wcscmp;::access;::bind;::connect;::difftime;::dlsym;::fnmatch;::getaddrinfo;::getopt;::htonl;::htons;::iconv_open;::inet_addr;::isascii;::isatty;::mmap;::newlocale;::openat;::pathconf;::pthread_equal;::pthread_getspecific;::pthread_mutex_trylock;::readdir;::readlink;::recvmsg;::regexec;::scandir;::semget;::setjmp;::shm_open;::shmget;::sigismember;::strcasecmp;::strsignal;::ttyname' -+ - key: modernize-use-default-member-init.UseAssignment -+ value: 'false' -+ - key: readability-function-size.NestingThreshold -+ value: '4294967295' -+ - key: modernize-use-override.AllowOverrideAndFinal -+ value: 'false' -+ - key: readability-function-size.ParameterThreshold -+ value: '4294967295' -+ - key: modernize-pass-by-value.ValuesOnly -+ value: 'false' -+ - key: modernize-loop-convert.IncludeStyle -+ value: llvm -+ - key: cert-str34-c.DiagnoseSignedUnsignedCharComparisons -+ value: '0' -+ - key: bugprone-suspicious-string-compare.WarnOnLogicalNotComparison -+ value: 'false' -+ - key: cppcoreguidelines-explicit-virtual-functions.AllowOverrideAndFinal -+ value: 'false' -+ - key: readability-redundant-smartptr-get.IgnoreMacros -+ value: 'true' -+ - key: readability-identifier-naming.AggressiveDependentMemberLookup -+ value: 'false' -+ - key: bugprone-suspicious-string-compare.WarnOnImplicitComparison -+ value: 'true' -+ - key: modernize-use-emplace.TupleTypes -+ value: '::std::pair;::std::tuple' -+ - key: modernize-use-emplace.TupleMakeFunctions -+ value: '::std::make_pair;::std::make_tuple' -+ - key: cppcoreguidelines-owning-memory.LegacyResourceProducers -+ value: '::malloc;::aligned_alloc;::realloc;::calloc;::fopen;::freopen;::tmpfile' -+ - key: bugprone-argument-comment.CommentNullPtrs -+ value: '0' -+ - key: bugprone-argument-comment.StrictMode -+ value: '0' -+ - key: cppcoreguidelines-init-variables.IncludeStyle -+ value: llvm -+ - key: modernize-use-nodiscard.ReplacementString -+ value: '[[nodiscard]]' -+ - key: modernize-loop-convert.MakeReverseRangeHeader -+ value: '' -+ - key: modernize-replace-random-shuffle.IncludeStyle -+ value: llvm -+ - key: cppcoreguidelines-narrowing-conversions.WarnOnFloatingPointNarrowingConversion -+ value: 'true' -+ - key: modernize-use-bool-literals.IgnoreMacros -+ value: 'true' -+ - key: bugprone-unhandled-self-assignment.WarnOnlyIfThisHasSuspiciousField -+ value: 'true' -+ - key: google-readability-namespace-comments.ShortNamespaceLines -+ value: '10' -+ - key: bugprone-suspicious-string-compare.StringCompareLikeFunctions -+ value: '' -+ - key: modernize-avoid-bind.PermissiveParameterList -+ value: 'false' -+ - key: modernize-use-override.FinalSpelling -+ value: final -+ - key: performance-move-constructor-init.IncludeStyle -+ value: llvm -+ - key: modernize-loop-convert.UseCxx20ReverseRanges -+ value: 'true' -+ - key: modernize-use-noexcept.ReplacementString -+ value: '' -+ - key: modernize-use-using.IgnoreMacros -+ value: 'true' -+ - key: performance-type-promotion-in-math-fn.IncludeStyle -+ value: llvm -+ - key: cppcoreguidelines-explicit-virtual-functions.FinalSpelling -+ value: final -+ - key: modernize-loop-convert.NamingStyle -+ value: CamelCase -+ - key: bugprone-suspicious-include.ImplementationFileExtensions -+ value: 'c;cc;cpp;cxx' -+ - key: cppcoreguidelines-pro-type-member-init.UseAssignment -+ value: 'false' -+ - key: modernize-loop-convert.MakeReverseRangeFunction -+ value: '' -+ - key: bugprone-suspicious-include.HeaderFileExtensions -+ value: ';h;hh;hpp;hxx' -+ - key: performance-no-automatic-move.AllowedTypes -+ value: '' -+ - key: performance-for-range-copy.WarnOnAllAutoCopies -+ value: 'false' -+ - key: bugprone-argument-comment.CommentIntegerLiterals -+ value: '0' -+ - key: bugprone-suspicious-missing-comma.SizeThreshold -+ value: '5' -+ - key: readability-inconsistent-declaration-parameter-name.IgnoreMacros -+ value: 'true' -+ - key: readability-identifier-naming.IgnoreFailedSplit -+ value: 'false' -+ - key: modernize-pass-by-value.IncludeStyle -+ value: llvm -+ - key: bugprone-sizeof-expression.WarnOnSizeOfThis -+ value: 'true' -+ - key: readability-qualified-auto.AddConstToQualified -+ value: 'true' -+ - key: bugprone-string-constructor.WarnOnLargeLength -+ value: 'true' -+ - key: bugprone-too-small-loop-variable.MagnitudeBitsUpperLimit -+ value: '16' -+ - key: readability-simplify-boolean-expr.ChainedConditionalReturn -+ value: 'false' -+ - key: cppcoreguidelines-explicit-virtual-functions.OverrideSpelling -+ value: override -+ - key: readability-else-after-return.WarnOnConditionVariables -+ value: 'true' -+ - key: readability-uppercase-literal-suffix.IgnoreMacros -+ value: 'true' -+ - key: modernize-use-nullptr.NullMacros -+ value: 'NULL' -+ - key: modernize-make-shared.IgnoreMacros -+ value: 'true' -+ - key: bugprone-dynamic-static-initializers.HeaderFileExtensions -+ value: ';h;hh;hpp;hxx' -+ - key: bugprone-suspicious-enum-usage.StrictMode -+ value: 'false' -+ - key: performance-unnecessary-copy-initialization.AllowedTypes -+ value: '' -+ - key: bugprone-suspicious-missing-comma.MaxConcatenatedTokens -+ value: '5' -+ - key: modernize-use-transparent-functors.SafeMode -+ value: 'false' -+ - key: cppcoreguidelines-macro-usage.AllowedRegexp -+ value: '^DEBUG_*' -+ - key: modernize-make-shared.IgnoreDefaultInitialization -+ value: 'true' -+ - key: bugprone-argument-comment.CommentCharacterLiterals -+ value: '0' -+ - key: cppcoreguidelines-narrowing-conversions.PedanticMode -+ value: 'false' -+ - key: bugprone-not-null-terminated-result.WantToUseSafeFunctions -+ value: 'true' -+ - key: modernize-make-shared.IncludeStyle -+ value: llvm -+ - key: bugprone-string-constructor.LargeLengthThreshold -+ value: '8388608' -+ - key: readability-simplify-boolean-expr.ChainedConditionalAssignment -+ value: 'false' -+ - key: cppcoreguidelines-special-member-functions.AllowMissingMoveFunctions -+ value: 'false' -+ - key: cert-oop54-cpp.WarnOnlyIfThisHasSuspiciousField -+ value: '0' -+ - key: bugprone-exception-escape.FunctionsThatShouldNotThrow -+ value: '' -+ - key: bugprone-signed-char-misuse.CharTypdefsToIgnore -+ value: '' -+ - key: performance-inefficient-vector-operation.EnableProto -+ value: 'false' -+ - key: modernize-loop-convert.MaxCopySize -+ value: '16' -+ - key: bugprone-argument-comment.CommentFloatLiterals -+ value: '0' -+ - key: readability-function-size.LineThreshold -+ value: '4294967295' -+ - key: portability-simd-intrinsics.Suggest -+ value: 'false' -+ - key: modernize-make-shared.MakeSmartPtrFunction -+ value: 'std::make_shared' -+ - key: cppcoreguidelines-explicit-virtual-functions.IgnoreDestructors -+ value: 'true' -+ - key: cppcoreguidelines-pro-bounds-constant-array-index.GslHeader -+ value: '' -+ - key: modernize-make-unique.IgnoreMacros -+ value: 'true' -+ - key: modernize-make-shared.MakeSmartPtrFunctionHeader -+ value: '' -+ - key: performance-for-range-copy.AllowedTypes -+ value: '' -+ - key: modernize-use-override.IgnoreDestructors -+ value: 'false' -+ - key: bugprone-sizeof-expression.WarnOnSizeOfConstant -+ value: 'true' -+ - key: readability-redundant-string-init.StringNames -+ value: '::std::basic_string_view;::std::basic_string' -+ - key: modernize-make-unique.IgnoreDefaultInitialization -+ value: 'true' -+ - key: modernize-use-emplace.ContainersWithPushBack -+ value: '::std::vector;::std::list;::std::deque' -+ - key: modernize-make-unique.IncludeStyle -+ value: llvm -+ - key: readability-braces-around-statements.ShortStatementLines -+ value: '0' -+ - key: bugprone-argument-comment.CommentUserDefinedLiterals -+ value: '0' -+ - key: bugprone-argument-comment.CommentBoolLiterals -+ value: '0' -+ - key: modernize-use-override.OverrideSpelling -+ value: override -+ - key: performance-inefficient-string-concatenation.StrictMode -+ value: 'false' -+ - key: readability-implicit-bool-conversion.AllowPointerConditions -+ value: 'false' -+ - key: readability-redundant-declaration.IgnoreMacros -+ value: 'true' -+ - key: google-readability-braces-around-statements.ShortStatementLines -+ value: '1' -+ - key: modernize-make-unique.MakeSmartPtrFunction -+ value: 'std::make_unique' -+ - key: cppcoreguidelines-pro-type-member-init.IgnoreArrays -+ value: 'false' -+ - key: readability-else-after-return.WarnOnUnfixable -+ value: 'true' -+ - key: bugprone-reserved-identifier.AllowedIdentifiers -+ value: '' -+ - key: modernize-use-emplace.IgnoreImplicitConstructors -+ value: 'false' -+ - key: modernize-make-unique.MakeSmartPtrFunctionHeader -+ value: '' -+ - key: portability-restrict-system-includes.Includes -+ value: '*' -+ - key: modernize-use-equals-delete.IgnoreMacros -+ value: 'true' -+ - key: cppcoreguidelines-pro-bounds-constant-array-index.IncludeStyle -+ value: llvm -+ - key: cppcoreguidelines-macro-usage.IgnoreCommandLineMacros -+ value: 'true' -+ - key: bugprone-misplaced-widening-cast.CheckImplicitCasts -+ value: 'false' -+ - key: cppcoreguidelines-non-private-member-variables-in-classes.IgnorePublicMemberVariables -+ value: 'false' -+ - key: modernize-loop-convert.MinConfidence -+ value: reasonable -+ - key: performance-unnecessary-value-param.AllowedTypes -+ value: '' -+ - key: bugprone-suspicious-missing-comma.RatioThreshold -+ value: '0.200000' -+ - key: cppcoreguidelines-special-member-functions.AllowMissingMoveFunctionsWhenCopyIsDeleted -+ value: 'false' -+ - key: readability-uppercase-literal-suffix.NewSuffixes -+ value: '' -+ - key: google-readability-namespace-comments.SpacesBeforeComments -+ value: '2' -+ - key: readability-function-cognitive-complexity.Threshold -+ value: '25' -+ - key: cppcoreguidelines-non-private-member-variables-in-classes.IgnoreClassesWithAllMemberVariablesBeingPublic -+ value: 'true' -+ - key: bugprone-argument-comment.IgnoreSingleArgument -+ value: '0' -+ - key: cppcoreguidelines-no-malloc.Allocations -+ value: '::malloc;::calloc' -+ - key: modernize-use-noexcept.UseNoexceptFalse -+ value: 'true' -+ - key: bugprone-sizeof-expression.WarnOnSizeOfIntegerExpression -+ value: 'false' -+ - key: performance-faster-string-find.StringLikeClasses -+ value: '::std::basic_string;::std::basic_string_view' -+ - key: bugprone-assert-side-effect.CheckFunctionCalls -+ value: 'false' -+ - key: readability-function-size.BranchThreshold -+ value: '4294967295' -+ - key: bugprone-string-constructor.StringNames -+ value: '::std::basic_string;::std::basic_string_view' -+ - key: bugprone-assert-side-effect.AssertMacros -+ value: assert -+ - key: bugprone-exception-escape.IgnoredExceptions -+ value: '' -+ - key: readability-function-size.StatementThreshold -+ value: '800' -+ - key: modernize-use-default-member-init.IgnoreMacros -+ value: 'true' -+ - key: llvm-qualified-auto.AddConstToQualified -+ value: '0' -+ - key: bugprone-argument-comment.CommentStringLiterals -+ value: '0' -+ - key: readability-identifier-naming.IgnoreMainLikeFunctions -+ value: 'false' -+ - key: bugprone-signed-char-misuse.DiagnoseSignedUnsignedCharComparisons -+ value: 'true' -+ - key: readability-implicit-bool-conversion.AllowIntegerConditions -+ value: 'false' -+ - key: cppcoreguidelines-init-variables.MathHeader -+ value: '' -+ - key: google-readability-function-size.StatementThreshold -+ value: '800' -+ - key: llvm-else-after-return.WarnOnConditionVariables -+ value: '0' -+ - key: bugprone-sizeof-expression.WarnOnSizeOfCompareToConstant -+ value: 'true' -+ - key: bugprone-reserved-identifier.AggressiveDependentMemberLookup -+ value: 'false' -+ - key: modernize-raw-string-literal.DelimiterStem -+ value: lit -+ - key: modernize-use-equals-default.IgnoreMacros -+ value: 'true' -+ - key: cppcoreguidelines-special-member-functions.AllowSoleDefaultDtor -+ value: 'false' -+ - key: modernize-raw-string-literal.ReplaceShorterLiterals -+ value: 'false' -+ - key: modernize-use-emplace.SmartPointers -+ value: '::std::shared_ptr;::std::unique_ptr;::std::auto_ptr;::std::weak_ptr' -+ - key: cppcoreguidelines-no-malloc.Deallocations -+ value: '::free' -+ - key: modernize-use-auto.RemoveStars -+ value: 'false' -+ - key: bugprone-dangling-handle.HandleClasses -+ value: 'std::basic_string_view;std::experimental::basic_string_view' -+ - key: performance-inefficient-vector-operation.VectorLikeClasses -+ value: '::std::vector' -+ - key: portability-simd-intrinsics.Std -+ value: '' -+ - key: performance-unnecessary-value-param.IncludeStyle -+ value: llvm -+ - key: readability-redundant-member-init.IgnoreBaseInCopyConstructors -+ value: 'false' -+ - key: modernize-replace-disallow-copy-and-assign-macro.MacroName -+ value: DISALLOW_COPY_AND_ASSIGN -+ - key: llvm-else-after-return.WarnOnUnfixable -+ value: '0' -+ - key: readability-simplify-subscript-expr.Types -+ value: '::std::basic_string;::std::basic_string_view;::std::vector;::std::array' -+... -diff --git a/tests/capture_tools_output/event_files.json b/tests/capture_tools_output/event_files.json -new file mode 100644 -index 0000000..ba8947a ---- /dev/null -+++ b/tests/capture_tools_output/event_files.json -@@ -0,0 +1,230 @@ -+[ -+ { -+ "sha": "7f1eed09c6c07682738de7b5141fd151d16cf368", -+ "filename": "CMakeLists.txt", -+ "status": "modified", -+ "additions": 14, -+ "deletions": 2, -+ "changes": 16, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/CMakeLists.txt", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/CMakeLists.txt", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/CMakeLists.txt?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -28,9 +28,21 @@ else()\n \"-Wredundant-decls\")\n endif()\n \n+option(ENABLE_SDL2_NET \"Enable SDL2_net\" On)\n+option(ENABLE_SDL2_MIXER \"Enable SDL2_mixer\" On)\n+\n find_package(SDL2 2.0.7)\n-find_package(SDL2_mixer 2.0.2)\n-find_package(SDL2_net 2.0.0)\n+if(ENABLE_SDL2_MIXER)\n+ find_package(SDL2_mixer 2.0.2)\n+else()\n+ add_compile_definitions(DISABLE_SDL2MIXER=1)\n+endif()\n+\n+if(ENABLE_SDL2_NET)\n+ find_package(SDL2_net 2.0.0)\n+else()\n+ add_compile_definitions(DISABLE_SDL2NET=1)\n+endif()\n \n # Check for libsamplerate.\n find_package(samplerate)" -+ }, -+ { -+ "sha": "fd5d8bcecd8a6edb6d77c8686ae32a995b571066", -+ "filename": "configure.ac", -+ "status": "modified", -+ "additions": 17, -+ "deletions": 2, -+ "changes": 19, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/configure.ac", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/configure.ac", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/configure.ac?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -32,8 +32,23 @@ then\n fi\n \n PKG_CHECK_MODULES(SDL, [sdl2 >= 2.0.7])\n-PKG_CHECK_MODULES(SDLMIXER, [SDL2_mixer >= 2.0.2])\n-PKG_CHECK_MODULES(SDLNET, [SDL2_net >= 2.0.0])\n+# Check for SDL2_mixer\n+AC_ARG_ENABLE([sdl2mixer],\n+AS_HELP_STRING([--disable-sdl2mixer], [Disable SDL2_mixer support])\n+)\n+AS_IF([test \"x$enable_sdl2mixer\" != xno], [\n+ PKG_CHECK_MODULES(SDLMIXER, [SDL2_mixer >= 2.0.2])], [\n+ AC_DEFINE([DISABLE_SDL2MIXER], [1], [SDL2_mixer disabled])\n+])\n+\n+# Check for networking\n+AC_ARG_ENABLE([sdl2net],\n+AS_HELP_STRING([--disable-sdl2net], [Disable SDL2_net support])\n+)\n+AS_IF([test \"x$enable_sdl2net\" != xno], [\n+ PKG_CHECK_MODULES(SDLNET, [SDL2_net >= 2.0.0])], [\n+ AC_DEFINE([DISABLE_SDL2NET], [1], [SDL2_net disabled])\n+])\n \n # Check for bash-completion.\n AC_ARG_ENABLE([bash-completion]," -+ }, -+ { -+ "sha": "151f7617dde7d1216f7212b45bb5aeb7661bc86b", -+ "filename": "opl/CMakeLists.txt", -+ "status": "modified", -+ "additions": 4, -+ "deletions": 1, -+ "changes": 5, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/opl%2FCMakeLists.txt", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/opl%2FCMakeLists.txt", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/opl%2FCMakeLists.txt?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -12,4 +12,7 @@ add_library(opl STATIC\n target_include_directories(opl\n INTERFACE \".\"\n PRIVATE \"${CMAKE_CURRENT_BINARY_DIR}/../\")\n-target_link_libraries(opl SDL2::mixer)\n+target_link_libraries(opl SDL2::SDL2)\n+if(ENABLE_SDL2_mixer)\n+ target_link_libraries(opl SDL2::mixer)\n+endif()" -+ }, -+ { -+ "sha": "46e082cf77c5e60affc7689f7357c926fc00cda3", -+ "filename": "opl/opl.c", -+ "status": "modified", -+ "additions": 2, -+ "deletions": 0, -+ "changes": 2, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/opl%2Fopl.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/opl%2Fopl.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/opl%2Fopl.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -50,7 +50,9 @@ static opl_driver_t *drivers[] =\n #ifdef _WIN32\n &opl_win32_driver,\n #endif\n+#ifndef DISABLE_SDL2MIXER\n &opl_sdl_driver,\n+#endif // DISABLE_SDL2MIXER\n NULL\n };\n " -+ }, -+ { -+ "sha": "6bd4e7e1f3374e289d3dc19972dcb1d5379d6b03", -+ "filename": "opl/opl_sdl.c", -+ "status": "modified", -+ "additions": 6, -+ "deletions": 0, -+ "changes": 6, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/opl%2Fopl_sdl.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/opl%2Fopl_sdl.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/opl%2Fopl_sdl.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -33,6 +33,10 @@\n \n #include \"opl_queue.h\"\n \n+\n+#ifndef DISABLE_SDL2MIXER\n+\n+\n #define MAX_SOUND_SLICE_TIME 100 /* ms */\n \n typedef struct\n@@ -511,3 +515,5 @@ opl_driver_t opl_sdl_driver =\n OPL_SDL_AdjustCallbacks,\n };\n \n+\n+#endif // DISABLE_SDL2MIXER" -+ }, -+ { -+ "sha": "9924263aea24261091948e455dfd2521787a04c4", -+ "filename": "pcsound/CMakeLists.txt", -+ "status": "modified", -+ "additions": 4, -+ "deletions": 1, -+ "changes": 5, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/pcsound%2FCMakeLists.txt", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/pcsound%2FCMakeLists.txt", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/pcsound%2FCMakeLists.txt?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -8,4 +8,7 @@ add_library(pcsound STATIC\n target_include_directories(pcsound\n INTERFACE \".\"\n PRIVATE \"${CMAKE_CURRENT_BINARY_DIR}/../\")\n-target_link_libraries(pcsound SDL2::mixer)\n+target_link_libraries(pcsound SDL2::SDL2)\n+if(ENABLE_SDL2_mixer)\n+ target_link_libraries(pcsound SDL2::mixer)\n+endif()" -+ }, -+ { -+ "sha": "58f9b75c2affd5f1dd990aaedab79d834575bf83", -+ "filename": "pcsound/pcsound.c", -+ "status": "modified", -+ "additions": 2, -+ "deletions": 0, -+ "changes": 2, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/pcsound%2Fpcsound.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/pcsound%2Fpcsound.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/pcsound%2Fpcsound.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -56,7 +56,9 @@ static pcsound_driver_t *drivers[] =\n #ifdef _WIN32\n &pcsound_win32_driver,\n #endif\n+#ifndef DISABLE_SDL2MIXER\n &pcsound_sdl_driver,\n+#endif // DISABLE_SDL2MIXER\n NULL,\n };\n " -+ }, -+ { -+ "sha": "e77c7b0d29de1b0c665686004dfda311c9d96719", -+ "filename": "pcsound/pcsound_sdl.c", -+ "status": "modified", -+ "additions": 6, -+ "deletions": 0, -+ "changes": 6, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/pcsound%2Fpcsound_sdl.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/pcsound%2Fpcsound_sdl.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/pcsound%2Fpcsound_sdl.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -24,6 +24,10 @@\n #include \"pcsound.h\"\n #include \"pcsound_internal.h\"\n \n+\n+#ifndef DISABLE_SDL2MIXER\n+\n+\n #define MAX_SOUND_SLICE_TIME 70 /* ms */\n #define SQUARE_WAVE_AMP 0x2000\n \n@@ -248,3 +252,5 @@ pcsound_driver_t pcsound_sdl_driver =\n PCSound_SDL_Shutdown,\n };\n \n+\n+#endif // DISABLE_SDL2MIXER" -+ }, -+ { -+ "sha": "bbb877641f2a8de6e6eb7dad5b8c866e147faf7d", -+ "filename": "src/CMakeLists.txt", -+ "status": "modified", -+ "additions": 18, -+ "deletions": 3, -+ "changes": 21, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2FCMakeLists.txt", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2FCMakeLists.txt", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2FCMakeLists.txt?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -32,7 +32,10 @@ set(DEDSERV_FILES\n add_executable(\"${PROGRAM_PREFIX}server\" WIN32 ${COMMON_SOURCE_FILES} ${DEDSERV_FILES})\n target_include_directories(\"${PROGRAM_PREFIX}server\"\n PRIVATE \"${CMAKE_CURRENT_BINARY_DIR}/../\")\n-target_link_libraries(\"${PROGRAM_PREFIX}server\" SDL2::SDL2main SDL2::net)\n+target_link_libraries(\"${PROGRAM_PREFIX}server\" SDL2::SDL2main SDL2::SDL2)\n+if(ENABLE_SDL2_NET)\n+ target_link_libraries(\"${PROGRAM_PREFIX}server\" SDL2::net)\n+endif()\n \n # Source files used by the game binaries (chocolate-doom, etc.)\n \n@@ -121,7 +124,13 @@ set(DEHACKED_SOURCE_FILES\n set(SOURCE_FILES ${COMMON_SOURCE_FILES} ${GAME_SOURCE_FILES})\n set(SOURCE_FILES_WITH_DEH ${SOURCE_FILES} ${DEHACKED_SOURCE_FILES})\n \n-set(EXTRA_LIBS SDL2::SDL2main SDL2::SDL2 SDL2::mixer SDL2::net textscreen pcsound opl)\n+set(EXTRA_LIBS SDL2::SDL2main SDL2::SDL2 textscreen pcsound opl)\n+if(ENABLE_SDL2_MIXER)\n+ list(APPEND EXTRA_LIBS SDL2::mixer)\n+endif()\n+if(ENABLE_SDL2_NET)\n+ list(APPEND EXTRA_LIBS SDL2::net)\n+endif()\n if(SAMPLERATE_FOUND)\n list(APPEND EXTRA_LIBS samplerate::samplerate)\n endif()\n@@ -213,7 +222,13 @@ endif()\n \n target_include_directories(\"${PROGRAM_PREFIX}setup\"\n PRIVATE \"${CMAKE_CURRENT_BINARY_DIR}/../\")\n-target_link_libraries(\"${PROGRAM_PREFIX}setup\" SDL2::SDL2main SDL2::SDL2 SDL2::mixer SDL2::net setup textscreen)\n+target_link_libraries(\"${PROGRAM_PREFIX}setup\" SDL2::SDL2main SDL2::SDL2 setup textscreen)\n+if(ENABLE_SDL2_mixer)\n+ target_link_libraries(\"${PROGRAM_PREFIX}setup\" SDL2::mixer)\n+endif()\n+if(ENABLE_SDL2_NET)\n+ target_link_libraries(\"${PROGRAM_PREFIX}setup\" SDL2::net)\n+endif()\n \n if(MSVC)\n set_target_properties(\"${PROGRAM_PREFIX}setup\" PROPERTIES" -+ }, -+ { -+ "sha": "82b114b4178595085c6745bc70a570922415be1f", -+ "filename": "src/doom/CMakeLists.txt", -+ "status": "modified", -+ "additions": 7, -+ "deletions": 1, -+ "changes": 8, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fdoom%2FCMakeLists.txt", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fdoom%2FCMakeLists.txt", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fdoom%2FCMakeLists.txt?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -68,4 +68,10 @@ add_library(doom STATIC\n wi_stuff.c wi_stuff.h)\n \n target_include_directories(doom PRIVATE \"../\" \"${CMAKE_CURRENT_BINARY_DIR}/../../\")\n-target_link_libraries(doom SDL2::SDL2 SDL2::mixer SDL2::net)\n+target_link_libraries(doom SDL2::SDL2)\n+if(ENABLE_SDL2_mixer)\n+ target_link_libraries(doom SDL2::mixer)\n+endif()\n+if(ENABLE_SDL2_NET)\n+ target_link_libraries(doom SDL2::net)\n+endif()" -+ }, -+ { -+ "sha": "1ea060bfdb8b3147e36b3431fee27934c8a93b34", -+ "filename": "src/heretic/CMakeLists.txt", -+ "status": "modified", -+ "additions": 7, -+ "deletions": 1, -+ "changes": 8, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fheretic%2FCMakeLists.txt", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fheretic%2FCMakeLists.txt", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fheretic%2FCMakeLists.txt?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -54,4 +54,10 @@ add_library(heretic STATIC\n s_sound.c s_sound.h)\n \n target_include_directories(heretic PRIVATE \"../\" \"${CMAKE_CURRENT_BINARY_DIR}/../../\")\n-target_link_libraries(heretic textscreen SDL2::SDL2 SDL2::mixer SDL2::net)\n+target_link_libraries(heretic textscreen SDL2::SDL2)\n+if(ENABLE_SDL2_mixer)\n+ target_link_libraries(heretic SDL2::mixer)\n+endif()\n+if(ENABLE_SDL2_NET)\n+ target_link_libraries(heretic SDL2::net)\n+endif()" -+ }, -+ { -+ "sha": "0dbd170bfdb06c8209ec654d4d5377e334be886e", -+ "filename": "src/hexen/CMakeLists.txt", -+ "status": "modified", -+ "additions": 7, -+ "deletions": 1, -+ "changes": 8, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fhexen%2FCMakeLists.txt", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fhexen%2FCMakeLists.txt", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fhexen%2FCMakeLists.txt?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -55,4 +55,10 @@ add_library(hexen STATIC\n xddefs.h)\n \n target_include_directories(hexen PRIVATE \"../\" \"${CMAKE_CURRENT_BINARY_DIR}/../../\")\n-target_link_libraries(hexen SDL2::SDL2 SDL2::mixer SDL2::net)\n+target_link_libraries(hexen SDL2::SDL2)\n+if(ENABLE_SDL2_mixer)\n+ target_link_libraries(hexen SDL2::mixer)\n+endif()\n+if(ENABLE_SDL2_NET)\n+ target_link_libraries(hexen SDL2::net)\n+endif()" -+ }, -+ { -+ "sha": "3facce6f01d02c5e8eef3088fa60576b9e949829", -+ "filename": "src/i_musicpack.c", -+ "status": "modified", -+ "additions": 87, -+ "deletions": 1, -+ "changes": 88, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_musicpack.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_musicpack.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fi_musicpack.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -44,6 +44,13 @@\n #include \"w_wad.h\"\n #include \"z_zone.h\"\n \n+\n+char *music_pack_path = \"\";\n+\n+\n+#ifndef DISABLE_SDL2MIXER\n+\n+\n #define MID_HEADER_MAGIC \"MThd\"\n #define MUS_HEADER_MAGIC \"MUS\\x1a\"\n \n@@ -99,7 +106,6 @@ static boolean music_initialized = false;\n \n static boolean sdl_was_initialized = false;\n \n-char *music_pack_path = \"\";\n \n // If true, we are playing a substitute digital track rather than in-WAD\n // MIDI/MUS track, and file_metadata contains loop metadata.\n@@ -1375,3 +1381,83 @@ music_module_t music_pack_module =\n I_MP_PollMusic,\n };\n \n+\n+#else // DISABLE_SDL2MIXER\n+\n+\n+static boolean I_NULL_InitMusic(void)\n+{\n+ return false;\n+}\n+\n+\n+static void I_NULL_ShutdownMusic(void)\n+{\n+}\n+\n+\n+static void I_NULL_SetMusicVolume(int volume)\n+{\n+}\n+\n+\n+static void I_NULL_PauseSong(void)\n+{\n+}\n+\n+\n+static void I_NULL_ResumeSong(void)\n+{\n+}\n+\n+\n+static void *I_NULL_RegisterSong(void *data, int len)\n+{\n+ return NULL;\n+}\n+\n+\n+static void I_NULL_UnRegisterSong(void *handle)\n+{\n+}\n+\n+\n+static void I_NULL_PlaySong(void *handle, boolean looping)\n+{\n+}\n+\n+\n+static void I_NULL_StopSong(void)\n+{\n+}\n+\n+\n+static boolean I_NULL_MusicIsPlaying(void)\n+{\n+ return false;\n+}\n+\n+\n+static void I_NULL_PollMusic(void)\n+{\n+}\n+\n+music_module_t music_pack_module =\n+{\n+ NULL,\n+ 0,\n+ I_NULL_InitMusic,\n+ I_NULL_ShutdownMusic,\n+ I_NULL_SetMusicVolume,\n+ I_NULL_PauseSong,\n+ I_NULL_ResumeSong,\n+ I_NULL_RegisterSong,\n+ I_NULL_UnRegisterSong,\n+ I_NULL_PlaySong,\n+ I_NULL_StopSong,\n+ I_NULL_MusicIsPlaying,\n+ I_NULL_PollMusic,\n+};\n+\n+\n+#endif // DISABLE_SDL2MIXER" -+ }, -+ { -+ "sha": "48fedfbd7608ad8c6575a4bd82a5e1cb3646e071", -+ "filename": "src/i_sdlmusic.c", -+ "status": "modified", -+ "additions": 21, -+ "deletions": 13, -+ "changes": 34, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sdlmusic.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sdlmusic.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fi_sdlmusic.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -44,19 +44,6 @@\n #include \"w_wad.h\"\n #include \"z_zone.h\"\n \n-#define MAXMIDLENGTH (96 * 1024)\n-\n-static boolean music_initialized = false;\n-\n-// If this is true, this module initialized SDL sound and has the \n-// responsibility to shut it down\n-\n-static boolean sdl_was_initialized = false;\n-\n-static boolean win_midi_stream_opened = false;\n-\n-static boolean musicpaused = false;\n-static int current_music_volume;\n \n char *fluidsynth_sf_path = \"\";\n char *timidity_cfg_path = \"\";\n@@ -138,6 +125,25 @@ void I_InitTimidityConfig(void)\n }\n }\n \n+\n+#ifndef DISABLE_SDL2MIXER\n+\n+\n+#define MAXMIDLENGTH (96 * 1024)\n+\n+static boolean music_initialized = false;\n+\n+// If this is true, this module initialized SDL sound and has the\n+// responsibility to shut it down\n+\n+static boolean sdl_was_initialized = false;\n+\n+static boolean win_midi_stream_opened = false;\n+\n+static boolean musicpaused = false;\n+static int current_music_volume;\n+\n+\n // Remove the temporary config file generated by I_InitTimidityConfig().\n \n static void RemoveTimidityConfig(void)\n@@ -588,3 +594,5 @@ music_module_t music_sdl_module =\n NULL, // Poll\n };\n \n+\n+#endif // DISABLE_SDL2MIXER" -+ }, -+ { -+ "sha": "7f2a26096b218c5323f5dc569c6f70ab399782bf", -+ "filename": "src/i_sdlsound.c", -+ "status": "modified", -+ "additions": 17, -+ "deletions": 9, -+ "changes": 26, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sdlsound.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sdlsound.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fi_sdlsound.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -41,6 +41,21 @@\n \n #include \"doomtype.h\"\n \n+\n+int use_libsamplerate = 0;\n+\n+// Scale factor used when converting libsamplerate floating point numbers\n+// to integers. Too high means the sounds can clip; too low means they\n+// will be too quiet. This is an amount that should avoid clipping most\n+// of the time: with all the Doom IWAD sound effects, at least. If a PWAD\n+// is used, clipping might occur.\n+\n+float libsamplerate_scale = 0.65f;\n+\n+\n+#ifndef DISABLE_SDL2MIXER\n+\n+\n #define LOW_PASS_FILTER\n //#define DEBUG_DUMP_WAVS\n #define NUM_CHANNELS 16\n@@ -77,15 +92,6 @@ static allocated_sound_t *allocated_sounds_head = NULL;\n static allocated_sound_t *allocated_sounds_tail = NULL;\n static int allocated_sounds_size = 0;\n \n-int use_libsamplerate = 0;\n-\n-// Scale factor used when converting libsamplerate floating point numbers\n-// to integers. Too high means the sounds can clip; too low means they\n-// will be too quiet. This is an amount that should avoid clipping most\n-// of the time: with all the Doom IWAD sound effects, at least. If a PWAD\n-// is used, clipping might occur.\n-\n-float libsamplerate_scale = 0.65f;\n \n // Hook a sound into the linked list at the head.\n \n@@ -1135,3 +1141,5 @@ sound_module_t sound_sdl_module =\n I_SDL_PrecacheSounds,\n };\n \n+\n+#endif // DISABLE_SDL2MIXER" -+ }, -+ { -+ "sha": "9cf1fd95db13504ffc6f98ead8c9150f03db72aa", -+ "filename": "src/i_sound.c", -+ "status": "modified", -+ "additions": 4, -+ "deletions": 0, -+ "changes": 4, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sound.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sound.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fi_sound.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -99,7 +99,9 @@ static int snd_mport = 0;\n \n static sound_module_t *sound_modules[] = \n {\n+#ifndef DISABLE_SDL2MIXER\n &sound_sdl_module,\n+#endif // DISABLE_SDL2MIXER\n &sound_pcsound_module,\n NULL,\n };\n@@ -108,7 +110,9 @@ static sound_module_t *sound_modules[] =\n \n static music_module_t *music_modules[] =\n {\n+#ifndef DISABLE_SDL2MIXER\n &music_sdl_module,\n+#endif // DISABLE_SDL2MIXER\n &music_opl_module,\n NULL,\n };" -+ }, -+ { -+ "sha": "c1f701c0b9f653eb3a36b71627747a6eda963d8f", -+ "filename": "src/net_sdl.c", -+ "status": "modified", -+ "additions": 63, -+ "deletions": 0, -+ "changes": 63, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fnet_sdl.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fnet_sdl.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fnet_sdl.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -33,6 +33,10 @@\n // NETWORKING\n //\n \n+\n+#ifndef DISABLE_SDL2NET\n+\n+\n #include \n \n #define DEFAULT_PORT 2342\n@@ -376,3 +380,62 @@ net_module_t net_sdl_module =\n NET_SDL_ResolveAddress,\n };\n \n+\n+#else // DISABLE_SDL2NET\n+\n+// no-op implementation\n+\n+\n+static boolean NET_NULL_InitClient(void)\n+{\n+ return false;\n+}\n+\n+\n+static boolean NET_NULL_InitServer(void)\n+{\n+ return false;\n+}\n+\n+\n+static void NET_NULL_SendPacket(net_addr_t *addr, net_packet_t *packet)\n+{\n+}\n+\n+\n+static boolean NET_NULL_RecvPacket(net_addr_t **addr, net_packet_t **packet)\n+{\n+ return false;\n+}\n+\n+\n+static void NET_NULL_AddrToString(net_addr_t *addr, char *buffer, int buffer_len)\n+{\n+\n+}\n+\n+\n+static void NET_NULL_FreeAddress(net_addr_t *addr)\n+{\n+}\n+\n+\n+net_addr_t *NET_NULL_ResolveAddress(const char *address)\n+{\n+ return NULL;\n+}\n+\n+\n+net_module_t net_sdl_module =\n+{\n+ NET_NULL_InitClient,\n+ NET_NULL_InitServer,\n+ NET_NULL_SendPacket,\n+ NET_NULL_RecvPacket,\n+ NET_NULL_AddrToString,\n+ NET_NULL_FreeAddress,\n+ NET_NULL_ResolveAddress,\n+};\n+\n+\n+#endif // DISABLE_SDL2NET" -+ }, -+ { -+ "sha": "90df2114163152ff732a9ba4a8bc18ef3d45d1bd", -+ "filename": "src/setup/CMakeLists.txt", -+ "status": "modified", -+ "additions": 4, -+ "deletions": 1, -+ "changes": 5, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fsetup%2FCMakeLists.txt", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fsetup%2FCMakeLists.txt", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fsetup%2FCMakeLists.txt?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -15,4 +15,7 @@ add_library(setup STATIC\n txt_mouseinput.c txt_mouseinput.h)\n \n target_include_directories(setup PRIVATE \"../\" \"${CMAKE_CURRENT_BINARY_DIR}/../../\")\n-target_link_libraries(setup textscreen SDL2::SDL2 SDL2::mixer)\n+target_link_libraries(setup textscreen SDL2::SDL2)\n+if(ENABLE_SDL2_mixer)\n+ target_link_libraries(setup SDL2::mixer)\n+endif()" -+ }, -+ { -+ "sha": "37b17ade983155369c0897c0935d40dfeb2048c7", -+ "filename": "src/strife/CMakeLists.txt", -+ "status": "modified", -+ "additions": 7, -+ "deletions": 1, -+ "changes": 8, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fstrife%2FCMakeLists.txt", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fstrife%2FCMakeLists.txt", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fstrife%2FCMakeLists.txt?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -70,4 +70,10 @@ set(STRIFE_SOURCES\n add_library(strife STATIC ${STRIFE_SOURCES})\n \n target_include_directories(strife PRIVATE \"../\" \"../../win32/\" \"${CMAKE_CURRENT_BINARY_DIR}/../../\")\n-target_link_libraries(strife textscreen SDL2::SDL2 SDL2::mixer SDL2::net)\n+target_link_libraries(strife textscreen SDL2::SDL2)\n+if(ENABLE_SDL2_mixer)\n+ target_link_libraries(strife SDL2::mixer)\n+endif()\n+if(ENABLE_SDL2_NET)\n+ target_link_libraries(strife SDL2::net)\n+endif()" -+ } -+] -diff --git a/tests/capture_tools_output/expected_result.json b/tests/capture_tools_output/expected_result.json -new file mode 100644 -index 0000000..0912b96 ---- /dev/null -+++ b/tests/capture_tools_output/expected_result.json -@@ -0,0 +1,320 @@ -+[ -+ { -+ "sha": "46e082cf77c5e60affc7689f7357c926fc00cda3", -+ "filename": "opl/opl.c", -+ "status": "modified", -+ "additions": 2, -+ "deletions": 0, -+ "changes": 2, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/opl%2Fopl.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/opl%2Fopl.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/opl%2Fopl.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -50,7 +50,9 @@ static opl_driver_t *drivers[] =\n #ifdef _WIN32\n &opl_win32_driver,\n #endif\n+#ifndef DISABLE_SDL2MIXER\n &opl_sdl_driver,\n+#endif // DISABLE_SDL2MIXER\n NULL\n };\n ", -+ "line_filter": { -+ "diff_chunks": [ -+ [ -+ 50, -+ 59 -+ ] -+ ], -+ "lines_added": [ -+ [ -+ 53, -+ 54 -+ ], -+ [ -+ 55, -+ 56 -+ ] -+ ] -+ } -+ }, -+ { -+ "sha": "6bd4e7e1f3374e289d3dc19972dcb1d5379d6b03", -+ "filename": "opl/opl_sdl.c", -+ "status": "modified", -+ "additions": 6, -+ "deletions": 0, -+ "changes": 6, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/opl%2Fopl_sdl.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/opl%2Fopl_sdl.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/opl%2Fopl_sdl.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -33,6 +33,10 @@\n \n #include \"opl_queue.h\"\n \n+\n+#ifndef DISABLE_SDL2MIXER\n+\n+\n #define MAX_SOUND_SLICE_TIME 100 /* ms */\n \n typedef struct\n@@ -511,3 +515,5 @@ opl_driver_t opl_sdl_driver =\n OPL_SDL_AdjustCallbacks,\n };\n \n+\n+#endif // DISABLE_SDL2MIXER", -+ "line_filter": { -+ "diff_chunks": [ -+ [ -+ 33, -+ 43 -+ ], -+ [ -+ 515, -+ 520 -+ ] -+ ], -+ "lines_added": [ -+ [ -+ 36, -+ 40 -+ ], -+ [ -+ 518, -+ 520 -+ ] -+ ] -+ } -+ }, -+ { -+ "sha": "58f9b75c2affd5f1dd990aaedab79d834575bf83", -+ "filename": "pcsound/pcsound.c", -+ "status": "modified", -+ "additions": 2, -+ "deletions": 0, -+ "changes": 2, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/pcsound%2Fpcsound.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/pcsound%2Fpcsound.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/pcsound%2Fpcsound.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -56,7 +56,9 @@ static pcsound_driver_t *drivers[] =\n #ifdef _WIN32\n &pcsound_win32_driver,\n #endif\n+#ifndef DISABLE_SDL2MIXER\n &pcsound_sdl_driver,\n+#endif // DISABLE_SDL2MIXER\n NULL,\n };\n ", -+ "line_filter": { -+ "diff_chunks": [ -+ [ -+ 56, -+ 65 -+ ] -+ ], -+ "lines_added": [ -+ [ -+ 59, -+ 60 -+ ], -+ [ -+ 61, -+ 62 -+ ] -+ ] -+ } -+ }, -+ { -+ "sha": "e77c7b0d29de1b0c665686004dfda311c9d96719", -+ "filename": "pcsound/pcsound_sdl.c", -+ "status": "modified", -+ "additions": 6, -+ "deletions": 0, -+ "changes": 6, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/pcsound%2Fpcsound_sdl.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/pcsound%2Fpcsound_sdl.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/pcsound%2Fpcsound_sdl.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -24,6 +24,10 @@\n #include \"pcsound.h\"\n #include \"pcsound_internal.h\"\n \n+\n+#ifndef DISABLE_SDL2MIXER\n+\n+\n #define MAX_SOUND_SLICE_TIME 70 /* ms */\n #define SQUARE_WAVE_AMP 0x2000\n \n@@ -248,3 +252,5 @@ pcsound_driver_t pcsound_sdl_driver =\n PCSound_SDL_Shutdown,\n };\n \n+\n+#endif // DISABLE_SDL2MIXER", -+ "line_filter": { -+ "diff_chunks": [ -+ [ -+ 24, -+ 34 -+ ], -+ [ -+ 252, -+ 257 -+ ] -+ ], -+ "lines_added": [ -+ [ -+ 27, -+ 31 -+ ], -+ [ -+ 255, -+ 257 -+ ] -+ ] -+ } -+ }, -+ { -+ "sha": "3facce6f01d02c5e8eef3088fa60576b9e949829", -+ "filename": "src/i_musicpack.c", -+ "status": "modified", -+ "additions": 87, -+ "deletions": 1, -+ "changes": 88, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_musicpack.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_musicpack.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fi_musicpack.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -44,6 +44,13 @@\n #include \"w_wad.h\"\n #include \"z_zone.h\"\n \n+\n+char *music_pack_path = \"\";\n+\n+\n+#ifndef DISABLE_SDL2MIXER\n+\n+\n #define MID_HEADER_MAGIC \"MThd\"\n #define MUS_HEADER_MAGIC \"MUS\\x1a\"\n \n@@ -99,7 +106,6 @@ static boolean music_initialized = false;\n \n static boolean sdl_was_initialized = false;\n \n-char *music_pack_path = \"\";\n \n // If true, we are playing a substitute digital track rather than in-WAD\n // MIDI/MUS track, and file_metadata contains loop metadata.\n@@ -1375,3 +1381,83 @@ music_module_t music_pack_module =\n I_MP_PollMusic,\n };\n \n+\n+#else // DISABLE_SDL2MIXER\n+\n+\n+static boolean I_NULL_InitMusic(void)\n+{\n+ return false;\n+}\n+\n+\n+static void I_NULL_ShutdownMusic(void)\n+{\n+}\n+\n+\n+static void I_NULL_SetMusicVolume(int volume)\n+{\n+}\n+\n+\n+static void I_NULL_PauseSong(void)\n+{\n+}\n+\n+\n+static void I_NULL_ResumeSong(void)\n+{\n+}\n+\n+\n+static void *I_NULL_RegisterSong(void *data, int len)\n+{\n+ return NULL;\n+}\n+\n+\n+static void I_NULL_UnRegisterSong(void *handle)\n+{\n+}\n+\n+\n+static void I_NULL_PlaySong(void *handle, boolean looping)\n+{\n+}\n+\n+\n+static void I_NULL_StopSong(void)\n+{\n+}\n+\n+\n+static boolean I_NULL_MusicIsPlaying(void)\n+{\n+ return false;\n+}\n+\n+\n+static void I_NULL_PollMusic(void)\n+{\n+}\n+\n+music_module_t music_pack_module =\n+{\n+ NULL,\n+ 0,\n+ I_NULL_InitMusic,\n+ I_NULL_ShutdownMusic,\n+ I_NULL_SetMusicVolume,\n+ I_NULL_PauseSong,\n+ I_NULL_ResumeSong,\n+ I_NULL_RegisterSong,\n+ I_NULL_UnRegisterSong,\n+ I_NULL_PlaySong,\n+ I_NULL_StopSong,\n+ I_NULL_MusicIsPlaying,\n+ I_NULL_PollMusic,\n+};\n+\n+\n+#endif // DISABLE_SDL2MIXER", -+ "line_filter": { -+ "diff_chunks": [ -+ [ -+ 44, -+ 57 -+ ], -+ [ -+ 106, -+ 112 -+ ], -+ [ -+ 1381, -+ 1464 -+ ] -+ ], -+ "lines_added": [ -+ [ -+ 47, -+ 54 -+ ], -+ [ -+ 1384, -+ 1464 -+ ] -+ ] -+ } -+ }, -+ { -+ "sha": "48fedfbd7608ad8c6575a4bd82a5e1cb3646e071", -+ "filename": "src/i_sdlmusic.c", -+ "status": "modified", -+ "additions": 21, -+ "deletions": 13, -+ "changes": 34, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sdlmusic.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sdlmusic.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fi_sdlmusic.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -44,19 +44,6 @@\n #include \"w_wad.h\"\n #include \"z_zone.h\"\n \n-#define MAXMIDLENGTH (96 * 1024)\n-\n-static boolean music_initialized = false;\n-\n-// If this is true, this module initialized SDL sound and has the \n-// responsibility to shut it down\n-\n-static boolean sdl_was_initialized = false;\n-\n-static boolean win_midi_stream_opened = false;\n-\n-static boolean musicpaused = false;\n-static int current_music_volume;\n \n char *fluidsynth_sf_path = \"\";\n char *timidity_cfg_path = \"\";\n@@ -138,6 +125,25 @@ void I_InitTimidityConfig(void)\n }\n }\n \n+\n+#ifndef DISABLE_SDL2MIXER\n+\n+\n+#define MAXMIDLENGTH (96 * 1024)\n+\n+static boolean music_initialized = false;\n+\n+// If this is true, this module initialized SDL sound and has the\n+// responsibility to shut it down\n+\n+static boolean sdl_was_initialized = false;\n+\n+static boolean win_midi_stream_opened = false;\n+\n+static boolean musicpaused = false;\n+static int current_music_volume;\n+\n+\n // Remove the temporary config file generated by I_InitTimidityConfig().\n \n static void RemoveTimidityConfig(void)\n@@ -588,3 +594,5 @@ music_module_t music_sdl_module =\n NULL, // Poll\n };\n \n+\n+#endif // DISABLE_SDL2MIXER", -+ "line_filter": { -+ "diff_chunks": [ -+ [ -+ 44, -+ 50 -+ ], -+ [ -+ 125, -+ 150 -+ ], -+ [ -+ 594, -+ 599 -+ ] -+ ], -+ "lines_added": [ -+ [ -+ 128, -+ 147 -+ ], -+ [ -+ 597, -+ 599 -+ ] -+ ] -+ } -+ }, -+ { -+ "sha": "7f2a26096b218c5323f5dc569c6f70ab399782bf", -+ "filename": "src/i_sdlsound.c", -+ "status": "modified", -+ "additions": 17, -+ "deletions": 9, -+ "changes": 26, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sdlsound.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sdlsound.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fi_sdlsound.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -41,6 +41,21 @@\n \n #include \"doomtype.h\"\n \n+\n+int use_libsamplerate = 0;\n+\n+// Scale factor used when converting libsamplerate floating point numbers\n+// to integers. Too high means the sounds can clip; too low means they\n+// will be too quiet. This is an amount that should avoid clipping most\n+// of the time: with all the Doom IWAD sound effects, at least. If a PWAD\n+// is used, clipping might occur.\n+\n+float libsamplerate_scale = 0.65f;\n+\n+\n+#ifndef DISABLE_SDL2MIXER\n+\n+\n #define LOW_PASS_FILTER\n //#define DEBUG_DUMP_WAVS\n #define NUM_CHANNELS 16\n@@ -77,15 +92,6 @@ static allocated_sound_t *allocated_sounds_head = NULL;\n static allocated_sound_t *allocated_sounds_tail = NULL;\n static int allocated_sounds_size = 0;\n \n-int use_libsamplerate = 0;\n-\n-// Scale factor used when converting libsamplerate floating point numbers\n-// to integers. Too high means the sounds can clip; too low means they\n-// will be too quiet. This is an amount that should avoid clipping most\n-// of the time: with all the Doom IWAD sound effects, at least. If a PWAD\n-// is used, clipping might occur.\n-\n-float libsamplerate_scale = 0.65f;\n \n // Hook a sound into the linked list at the head.\n \n@@ -1135,3 +1141,5 @@ sound_module_t sound_sdl_module =\n I_SDL_PrecacheSounds,\n };\n \n+\n+#endif // DISABLE_SDL2MIXER", -+ "line_filter": { -+ "diff_chunks": [ -+ [ -+ 41, -+ 62 -+ ], -+ [ -+ 92, -+ 98 -+ ], -+ [ -+ 1141, -+ 1146 -+ ] -+ ], -+ "lines_added": [ -+ [ -+ 44, -+ 59 -+ ], -+ [ -+ 1144, -+ 1146 -+ ] -+ ] -+ } -+ }, -+ { -+ "sha": "9cf1fd95db13504ffc6f98ead8c9150f03db72aa", -+ "filename": "src/i_sound.c", -+ "status": "modified", -+ "additions": 4, -+ "deletions": 0, -+ "changes": 4, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sound.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fi_sound.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fi_sound.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -99,7 +99,9 @@ static int snd_mport = 0;\n \n static sound_module_t *sound_modules[] = \n {\n+#ifndef DISABLE_SDL2MIXER\n &sound_sdl_module,\n+#endif // DISABLE_SDL2MIXER\n &sound_pcsound_module,\n NULL,\n };\n@@ -108,7 +110,9 @@ static sound_module_t *sound_modules[] =\n \n static music_module_t *music_modules[] =\n {\n+#ifndef DISABLE_SDL2MIXER\n &music_sdl_module,\n+#endif // DISABLE_SDL2MIXER\n &music_opl_module,\n NULL,\n };", -+ "line_filter": { -+ "diff_chunks": [ -+ [ -+ 99, -+ 108 -+ ], -+ [ -+ 110, -+ 119 -+ ] -+ ], -+ "lines_added": [ -+ [ -+ 102, -+ 103 -+ ], -+ [ -+ 104, -+ 105 -+ ], -+ [ -+ 113, -+ 114 -+ ], -+ [ -+ 115, -+ 116 -+ ] -+ ] -+ } -+ }, -+ { -+ "sha": "c1f701c0b9f653eb3a36b71627747a6eda963d8f", -+ "filename": "src/net_sdl.c", -+ "status": "modified", -+ "additions": 63, -+ "deletions": 0, -+ "changes": 63, -+ "blob_url": "https://github.com/chocolate-doom/chocolate-doom/blob/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fnet_sdl.c", -+ "raw_url": "https://github.com/chocolate-doom/chocolate-doom/raw/3c35acde7d398c32f6fad50fa902de391f573ffa/src%2Fnet_sdl.c", -+ "contents_url": "https://api.github.com/repos/chocolate-doom/chocolate-doom/contents/src%2Fnet_sdl.c?ref=3c35acde7d398c32f6fad50fa902de391f573ffa", -+ "patch": "@@ -33,6 +33,10 @@\n // NETWORKING\n //\n \n+\n+#ifndef DISABLE_SDL2NET\n+\n+\n #include \n \n #define DEFAULT_PORT 2342\n@@ -376,3 +380,62 @@ net_module_t net_sdl_module =\n NET_SDL_ResolveAddress,\n };\n \n+\n+#else // DISABLE_SDL2NET\n+\n+// no-op implementation\n+\n+\n+static boolean NET_NULL_InitClient(void)\n+{\n+ return false;\n+}\n+\n+\n+static boolean NET_NULL_InitServer(void)\n+{\n+ return false;\n+}\n+\n+\n+static void NET_NULL_SendPacket(net_addr_t *addr, net_packet_t *packet)\n+{\n+}\n+\n+\n+static boolean NET_NULL_RecvPacket(net_addr_t **addr, net_packet_t **packet)\n+{\n+ return false;\n+}\n+\n+\n+static void NET_NULL_AddrToString(net_addr_t *addr, char *buffer, int buffer_len)\n+{\n+\n+}\n+\n+\n+static void NET_NULL_FreeAddress(net_addr_t *addr)\n+{\n+}\n+\n+\n+net_addr_t *NET_NULL_ResolveAddress(const char *address)\n+{\n+ return NULL;\n+}\n+\n+\n+net_module_t net_sdl_module =\n+{\n+ NET_NULL_InitClient,\n+ NET_NULL_InitServer,\n+ NET_NULL_SendPacket,\n+ NET_NULL_RecvPacket,\n+ NET_NULL_AddrToString,\n+ NET_NULL_FreeAddress,\n+ NET_NULL_ResolveAddress,\n+};\n+\n+\n+#endif // DISABLE_SDL2NET", -+ "line_filter": { -+ "diff_chunks": [ -+ [ -+ 33, -+ 43 -+ ], -+ [ -+ 380, -+ 442 -+ ] -+ ], -+ "lines_added": [ -+ [ -+ 36, -+ 40 -+ ], -+ [ -+ 383, -+ 442 -+ ] -+ ] -+ } -+ } -+] -diff --git a/tests/capture_tools_output/test_database_path.py b/tests/capture_tools_output/test_database_path.py -new file mode 100644 -index 0000000..3bafa66 ---- /dev/null -+++ b/tests/capture_tools_output/test_database_path.py -@@ -0,0 +1,59 @@ -+"""Tests specific to specifying the compilation database path.""" -+from typing import List -+from pathlib import Path, PurePath -+import logging -+import re -+import pytest -+from cpp_linter import logger -+import cpp_linter.run -+from cpp_linter.run import run_clang_tidy -+ -+CLANG_TIDY_COMMAND = re.compile(r"\"clang-tidy(.*)(?:\")") -+ -+ABS_DB_PATH = str(Path("tests/demo").resolve()) -+ -+ -+@pytest.mark.parametrize( -+ "database,expected_args", -+ [ -+ # implicit path to the compilation database -+ ("", []), -+ # explicit relative path to the compilation database -+ ("../../demo", ["-p", ABS_DB_PATH]), -+ # explicit absolute path to the compilation database -+ (ABS_DB_PATH, ["-p", ABS_DB_PATH]), -+ ], -+ ids=["implicit path", "relative path", "absolute path"], -+) -+def test_db_detection( -+ caplog: pytest.LogCaptureFixture, -+ monkeypatch: pytest.MonkeyPatch, -+ pytestconfig: pytest.Config, -+ database: str, -+ expected_args: List[str], -+): -+ """test clang-tidy using a implicit path to the compilation database.""" -+ monkeypatch.chdir(PurePath(__file__).parent.as_posix()) -+ demo_src = "../demo/demo.cpp" -+ rel_root = str(Path(*Path(__file__).parts[-2:])) -+ cpp_linter.run.RUNNER_WORKSPACE = ( -+ Path(pytestconfig.rootpath, "tests").resolve().as_posix() -+ ) -+ caplog.set_level(logging.DEBUG, logger=logger.name) -+ run_clang_tidy( -+ filename=(demo_src), -+ file_obj={}, # only used when filtering lines -+ version="", -+ checks="", # let clang-tidy use a .clang-tidy config file -+ lines_changed_only=0, # analyze complete file -+ database=database, -+ repo_root=rel_root, -+ ) -+ matched_args = [] -+ for record in caplog.records: -+ msg_match = CLANG_TIDY_COMMAND.search(record.message) -+ if msg_match is not None: -+ matched_args = msg_match.group(0)[:-1].split()[2:] -+ assert "Error while trying to load a compilation database" not in record.message -+ expected_args.append(demo_src) -+ assert matched_args == expected_args -diff --git a/tests/capture_tools_output/test_tools_output.py b/tests/capture_tools_output/test_tools_output.py -new file mode 100644 -index 0000000..c7467fa ---- /dev/null -+++ b/tests/capture_tools_output/test_tools_output.py -@@ -0,0 +1,207 @@ -+"""Various tests related to the ``lines_changed_only`` option.""" -+import os -+import logging -+from typing import Dict, Any, cast, List, Optional -+from pathlib import Path -+import json -+import re -+import pytest -+import cpp_linter -+from cpp_linter.run import ( -+ filter_out_non_source_files, -+ verify_files_are_present, -+ capture_clang_tools_output, -+ make_annotations, -+ log_commander, -+) -+from cpp_linter.thread_comments import list_diff_comments -+ -+CLANG_VERSION = os.getenv("CLANG_VERSION", "12") -+ -+ -+@pytest.mark.parametrize( -+ "extensions", [(["c"]), pytest.param(["h"], marks=pytest.mark.xfail)] -+) -+def test_lines_changed_only( -+ monkeypatch: pytest.MonkeyPatch, -+ caplog: pytest.LogCaptureFixture, -+ extensions: List[str], -+): -+ """Test for lines changes in diff. -+ -+ This checks for -+ 1. ranges of diff chunks. -+ 2. ranges of lines in diff that only contain additions. -+ """ -+ monkeypatch.chdir(str(Path(__file__).parent)) -+ caplog.set_level(logging.DEBUG, logger=cpp_linter.logger.name) -+ cpp_linter.Globals.FILES = json.loads( -+ Path("event_files.json").read_text(encoding="utf-8") -+ ) -+ if filter_out_non_source_files( -+ ext_list=extensions, -+ ignored=[".github"], -+ not_ignored=[], -+ ): -+ test_result = Path("expected_result.json").read_text(encoding="utf-8") -+ for file, result in zip( -+ cpp_linter.Globals.FILES, -+ json.loads(test_result), -+ ): -+ expected = result["line_filter"]["diff_chunks"] -+ assert file["line_filter"]["diff_chunks"] == expected -+ expected = result["line_filter"]["lines_added"] -+ assert file["line_filter"]["lines_added"] == expected -+ else: -+ raise RuntimeError("test failed to find files") -+ -+ -+TEST_REPO = re.compile(r".*github.com/(?:\w|\-|_)+/((?:\w|\-|_)+)/.*") -+ -+ -+@pytest.fixture(autouse=True) -+def setup_test_repo(monkeypatch: pytest.MonkeyPatch) -> None: -+ """Setup a test repo to run the rest of the tests in this module.""" -+ test_root = Path(__file__).parent -+ cpp_linter.Globals.FILES = json.loads( -+ Path(test_root / "expected_result.json").read_text(encoding="utf-8") -+ ) -+ # flush output from any previous tests -+ cpp_linter.Globals.OUTPUT = "" -+ cpp_linter.GlobalParser.format_advice = [] -+ cpp_linter.GlobalParser.tidy_notes = [] -+ cpp_linter.GlobalParser.tidy_advice = [] -+ -+ repo_root = TEST_REPO.sub("\\1", cpp_linter.Globals.FILES[0]["blob_url"]) -+ return_path = test_root / repo_root -+ if not return_path.exists(): -+ return_path.mkdir() -+ monkeypatch.chdir(str(return_path)) -+ verify_files_are_present() -+ -+ -+def match_file_json(filename: str) -> Optional[Dict[str, Any]]: -+ """A helper function to match a given filename with a file's JSON object.""" -+ for file in cpp_linter.Globals.FILES: -+ if file["filename"] == filename: -+ return file -+ print("file", filename, "not found in expected_result.json") -+ return None -+ -+ -+RECORD_FILE = re.compile(r".*file=(.*?),.*") -+FORMAT_RECORD = re.compile(r"Run clang-format on ") -+FORMAT_RECORD_LINES = re.compile(r".*\(lines (.*)\).*") -+TIDY_RECORD = re.compile(r":\d+:\d+ \[.*\]::") -+TIDY_RECORD_LINE = re.compile(r".*,line=(\d+).*") -+ -+ -+@pytest.mark.parametrize( -+ "lines_changed_only", [0, 1, 2], ids=["all lines", "only diff", "only added"] -+) -+@pytest.mark.parametrize("style", ["file", "llvm", "google"]) -+def test_format_annotations( -+ caplog: pytest.LogCaptureFixture, -+ lines_changed_only: int, -+ style: str, -+): -+ """Test clang-format annotations.""" -+ capture_clang_tools_output( -+ version=CLANG_VERSION, -+ checks="-*", # disable clang-tidy output -+ style=style, -+ lines_changed_only=lines_changed_only, -+ database="", -+ repo_root="", -+ ) -+ assert "Output from `clang-tidy`" not in cpp_linter.Globals.OUTPUT -+ caplog.set_level(logging.INFO, logger=log_commander.name) -+ log_commander.propagate = True -+ make_annotations( -+ style=style, file_annotations=True, lines_changed_only=lines_changed_only -+ ) -+ for message in [r.message for r in caplog.records if r.levelno == logging.INFO]: -+ if FORMAT_RECORD.search(message) is not None: -+ lines = [ -+ int(l.strip()) -+ for l in FORMAT_RECORD_LINES.sub("\\1", message).split(",") -+ ] -+ file = match_file_json(RECORD_FILE.sub("\\1", message).replace("\\", "/")) -+ if file is None: -+ continue -+ ranges = cpp_linter.range_of_changed_lines(file, lines_changed_only) -+ if ranges: # an empty list if lines_changed_only == 0 -+ for line in lines: -+ assert line in ranges -+ else: -+ raise RuntimeWarning(f"unrecognized record: {message}") -+ -+ -+@pytest.mark.parametrize( -+ "lines_changed_only", [0, 1, 2], ids=["all lines", "only diff", "only added"] -+) -+@pytest.mark.parametrize( -+ "checks", -+ [ -+ "", -+ "boost-*,bugprone-*,performance-*,readability-*,portability-*,modernize-*," -+ "clang-analyzer-*,cppcoreguidelines-*", -+ ], -+ ids=["config file", "action defaults"], -+) -+def test_tidy_annotations( -+ caplog: pytest.LogCaptureFixture, -+ lines_changed_only: int, -+ checks: str, -+): -+ """Test clang-tidy annotations.""" -+ capture_clang_tools_output( -+ version=CLANG_VERSION, -+ checks=checks, -+ style="", # disable clang-format output -+ lines_changed_only=lines_changed_only, -+ database="", -+ repo_root="", -+ ) -+ assert "Run `clang-format` on the following files" not in cpp_linter.Globals.OUTPUT -+ caplog.set_level(logging.INFO, logger=log_commander.name) -+ log_commander.propagate = True -+ make_annotations( -+ style="", file_annotations=True, lines_changed_only=lines_changed_only -+ ) -+ for message in [r.message for r in caplog.records if r.levelno == logging.INFO]: -+ if TIDY_RECORD.search(message) is not None: -+ line = int(TIDY_RECORD_LINE.sub("\\1", message)) -+ file = match_file_json(RECORD_FILE.sub("\\1", message).replace("\\", "/")) -+ if file is None: -+ continue -+ ranges = cpp_linter.range_of_changed_lines(file, lines_changed_only) -+ if ranges: # an empty list if lines_changed_only == 0 -+ assert line in ranges -+ else: -+ raise RuntimeWarning(f"unrecognized record: {message}") -+ -+ -+@pytest.mark.parametrize("lines_changed_only", [1, 2], ids=["only diff", "only added"]) -+def test_diff_comment(lines_changed_only: int): -+ """Tests code that isn't actually used (yet) for posting -+ comments (not annotations) in the event's diff. -+ -+ Remember, diff comments should only focus on lines in the diff.""" -+ capture_clang_tools_output( -+ version=CLANG_VERSION, -+ checks="", -+ style="file", -+ lines_changed_only=lines_changed_only, -+ database="", -+ repo_root="", -+ ) -+ diff_comments = list_diff_comments(lines_changed_only) -+ # output = Path(__file__).parent / "diff_comments.json" -+ # output.write_text(json.dumps(diff_comments, indent=2), encoding="utf-8") -+ for comment in diff_comments: -+ file = match_file_json(cast(str, comment["path"])) -+ if file is None: -+ continue -+ ranges = cpp_linter.range_of_changed_lines(file, lines_changed_only) -+ assert comment["line"] in ranges -diff --git a/tests/demo/.clang-format b/tests/demo/.clang-format -new file mode 100644 -index 0000000..1dd236c ---- /dev/null -+++ b/tests/demo/.clang-format -@@ -0,0 +1,3 @@ -+--- -+Language: Cpp -+BasedOnStyle: WebKit -diff --git a/tests/demo/.clang-tidy b/tests/demo/.clang-tidy -new file mode 100644 -index 0000000..d3865ad ---- /dev/null -+++ b/tests/demo/.clang-tidy -@@ -0,0 +1,186 @@ -+--- -+Checks: 'clang-diagnostic-*,clang-analyzer-*,-*,performance-*,bugprone-*,clang-analyzer-*,mpi-*,misc-*,readability-*' -+WarningsAsErrors: '' -+HeaderFilterRegex: '' -+AnalyzeTemporaryDtors: false -+FormatStyle: 'file' -+CheckOptions: -+ - key: bugprone-argument-comment.CommentBoolLiterals -+ value: '0' -+ - key: bugprone-argument-comment.CommentCharacterLiterals -+ value: '0' -+ - key: bugprone-argument-comment.CommentFloatLiterals -+ value: '0' -+ - key: bugprone-argument-comment.CommentIntegerLiterals -+ value: '0' -+ - key: bugprone-argument-comment.CommentNullPtrs -+ value: '0' -+ - key: bugprone-argument-comment.CommentStringLiterals -+ value: '0' -+ - key: bugprone-argument-comment.CommentUserDefinedLiterals -+ value: '0' -+ - key: bugprone-argument-comment.IgnoreSingleArgument -+ value: '0' -+ - key: bugprone-argument-comment.StrictMode -+ value: '0' -+ - key: bugprone-assert-side-effect.AssertMacros -+ value: assert -+ - key: bugprone-assert-side-effect.CheckFunctionCalls -+ value: '0' -+ - key: bugprone-dangling-handle.HandleClasses -+ value: 'std::basic_string_view;std::experimental::basic_string_view' -+ - key: bugprone-dynamic-static-initializers.HeaderFileExtensions -+ value: ',h,hh,hpp,hxx' -+ - key: bugprone-exception-escape.FunctionsThatShouldNotThrow -+ value: '' -+ - key: bugprone-exception-escape.IgnoredExceptions -+ value: '' -+ - key: bugprone-misplaced-widening-cast.CheckImplicitCasts -+ value: '0' -+ - key: bugprone-not-null-terminated-result.WantToUseSafeFunctions -+ value: '1' -+ - key: bugprone-signed-char-misuse.CharTypdefsToIgnore -+ value: '' -+ - key: bugprone-sizeof-expression.WarnOnSizeOfCompareToConstant -+ value: '1' -+ - key: bugprone-sizeof-expression.WarnOnSizeOfConstant -+ value: '1' -+ - key: bugprone-sizeof-expression.WarnOnSizeOfIntegerExpression -+ value: '0' -+ - key: bugprone-sizeof-expression.WarnOnSizeOfThis -+ value: '1' -+ - key: bugprone-string-constructor.LargeLengthThreshold -+ value: '8388608' -+ - key: bugprone-string-constructor.WarnOnLargeLength -+ value: '1' -+ - key: bugprone-suspicious-enum-usage.StrictMode -+ value: '0' -+ - key: bugprone-suspicious-missing-comma.MaxConcatenatedTokens -+ value: '5' -+ - key: bugprone-suspicious-missing-comma.RatioThreshold -+ value: '0.200000' -+ - key: bugprone-suspicious-missing-comma.SizeThreshold -+ value: '5' -+ - key: bugprone-suspicious-string-compare.StringCompareLikeFunctions -+ value: '' -+ - key: bugprone-suspicious-string-compare.WarnOnImplicitComparison -+ value: '1' -+ - key: bugprone-suspicious-string-compare.WarnOnLogicalNotComparison -+ value: '0' -+ - key: bugprone-too-small-loop-variable.MagnitudeBitsUpperLimit -+ value: '16' -+ - key: bugprone-unhandled-self-assignment.WarnOnlyIfThisHasSuspiciousField -+ value: '1' -+ - key: bugprone-unused-return-value.CheckedFunctions -+ value: '::std::async;::std::launder;::std::remove;::std::remove_if;::std::unique;::std::unique_ptr::release;::std::basic_string::empty;::std::vector::empty' -+ - key: cert-dcl16-c.NewSuffixes -+ value: 'L;LL;LU;LLU' -+ - key: cert-oop54-cpp.WarnOnlyIfThisHasSuspiciousField -+ value: '0' -+ - key: cppcoreguidelines-explicit-virtual-functions.IgnoreDestructors -+ value: '1' -+ - key: cppcoreguidelines-non-private-member-variables-in-classes.IgnoreClassesWithAllMemberVariablesBeingPublic -+ value: '1' -+ - key: google-readability-braces-around-statements.ShortStatementLines -+ value: '1' -+ - key: google-readability-function-size.StatementThreshold -+ value: '800' -+ - key: google-readability-namespace-comments.ShortNamespaceLines -+ value: '10' -+ - key: google-readability-namespace-comments.SpacesBeforeComments -+ value: '2' -+ - key: misc-definitions-in-headers.HeaderFileExtensions -+ value: ',h,hh,hpp,hxx' -+ - key: misc-definitions-in-headers.UseHeaderFileExtension -+ value: '1' -+ - key: misc-throw-by-value-catch-by-reference.CheckThrowTemporaries -+ value: '1' -+ - key: misc-unused-parameters.StrictMode -+ value: '0' -+ - key: modernize-loop-convert.MaxCopySize -+ value: '16' -+ - key: modernize-loop-convert.MinConfidence -+ value: reasonable -+ - key: modernize-loop-convert.NamingStyle -+ value: CamelCase -+ - key: modernize-pass-by-value.IncludeStyle -+ value: llvm -+ - key: modernize-replace-auto-ptr.IncludeStyle -+ value: llvm -+ - key: modernize-use-nullptr.NullMacros -+ value: 'NULL' -+ - key: performance-faster-string-find.StringLikeClasses -+ value: 'std::basic_string' -+ - key: performance-for-range-copy.AllowedTypes -+ value: '' -+ - key: performance-for-range-copy.WarnOnAllAutoCopies -+ value: '0' -+ - key: performance-inefficient-string-concatenation.StrictMode -+ value: '0' -+ - key: performance-inefficient-vector-operation.EnableProto -+ value: '0' -+ - key: performance-inefficient-vector-operation.VectorLikeClasses -+ value: '::std::vector' -+ - key: performance-move-const-arg.CheckTriviallyCopyableMove -+ value: '1' -+ - key: performance-move-constructor-init.IncludeStyle -+ value: llvm -+ - key: performance-no-automatic-move.AllowedTypes -+ value: '' -+ - key: performance-type-promotion-in-math-fn.IncludeStyle -+ value: llvm -+ - key: performance-unnecessary-copy-initialization.AllowedTypes -+ value: '' -+ - key: performance-unnecessary-value-param.AllowedTypes -+ value: '' -+ - key: performance-unnecessary-value-param.IncludeStyle -+ value: llvm -+ - key: readability-braces-around-statements.ShortStatementLines -+ value: '0' -+ - key: readability-else-after-return.WarnOnUnfixable -+ value: '1' -+ - key: readability-function-size.BranchThreshold -+ value: '4294967295' -+ - key: readability-function-size.LineThreshold -+ value: '4294967295' -+ - key: readability-function-size.NestingThreshold -+ value: '4294967295' -+ - key: readability-function-size.ParameterThreshold -+ value: '4294967295' -+ - key: readability-function-size.StatementThreshold -+ value: '800' -+ - key: readability-function-size.VariableThreshold -+ value: '4294967295' -+ - key: readability-identifier-naming.IgnoreFailedSplit -+ value: '0' -+ - key: readability-implicit-bool-conversion.AllowIntegerConditions -+ value: '0' -+ - key: readability-implicit-bool-conversion.AllowPointerConditions -+ value: '0' -+ - key: readability-inconsistent-declaration-parameter-name.IgnoreMacros -+ value: '1' -+ - key: readability-inconsistent-declaration-parameter-name.Strict -+ value: '0' -+ - key: readability-magic-numbers.IgnoredFloatingPointValues -+ value: '1.0;100.0;' -+ - key: readability-magic-numbers.IgnoredIntegerValues -+ value: '1;2;3;4;' -+ - key: readability-redundant-member-init.IgnoreBaseInCopyConstructors -+ value: '0' -+ - key: readability-redundant-smartptr-get.IgnoreMacros -+ value: '1' -+ - key: readability-redundant-string-init.StringNames -+ value: '::std::basic_string' -+ - key: readability-simplify-boolean-expr.ChainedConditionalAssignment -+ value: '0' -+ - key: readability-simplify-boolean-expr.ChainedConditionalReturn -+ value: '0' -+ - key: readability-simplify-subscript-expr.Types -+ value: '::std::basic_string;::std::basic_string_view;::std::vector;::std::array' -+ - key: readability-static-accessed-through-instance.NameSpecifierNestingThreshold -+ value: '3' -+ - key: readability-uppercase-literal-suffix.IgnoreMacros -+ value: '1' -+ - key: readability-uppercase-literal-suffix.NewSuffixes -+ value: '' -+... -diff --git a/tests/demo/compile_flags.txt b/tests/demo/compile_flags.txt -new file mode 100644 -index 0000000..03e4446 ---- /dev/null -+++ b/tests/demo/compile_flags.txt -@@ -0,0 +1,2 @@ -+-Wall -+-Werror -diff --git a/tests/demo/demo.cpp b/tests/demo/demo.cpp -new file mode 100644 -index 0000000..1bf553e ---- /dev/null -+++ b/tests/demo/demo.cpp -@@ -0,0 +1,18 @@ -+/** This is a very ugly test code (doomed to fail linting) */ -+#include "demo.hpp" -+#include -+ -+ -+ -+ -+int main(){ -+ -+ for (;;) break; -+ -+ -+ printf("Hello world!\n"); -+ -+ -+ -+ -+ return 0;} -diff --git a/tests/demo/demo.hpp b/tests/demo/demo.hpp -new file mode 100644 -index 0000000..f93d012 ---- /dev/null -+++ b/tests/demo/demo.hpp -@@ -0,0 +1,36 @@ -+#pragma once -+ -+ -+ -+class Dummy { -+ char* useless; -+ int numb; -+ Dummy() :numb(0), useless("\0"){} -+ -+ public: -+ void *not_useful(char *str){useless = str;} -+}; -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+ -+struct LongDiff -+{ -+ -+ long diff; -+ -+}; -diff --git a/tests/ignored_paths/.gitmodules b/tests/ignored_paths/.gitmodules -new file mode 100644 -index 0000000..3696a31 ---- /dev/null -+++ b/tests/ignored_paths/.gitmodules -@@ -0,0 +1,12 @@ -+[submodule "RF24"] -+ path = RF24 -+ url = https://github.com/nRF24/RF24.git -+[submodule "RF24Network"] -+ path = RF24Network -+ url = https://github.com/nRF24/RF24Network.git -+[submodule "RF24Mesh"] -+ path = RF24Mesh -+ url = https://github.com/nRF24/RF24Mesh.git -+[submodule "pybind11"] -+ path = pybind11 -+ url = https://github.com/pybind/pybind11.git -diff --git a/tests/ignored_paths/test_ignored_paths.py b/tests/ignored_paths/test_ignored_paths.py -new file mode 100644 -index 0000000..b27d3b8 ---- /dev/null -+++ b/tests/ignored_paths/test_ignored_paths.py -@@ -0,0 +1,32 @@ -+"""Tests that focus on the ``ignore`` option's parsing.""" -+from pathlib import Path -+from typing import List -+import pytest -+from cpp_linter.run import parse_ignore_option, is_file_in_list -+ -+ -+@pytest.mark.parametrize( -+ "user_in,is_ignored,is_not_ignored,expected", -+ [ -+ ("src", "src", "src", [True, False]), -+ ("!src|./", "", "src", [True, True]), -+ ], -+) -+def test_ignore( -+ user_in: str, is_ignored: str, is_not_ignored: str, expected: List[bool] -+): -+ """test ignoring of a specified path.""" -+ ignored, not_ignored = parse_ignore_option(user_in) -+ assert expected == [ -+ is_file_in_list(ignored, is_ignored, "ignored"), -+ is_file_in_list(not_ignored, is_not_ignored, "not ignored"), -+ ] -+ -+ -+def test_ignore_submodule(monkeypatch: pytest.MonkeyPatch): -+ """test auto detection of submodules and ignore the paths appropriately.""" -+ monkeypatch.chdir(str(Path(__file__).parent)) -+ ignored, not_ignored = parse_ignore_option("!pybind11") -+ for ignored_submodule in ["RF24", "RF24Network", "RF24Mesh"]: -+ assert ignored_submodule in ignored -+ assert "pybind11" in not_ignored -diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py -new file mode 100644 -index 0000000..4015877 ---- /dev/null -+++ b/tests/test_cli_args.py -@@ -0,0 +1,75 @@ -+"""Tests related parsing input from CLI arguments.""" -+from typing import List, Union -+import pytest -+from cpp_linter.run import cli_arg_parser -+ -+ -+class Args: -+ """A pseudo namespace declaration. Each attribute is initialized with the -+ corresponding CLI arg's default value.""" -+ -+ verbosity: int = 10 -+ database: str = "" -+ style: str = "llvm" -+ tidy_checks: str = ( -+ "boost-*,bugprone-*,performance-*,readability-*,portability-*,modernize-*," -+ "clang-analyzer-*,cppcoreguidelines-*" -+ ) -+ version: str = "" -+ extensions: List[str] = [ -+ "c", -+ "h", -+ "C", -+ "H", -+ "cpp", -+ "hpp", -+ "cc", -+ "hh", -+ "c++", -+ "h++", -+ "cxx", -+ "hxx", -+ ] -+ repo_root: str = "." -+ ignore: str = ".github" -+ lines_changed_only: int = 0 -+ files_changed_only: bool = False -+ thread_comments: bool = False -+ file_annotations: bool = True -+ -+ -+def test_defaults(): -+ """test default values""" -+ args = cli_arg_parser.parse_args("") -+ for key in args.__dict__.keys(): -+ assert args.__dict__[key] == getattr(Args, key) -+ -+ -+@pytest.mark.parametrize( -+ "arg_name,arg_value,attr_name,attr_value", -+ [ -+ ("verbosity", "20", "verbosity", 20), -+ ("database", "build", "database", "build"), -+ ("style", "file", "style", "file"), -+ ("tidy-checks", "-*", "tidy_checks", "-*"), -+ ("version", "14", "version", "14"), -+ ("extensions", ".cpp, .h", "extensions", ["cpp", "h"]), -+ ("extensions", "cxx,.hpp", "extensions", ["cxx", "hpp"]), -+ ("repo-root", "src", "repo_root", "src"), -+ ("ignore", "!src|", "ignore", "!src|"), -+ ("lines-changed-only", "True", "lines_changed_only", 2), -+ ("lines-changed-only", "difF", "lines_changed_only", 1), -+ ("files-changed-only", "True", "files_changed_only", True), -+ ("thread-comments", "True", "thread_comments", True), -+ ("file-annotations", "False", "file_annotations", False), -+ ], -+) -+def test_arg_parser( -+ arg_name: str, -+ arg_value: str, -+ attr_name: str, -+ attr_value: Union[int, str, List[str], bool], -+): -+ """parameterized test of specific args compared to their parsed value""" -+ args = cli_arg_parser.parse_args([f"--{arg_name}={arg_value}"]) -+ assert getattr(args, attr_name) == attr_value -diff --git a/tests/test_misc.py b/tests/test_misc.py -new file mode 100644 -index 0000000..ce7bd26 ---- /dev/null -+++ b/tests/test_misc.py -@@ -0,0 +1,104 @@ -+"""Tests that complete coverage that aren't prone to failure.""" -+import logging -+from pathlib import Path -+from typing import List -+import pytest -+import requests -+import cpp_linter -+import cpp_linter.run -+from cpp_linter import Globals, log_response_msg, get_line_cnt_from_cols -+from cpp_linter.run import ( -+ log_commander, -+ start_log_group, -+ end_log_group, -+ set_exit_code, -+ list_source_files, -+ get_list_of_changed_files, -+) -+ -+ -+def test_exit_override(): -+ """Test exit code that indicates if action encountered lining errors.""" -+ assert 1 == set_exit_code(1) -+ -+ -+def test_exit_implicit(): -+ """Test the exit code issued when a thread comment is to be made.""" -+ Globals.OUTPUT = "TEST" # fake content for a thread comment -+ assert 1 == set_exit_code() -+ -+ -+# see https://github.com/pytest-dev/pytest/issues/5997 -+def test_end_group(caplog: pytest.LogCaptureFixture): -+ """Test the output that concludes a group of runner logs.""" -+ caplog.set_level(logging.INFO, logger=log_commander.name) -+ log_commander.propagate = True -+ end_log_group() -+ messages = caplog.messages -+ assert "::endgroup::" in messages -+ -+ -+# see https://github.com/pytest-dev/pytest/issues/5997 -+def test_start_group(caplog: pytest.LogCaptureFixture): -+ """Test the output that begins a group of runner logs.""" -+ caplog.set_level(logging.INFO, logger=log_commander.name) -+ log_commander.propagate = True -+ start_log_group("TEST") -+ messages = caplog.messages -+ assert "::group::TEST" in messages -+ -+ -+@pytest.mark.parametrize( -+ "url", -+ [ -+ ("https://api.github.com/users/cpp-linter/starred"), -+ pytest.param(("https://github.com/cpp-linter/repo"), marks=pytest.mark.xfail), -+ ], -+) -+def test_response_logs(url: str): -+ """Test the log output for a requests.response buffer.""" -+ Globals.response_buffer = requests.get(url) -+ assert log_response_msg() -+ -+ -+@pytest.mark.parametrize( -+ "extensions", -+ [ -+ (["cpp", "hpp"]), -+ pytest.param(["cxx", "h"], marks=pytest.mark.xfail), -+ ], -+) -+def test_list_src_files( -+ monkeypatch: pytest.MonkeyPatch, -+ caplog: pytest.LogCaptureFixture, -+ extensions: List[str], -+): -+ """List the source files in the demo folder of this repo.""" -+ Globals.FILES = [] -+ monkeypatch.chdir(Path(__file__).parent.parent.as_posix()) -+ caplog.set_level(logging.DEBUG, logger=cpp_linter.logger.name) -+ assert list_source_files(ext_list=extensions, ignored_paths=[], not_ignored=[]) -+ -+ -+def test_get_changed_files(caplog: pytest.LogCaptureFixture): -+ """test getting a list of changed files for an event. -+ -+ This is expected to fail if a github token not supplied as an env var. -+ We don't need to supply one for this test because the tested code will -+ execute anyway. -+ """ -+ caplog.set_level(logging.DEBUG, logger=cpp_linter.logger.name) -+ cpp_linter.run.GITHUB_REPOSITORY = "cpp-linter/test-cpp-linter-action" -+ cpp_linter.run.GITHUB_SHA = "76adde5367196cd57da5bef49a4f09af6175fd3f" -+ cpp_linter.run.GITHUB_EVENT_NAME = "push" -+ get_list_of_changed_files() -+ # pylint: disable=no-member -+ assert Globals.FILES -+ # pylint: enable=no-member -+ -+ -+@pytest.mark.parametrize("line,cols,offset", [(13, 5, 144), (19, 1, 189)]) -+def test_file_offset_translation(line: int, cols: int, offset: int): -+ """Validate output from ``get_line_cnt_from_cols()``""" -+ test_file = str(Path("tests/demo/demo.cpp").resolve()) -+ assert (line, cols) == get_line_cnt_from_cols(test_file, offset) diff --git a/cpp-linter/tests/ignored_paths/.gitmodules b/cpp-linter/tests/ignored_paths/.gitmodules deleted file mode 100644 index 3696a312..00000000 --- a/cpp-linter/tests/ignored_paths/.gitmodules +++ /dev/null @@ -1,12 +0,0 @@ -[submodule "RF24"] - path = RF24 - url = https://github.com/nRF24/RF24.git -[submodule "RF24Network"] - path = RF24Network - url = https://github.com/nRF24/RF24Network.git -[submodule "RF24Mesh"] - path = RF24Mesh - url = https://github.com/nRF24/RF24Mesh.git -[submodule "pybind11"] - path = pybind11 - url = https://github.com/pybind/pybind11.git diff --git a/cpp-linter/tests/paginated_changed_files.rs b/cpp-linter/tests/paginated_changed_files.rs index 15015480..f8b57001 100644 --- a/cpp-linter/tests/paginated_changed_files.rs +++ b/cpp-linter/tests/paginated_changed_files.rs @@ -1,15 +1,12 @@ +#![cfg(feature = "bin")] mod common; use chrono::Utc; use common::{create_test_space, mock_server}; +use git_bot_feedback::{FileFilter, LinesChangedOnly}; use mockito::Matcher; use tempfile::{NamedTempFile, TempDir}; -use cpp_linter::{ - cli::LinesChangedOnly, - common_fs::FileFilter, - logger, - rest_api::{RestApiClient, github::GithubApiClient}, -}; +use cpp_linter::{logger, rest_client::RestClient}; use std::{env, io::Write, path::Path}; #[derive(PartialEq, Default)] @@ -31,7 +28,6 @@ const REPO: &str = "cpp-linter/test-cpp-linter-action"; const SHA: &str = "DEADBEEF"; const PR: u8 = 42; const TOKEN: &str = "123456"; -const EVENT_PAYLOAD: &str = r#"{"number": 42}"#; const RESET_RATE_LIMIT_HEADER: &str = "x-ratelimit-reset"; const REMAINING_RATE_LIMIT_HEADER: &str = "x-ratelimit-remaining"; const MALFORMED_RESPONSE_PAYLOAD: &str = "{\"message\":\"Resource not accessible by integration\"}"; @@ -41,6 +37,7 @@ async fn get_paginated_changes(lib_root: &Path, test_params: &TestParams) { let mut event_payload = NamedTempFile::new_in(tmp.path()) .expect("Failed to spawn a tmp file for test event payload"); unsafe { + env::set_var("GITHUB_ACTIONS", "true"); env::set_var("GITHUB_REPOSITORY", REPO); env::set_var("GITHUB_SHA", SHA); env::set_var("GITHUB_TOKEN", TOKEN); @@ -66,8 +63,17 @@ async fn get_paginated_changes(lib_root: &Path, test_params: &TestParams) { && !test_params.fail_serde_event_payload && !test_params.no_event_payload { + let payload = serde_json::json!({ + "pull_request": { + "draft": false, + "state": "open", + "number": PR, + "locked": false, + } + }) + .to_string(); event_payload - .write_all(EVENT_PAYLOAD.as_bytes()) + .write_all(payload.as_bytes()) .expect("Failed to write data to test event payload file") } @@ -81,7 +87,7 @@ async fn get_paginated_changes(lib_root: &Path, test_params: &TestParams) { env::set_current_dir(tmp.path()).unwrap(); logger::try_init(); log::set_max_level(log::LevelFilter::Debug); - let gh_client = GithubApiClient::new(); + let gh_client = RestClient::new(); if test_params.fail_serde_event_payload || test_params.no_event_payload { assert!(gh_client.is_err()); return; @@ -97,16 +103,6 @@ async fn get_paginated_changes(lib_root: &Path, test_params: &TestParams) { format!("commits/{SHA}") } ); - mocks.push( - server - .mock("GET", diff_end_point.as_str()) - .match_header("Accept", "application/vnd.github.diff") - .match_header("Authorization", format!("token {TOKEN}").as_str()) - .with_header(REMAINING_RATE_LIMIT_HEADER, "50") - .with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str()) - .with_status(403) - .create(), - ); let pg_end_point = if test_params.event_t == EventType::Push { diff_end_point.clone() } else { @@ -142,9 +138,9 @@ async fn get_paginated_changes(lib_root: &Path, test_params: &TestParams) { mocks.push(mock.create()); } - let file_filter = FileFilter::new(&[], vec!["cpp".to_string(), "hpp".to_string()]); + let file_filter = FileFilter::new(&[], &["cpp", "hpp"], None); let files = client - .get_list_of_changed_files(&file_filter, &LinesChangedOnly::Off, &None::, false) + .get_list_of_changed_files(&file_filter, &LinesChangedOnly::Off, &None::, false) .await; match files { Err(e) => { diff --git a/cpp-linter/tests/reviews.rs b/cpp-linter/tests/reviews.rs index 21fa2308..f18c7557 100644 --- a/cpp-linter/tests/reviews.rs +++ b/cpp-linter/tests/reviews.rs @@ -1,11 +1,11 @@ +#![cfg(feature = "bin")] use chrono::Utc; use cpp_linter::{ - cli::LinesChangedOnly, - rest_api::{COMMENT_MARKER, USER_OUTREACH}, + rest_client::{COMMENT_MARKER, USER_OUTREACH}, run::run_main, }; +use git_bot_feedback::LinesChangedOnly; use mockito::Matcher; -use serde_json::json; use std::{env, io::Write, path::Path}; use tempfile::NamedTempFile; @@ -17,7 +17,6 @@ const REPO: &str = "cpp-linter/test-cpp-linter-action"; const PR: i64 = 27; const TOKEN: &str = "123456"; const MOCK_ASSETS_PATH: &str = "tests/reviews_test_assets/"; -const EVENT_PAYLOAD: &str = "{\"number\": 27}"; const RESET_RATE_LIMIT_HEADER: &str = "x-ratelimit-reset"; const REMAINING_RATE_LIMIT_HEADER: &str = "x-ratelimit-remaining"; @@ -73,11 +72,22 @@ fn generate_tool_summary(review_enabled: bool, force_lgtm: bool, tool_name: &str async fn setup(lib_root: &Path, test_params: &TestParams) { let mut event_payload_path = NamedTempFile::new_in("./").unwrap(); + let event_payload = serde_json::json!({ + "pull_request": { + "draft": test_params.pr_draft, + "state": test_params.pr_state, + "number": PR, + "locked": false, + } + }) + .to_string(); event_payload_path - .write_all(EVENT_PAYLOAD.as_bytes()) + .write_all(event_payload.as_bytes()) .expect("Failed to create mock event payload."); + let tmp_out = NamedTempFile::new().unwrap(); unsafe { - env::remove_var("GITHUB_OUTPUT"); // avoid writing to GH_OUT in parallel-running tests + env::set_var("GITHUB_ACTIONS", "true"); + env::set_var("GITHUB_OUTPUT", tmp_out.path()); env::set_var("GITHUB_EVENT_NAME", "pull_request"); env::set_var("GITHUB_REPOSITORY", REPO); env::set_var("GITHUB_SHA", SHA); @@ -101,24 +111,11 @@ async fn setup(lib_root: &Path, test_params: &TestParams) { let pr_endpoint = format!("/repos/{REPO}/pulls/{PR}"); mocks.push( server - .mock("GET", pr_endpoint.as_str()) - .match_header("Accept", "application/vnd.github.diff") - .match_header("Authorization", format!("token {TOKEN}").as_str()) - .with_body_from_file(format!("{asset_path}pr_{PR}.diff")) - .with_header(REMAINING_RATE_LIMIT_HEADER, "50") - .with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str()) - .create(), - ); - mocks.push( - server - .mock("GET", pr_endpoint.as_str()) + .mock("GET", format!("{pr_endpoint}/files").as_str()) .match_header("Accept", "application/vnd.github.raw+json") .match_header("Authorization", format!("token {TOKEN}").as_str()) - .with_body(if test_params.bad_pr_info { - String::new() - } else { - json!({"state": test_params.pr_state, "draft": test_params.pr_draft}).to_string() - }) + .match_query(Matcher::Any) + .with_body_from_file(format!("{asset_path}pr_27.json")) .with_header(REMAINING_RATE_LIMIT_HEADER, "50") .with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str()) .create(), @@ -126,35 +123,40 @@ async fn setup(lib_root: &Path, test_params: &TestParams) { let reviews_endpoint = format!("/repos/{REPO}/pulls/{PR}/reviews"); - let mut mock = server - .mock("GET", reviews_endpoint.as_str()) - .match_header("Accept", "application/vnd.github.raw+json") - .match_header("Authorization", format!("token {TOKEN}").as_str()) - .match_body(Matcher::Any) - .match_query(Matcher::UrlEncoded("page".to_string(), "1".to_string())) - .with_header(REMAINING_RATE_LIMIT_HEADER, "50") - .with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str()) - .with_status(if test_params.fail_get_existing_reviews { - 403 + if test_params.pr_state != "closed" { + let mut mock = server + .mock("GET", reviews_endpoint.as_str()) + .match_header("Accept", "application/vnd.github.raw+json") + .match_header("Authorization", format!("token {TOKEN}").as_str()) + .match_body(Matcher::Any) + .match_query(Matcher::UrlEncoded("page".to_string(), "1".to_string())) + .with_header(REMAINING_RATE_LIMIT_HEADER, "50") + .with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str()) + .with_status(if test_params.fail_get_existing_reviews { + 403 + } else { + 200 + }); + if test_params.bad_existing_reviews { + mock = mock.with_body(String::new()).create(); } else { - 200 - }); - if test_params.bad_existing_reviews { - mock = mock.with_body(String::new()).create(); - } else { - mock = mock - .with_body_from_file(format!("{asset_path}pr_reviews.json")) - .create() + mock = mock + .with_body_from_file(format!("{asset_path}pr_reviews.json")) + .create() + } + mocks.push(mock); } - mocks.push(mock); - if !test_params.fail_get_existing_reviews && !test_params.bad_existing_reviews { + if !test_params.fail_get_existing_reviews + && !test_params.bad_existing_reviews + && test_params.pr_state != "closed" + { mocks.push( server .mock( "PUT", format!("{reviews_endpoint}/1807607546/dismissals").as_str(), ) - .match_body(r#"{"event":"DISMISS","message":"outdated suggestion"}"#) + .match_body(r#"{"event":"DISMISS","message":"outdated review"}"#) .match_header("Authorization", format!("token {TOKEN}").as_str()) .with_header(REMAINING_RATE_LIMIT_HEADER, "50") .with_header(RESET_RATE_LIMIT_HEADER, reset_timestamp.as_str()) @@ -243,8 +245,18 @@ async fn setup(lib_root: &Path, test_params: &TestParams) { } else { args.push("--style=file".to_string()); // use .clang-format file } - let result = run_main(args).await; - assert!(result.is_ok()); + match run_main(args).await { + Ok(_) => { + if test_params.bad_existing_reviews { + panic!("Expected failure, but it succeeded"); + } + } + Err(e) => { + if !test_params.bad_existing_reviews { + panic!("Failed unexpectedly: {e:?}"); + } + } + } for mock in mocks { mock.assert(); } diff --git a/cpp-linter/tests/reviews_test_assets/pr_27.diff b/cpp-linter/tests/reviews_test_assets/pr_27.diff deleted file mode 100644 index 3c5dd0b5..00000000 --- a/cpp-linter/tests/reviews_test_assets/pr_27.diff +++ /dev/null @@ -1,108 +0,0 @@ -diff --git a/.github/workflows/cpp-lint-package.yml b/.github/workflows/cpp-lint-package.yml -index 0418957..3b8c454 100644 ---- a/.github/workflows/cpp-lint-package.yml -+++ b/.github/workflows/cpp-lint-package.yml -@@ -7,6 +7,7 @@ on: - description: 'which branch to test' - default: 'main' - required: true -+ pull_request: - - jobs: - cpp-linter: -@@ -14,9 +15,9 @@ jobs: - - strategy: - matrix: -- clang-version: ['7', '8', '9','10', '11', '12', '13', '14', '15', '16', '17'] -+ clang-version: ['10', '11', '12', '13', '14', '15', '16', '17'] - repo: ['cpp-linter/cpp-linter'] -- branch: ['${{ inputs.branch }}'] -+ branch: ['pr-review-suggestions'] - fail-fast: false - - steps: -@@ -62,10 +63,12 @@ jobs: - -i=build - -p=build - -V=${{ runner.temp }}/llvm -- -f=false - --extra-arg="-std=c++14 -Wall" -- --thread-comments=${{ matrix.clang-version == '12' }} -- -a=${{ matrix.clang-version == '12' }} -+ --file-annotations=false -+ --lines-changed-only=true -+ --thread-comments=${{ matrix.clang-version == '16' }} -+ --tidy-review=${{ matrix.clang-version == '16' }} -+ --format-review=${{ matrix.clang-version == '16' }} - - - name: Fail fast?! - if: steps.linter.outputs.checks-failed > 0 -diff --git a/src/demo.cpp b/src/demo.cpp -index 0c1db60..1bf553e 100644 ---- a/src/demo.cpp -+++ b/src/demo.cpp -@@ -1,17 +1,18 @@ - /** This is a very ugly test code (doomed to fail linting) */ - #include "demo.hpp" --#include --#include -+#include - --// using size_t from cstddef --size_t dummyFunc(size_t i) { return i; } - --int main() --{ -- for (;;) -- break; -+ -+ -+int main(){ -+ -+ for (;;) break; -+ - - printf("Hello world!\n"); - -- return 0; --} -+ -+ -+ -+ return 0;} -diff --git a/src/demo.hpp b/src/demo.hpp -index 2695731..f93d012 100644 ---- a/src/demo.hpp -+++ b/src/demo.hpp -@@ -5,12 +5,10 @@ - class Dummy { - char* useless; - int numb; -+ Dummy() :numb(0), useless("\0"){} - - public: -- void *not_usefull(char *str){ -- useless = str; -- return 0; -- } -+ void *not_useful(char *str){useless = str;} - }; - - -@@ -28,14 +26,11 @@ class Dummy { - - - -- -- -- -- - - - struct LongDiff - { -+ - long diff; - - }; diff --git a/cpp-linter/tests/reviews_test_assets/pr_27.json b/cpp-linter/tests/reviews_test_assets/pr_27.json new file mode 100644 index 00000000..f28a8901 --- /dev/null +++ b/cpp-linter/tests/reviews_test_assets/pr_27.json @@ -0,0 +1,38 @@ +[ + { + "sha": "52501fa1dc96d6bc6f8a155816df041b1de975d9", + "filename": ".github/workflows/cpp-lint-package.yml", + "status": "modified", + "additions": 9, + "deletions": 5, + "changes": 14, + "blob_url": "https://github.com/cpp-linter/test-cpp-linter-action/blob/635a9c57bdcca07b99ddef52c2640337c50280b1/.github%2Fworkflows%2Fcpp-lint-package.yml", + "raw_url": "https://github.com/cpp-linter/test-cpp-linter-action/raw/635a9c57bdcca07b99ddef52c2640337c50280b1/.github%2Fworkflows%2Fcpp-lint-package.yml", + "contents_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/contents/.github%2Fworkflows%2Fcpp-lint-package.yml?ref=635a9c57bdcca07b99ddef52c2640337c50280b1", + "patch": "@@ -7,16 +7,17 @@ on:\n description: 'which branch to test'\n default: 'main'\n required: true\n+ pull_request:\n \n jobs:\n cpp-linter:\n runs-on: windows-latest\n \n strategy:\n matrix:\n- clang-version: ['7', '8', '9','10', '11', '12', '13', '14', '15', '16', '17']\n+ clang-version: ['10', '11', '12', '13', '14', '15', '16', '17']\n repo: ['cpp-linter/cpp-linter']\n- branch: ['${{ inputs.branch }}']\n+ branch: ['pr-review-suggestions']\n fail-fast: false\n \n steps:\n@@ -62,10 +63,13 @@ jobs:\n -i=build \n -p=build \n -V=${{ runner.temp }}/llvm \n- -f=false \n --extra-arg=\"-std=c++14 -Wall\" \n- --thread-comments=${{ matrix.clang-version == '12' }} \n- -a=${{ matrix.clang-version == '12' }}\n+ --file-annotations=false\n+ --lines-changed-only=false\n+ --extension=h,c\n+ --thread-comments=${{ matrix.clang-version == '16' }} \n+ --tidy-review=${{ matrix.clang-version == '16' }}\n+ --format-review=${{ matrix.clang-version == '16' }}\n \n - name: Fail fast?!\n if: steps.linter.outputs.checks-failed > 0" + }, + { + "sha": "1bf553e06e4b7c6c9a9be5da4845acbdeb04f6a5", + "filename": "src/demo.cpp", + "status": "modified", + "additions": 11, + "deletions": 10, + "changes": 21, + "blob_url": "https://github.com/cpp-linter/test-cpp-linter-action/blob/635a9c57bdcca07b99ddef52c2640337c50280b1/src%2Fdemo.cpp", + "raw_url": "https://github.com/cpp-linter/test-cpp-linter-action/raw/635a9c57bdcca07b99ddef52c2640337c50280b1/src%2Fdemo.cpp", + "contents_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/contents/src%2Fdemo.cpp?ref=635a9c57bdcca07b99ddef52c2640337c50280b1", + "patch": "@@ -1,17 +1,18 @@\n /** This is a very ugly test code (doomed to fail linting) */\n #include \"demo.hpp\"\n-#include \n-#include \n+#include \n \n-// using size_t from cstddef\n-size_t dummyFunc(size_t i) { return i; }\n \n-int main()\n-{\n- for (;;)\n- break;\n+\n+\n+int main(){\n+\n+ for (;;) break;\n+\n \n printf(\"Hello world!\\n\");\n \n- return 0;\n-}\n+\n+\n+\n+ return 0;}" + }, + { + "sha": "f93d0122ae2e3c1952c795837d71c432036b55eb", + "filename": "src/demo.hpp", + "status": "modified", + "additions": 3, + "deletions": 8, + "changes": 11, + "blob_url": "https://github.com/cpp-linter/test-cpp-linter-action/blob/635a9c57bdcca07b99ddef52c2640337c50280b1/src%2Fdemo.hpp", + "raw_url": "https://github.com/cpp-linter/test-cpp-linter-action/raw/635a9c57bdcca07b99ddef52c2640337c50280b1/src%2Fdemo.hpp", + "contents_url": "https://api.github.com/repos/cpp-linter/test-cpp-linter-action/contents/src%2Fdemo.hpp?ref=635a9c57bdcca07b99ddef52c2640337c50280b1", + "patch": "@@ -5,12 +5,10 @@\n class Dummy {\n char* useless;\n int numb;\n+ Dummy() :numb(0), useless(\"\\0\"){}\n \n public:\n- void *not_usefull(char *str){\n- useless = str;\n- return 0;\n- }\n+ void *not_useful(char *str){useless = str;}\n };\n \n \n@@ -28,14 +26,11 @@ class Dummy {\n \n \n \n-\n-\n-\n-\n \n \n struct LongDiff\n {\n+\n long diff;\n \n };" + } +]