diff --git a/Cargo.lock b/Cargo.lock index 88a434e..45a55d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,7 +179,7 @@ checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" [[package]] name = "codeowners" -version = "0.2.7" +version = "0.2.8" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index 7b14a05..2132df5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeowners" -version = "0.2.7" +version = "0.2.8" edition = "2024" [profile.release] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index a1e0e05..ff1a27d 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.86.0" +channel = "1.89.0" components = ["clippy", "rustfmt"] targets = ["x86_64-apple-darwin", "aarch64-apple-darwin", "x86_64-unknown-linux-gnu"] diff --git a/src/cache/file.rs b/src/cache/file.rs index 36fc52d..ba76126 100644 --- a/src/cache/file.rs +++ b/src/cache/file.rs @@ -21,26 +21,24 @@ const DEFAULT_CACHE_CAPACITY: usize = 10000; impl Caching for GlobalCache { fn get_file_owner(&self, path: &Path) -> Result, Error> { - if let Some(cache_mutex) = self.file_owner_cache.as_ref() { - if let Ok(cache) = cache_mutex.lock() { - if let Some(cached_entry) = cache.get(path) { - let timestamp = get_file_timestamp(path)?; - if cached_entry.timestamp == timestamp { - return Ok(Some(cached_entry.clone())); - } - } + if let Some(cache_mutex) = self.file_owner_cache.as_ref() + && let Ok(cache) = cache_mutex.lock() + && let Some(cached_entry) = cache.get(path) + { + let timestamp = get_file_timestamp(path)?; + if cached_entry.timestamp == timestamp { + return Ok(Some(cached_entry.clone())); } } Ok(None) } fn write_file_owner(&self, path: &Path, owner: Option) { - if let Some(cache_mutex) = self.file_owner_cache.as_ref() { - if let Ok(mut cache) = cache_mutex.lock() { - if let Ok(timestamp) = get_file_timestamp(path) { - cache.insert(path.to_path_buf(), FileOwnerCacheEntry { timestamp, owner }); - } - } + if let Some(cache_mutex) = self.file_owner_cache.as_ref() + && let Ok(mut cache) = cache_mutex.lock() + && let Ok(timestamp) = get_file_timestamp(path) + { + cache.insert(path.to_path_buf(), FileOwnerCacheEntry { timestamp, owner }); } } diff --git a/src/cli.rs b/src/cli.rs index f0e45ec..4b6343e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -16,7 +16,7 @@ enum Command { default_value = "false", help = "Find the owner from the CODEOWNERS file and just return the team name and yml path" )] - fast: bool, + from_codeowners: bool, name: String, }, @@ -46,6 +46,9 @@ enum Command { #[clap(about = "Delete the cache file.", visible_alias = "d")] DeleteCache, + + #[clap(about = "Compare the CODEOWNERS file to the for-file command.", hide = true)] + CrosscheckOwners, } /// A CLI to validate and generate Github's CODEOWNERS file. @@ -110,9 +113,10 @@ pub fn cli() -> Result { Command::Validate => runner::validate(&run_config, vec![]), Command::Generate { skip_stage } => runner::generate(&run_config, !skip_stage), Command::GenerateAndValidate { skip_stage } => runner::generate_and_validate(&run_config, vec![], !skip_stage), - Command::ForFile { name, fast: _ } => runner::for_file(&run_config, &name), + Command::ForFile { name, from_codeowners } => runner::for_file(&run_config, &name, from_codeowners), Command::ForTeam { name } => runner::for_team(&run_config, &name), Command::DeleteCache => runner::delete_cache(&run_config), + Command::CrosscheckOwners => runner::crosscheck_owners(&run_config), }; Ok(runner_result) diff --git a/src/crosscheck.rs b/src/crosscheck.rs new file mode 100644 index 0000000..02eba9b --- /dev/null +++ b/src/crosscheck.rs @@ -0,0 +1,90 @@ +use std::path::Path; + +use crate::{ + cache::Cache, + config::Config, + ownership::for_file_fast::find_file_owners, + project::Project, + project_builder::ProjectBuilder, + runner::{RunConfig, RunResult, config_from_path, team_for_file_from_codeowners}, +}; + +pub fn crosscheck_owners(run_config: &RunConfig, cache: &Cache) -> RunResult { + match do_crosscheck_owners(run_config, cache) { + Ok(mismatches) if mismatches.is_empty() => RunResult { + info_messages: vec!["Success! All files match between CODEOWNERS and for-file command.".to_string()], + ..Default::default() + }, + Ok(mismatches) => RunResult { + validation_errors: mismatches, + ..Default::default() + }, + Err(err) => RunResult { + io_errors: vec![err], + ..Default::default() + }, + } +} + +fn do_crosscheck_owners(run_config: &RunConfig, cache: &Cache) -> Result, String> { + let config = load_config(run_config)?; + let project = build_project(&config, run_config, cache)?; + + let mut mismatches: Vec = Vec::new(); + for file in &project.files { + let (codeowners_team, fast_display) = owners_for_file(&file.path, run_config, &config)?; + let codeowners_display = codeowners_team.clone().unwrap_or_else(|| "Unowned".to_string()); + if !is_match(codeowners_team.as_deref(), &fast_display) { + mismatches.push(format_mismatch(&project, &file.path, &codeowners_display, &fast_display)); + } + } + + Ok(mismatches) +} + +fn load_config(run_config: &RunConfig) -> Result { + config_from_path(&run_config.config_path).map_err(|e| e.to_string()) +} + +fn build_project(config: &Config, run_config: &RunConfig, cache: &Cache) -> Result { + let mut project_builder = ProjectBuilder::new( + config, + run_config.project_root.clone(), + run_config.codeowners_file_path.clone(), + cache, + ); + project_builder.build().map_err(|e| e.to_string()) +} + +fn owners_for_file(path: &Path, run_config: &RunConfig, config: &Config) -> Result<(Option, String), String> { + let file_path_str = path.to_string_lossy().to_string(); + + let codeowners_team = team_for_file_from_codeowners(run_config, &file_path_str) + .map_err(|e| e.to_string())? + .map(|t| t.name); + + let fast_owners = find_file_owners(&run_config.project_root, config, Path::new(&file_path_str))?; + let fast_display = match fast_owners.len() { + 0 => "Unowned".to_string(), + 1 => fast_owners[0].team.name.clone(), + _ => { + let names: Vec = fast_owners.into_iter().map(|fo| fo.team.name).collect(); + format!("Multiple: {}", names.join(", ")) + } + }; + + Ok((codeowners_team, fast_display)) +} + +fn is_match(codeowners_team: Option<&str>, fast_display: &str) -> bool { + match (codeowners_team, fast_display) { + (None, "Unowned") => true, + (Some(t), fd) if fd == t => true, + _ => false, + } +} + +fn format_mismatch(project: &Project, file_path: &Path, codeowners_display: &str, fast_display: &str) -> String { + let rel = project.relative_path(file_path).to_string_lossy().to_string(); + format!("- {}: CODEOWNERS={} fast={}", rel, codeowners_display, fast_display) +} diff --git a/src/lib.rs b/src/lib.rs index e769652..cd7309f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod cache; pub(crate) mod common_test; pub mod config; +pub mod crosscheck; pub mod ownership; pub(crate) mod project; pub mod project_builder; diff --git a/src/ownership/file_generator.rs b/src/ownership/file_generator.rs index 6d8a34d..8878d41 100644 --- a/src/ownership/file_generator.rs +++ b/src/ownership/file_generator.rs @@ -49,15 +49,15 @@ impl FileGenerator { } pub fn compare_lines(a: &String, b: &String) -> Ordering { - if let Some((prefix, _)) = a.split_once("**") { - if b.starts_with(prefix) { - return Ordering::Less; - } + if let Some((prefix, _)) = a.split_once("**") + && b.starts_with(prefix) + { + return Ordering::Less; } - if let Some((prefix, _)) = b.split_once("**") { - if a.starts_with(prefix) { - return Ordering::Greater; - } + if let Some((prefix, _)) = b.split_once("**") + && a.starts_with(prefix) + { + return Ordering::Greater; } a.cmp(b) } diff --git a/src/ownership/for_file_fast.rs b/src/ownership/for_file_fast.rs index 081cb8a..51d9877 100644 --- a/src/ownership/for_file_fast.rs +++ b/src/ownership/for_file_fast.rs @@ -32,10 +32,11 @@ pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path) if let Some(rel_str) = relative_file_path.to_str() { let is_config_owned = glob_list_matches(rel_str, &config.owned_globs); let is_config_unowned = glob_list_matches(rel_str, &config.unowned_globs); - if is_config_owned && !is_config_unowned { - if let Some(team) = teams_by_name.get(&team_name) { - sources_by_team.entry(team.name.clone()).or_default().push(Source::TeamFile); - } + if is_config_owned + && !is_config_unowned + && let Some(team) = teams_by_name.get(&team_name) + { + sources_by_team.entry(team.name.clone()).or_default().push(Source::TeamFile); } } } @@ -194,32 +195,30 @@ fn nearest_package_owner( if let Some(rel_str) = parent_rel.to_str() { if glob_list_matches(rel_str, &config.ruby_package_paths) { let pkg_yml = current.join("package.yml"); - if pkg_yml.exists() { - if let Ok(owner) = read_ruby_package_owner(&pkg_yml) { - if let Some(team) = teams_by_name.get(&owner) { - let package_path = parent_rel.join("package.yml"); - let package_glob = format!("{rel_str}/**/**"); - return Some(( - team.name.clone(), - Source::Package(package_path.to_string_lossy().to_string(), package_glob), - )); - } - } + if pkg_yml.exists() + && let Ok(owner) = read_ruby_package_owner(&pkg_yml) + && let Some(team) = teams_by_name.get(&owner) + { + let package_path = parent_rel.join("package.yml"); + let package_glob = format!("{rel_str}/**/**"); + return Some(( + team.name.clone(), + Source::Package(package_path.to_string_lossy().to_string(), package_glob), + )); } } if glob_list_matches(rel_str, &config.javascript_package_paths) { let pkg_json = current.join("package.json"); - if pkg_json.exists() { - if let Ok(owner) = read_js_package_owner(&pkg_json) { - if let Some(team) = teams_by_name.get(&owner) { - let package_path = parent_rel.join("package.json"); - let package_glob = format!("{rel_str}/**/**"); - return Some(( - team.name.clone(), - Source::Package(package_path.to_string_lossy().to_string(), package_glob), - )); - } - } + if pkg_json.exists() + && let Ok(owner) = read_js_package_owner(&pkg_json) + && let Some(team) = teams_by_name.get(&owner) + { + let package_path = parent_rel.join("package.json"); + let package_glob = format!("{rel_str}/**/**"); + return Some(( + team.name.clone(), + Source::Package(package_path.to_string_lossy().to_string(), package_glob), + )); } } } diff --git a/src/ownership/mapper/package_mapper.rs b/src/ownership/mapper/package_mapper.rs index 5d7dc76..351dfd6 100644 --- a/src/ownership/mapper/package_mapper.rs +++ b/src/ownership/mapper/package_mapper.rs @@ -122,10 +122,10 @@ fn remove_nested_packages<'a>(packages: &'a [&'a Package]) -> Vec<&'a Package> { for package in packages.iter().sorted_by_key(|package| package.package_root()) { if let Some(last_package) = top_level_packages.last() { - if let (Some(current_root), Some(last_root)) = (package.package_root(), last_package.package_root()) { - if !current_root.starts_with(last_root) { - top_level_packages.push(package); - } + if let (Some(current_root), Some(last_root)) = (package.package_root(), last_package.package_root()) + && !current_root.starts_with(last_root) + { + top_level_packages.push(package); } } else { top_level_packages.push(package); diff --git a/src/ownership/validator.rs b/src/ownership/validator.rs index 6612e73..dd89f66 100644 --- a/src/ownership/validator.rs +++ b/src/ownership/validator.rs @@ -74,13 +74,13 @@ impl Validator { .files .par_iter() .flat_map(|file| { - if let Some(owner) = &file.owner { - if !team_names.contains(owner) { - return Some(Error::InvalidTeam { - name: owner.clone(), - path: project.relative_path(&file.path).to_owned(), - }); - } + if let Some(owner) = &file.owner + && !team_names.contains(owner) + { + return Some(Error::InvalidTeam { + name: owner.clone(), + path: project.relative_path(&file.path).to_owned(), + }); } None diff --git a/src/project_builder.rs b/src/project_builder.rs index 3fecf05..9560333 100644 --- a/src/project_builder.rs +++ b/src/project_builder.rs @@ -61,14 +61,13 @@ impl<'a> ProjectBuilder<'a> { builder.filter_entry(move |entry: &DirEntry| { let path = entry.path(); let file_name = entry.file_name().to_str().unwrap_or(""); - if let Some(ft) = entry.file_type() { - if ft.is_dir() { - if let Ok(rel) = path.strip_prefix(&base_path) { - if rel.components().count() == 1 && ignore_dirs.iter().any(|d| *d == file_name) { - return false; - } - } - } + if let Some(ft) = entry.file_type() + && ft.is_dir() + && let Ok(rel) = path.strip_prefix(&base_path) + && rel.components().count() == 1 + && ignore_dirs.iter().any(|d| *d == file_name) + { + return false; } true @@ -92,10 +91,10 @@ impl<'a> ProjectBuilder<'a> { let _ = tx.send(entry_type); } Err(report) => { - if let Ok(mut slot) = error_holder.lock() { - if slot.is_none() { - *slot = Some(report); - } + if let Ok(mut slot) = error_holder.lock() + && slot.is_none() + { + *slot = Some(report); } } } diff --git a/src/runner.rs b/src/runner.rs index b1d71d0..345f83f 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -36,10 +36,37 @@ pub struct Runner { cache: Cache, } -pub fn for_file(run_config: &RunConfig, file_path: &str) -> RunResult { +pub fn for_file(run_config: &RunConfig, file_path: &str, from_codeowners: bool) -> RunResult { + if from_codeowners { + return for_file_codeowners_only(run_config, file_path); + } for_file_optimized(run_config, file_path) } +fn for_file_codeowners_only(run_config: &RunConfig, file_path: &str) -> RunResult { + match team_for_file_from_codeowners(run_config, file_path) { + Ok(Some(team)) => { + let relative_team_path = team + .path + .strip_prefix(&run_config.project_root) + .unwrap_or(team.path.as_path()) + .to_string_lossy() + .to_string(); + RunResult { + info_messages: vec![format!( + "Team: {}\nGithub Team: {}\nTeam YML: {}\nDescription:\n- Owner inferred from codeowners file", + team.name, team.github_team, relative_team_path + )], + ..Default::default() + } + } + Ok(None) => RunResult::default(), + Err(err) => RunResult { + io_errors: vec![err.to_string()], + ..Default::default() + }, + } +} pub fn team_for_file_from_codeowners(run_config: &RunConfig, file_path: &str) -> Result, Error> { let config = config_from_path(&run_config.config_path)?; let relative_file_path = Path::new(file_path) @@ -132,6 +159,10 @@ pub fn delete_cache(run_config: &RunConfig) -> RunResult { run_with_runner(run_config, |runner| runner.delete_cache()) } +pub fn crosscheck_owners(run_config: &RunConfig) -> RunResult { + run_with_runner(run_config, |runner| runner.crosscheck_owners()) +} + pub type Runnable = fn(Runner) -> RunResult; pub fn run_with_runner(run_config: &RunConfig, runnable: F) -> RunResult @@ -172,7 +203,7 @@ impl fmt::Display for Error { } } -fn config_from_path(path: &PathBuf) -> Result { +pub(crate) fn config_from_path(path: &PathBuf) -> Result { let config_file = File::open(path) .change_context(Error::Io(format!("Can't open config file: {}", &path.to_string_lossy()))) .attach_printable(format!("Can't open config file: {}", &path.to_string_lossy()))?; @@ -299,6 +330,10 @@ impl Runner { }, } } + + pub fn crosscheck_owners(&self) -> RunResult { + crate::crosscheck::crosscheck_owners(&self.run_config, &self.cache) + } } #[cfg(test)] diff --git a/tests/fixtures/valid_project_with_overrides/.github/CODEOWNERS b/tests/fixtures/valid_project_with_overrides/.github/CODEOWNERS new file mode 100644 index 0000000..8ae8814 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/.github/CODEOWNERS @@ -0,0 +1,53 @@ +# STOP! - DO NOT EDIT THIS FILE MANUALLY +# This file was automatically generated by "bin/codeownership validate". +# +# CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub +# teams. This is useful when developers create Pull Requests since the +# code/file owner is notified. Reference GitHub docs for more details: +# https://help.github.com/en/articles/about-code-owners + + +# Annotations at the top of file +/frontend/packages/components/datepicker/src/picks/dp.tsx @RockiesTeam +/frontend/packages/components/list/src/item.tsx @BrewersTeam +/frontend/packages/components/textfield/src/field.tsx @GiantsTeam +/frontend/packages/components/textfield/src/fields/small.tsx @GiantsTeam +/gems/apollo/lib/apollo.rb @GiantsTeam +/gems/ivy/lib/ivy.rb @CubsTeam +/gems/lager/lib/lager.rb @BrewersTeam +/gems/summit/lib/summit.rb @RockiesTeam +/ruby/app/cubs/services/models/db/price.rb @BrewersTeam +/ruby/app/cubs/services/play.rb @CubsTeam + +# Team-specific owned globs +/frontend/packages/components/** @BrewersTeam +/ruby/app/brewers/**/* @BrewersTeam +/ruby/app/cubs/**/* @CubsTeam +/ruby/app/giants/**/* @GiantsTeam +/ruby/app/rockies/**/* @RockiesTeam + +# Owner in .codeowner +/ruby/app/cubs/**/** @CubsTeam +/ruby/app/cubs/services/models/**/** @RockiesTeam + +# Owner metadata key in package.yml +/packs/games/**/** @RockiesTeam +/packs/locations/**/** @GiantsTeam +/packs/schedule/**/** @BrewersTeam + +# Owner metadata key in package.json +/frontend/packages/components/datepicker/**/** @RockiesTeam +/frontend/packages/components/list/**/** @BrewersTeam +/frontend/packages/components/textfield/**/** @GiantsTeam + +# Team YML ownership +/config/teams/brewers.yml @BrewersTeam +/config/teams/cubs.yml @CubsTeam +/config/teams/giants.yml @GiantsTeam +/config/teams/rockies.yml @RockiesTeam + +# Team owned gems +/gems/apollo/**/** @GiantsTeam +/gems/ivy/**/** @CubsTeam +/gems/lager/**/** @BrewersTeam +/gems/summit/**/** @RockiesTeam diff --git a/tests/fixtures/valid_project_with_overrides/config/code_ownership.yml b/tests/fixtures/valid_project_with_overrides/config/code_ownership.yml new file mode 100644 index 0000000..b0fc127 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/config/code_ownership.yml @@ -0,0 +1,10 @@ +owned_globs: + - "{gems,config,frontend,ruby,components,packs}/**/*.{rb,tsx}" +ruby_package_paths: + - packs/**/* +javascript_package_paths: + - frontend/packages/** +team_file_glob: + - config/teams/**/*.yml +unbuilt_gems_path: gems +unowned_globs: diff --git a/tests/fixtures/valid_project_with_overrides/config/teams/brewers.yml b/tests/fixtures/valid_project_with_overrides/config/teams/brewers.yml new file mode 100644 index 0000000..72a3654 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/config/teams/brewers.yml @@ -0,0 +1,11 @@ +name: Brewers +github: + team: '@BrewersTeam' +owned_globs: + - ruby/app/brewers/**/* + - frontend/packages/components/** +subtracted_globs: + - frontend/packages/components/textfield/** +ruby: + owned_gems: + - lager \ No newline at end of file diff --git a/tests/fixtures/valid_project_with_overrides/config/teams/cubs.yml b/tests/fixtures/valid_project_with_overrides/config/teams/cubs.yml new file mode 100644 index 0000000..81797e5 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/config/teams/cubs.yml @@ -0,0 +1,9 @@ +name: Cubs +github: + team: '@CubsTeam' +owned_globs: + - ruby/app/cubs/**/* +ruby: + owned_gems: + - ivy + diff --git a/tests/fixtures/valid_project_with_overrides/config/teams/giants.yml b/tests/fixtures/valid_project_with_overrides/config/teams/giants.yml new file mode 100644 index 0000000..22a8b9f --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/config/teams/giants.yml @@ -0,0 +1,8 @@ +name: Giants +github: + team: '@GiantsTeam' +owned_globs: + - ruby/app/giants/**/* +ruby: + owned_gems: + - apollo \ No newline at end of file diff --git a/tests/fixtures/valid_project_with_overrides/config/teams/rockies.yml b/tests/fixtures/valid_project_with_overrides/config/teams/rockies.yml new file mode 100644 index 0000000..52cb132 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/config/teams/rockies.yml @@ -0,0 +1,8 @@ +name: Rockies +github: + team: '@RockiesTeam' +owned_globs: + - ruby/app/rockies/**/* +ruby: + owned_gems: + - summit \ No newline at end of file diff --git a/tests/fixtures/valid_project_with_overrides/frontend/packages/components/datepicker/package.json b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/datepicker/package.json new file mode 100644 index 0000000..968cf7a --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/datepicker/package.json @@ -0,0 +1,5 @@ +{ + "metadata": { + "owner": "Rockies" + } +} diff --git a/tests/fixtures/valid_project_with_overrides/frontend/packages/components/datepicker/src/picks/dp.tsx b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/datepicker/src/picks/dp.tsx new file mode 100644 index 0000000..5fad919 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/datepicker/src/picks/dp.tsx @@ -0,0 +1,3 @@ +// @team Rockies +export const DP = () => null; + diff --git a/tests/fixtures/valid_project_with_overrides/frontend/packages/components/list/package.json b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/list/package.json new file mode 100644 index 0000000..b9aa9ce --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/list/package.json @@ -0,0 +1,5 @@ +{ + "metadata": { + "owner": "Brewers" + } +} diff --git a/tests/fixtures/valid_project_with_overrides/frontend/packages/components/list/src/item.tsx b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/list/src/item.tsx new file mode 100644 index 0000000..1a2f5cd --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/list/src/item.tsx @@ -0,0 +1,3 @@ +// @team Brewers +export const Item = () => null; + diff --git a/tests/fixtures/valid_project_with_overrides/frontend/packages/components/textfield/package.json b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/textfield/package.json new file mode 100644 index 0000000..e10d6bc --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/textfield/package.json @@ -0,0 +1,5 @@ +{ + "metadata": { + "owner": "Giants" + } +} diff --git a/tests/fixtures/valid_project_with_overrides/frontend/packages/components/textfield/src/field.tsx b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/textfield/src/field.tsx new file mode 100644 index 0000000..1ebf50c --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/textfield/src/field.tsx @@ -0,0 +1,3 @@ +// @team Giants +export const Field = () => null; + diff --git a/tests/fixtures/valid_project_with_overrides/frontend/packages/components/textfield/src/fields/small.tsx b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/textfield/src/fields/small.tsx new file mode 100644 index 0000000..965ced4 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/frontend/packages/components/textfield/src/fields/small.tsx @@ -0,0 +1,3 @@ +// @team Giants +export const Small = () => null; + diff --git a/tests/fixtures/valid_project_with_overrides/gems/apollo/lib/apollo.rb b/tests/fixtures/valid_project_with_overrides/gems/apollo/lib/apollo.rb new file mode 100644 index 0000000..ce16cf8 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/gems/apollo/lib/apollo.rb @@ -0,0 +1,6 @@ +# @team Giants + +module Apollo +end + + diff --git a/tests/fixtures/valid_project_with_overrides/gems/ivy/lib/ivy.rb b/tests/fixtures/valid_project_with_overrides/gems/ivy/lib/ivy.rb new file mode 100644 index 0000000..8902daa --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/gems/ivy/lib/ivy.rb @@ -0,0 +1,6 @@ +# @team Cubs + +module Ivy +end + + diff --git a/tests/fixtures/valid_project_with_overrides/gems/lager/lib/lager.rb b/tests/fixtures/valid_project_with_overrides/gems/lager/lib/lager.rb new file mode 100644 index 0000000..75cd552 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/gems/lager/lib/lager.rb @@ -0,0 +1,6 @@ +# @team Brewers + +module Lager +end + + diff --git a/tests/fixtures/valid_project_with_overrides/gems/summit/lib/summit.rb b/tests/fixtures/valid_project_with_overrides/gems/summit/lib/summit.rb new file mode 100644 index 0000000..cfdbfdd --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/gems/summit/lib/summit.rb @@ -0,0 +1,6 @@ +# @team Rockies + +module Summit +end + + diff --git a/tests/fixtures/valid_project_with_overrides/packs/games/app/services/stats.rb b/tests/fixtures/valid_project_with_overrides/packs/games/app/services/stats.rb new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/valid_project_with_overrides/packs/games/package.yml b/tests/fixtures/valid_project_with_overrides/packs/games/package.yml new file mode 100644 index 0000000..a72f2f3 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/packs/games/package.yml @@ -0,0 +1 @@ +owner: Rockies \ No newline at end of file diff --git a/tests/fixtures/valid_project_with_overrides/packs/locations/app/services/capacity.rb b/tests/fixtures/valid_project_with_overrides/packs/locations/app/services/capacity.rb new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/valid_project_with_overrides/packs/locations/package.yml b/tests/fixtures/valid_project_with_overrides/packs/locations/package.yml new file mode 100644 index 0000000..b7d6c55 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/packs/locations/package.yml @@ -0,0 +1 @@ +owner: Giants \ No newline at end of file diff --git a/tests/fixtures/valid_project_with_overrides/packs/schedule/app/services/date.rb b/tests/fixtures/valid_project_with_overrides/packs/schedule/app/services/date.rb new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/valid_project_with_overrides/packs/schedule/package.yml b/tests/fixtures/valid_project_with_overrides/packs/schedule/package.yml new file mode 100644 index 0000000..27e4d1a --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/packs/schedule/package.yml @@ -0,0 +1 @@ +owner: Brewers \ No newline at end of file diff --git a/tests/fixtures/valid_project_with_overrides/ruby/app/brewers/lib/util.rb b/tests/fixtures/valid_project_with_overrides/ruby/app/brewers/lib/util.rb new file mode 100644 index 0000000..3c743d3 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/ruby/app/brewers/lib/util.rb @@ -0,0 +1,3 @@ +class Util + +end \ No newline at end of file diff --git a/tests/fixtures/valid_project_with_overrides/ruby/app/brewers/services/play.rb b/tests/fixtures/valid_project_with_overrides/ruby/app/brewers/services/play.rb new file mode 100644 index 0000000..37c3af8 --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/ruby/app/brewers/services/play.rb @@ -0,0 +1,9 @@ +class Play + def initialize(team) + @team = team + end + + def play + puts "Playing #{@team}!" + end +end \ No newline at end of file diff --git a/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/.codeowner b/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/.codeowner new file mode 100644 index 0000000..959006a --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/.codeowner @@ -0,0 +1,3 @@ +Cubs + + diff --git a/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/services/models/.codeowner b/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/services/models/.codeowner new file mode 100644 index 0000000..00089bd --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/services/models/.codeowner @@ -0,0 +1 @@ +Rockies \ No newline at end of file diff --git a/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/services/models/db/price.rb b/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/services/models/db/price.rb new file mode 100644 index 0000000..5c15cca --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/services/models/db/price.rb @@ -0,0 +1,5 @@ +# @team Brewers + +class Price +end + diff --git a/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/services/models/entertainment.rb b/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/services/models/entertainment.rb new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/services/play.rb b/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/services/play.rb new file mode 100644 index 0000000..c4c169b --- /dev/null +++ b/tests/fixtures/valid_project_with_overrides/ruby/app/cubs/services/play.rb @@ -0,0 +1,5 @@ +# @team Cubs + +class Play + +end \ No newline at end of file diff --git a/tests/fixtures/valid_project_with_overrides/ruby/app/giants/services/play.rb b/tests/fixtures/valid_project_with_overrides/ruby/app/giants/services/play.rb new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/valid_project_with_overrides/ruby/app/rockies/services/play.rb b/tests/fixtures/valid_project_with_overrides/ruby/app/rockies/services/play.rb new file mode 100644 index 0000000..e69de29 diff --git a/tests/valid_project_test.rs b/tests/valid_project_test.rs index 790f9f2..795bff9 100644 --- a/tests/valid_project_test.rs +++ b/tests/valid_project_test.rs @@ -36,6 +36,22 @@ fn test_generate() -> Result<(), Box> { Ok(()) } +#[test] +fn test_crosscheck_owners() -> Result<(), Box> { + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg("tests/fixtures/valid_project") + .arg("--no-cache") + .arg("crosscheck-owners") + .assert() + .success() + .stdout(predicate::eq(indoc! {" + Success! All files match between CODEOWNERS and for-file command. + "})); + + Ok(()) +} + #[test] fn test_for_file() -> Result<(), Box> { Command::cargo_bin("codeowners")? diff --git a/tests/valid_project_with_overrides_test.rs b/tests/valid_project_with_overrides_test.rs new file mode 100644 index 0000000..6d92fb4 --- /dev/null +++ b/tests/valid_project_with_overrides_test.rs @@ -0,0 +1,35 @@ +use assert_cmd::prelude::*; +use indoc::indoc; +use predicates::prelude::predicate; +use std::{error::Error, process::Command}; + +#[test] +#[ignore] +fn test_validate() -> Result<(), Box> { + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg("tests/fixtures/valid_project_with_overrides") + .arg("--no-cache") + .arg("validate") + .assert() + .success(); + + Ok(()) +} + +#[test] +#[ignore] +fn test_crosscheck_owners() -> Result<(), Box> { + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg("tests/fixtures/valid_project_with_overrides") + .arg("--no-cache") + .arg("crosscheck-owners") + .assert() + .success() + .stdout(predicate::eq(indoc! {" + Success! All files match between CODEOWNERS and for-file command. + "})); + + Ok(()) +} diff --git a/tests/verify_compare_for_file_mismatch_test.rs b/tests/verify_compare_for_file_mismatch_test.rs new file mode 100644 index 0000000..c9252a3 --- /dev/null +++ b/tests/verify_compare_for_file_mismatch_test.rs @@ -0,0 +1,70 @@ +use assert_cmd::prelude::*; +use indoc::indoc; +use predicates::prelude::*; +use std::{error::Error, fs, path::Path, process::Command}; + +mod common; +use common::setup_fixture_repo; + +const FIXTURE: &str = "tests/fixtures/valid_project"; + +#[test] +fn test_crosscheck_owners_reports_team_mismatch() -> Result<(), Box> { + // Arrange: copy fixture to temp dir and change a single CODEOWNERS mapping + let temp_dir = setup_fixture_repo(Path::new(FIXTURE)); + let project_root = temp_dir.path(); + let codeowners_path = project_root.join(".github/CODEOWNERS"); + + let original = fs::read_to_string(&codeowners_path)?; + // Change payroll.rb ownership from @PayrollTeam to @PaymentsTeam to induce a mismatch + let modified = original.replace( + "/ruby/app/models/payroll.rb @PayrollTeam", + "/ruby/app/models/payroll.rb @PaymentsTeam", + ); + fs::write(&codeowners_path, modified)?; + + // Act + Assert + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("crosscheck-owners") + .assert() + .failure() + .stdout(predicate::str::contains( + indoc! {"- ruby/app/models/payroll.rb: CODEOWNERS=Payments fast=Payroll"}, + )); + + Ok(()) +} + +#[test] +fn test_crosscheck_owners_reports_unowned_mismatch() -> Result<(), Box> { + // Arrange: copy fixture to temp dir and remove a CODEOWNERS rule for an owned file + let temp_dir = setup_fixture_repo(Path::new(FIXTURE)); + let project_root = temp_dir.path(); + let codeowners_path = project_root.join(".github/CODEOWNERS"); + + // Remove the explicit mapping for bank_account.rb so CODEOWNERS reports Unowned + let original = fs::read_to_string(&codeowners_path)?; + let modified: String = original + .lines() + .filter(|line| !line.trim().starts_with("/ruby/app/models/bank_account.rb ")) + .map(|l| format!("{}\n", l)) + .collect(); + fs::write(&codeowners_path, modified)?; + + // Act + Assert + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg(project_root) + .arg("--no-cache") + .arg("crosscheck-owners") + .assert() + .failure() + .stdout(predicate::str::contains( + "- ruby/app/models/bank_account.rb: CODEOWNERS=Unowned fast=Payments", + )); + + Ok(()) +}