From d04194db9ce9598dfaae28984f281d978129b296 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Wed, 20 Aug 2025 17:18:14 -0500 Subject: [PATCH 01/13] stubbing out teams_for_files_from_codeowners --- src/ownership.rs | 4 +- src/ownership/codeowners_file_parser.rs | 75 +++++++++---------------- src/runner.rs | 63 +-------------------- 3 files changed, 30 insertions(+), 112 deletions(-) diff --git a/src/ownership.rs b/src/ownership.rs index 88516f8..ceda129 100644 --- a/src/ownership.rs +++ b/src/ownership.rs @@ -9,11 +9,11 @@ use std::{ }; use tracing::{info, instrument}; -pub(crate) mod codeowners_file_parser; mod file_generator; mod file_owner_finder; pub mod for_file_fast; pub(crate) mod mapper; +pub(crate) mod codeowners_file_parser; mod validator; use crate::{ @@ -24,9 +24,9 @@ use crate::{ pub use validator::Errors as ValidatorErrors; use self::{ - codeowners_file_parser::parse_for_team, file_generator::FileGenerator, mapper::{JavascriptPackageMapper, Mapper, RubyPackageMapper, TeamFileMapper, TeamGemMapper, TeamGlobMapper, TeamYmlMapper}, + codeowners_file_parser::parse_for_team, validator::Validator, }; diff --git a/src/ownership/codeowners_file_parser.rs b/src/ownership/codeowners_file_parser.rs index 75ab6c7..d7d696d 100644 --- a/src/ownership/codeowners_file_parser.rs +++ b/src/ownership/codeowners_file_parser.rs @@ -4,7 +4,6 @@ use crate::{ }; use fast_glob::glob_match; use memoize::memoize; -use rayon::prelude::*; use regex::Regex; use std::{ collections::HashMap, @@ -23,57 +22,33 @@ pub struct Parser { } impl Parser { - pub fn teams_from_files_paths(&self, file_paths: &[PathBuf]) -> Result>, Box> { - let file_inputs: Vec<(String, String)> = file_paths - .iter() - .map(|path| { - let file_path_str = path - .to_str() - .ok_or(IoError::new(std::io::ErrorKind::InvalidInput, "Invalid file path"))?; - let original = file_path_str.to_string(); - let prefixed = if file_path_str.starts_with('/') { - original.clone() - } else { - format!("/{}", file_path_str) - }; - Ok((original, prefixed)) - }) - .collect::, IoError>>()?; - - if file_inputs.is_empty() { - return Ok(HashMap::new()); - } - - let codeowners_entries: Vec<(String, String)> = - build_codeowners_lines_in_priority(self.codeowners_file_path.to_string_lossy().into_owned()) - .iter() - .map(|line| { - line.split_once(' ') - .map(|(glob, team_name)| (glob.to_string(), team_name.to_string())) - .ok_or_else(|| IoError::new(std::io::ErrorKind::InvalidInput, "Invalid line")) - }) - .collect::>() - .map_err(|e| Box::new(e) as Box)?; - - let teams_by_name = teams_by_github_team_name(self.absolute_team_files_globs()); - - let result: HashMap> = file_inputs - .par_iter() - .map(|(key, prefixed)| { - let team = codeowners_entries - .iter() - .find(|(glob, _)| glob_match(glob, prefixed)) - .and_then(|(_, team_name)| teams_by_name.get(team_name).cloned()); - (key.clone(), team) - }) - .collect(); - - Ok(result) + pub fn teams_from_files_paths(&self, file_paths: &[PathBuf]) -> Result, Box> { + todo!() } - + pub fn team_from_file_path(&self, file_path: &Path) -> Result, Box> { - let teams = self.teams_from_files_paths(&[file_path.to_path_buf()])?; - Ok(teams.get(file_path.to_string_lossy().into_owned().as_str()).cloned().flatten()) + let file_path_str = file_path + .to_str() + .ok_or(IoError::new(std::io::ErrorKind::InvalidInput, "Invalid file path"))?; + let slash_prefixed = if file_path_str.starts_with("/") { + file_path_str.to_string() + } else { + format!("/{}", file_path_str) + }; + + let codeowners_lines_in_priorty = build_codeowners_lines_in_priority(self.codeowners_file_path.to_string_lossy().into_owned()); + for line in codeowners_lines_in_priorty { + let (glob, team_name) = line + .split_once(' ') + .ok_or(IoError::new(std::io::ErrorKind::InvalidInput, "Invalid line"))?; + if glob_match(glob, &slash_prefixed) { + let tbn = teams_by_github_team_name(self.absolute_team_files_globs()); + let team: Option = tbn.get(team_name.to_string().as_str()).cloned(); + return Ok(team); + } + } + + Ok(None) } fn absolute_team_files_globs(&self) -> Vec { diff --git a/src/runner.rs b/src/runner.rs index 114ab04..3e78699 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -283,7 +283,7 @@ fn for_file_codeowners_only(run_config: &RunConfig, file_path: &str) -> RunResul } // For an array of file paths, return a map of file path to its owning team -pub fn teams_for_files_from_codeowners(run_config: &RunConfig, file_paths: &[String]) -> Result>, Error> { +pub fn teams_for_files_from_codeowners(run_config: &RunConfig, file_paths: &[String]) -> Result, Error> { let relative_file_paths: Vec = file_paths .iter() .map(|path| Path::new(path).strip_prefix(&run_config.project_root).unwrap_or(Path::new(path))) @@ -362,9 +362,10 @@ fn for_file_optimized(run_config: &RunConfig, file_path: &str) -> RunResult { mod tests { use tempfile::tempdir; - use super::*; use crate::{common_test, ownership::mapper::Source}; + use super::*; + #[test] fn test_version() { assert_eq!(version(), env!("CARGO_PKG_VERSION").to_string()); @@ -416,62 +417,4 @@ mod tests { assert_eq!(team.github_team, "@b"); assert!(team.path.to_string_lossy().ends_with("config/teams/b.yml")); } - - #[test] - fn test_teams_for_files_from_codeowners() { - let project_root = Path::new("tests/fixtures/valid_project"); - let file_paths = [ - "javascript/packages/items/item.ts", - "config/teams/payroll.yml", - "ruby/app/models/bank_account.rb", - "made/up/file.rb", - "ruby/ignored_files/git_ignored.rb", - ]; - let run_config = RunConfig { - project_root: project_root.to_path_buf(), - codeowners_file_path: project_root.join(".github/CODEOWNERS").to_path_buf(), - config_path: project_root.join("config/code_ownership.yml").to_path_buf(), - no_cache: false, - }; - let teams = - teams_for_files_from_codeowners(&run_config, &file_paths.iter().map(|s| s.to_string()).collect::>()).unwrap(); - assert_eq!(teams.len(), 5); - assert_eq!( - teams - .get("javascript/packages/items/item.ts") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - Some("Payroll") - ); - assert_eq!( - teams.get("config/teams/payroll.yml").unwrap().as_ref().map(|t| t.name.as_str()), - Some("Payroll") - ); - assert_eq!( - teams - .get("ruby/app/models/bank_account.rb") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - Some("Payments") - ); - assert_eq!(teams.get("made/up/file.rb").unwrap().as_ref().map(|t| t.name.as_str()), None); - assert_eq!( - teams - .get("ruby/ignored_files/git_ignored.rb") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - None - ); - assert_eq!( - teams - .get("ruby/ignored_files/git_ignored.rb") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - None - ); - } } From ea4f1cd43e222dfaddf1fb48ac2a9673b4a92073 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Wed, 20 Aug 2025 18:58:56 -0500 Subject: [PATCH 02/13] first try..slow --- src/ownership/codeowners_file_parser.rs | 86 ++++++++++++++++++------- src/runner.rs | 42 ++++++++++++ 2 files changed, 104 insertions(+), 24 deletions(-) diff --git a/src/ownership/codeowners_file_parser.rs b/src/ownership/codeowners_file_parser.rs index d7d696d..b2d0263 100644 --- a/src/ownership/codeowners_file_parser.rs +++ b/src/ownership/codeowners_file_parser.rs @@ -5,6 +5,7 @@ use crate::{ use fast_glob::glob_match; use memoize::memoize; use regex::Regex; +use rayon::prelude::*; use std::{ collections::HashMap, error::Error, @@ -23,32 +24,69 @@ pub struct Parser { impl Parser { pub fn teams_from_files_paths(&self, file_paths: &[PathBuf]) -> Result, Box> { - todo!() - } - - pub fn team_from_file_path(&self, file_path: &Path) -> Result, Box> { - let file_path_str = file_path - .to_str() - .ok_or(IoError::new(std::io::ErrorKind::InvalidInput, "Invalid file path"))?; - let slash_prefixed = if file_path_str.starts_with("/") { - file_path_str.to_string() - } else { - format!("/{}", file_path_str) - }; - - let codeowners_lines_in_priorty = build_codeowners_lines_in_priority(self.codeowners_file_path.to_string_lossy().into_owned()); - for line in codeowners_lines_in_priorty { - let (glob, team_name) = line - .split_once(' ') - .ok_or(IoError::new(std::io::ErrorKind::InvalidInput, "Invalid line"))?; - if glob_match(glob, &slash_prefixed) { - let tbn = teams_by_github_team_name(self.absolute_team_files_globs()); - let team: Option = tbn.get(team_name.to_string().as_str()).cloned(); - return Ok(team); - } + let mut file_inputs: Vec<(String, String)> = Vec::with_capacity(file_paths.len()); + for path in file_paths { + let file_path_str = path + .to_str() + .ok_or(IoError::new(std::io::ErrorKind::InvalidInput, "Invalid file path"))?; + let key = file_path_str.to_string(); + let slash_prefixed = if file_path_str.starts_with('/') { + file_path_str.to_string() + } else { + format!("/{}", file_path_str) + }; + file_inputs.push((key, slash_prefixed)); + } + + if file_inputs.is_empty() { + return Ok(HashMap::new()); + } + + let codeowners_lines_in_priority = build_codeowners_lines_in_priority( + self.codeowners_file_path.to_string_lossy().into_owned(), + ); + // Pre-parse lines once to avoid repeated split and to handle malformed lines early + let codeowners_entries: Vec<(String, String)> = codeowners_lines_in_priority + .iter() + .map(|line| { + line + .split_once(' ') + .map(|(glob, team_name)| (glob.to_string(), team_name.to_string())) + .ok_or_else(|| IoError::new(std::io::ErrorKind::InvalidInput, "Invalid line")) + }) + .collect::>() + .map_err(|e| Box::new(e) as Box)?; + let teams_by_name = teams_by_github_team_name(self.absolute_team_files_globs()); + + // Parallelize across files: for each file, scan lines in priority order + let result_pairs: Vec<(String, Team)> = file_inputs + .par_iter() + .filter_map(|(key, prefixed)| { + for (glob, team_name) in &codeowners_entries { + if glob_match(glob, prefixed) { + // Stop at first match (highest priority). If team missing, treat as unowned. + if let Some(team) = teams_by_name.get(team_name) { + return Some((key.clone(), team.clone())); + } else { + return None; + } + } + } + None + }) + .collect(); + + let mut result: HashMap = HashMap::with_capacity(result_pairs.len()); + for (k, t) in result_pairs { + result.insert(k, t); } - Ok(None) + Ok(result) + } + + pub fn team_from_file_path(&self, file_path: &Path) -> Result, Box> { + let teams = self.teams_from_files_paths(&[file_path.to_path_buf()])?; + Ok(teams.get(file_path.to_string_lossy().into_owned().as_str()).cloned()) } fn absolute_team_files_globs(&self) -> Vec { diff --git a/src/runner.rs b/src/runner.rs index 3e78699..0cceede 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -363,6 +363,7 @@ mod tests { use tempfile::tempdir; use crate::{common_test, ownership::mapper::Source}; + use ignore::{DirEntry, WalkBuilder, WalkParallel, WalkState}; use super::*; @@ -417,4 +418,45 @@ mod tests { assert_eq!(team.github_team, "@b"); assert!(team.path.to_string_lossy().ends_with("config/teams/b.yml")); } + + #[test] + fn test_teams_for_files_from_codeowners() { + let project_root = Path::new("/Users/perryhertler/workspace/zenpayroll"); + let codeowners_file_path = project_root.join(".github/CODEOWNERS"); + let config_path = project_root.join("config/code_ownership.yml"); + let run_config = RunConfig { + project_root: project_root.to_path_buf(), + codeowners_file_path: codeowners_file_path.to_path_buf(), + config_path: config_path.to_path_buf(), + no_cache: false, + }; + + // Collect all files in packs and frontend directories recursively + let mut file_paths = Vec::new(); + for dir in ["packs", "frontend"] { + let dir_path = project_root.join(dir); + if dir_path.exists() && dir_path.is_dir() { + for entry in WalkBuilder::new(&dir_path) + .filter_entry(|e| { + let name = e.file_name().to_str().unwrap_or(""); + !(name == "node_modules" || name == "dist" || name == ".git") + }) + .build() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) + .filter_map(|e| e.path().strip_prefix(project_root).ok().map(|p| p.to_string_lossy().to_string())) + { + file_paths.push(entry); + } + } + } + + let start_time = std::time::Instant::now(); + let teams = teams_for_files_from_codeowners(&run_config, &file_paths).unwrap(); + let end_time = std::time::Instant::now(); + println!("Time taken: {:?}", end_time.duration_since(start_time)); + println!("Teams: {:?}", teams); + assert_eq!(teams.len(), 1); + + } } From 7f18c2f9599378a456042f93d94055c77b914c88 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Wed, 20 Aug 2025 19:37:04 -0500 Subject: [PATCH 03/13] much faster --- src/ownership.rs | 4 +- src/ownership/codeowners_file_parser.rs | 79 +++++++++++-------------- src/runner.rs | 69 +++++++++------------ 3 files changed, 64 insertions(+), 88 deletions(-) diff --git a/src/ownership.rs b/src/ownership.rs index ceda129..88516f8 100644 --- a/src/ownership.rs +++ b/src/ownership.rs @@ -9,11 +9,11 @@ use std::{ }; use tracing::{info, instrument}; +pub(crate) mod codeowners_file_parser; mod file_generator; mod file_owner_finder; pub mod for_file_fast; pub(crate) mod mapper; -pub(crate) mod codeowners_file_parser; mod validator; use crate::{ @@ -24,9 +24,9 @@ use crate::{ pub use validator::Errors as ValidatorErrors; use self::{ + codeowners_file_parser::parse_for_team, file_generator::FileGenerator, mapper::{JavascriptPackageMapper, Mapper, RubyPackageMapper, TeamFileMapper, TeamGemMapper, TeamGlobMapper, TeamYmlMapper}, - codeowners_file_parser::parse_for_team, validator::Validator, }; diff --git a/src/ownership/codeowners_file_parser.rs b/src/ownership/codeowners_file_parser.rs index b2d0263..f2872cf 100644 --- a/src/ownership/codeowners_file_parser.rs +++ b/src/ownership/codeowners_file_parser.rs @@ -4,8 +4,8 @@ use crate::{ }; use fast_glob::glob_match; use memoize::memoize; -use regex::Regex; use rayon::prelude::*; +use regex::Regex; use std::{ collections::HashMap, error::Error, @@ -24,63 +24,50 @@ pub struct Parser { impl Parser { pub fn teams_from_files_paths(&self, file_paths: &[PathBuf]) -> Result, Box> { - let mut file_inputs: Vec<(String, String)> = Vec::with_capacity(file_paths.len()); - for path in file_paths { - let file_path_str = path - .to_str() - .ok_or(IoError::new(std::io::ErrorKind::InvalidInput, "Invalid file path"))?; - let key = file_path_str.to_string(); - let slash_prefixed = if file_path_str.starts_with('/') { - file_path_str.to_string() - } else { - format!("/{}", file_path_str) - }; - file_inputs.push((key, slash_prefixed)); - } + let file_inputs: Vec<(String, String)> = file_paths + .iter() + .map(|path| { + let file_path_str = path + .to_str() + .ok_or(IoError::new(std::io::ErrorKind::InvalidInput, "Invalid file path"))?; + let original = file_path_str.to_string(); + let prefixed = if file_path_str.starts_with('/') { + original.clone() + } else { + format!("/{}", file_path_str) + }; + Ok((original, prefixed)) + }) + .collect::, IoError>>()?; if file_inputs.is_empty() { return Ok(HashMap::new()); } - let codeowners_lines_in_priority = build_codeowners_lines_in_priority( - self.codeowners_file_path.to_string_lossy().into_owned(), - ); - // Pre-parse lines once to avoid repeated split and to handle malformed lines early - let codeowners_entries: Vec<(String, String)> = codeowners_lines_in_priority - .iter() - .map(|line| { - line - .split_once(' ') - .map(|(glob, team_name)| (glob.to_string(), team_name.to_string())) - .ok_or_else(|| IoError::new(std::io::ErrorKind::InvalidInput, "Invalid line")) - }) - .collect::>() - .map_err(|e| Box::new(e) as Box)?; + let codeowners_entries: Vec<(String, String)> = + build_codeowners_lines_in_priority(self.codeowners_file_path.to_string_lossy().into_owned()) + .iter() + .map(|line| { + line.split_once(' ') + .map(|(glob, team_name)| (glob.to_string(), team_name.to_string())) + .ok_or_else(|| IoError::new(std::io::ErrorKind::InvalidInput, "Invalid line")) + }) + .collect::>() + .map_err(|e| Box::new(e) as Box)?; + let teams_by_name = teams_by_github_team_name(self.absolute_team_files_globs()); - // Parallelize across files: for each file, scan lines in priority order - let result_pairs: Vec<(String, Team)> = file_inputs + let result: HashMap = file_inputs .par_iter() .filter_map(|(key, prefixed)| { - for (glob, team_name) in &codeowners_entries { - if glob_match(glob, prefixed) { - // Stop at first match (highest priority). If team missing, treat as unowned. - if let Some(team) = teams_by_name.get(team_name) { - return Some((key.clone(), team.clone())); - } else { - return None; - } - } - } - None + codeowners_entries + .iter() + .find(|(glob, _)| glob_match(glob, prefixed)) + .and_then(|(_, team_name)| teams_by_name.get(team_name)) + .map(|team| (key.clone(), team.clone())) }) .collect(); - let mut result: HashMap = HashMap::with_capacity(result_pairs.len()); - for (k, t) in result_pairs { - result.insert(k, t); - } - Ok(result) } diff --git a/src/runner.rs b/src/runner.rs index 0cceede..61eef4f 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -362,10 +362,8 @@ fn for_file_optimized(run_config: &RunConfig, file_path: &str) -> RunResult { mod tests { use tempfile::tempdir; - use crate::{common_test, ownership::mapper::Source}; - use ignore::{DirEntry, WalkBuilder, WalkParallel, WalkState}; - use super::*; + use crate::{common_test, ownership::mapper::Source}; #[test] fn test_version() { @@ -421,42 +419,33 @@ mod tests { #[test] fn test_teams_for_files_from_codeowners() { - let project_root = Path::new("/Users/perryhertler/workspace/zenpayroll"); - let codeowners_file_path = project_root.join(".github/CODEOWNERS"); - let config_path = project_root.join("config/code_ownership.yml"); - let run_config = RunConfig { - project_root: project_root.to_path_buf(), - codeowners_file_path: codeowners_file_path.to_path_buf(), - config_path: config_path.to_path_buf(), - no_cache: false, - }; - - // Collect all files in packs and frontend directories recursively - let mut file_paths = Vec::new(); - for dir in ["packs", "frontend"] { - let dir_path = project_root.join(dir); - if dir_path.exists() && dir_path.is_dir() { - for entry in WalkBuilder::new(&dir_path) - .filter_entry(|e| { - let name = e.file_name().to_str().unwrap_or(""); - !(name == "node_modules" || name == "dist" || name == ".git") - }) - .build() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)) - .filter_map(|e| e.path().strip_prefix(project_root).ok().map(|p| p.to_string_lossy().to_string())) - { - file_paths.push(entry); - } - } - } - - let start_time = std::time::Instant::now(); - let teams = teams_for_files_from_codeowners(&run_config, &file_paths).unwrap(); - let end_time = std::time::Instant::now(); - println!("Time taken: {:?}", end_time.duration_since(start_time)); - println!("Teams: {:?}", teams); - assert_eq!(teams.len(), 1); - + let project_root = Path::new("tests/fixtures/valid_project"); + let file_paths = [ + "javascript/packages/items/item.ts", + "config/teams/payroll.yml", + "ruby/app/models/bank_account.rb", + "made/up/file.rb", + "ruby/ignored_files/git_ignored.rb", + ]; + let run_config = RunConfig { + project_root: project_root.to_path_buf(), + codeowners_file_path: project_root.join(".github/CODEOWNERS").to_path_buf(), + config_path: project_root.join("config/code_ownership.yml").to_path_buf(), + no_cache: false, + }; + let teams = + teams_for_files_from_codeowners(&run_config, &file_paths.iter().map(|s| s.to_string()).collect::>()).unwrap(); + assert_eq!(teams.len(), 3); + assert_eq!( + teams.get("javascript/packages/items/item.ts").map(|t| t.name.as_str()), + Some("Payroll") + ); + assert_eq!(teams.get("config/teams/payroll.yml").map(|t| t.name.as_str()), Some("Payroll")); + assert_eq!( + teams.get("ruby/app/models/bank_account.rb").map(|t| t.name.as_str()), + Some("Payments") + ); + assert_eq!(teams.get("made/up/file.rb").map(|t| t.name.as_str()), None); + assert_eq!(teams.get("ruby/ignored_files/git_ignored.rb").map(|t| t.name.as_str()), None); } } From 470ac7b4d8f63dcb6c0ae79557b9ca823245aabb Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Wed, 20 Aug 2025 19:53:27 -0500 Subject: [PATCH 04/13] bumping crates --- src/ownership/codeowners_file_parser.rs | 14 ++++----- src/runner.rs | 40 ++++++++++++++++++++----- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/ownership/codeowners_file_parser.rs b/src/ownership/codeowners_file_parser.rs index f2872cf..75ab6c7 100644 --- a/src/ownership/codeowners_file_parser.rs +++ b/src/ownership/codeowners_file_parser.rs @@ -23,7 +23,7 @@ pub struct Parser { } impl Parser { - pub fn teams_from_files_paths(&self, file_paths: &[PathBuf]) -> Result, Box> { + pub fn teams_from_files_paths(&self, file_paths: &[PathBuf]) -> Result>, Box> { let file_inputs: Vec<(String, String)> = file_paths .iter() .map(|path| { @@ -57,14 +57,14 @@ impl Parser { let teams_by_name = teams_by_github_team_name(self.absolute_team_files_globs()); - let result: HashMap = file_inputs + let result: HashMap> = file_inputs .par_iter() - .filter_map(|(key, prefixed)| { - codeowners_entries + .map(|(key, prefixed)| { + let team = codeowners_entries .iter() .find(|(glob, _)| glob_match(glob, prefixed)) - .and_then(|(_, team_name)| teams_by_name.get(team_name)) - .map(|team| (key.clone(), team.clone())) + .and_then(|(_, team_name)| teams_by_name.get(team_name).cloned()); + (key.clone(), team) }) .collect(); @@ -73,7 +73,7 @@ impl Parser { pub fn team_from_file_path(&self, file_path: &Path) -> Result, Box> { let teams = self.teams_from_files_paths(&[file_path.to_path_buf()])?; - Ok(teams.get(file_path.to_string_lossy().into_owned().as_str()).cloned()) + Ok(teams.get(file_path.to_string_lossy().into_owned().as_str()).cloned().flatten()) } fn absolute_team_files_globs(&self) -> Vec { diff --git a/src/runner.rs b/src/runner.rs index 61eef4f..114ab04 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -283,7 +283,7 @@ fn for_file_codeowners_only(run_config: &RunConfig, file_path: &str) -> RunResul } // For an array of file paths, return a map of file path to its owning team -pub fn teams_for_files_from_codeowners(run_config: &RunConfig, file_paths: &[String]) -> Result, Error> { +pub fn teams_for_files_from_codeowners(run_config: &RunConfig, file_paths: &[String]) -> Result>, Error> { let relative_file_paths: Vec = file_paths .iter() .map(|path| Path::new(path).strip_prefix(&run_config.project_root).unwrap_or(Path::new(path))) @@ -435,17 +435,43 @@ mod tests { }; let teams = teams_for_files_from_codeowners(&run_config, &file_paths.iter().map(|s| s.to_string()).collect::>()).unwrap(); - assert_eq!(teams.len(), 3); + assert_eq!(teams.len(), 5); assert_eq!( - teams.get("javascript/packages/items/item.ts").map(|t| t.name.as_str()), + teams + .get("javascript/packages/items/item.ts") + .unwrap() + .as_ref() + .map(|t| t.name.as_str()), Some("Payroll") ); - assert_eq!(teams.get("config/teams/payroll.yml").map(|t| t.name.as_str()), Some("Payroll")); assert_eq!( - teams.get("ruby/app/models/bank_account.rb").map(|t| t.name.as_str()), + teams.get("config/teams/payroll.yml").unwrap().as_ref().map(|t| t.name.as_str()), + Some("Payroll") + ); + assert_eq!( + teams + .get("ruby/app/models/bank_account.rb") + .unwrap() + .as_ref() + .map(|t| t.name.as_str()), Some("Payments") ); - assert_eq!(teams.get("made/up/file.rb").map(|t| t.name.as_str()), None); - assert_eq!(teams.get("ruby/ignored_files/git_ignored.rb").map(|t| t.name.as_str()), None); + assert_eq!(teams.get("made/up/file.rb").unwrap().as_ref().map(|t| t.name.as_str()), None); + assert_eq!( + teams + .get("ruby/ignored_files/git_ignored.rb") + .unwrap() + .as_ref() + .map(|t| t.name.as_str()), + None + ); + assert_eq!( + teams + .get("ruby/ignored_files/git_ignored.rb") + .unwrap() + .as_ref() + .map(|t| t.name.as_str()), + None + ); } } From 9c285cf5909e29caa30bfdec214aa4dd611d11ad Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Thu, 21 Aug 2025 20:34:04 -0500 Subject: [PATCH 05/13] extracting separate concerns from runner --- src/config.rs | 8 + src/ownership.rs | 1 + src/ownership/codeowners_query.rs | 53 +++++++ src/runner.rs | 253 ++++++++---------------------- src/runner/api.rs | 84 ++++++++++ src/runner/types.rs | 43 +++++ 6 files changed, 257 insertions(+), 185 deletions(-) create mode 100644 src/ownership/codeowners_query.rs create mode 100644 src/runner/api.rs create mode 100644 src/runner/types.rs diff --git a/src/config.rs b/src/config.rs index aa087df..893cd06 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use serde::Deserialize; +use std::{fs::File, path::Path}; #[derive(Deserialize, Debug, Clone)] pub struct Config { @@ -77,6 +78,13 @@ fn default_ignore_dirs() -> Vec { ] } +impl Config { + pub fn load_from_path(path: &Path) -> std::result::Result { + let file = File::open(path).map_err(|e| format!("Can't open config file: {} ({})", path.to_string_lossy(), e))?; + serde_yaml::from_reader(file).map_err(|e| format!("Can't parse config file: {} ({})", path.to_string_lossy(), e)) + } +} + #[cfg(test)] mod tests { use std::{ diff --git a/src/ownership.rs b/src/ownership.rs index 88516f8..5641dfc 100644 --- a/src/ownership.rs +++ b/src/ownership.rs @@ -10,6 +10,7 @@ use std::{ use tracing::{info, instrument}; pub(crate) mod codeowners_file_parser; +pub(crate) mod codeowners_query; mod file_generator; mod file_owner_finder; pub mod for_file_fast; diff --git a/src/ownership/codeowners_query.rs b/src/ownership/codeowners_query.rs new file mode 100644 index 0000000..b36f7d0 --- /dev/null +++ b/src/ownership/codeowners_query.rs @@ -0,0 +1,53 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::ownership::codeowners_file_parser::Parser; +use crate::project::Team; + +pub(crate) fn team_for_file_from_codeowners( + project_root: &Path, + codeowners_file_path: &Path, + team_file_globs: &[String], + file_path: &Path, +) -> Result, String> { + let relative_file_path = if file_path.is_absolute() { + file_path.strip_prefix(project_root).unwrap_or(file_path).to_path_buf() + } else { + PathBuf::from(file_path) + }; + + let parser = Parser { + codeowners_file_path: codeowners_file_path.to_path_buf(), + project_root: project_root.to_path_buf(), + team_file_globs: team_file_globs.to_vec(), + }; + + parser.team_from_file_path(&relative_file_path).map_err(|e| e.to_string()) +} + +pub(crate) fn teams_for_files_from_codeowners( + project_root: &Path, + codeowners_file_path: &Path, + team_file_globs: &[String], + file_paths: &[String], +) -> Result>, String> { + let relative_file_paths: Vec = file_paths + .iter() + .map(Path::new) + .map(|path| { + if path.is_absolute() { + path.strip_prefix(project_root).unwrap_or(path).to_path_buf() + } else { + path.to_path_buf() + } + }) + .collect(); + + let parser = Parser { + codeowners_file_path: codeowners_file_path.to_path_buf(), + project_root: project_root.to_path_buf(), + team_file_globs: team_file_globs.to_vec(), + }; + + parser.teams_from_files_paths(&relative_file_paths).map_err(|e| e.to_string()) +} diff --git a/src/runner.rs b/src/runner.rs index 114ab04..a27a7c3 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,92 +1,33 @@ -use core::fmt; -use std::{ - collections::HashMap, - fs::File, - path::{Path, PathBuf}, - process::Command, -}; +use std::{path::Path, process::Command}; -use error_stack::{Context, Result, ResultExt}; -use serde::{Deserialize, Serialize}; +use error_stack::{Result, ResultExt}; use crate::{ cache::{Cache, Caching, file::GlobalCache, noop::NoopCache}, config::Config, ownership::{FileOwner, Ownership}, - project::Team, project_builder::ProjectBuilder, }; -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct RunResult { - pub validation_errors: Vec, - pub io_errors: Vec, - pub info_messages: Vec, -} -#[derive(Debug, Clone)] -pub struct RunConfig { - pub project_root: PathBuf, - pub codeowners_file_path: PathBuf, - pub config_path: PathBuf, - pub no_cache: bool, -} +mod types; +pub use self::types::{Error, RunConfig, RunResult}; +mod api; +pub use self::api::*; pub struct Runner { run_config: RunConfig, ownership: Ownership, cache: Cache, -} - -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) -} - -pub fn file_owner_for_file(run_config: &RunConfig, file_path: &str) -> Result, Error> { - let config = config_from_path(&run_config.config_path)?; - use crate::ownership::for_file_fast::find_file_owners; - let owners = find_file_owners(&run_config.project_root, &config, std::path::Path::new(file_path)).map_err(Error::Io)?; - Ok(owners.first().cloned()) -} - -pub fn team_for_file(run_config: &RunConfig, file_path: &str) -> Result, Error> { - let owner = file_owner_for_file(run_config, file_path)?; - Ok(owner.map(|fo| fo.team.clone())) + config: Config, } pub fn version() -> String { env!("CARGO_PKG_VERSION").to_string() } -pub fn for_team(run_config: &RunConfig, team_name: &str) -> RunResult { - run_with_runner(run_config, |runner| runner.for_team(team_name)) -} - -pub fn validate(run_config: &RunConfig, _file_paths: Vec) -> RunResult { - run_with_runner(run_config, |runner| runner.validate()) -} - -pub fn generate(run_config: &RunConfig, git_stage: bool) -> RunResult { - run_with_runner(run_config, |runner| runner.generate(git_stage)) -} - -pub fn generate_and_validate(run_config: &RunConfig, _file_paths: Vec, git_stage: bool) -> RunResult { - run_with_runner(run_config, |runner| runner.generate_and_validate(git_stage)) -} - -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 +pub fn run(run_config: &RunConfig, runnable: F) -> RunResult where F: FnOnce(Runner) -> RunResult, { @@ -102,35 +43,12 @@ where runnable(runner) } -impl RunResult { - pub fn has_errors(&self) -> bool { - !self.validation_errors.is_empty() || !self.io_errors.is_empty() - } -} - -#[derive(Debug)] -pub enum Error { - Io(String), - ValidationFailed, -} - -impl Context for Error {} -impl fmt::Display for Error { - fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Error::Io(msg) => fmt.write_str(msg), - Error::ValidationFailed => fmt.write_str("Error::ValidationFailed"), - } +pub(crate) fn config_from_path(path: &Path) -> Result { + match crate::config::Config::load_from_path(path) { + Ok(c) => Ok(c), + Err(msg) => Err(error_stack::Report::new(Error::Io(msg))), } } - -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()))?; - - serde_yaml::from_reader(config_file).change_context(Error::Io(format!("Can't parse config file: {}", &path.to_string_lossy()))) -} impl Runner { pub fn new(run_config: &RunConfig) -> Result { let config = config_from_path(&run_config.config_path)?; @@ -168,6 +86,7 @@ impl Runner { run_config: run_config.clone(), ownership, cache, + config, }) } @@ -255,111 +174,75 @@ impl Runner { pub fn crosscheck_owners(&self) -> RunResult { crate::crosscheck::crosscheck_owners(&self.run_config, &self.cache) } -} -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 owners_for_file(&self, file_path: &str) -> Result, Error> { + use crate::ownership::for_file_fast::find_file_owners; + let owners = find_file_owners(&self.run_config.project_root, &self.config, std::path::Path::new(file_path)).map_err(Error::Io)?; + Ok(owners) } -} -// For an array of file paths, return a map of file path to its owning team -pub fn teams_for_files_from_codeowners(run_config: &RunConfig, file_paths: &[String]) -> Result>, Error> { - let relative_file_paths: Vec = file_paths - .iter() - .map(|path| Path::new(path).strip_prefix(&run_config.project_root).unwrap_or(Path::new(path))) - .map(|path| path.to_path_buf()) - .collect(); - - let parser = build_codeowners_parser(run_config)?; - Ok(parser - .teams_from_files_paths(&relative_file_paths) - .map_err(|e| Error::Io(e.to_string()))?) -} - -fn build_codeowners_parser(run_config: &RunConfig) -> Result { - let config = config_from_path(&run_config.config_path)?; - Ok(crate::ownership::codeowners_file_parser::Parser { - codeowners_file_path: run_config.codeowners_file_path.clone(), - project_root: run_config.project_root.clone(), - team_file_globs: config.team_file_glob.clone(), - }) -} - -pub fn team_for_file_from_codeowners(run_config: &RunConfig, file_path: &str) -> Result, Error> { - let relative_file_path = Path::new(file_path) - .strip_prefix(&run_config.project_root) - .unwrap_or(Path::new(file_path)); - - let parser = build_codeowners_parser(run_config)?; - Ok(parser - .team_from_file_path(Path::new(relative_file_path)) - .map_err(|e| Error::Io(e.to_string()))?) -} - -fn for_file_optimized(run_config: &RunConfig, file_path: &str) -> RunResult { - let config = match config_from_path(&run_config.config_path) { - Ok(c) => c, - Err(err) => { - return RunResult { - io_errors: vec![err.to_string()], - ..Default::default() - }; - } - }; + pub fn for_file_optimized(&self, file_path: &str) -> RunResult { + let file_owners = match self.owners_for_file(file_path) { + Ok(v) => v, + Err(err) => { + return RunResult { + io_errors: vec![err.to_string()], + ..Default::default() + }; + } + }; - use crate::ownership::for_file_fast::find_file_owners; - let file_owners = match find_file_owners(&run_config.project_root, &config, std::path::Path::new(file_path)) { - Ok(v) => v, - Err(err) => { - return RunResult { - io_errors: vec![err], - ..Default::default() - }; + let info_messages: Vec = match file_owners.len() { + 0 => vec![format!("{}", FileOwner::default())], + 1 => vec![format!("{}", file_owners[0])], + _ => { + let mut error_messages = vec!["Error: file is owned by multiple teams!".to_string()]; + for file_owner in file_owners { + error_messages.push(format!("\n{}", file_owner)); + } + return RunResult { + validation_errors: error_messages, + ..Default::default() + }; + } + }; + RunResult { + info_messages, + ..Default::default() } - }; + } - let info_messages: Vec = match file_owners.len() { - 0 => vec![format!("{}", FileOwner::default())], - 1 => vec![format!("{}", file_owners[0])], - _ => { - let mut error_messages = vec!["Error: file is owned by multiple teams!".to_string()]; - for file_owner in file_owners { - error_messages.push(format!("\n{}", file_owner)); + pub fn for_file_codeowners_only(&self, file_path: &str) -> RunResult { + match team_for_file_from_codeowners(&self.run_config, file_path) { + Ok(Some(team)) => { + let relative_team_path = team + .path + .strip_prefix(&self.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() + } } - return RunResult { - validation_errors: error_messages, + Ok(None) => RunResult::default(), + Err(err) => RunResult { + io_errors: vec![err.to_string()], ..Default::default() - }; + }, } - }; - RunResult { - info_messages, - ..Default::default() } } +// removed free functions for for_file_* variants in favor of Runner methods + #[cfg(test)] mod tests { + use std::path::Path; use tempfile::tempdir; use super::*; diff --git a/src/runner/api.rs b/src/runner/api.rs new file mode 100644 index 0000000..556181d --- /dev/null +++ b/src/runner/api.rs @@ -0,0 +1,84 @@ +use std::collections::HashMap; +use std::path::Path; + +use crate::ownership::FileOwner; +use crate::project::Team; + +use super::{Error, RunConfig, RunResult, Runner, config_from_path, run}; + +pub fn for_file(run_config: &RunConfig, file_path: &str, from_codeowners: bool) -> RunResult { + run(run_config, |runner| { + if from_codeowners { + runner.for_file_codeowners_only(file_path) + } else { + runner.for_file_optimized(file_path) + } + }) +} + +pub fn for_team(run_config: &RunConfig, team_name: &str) -> RunResult { + run(run_config, |runner| runner.for_team(team_name)) +} + +pub fn validate(run_config: &RunConfig, _file_paths: Vec) -> RunResult { + run(run_config, |runner| runner.validate()) +} + +pub fn generate(run_config: &RunConfig, git_stage: bool) -> RunResult { + run(run_config, |runner| runner.generate(git_stage)) +} + +pub fn generate_and_validate(run_config: &RunConfig, _file_paths: Vec, git_stage: bool) -> RunResult { + run(run_config, |runner| runner.generate_and_validate(git_stage)) +} + +pub fn delete_cache(run_config: &RunConfig) -> RunResult { + run(run_config, |runner| runner.delete_cache()) +} + +pub fn crosscheck_owners(run_config: &RunConfig) -> RunResult { + run(run_config, |runner| runner.crosscheck_owners()) +} + +pub fn file_owner_for_file(run_config: &RunConfig, file_path: &str) -> error_stack::Result, Error> { + let runner = Runner::new(run_config)?; + let owners = runner.owners_for_file(file_path)?; + Ok(owners.first().cloned()) +} + +pub fn team_for_file(run_config: &RunConfig, file_path: &str) -> error_stack::Result, Error> { + let owner = file_owner_for_file(run_config, file_path)?; + Ok(owner.map(|fo| fo.team.clone())) +} + +// For an array of file paths, return a map of file path to its owning team +pub fn teams_for_files_from_codeowners( + run_config: &RunConfig, + file_paths: &[String], +) -> error_stack::Result>, Error> { + let config = config_from_path(&run_config.config_path)?; + let res = crate::ownership::codeowners_query::teams_for_files_from_codeowners( + &run_config.project_root, + &run_config.codeowners_file_path, + &config.team_file_glob, + file_paths, + ) + .map_err(Error::Io)?; + Ok(res) +} + +pub fn team_for_file_from_codeowners(run_config: &RunConfig, file_path: &str) -> error_stack::Result, Error> { + let relative_file_path = Path::new(file_path) + .strip_prefix(&run_config.project_root) + .unwrap_or(Path::new(file_path)); + + let config = config_from_path(&run_config.config_path)?; + let res = crate::ownership::codeowners_query::team_for_file_from_codeowners( + &run_config.project_root, + &run_config.codeowners_file_path, + &config.team_file_glob, + Path::new(relative_file_path), + ) + .map_err(Error::Io)?; + Ok(res) +} diff --git a/src/runner/types.rs b/src/runner/types.rs new file mode 100644 index 0000000..5ba38ea --- /dev/null +++ b/src/runner/types.rs @@ -0,0 +1,43 @@ +use core::fmt; +use std::path::PathBuf; + +use error_stack::Context; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct RunResult { + pub validation_errors: Vec, + pub io_errors: Vec, + pub info_messages: Vec, +} + +impl RunResult { + pub fn has_errors(&self) -> bool { + !self.validation_errors.is_empty() || !self.io_errors.is_empty() + } +} + +#[derive(Debug, Clone)] +pub struct RunConfig { + pub project_root: PathBuf, + pub codeowners_file_path: PathBuf, + pub config_path: PathBuf, + pub no_cache: bool, +} + +#[derive(Debug)] +pub enum Error { + Io(String), + ValidationFailed, +} + +impl Context for Error {} + +impl fmt::Display for Error { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Io(msg) => fmt.write_str(msg), + Error::ValidationFailed => fmt.write_str("Error::ValidationFailed"), + } + } +} From 0c00aabe0329f29d7c4f838802e5d3a79da43209 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Thu, 21 Aug 2025 21:06:45 -0500 Subject: [PATCH 06/13] updating README with usage examples --- README.md | 41 ++++++++++ src/runner.rs | 109 ------------------------- tests/runner_api.rs | 189 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 109 deletions(-) create mode 100644 tests/runner_api.rs diff --git a/README.md b/README.md index 17b2fd1..3e5755c 100644 --- a/README.md +++ b/README.md @@ -190,3 +190,44 @@ codeowners for-team Payroll ``` - Please update `CHANGELOG.md` and this `README.md` when making changes. + +### Module layout (for library users) + +- `src/runner.rs`: public façade re-exporting the API and types. +- `src/runner/api.rs`: externally available functions used by the CLI and other crates. +- `src/runner/types.rs`: `RunConfig`, `RunResult`, and runner `Error`. +- `src/ownership/`: all ownership logic (parsing, mapping, validation, generation). +- `src/ownership/codeowners_query.rs`: CODEOWNERS-only queries consumed by the façade. + +Import public APIs from `codeowners::runner::*`. + +### Library usage example + +```rust +use codeowners::runner::{RunConfig, for_file, teams_for_files_from_codeowners}; + +fn main() { + let run_config = RunConfig { + project_root: std::path::PathBuf::from("."), + codeowners_file_path: std::path::PathBuf::from(".github/CODEOWNERS"), + config_path: std::path::PathBuf::from("config/code_ownership.yml"), + no_cache: true, // set false to enable on-disk caching + }; + + // Find owner for a single file using the optimized path (not just CODEOWNERS) + let result = for_file(&run_config, "app/models/user.rb", false); + for msg in result.info_messages { println!("{}", msg); } + for err in result.io_errors { eprintln!("io: {}", err); } + for err in result.validation_errors { eprintln!("validation: {}", err); } + + // Map multiple files to teams using CODEOWNERS rules only + let files = vec![ + "app/models/user.rb".to_string(), + "config/teams/payroll.yml".to_string(), + ]; + match teams_for_files_from_codeowners(&run_config, &files) { + Ok(map) => println!("{:?}", map), + Err(e) => eprintln!("error: {}", e), + } +} +``` diff --git a/src/runner.rs b/src/runner.rs index a27a7c3..00e7f54 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -242,119 +242,10 @@ impl Runner { #[cfg(test)] mod tests { - use std::path::Path; - use tempfile::tempdir; - use super::*; - use crate::{common_test, ownership::mapper::Source}; #[test] fn test_version() { assert_eq!(version(), env!("CARGO_PKG_VERSION").to_string()); } - fn write_file(temp_dir: &Path, file_path: &str, content: &str) { - let file_path = temp_dir.join(file_path); - let _ = std::fs::create_dir_all(file_path.parent().unwrap()); - std::fs::write(file_path, content).unwrap(); - } - - #[test] - fn test_file_owners_for_file() { - let temp_dir = tempdir().unwrap(); - write_file( - temp_dir.path(), - "config/code_ownership.yml", - common_test::tests::DEFAULT_CODE_OWNERSHIP_YML, - ); - ["a", "b", "c"].iter().for_each(|name| { - let team_yml = format!("name: {}\ngithub:\n team: \"@{}\"\n members:\n - {}member\n", name, name, name); - write_file(temp_dir.path(), &format!("config/teams/{}.yml", name), &team_yml); - }); - write_file( - temp_dir.path(), - "app/consumers/deep/nesting/nestdir/deep_file.rb", - "# @team b\nclass DeepFile end;", - ); - - let run_config = RunConfig { - project_root: temp_dir.path().to_path_buf(), - codeowners_file_path: temp_dir.path().join(".github/CODEOWNERS").to_path_buf(), - config_path: temp_dir.path().join("config/code_ownership.yml").to_path_buf(), - no_cache: false, - }; - - let file_owner = file_owner_for_file(&run_config, "app/consumers/deep/nesting/nestdir/deep_file.rb") - .unwrap() - .unwrap(); - assert_eq!(file_owner.team.name, "b"); - assert_eq!(file_owner.team.github_team, "@b"); - assert!(file_owner.team.path.to_string_lossy().ends_with("config/teams/b.yml")); - assert_eq!(file_owner.sources.len(), 1); - assert_eq!(file_owner.sources, vec![Source::AnnotatedFile]); - - let team = team_for_file(&run_config, "app/consumers/deep/nesting/nestdir/deep_file.rb") - .unwrap() - .unwrap(); - assert_eq!(team.name, "b"); - assert_eq!(team.github_team, "@b"); - assert!(team.path.to_string_lossy().ends_with("config/teams/b.yml")); - } - - #[test] - fn test_teams_for_files_from_codeowners() { - let project_root = Path::new("tests/fixtures/valid_project"); - let file_paths = [ - "javascript/packages/items/item.ts", - "config/teams/payroll.yml", - "ruby/app/models/bank_account.rb", - "made/up/file.rb", - "ruby/ignored_files/git_ignored.rb", - ]; - let run_config = RunConfig { - project_root: project_root.to_path_buf(), - codeowners_file_path: project_root.join(".github/CODEOWNERS").to_path_buf(), - config_path: project_root.join("config/code_ownership.yml").to_path_buf(), - no_cache: false, - }; - let teams = - teams_for_files_from_codeowners(&run_config, &file_paths.iter().map(|s| s.to_string()).collect::>()).unwrap(); - assert_eq!(teams.len(), 5); - assert_eq!( - teams - .get("javascript/packages/items/item.ts") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - Some("Payroll") - ); - assert_eq!( - teams.get("config/teams/payroll.yml").unwrap().as_ref().map(|t| t.name.as_str()), - Some("Payroll") - ); - assert_eq!( - teams - .get("ruby/app/models/bank_account.rb") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - Some("Payments") - ); - assert_eq!(teams.get("made/up/file.rb").unwrap().as_ref().map(|t| t.name.as_str()), None); - assert_eq!( - teams - .get("ruby/ignored_files/git_ignored.rb") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - None - ); - assert_eq!( - teams - .get("ruby/ignored_files/git_ignored.rb") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), - None - ); - } } diff --git a/tests/runner_api.rs b/tests/runner_api.rs new file mode 100644 index 0000000..8f670f3 --- /dev/null +++ b/tests/runner_api.rs @@ -0,0 +1,189 @@ +use std::path::Path; + +use codeowners::runner::{self, RunConfig}; + +fn write_file(temp_dir: &Path, file_path: &str, content: &str) { + let file_path = temp_dir.join(file_path); + let _ = std::fs::create_dir_all(file_path.parent().unwrap()); + std::fs::write(file_path, content).unwrap(); +} + +#[test] +fn test_file_owners_for_file() { + let temp_dir = tempfile::tempdir().unwrap(); + const DEFAULT_CODE_OWNERSHIP_YML: &str = r#"--- +owned_globs: + - "{app,components,config,frontend,lib,packs,spec,ruby}/**/*.{rb,rake,js,jsx,ts,tsx,json,yml,erb}" +unowned_globs: + - config/code_ownership.yml +javascript_package_paths: + - javascript/packages/** +vendored_gems_path: gems +team_file_glob: + - config/teams/**/*.yml +"#; + write_file(temp_dir.path(), "config/code_ownership.yml", DEFAULT_CODE_OWNERSHIP_YML); + ["a", "b", "c"].iter().for_each(|name| { + let team_yml = format!( + "name: {}\ngithub:\n team: \"@{}\"\n members:\n - {}member\n", + name, name, name + ); + write_file(temp_dir.path(), &format!("config/teams/{}.yml", name), &team_yml); + }); + write_file( + temp_dir.path(), + "app/consumers/deep/nesting/nestdir/deep_file.rb", + "# @team b\nclass DeepFile end;", + ); + + let run_config = RunConfig { + project_root: temp_dir.path().to_path_buf(), + codeowners_file_path: temp_dir.path().join(".github/CODEOWNERS").to_path_buf(), + config_path: temp_dir.path().join("config/code_ownership.yml").to_path_buf(), + no_cache: true, + }; + + let file_owner = runner::file_owner_for_file(&run_config, "app/consumers/deep/nesting/nestdir/deep_file.rb") + .unwrap() + .unwrap(); + assert_eq!(file_owner.team.name, "b"); + assert_eq!(file_owner.team.github_team, "@b"); + assert!(file_owner.team.path.to_string_lossy().ends_with("config/teams/b.yml")); +} + +#[test] +fn test_teams_for_files_from_codeowners() { + let project_root = Path::new("tests/fixtures/valid_project"); + let file_paths = [ + "javascript/packages/items/item.ts", + "config/teams/payroll.yml", + "ruby/app/models/bank_account.rb", + "made/up/file.rb", + "ruby/ignored_files/git_ignored.rb", + ]; + let run_config = RunConfig { + project_root: project_root.to_path_buf(), + codeowners_file_path: project_root.join(".github/CODEOWNERS").to_path_buf(), + config_path: project_root.join("config/code_ownership.yml").to_path_buf(), + no_cache: true, + }; + let teams = runner::teams_for_files_from_codeowners( + &run_config, + &file_paths.iter().map(|s| s.to_string()).collect::>(), + ) + .unwrap(); + assert_eq!(teams.len(), 5); + assert_eq!( + teams + .get("javascript/packages/items/item.ts") + .unwrap() + .as_ref() + .map(|t| t.name.as_str()), + Some("Payroll") + ); + assert_eq!( + teams + .get("config/teams/payroll.yml") + .unwrap() + .as_ref() + .map(|t| t.name.as_str()), + Some("Payroll") + ); + assert_eq!( + teams + .get("ruby/app/models/bank_account.rb") + .unwrap() + .as_ref() + .map(|t| t.name.as_str()), + Some("Payments") + ); + assert_eq!(teams.get("made/up/file.rb").unwrap().as_ref().map(|t| t.name.as_str()), None); + assert_eq!( + teams + .get("ruby/ignored_files/git_ignored.rb") + .unwrap() + .as_ref() + .map(|t| t.name.as_str()), + None + ); +} + +#[test] +fn test_for_team_reads_codeowners() { + let td = tempfile::tempdir().unwrap(); + // minimal config + const DEFAULT_CODE_OWNERSHIP_YML: &str = r#"--- +owned_globs: + - "app/**/*" +unowned_globs: + - config/code_ownership.yml +team_file_glob: + - config/teams/**/*.yml +vendored_gems_path: gems +javascript_package_paths: + - javascript/packages/** +"#; + write_file(td.path(), "config/code_ownership.yml", DEFAULT_CODE_OWNERSHIP_YML); + + // team file for Foo + write_file( + td.path(), + "config/teams/foo.yml", + "name: Foo\ngithub:\n team: \"@Foo\"\n members:\n - user\n", + ); + // provide a CODEOWNERS file referencing @Foo + write_file(td.path(), ".github/CODEOWNERS", "/app/** @Foo\n"); + + let rc = RunConfig { + project_root: td.path().to_path_buf(), + codeowners_file_path: td.path().join(".github/CODEOWNERS"), + config_path: td.path().join("config/code_ownership.yml"), + no_cache: true, + }; + + // Ensure CODEOWNERS file matches generator output to avoid out-of-date errors + let _ = runner::generate(&rc, false); + let res = runner::for_team(&rc, "Foo"); + assert!(res.io_errors.is_empty(), "unexpected io errors: {:?}", res.io_errors); + assert!(res.validation_errors.is_empty()); + assert!(res.info_messages.iter().any(|m| m.contains("# Code Ownership Report for `Foo` Team"))); +} + +#[test] +fn test_validate_and_generate_and_validate() { + let td = tempfile::tempdir().unwrap(); + // config and team so generation has inputs + const DEFAULT_CODE_OWNERSHIP_YML: &str = r#"--- +owned_globs: + - "**/*" +team_file_glob: + - config/teams/**/*.yml +vendored_gems_path: gems +javascript_package_paths: + - javascript/packages/** +"#; + write_file(td.path(), "config/code_ownership.yml", DEFAULT_CODE_OWNERSHIP_YML); + write_file( + td.path(), + "config/teams/foo.yml", + "name: Foo\ngithub:\n team: \"@Foo\"\n members:\n - user\nowned_globs:\n - \"app/**\"\n - \"config/code_ownership.yml\"\n", + ); + // create a file to be matched (no annotation to avoid multi-source ownership) + write_file(td.path(), "app/x.rb", "puts :x\n"); + + let rc = RunConfig { + project_root: td.path().to_path_buf(), + codeowners_file_path: td.path().join(".github/CODEOWNERS"), + config_path: td.path().join("config/code_ownership.yml"), + no_cache: true, + }; + + let gv = runner::generate_and_validate(&rc, vec![], true); + assert!(gv.io_errors.is_empty(), "io: {:?}", gv.io_errors); + assert!(gv.validation_errors.is_empty(), "val: {:?}", gv.validation_errors); + // file should exist after generate + let content = std::fs::read_to_string(td.path().join(".github/CODEOWNERS")).unwrap(); + assert!(!content.is_empty()); +} + + From 13bae8c28efc84c609344b4d38c59134d15d4053 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Thu, 21 Aug 2025 21:12:42 -0500 Subject: [PATCH 07/13] bumping version --- Cargo.lock | 2 +- Cargo.toml | 2 +- tests/runner_api.rs | 26 +++++++++----------------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c1317d..57a82a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,7 +179,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "codeowners" -version = "0.2.15" +version = "0.2.16" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index 7c3c47c..a11c487 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeowners" -version = "0.2.15" +version = "0.2.16" edition = "2024" [profile.release] diff --git a/tests/runner_api.rs b/tests/runner_api.rs index 8f670f3..1d1f9f6 100644 --- a/tests/runner_api.rs +++ b/tests/runner_api.rs @@ -24,10 +24,7 @@ team_file_glob: "#; write_file(temp_dir.path(), "config/code_ownership.yml", DEFAULT_CODE_OWNERSHIP_YML); ["a", "b", "c"].iter().for_each(|name| { - let team_yml = format!( - "name: {}\ngithub:\n team: \"@{}\"\n members:\n - {}member\n", - name, name, name - ); + let team_yml = format!("name: {}\ngithub:\n team: \"@{}\"\n members:\n - {}member\n", name, name, name); write_file(temp_dir.path(), &format!("config/teams/{}.yml", name), &team_yml); }); write_file( @@ -67,11 +64,8 @@ fn test_teams_for_files_from_codeowners() { config_path: project_root.join("config/code_ownership.yml").to_path_buf(), no_cache: true, }; - let teams = runner::teams_for_files_from_codeowners( - &run_config, - &file_paths.iter().map(|s| s.to_string()).collect::>(), - ) - .unwrap(); + let teams = + runner::teams_for_files_from_codeowners(&run_config, &file_paths.iter().map(|s| s.to_string()).collect::>()).unwrap(); assert_eq!(teams.len(), 5); assert_eq!( teams @@ -82,11 +76,7 @@ fn test_teams_for_files_from_codeowners() { Some("Payroll") ); assert_eq!( - teams - .get("config/teams/payroll.yml") - .unwrap() - .as_ref() - .map(|t| t.name.as_str()), + teams.get("config/teams/payroll.yml").unwrap().as_ref().map(|t| t.name.as_str()), Some("Payroll") ); assert_eq!( @@ -146,7 +136,11 @@ javascript_package_paths: let res = runner::for_team(&rc, "Foo"); assert!(res.io_errors.is_empty(), "unexpected io errors: {:?}", res.io_errors); assert!(res.validation_errors.is_empty()); - assert!(res.info_messages.iter().any(|m| m.contains("# Code Ownership Report for `Foo` Team"))); + assert!( + res.info_messages + .iter() + .any(|m| m.contains("# Code Ownership Report for `Foo` Team")) + ); } #[test] @@ -185,5 +179,3 @@ javascript_package_paths: let content = std::fs::read_to_string(td.path().join(".github/CODEOWNERS")).unwrap(); assert!(!content.is_empty()); } - - From 3d3edf0d1c4b13d0df29d2810856e918f6132b9a Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Thu, 21 Aug 2025 21:30:10 -0500 Subject: [PATCH 08/13] DRY --- src/crosscheck.rs | 2 +- src/lib.rs | 1 + src/ownership.rs | 2 +- src/ownership/codeowners_query.rs | 4 +- ...or_file_fast.rs => file_owner_resolver.rs} | 18 ++----- src/path_utils.rs | 49 +++++++++++++++++++ src/runner.rs | 7 +-- src/runner/api.rs | 4 +- 8 files changed, 62 insertions(+), 25 deletions(-) rename src/ownership/{for_file_fast.rs => file_owner_resolver.rs} (96%) create mode 100644 src/path_utils.rs diff --git a/src/crosscheck.rs b/src/crosscheck.rs index 02eba9b..b8e9405 100644 --- a/src/crosscheck.rs +++ b/src/crosscheck.rs @@ -3,7 +3,7 @@ use std::path::Path; use crate::{ cache::Cache, config::Config, - ownership::for_file_fast::find_file_owners, + ownership::file_owner_resolver::find_file_owners, project::Project, project_builder::ProjectBuilder, runner::{RunConfig, RunResult, config_from_path, team_for_file_from_codeowners}, diff --git a/src/lib.rs b/src/lib.rs index e5e2cec..13d53a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub(crate) mod common_test; pub mod config; pub mod crosscheck; pub mod ownership; +pub mod path_utils; pub(crate) mod project; pub mod project_builder; pub mod project_file_builder; diff --git a/src/ownership.rs b/src/ownership.rs index 5641dfc..8f109ef 100644 --- a/src/ownership.rs +++ b/src/ownership.rs @@ -13,7 +13,7 @@ pub(crate) mod codeowners_file_parser; pub(crate) mod codeowners_query; mod file_generator; mod file_owner_finder; -pub mod for_file_fast; +pub mod file_owner_resolver; pub(crate) mod mapper; mod validator; diff --git a/src/ownership/codeowners_query.rs b/src/ownership/codeowners_query.rs index b36f7d0..70846e4 100644 --- a/src/ownership/codeowners_query.rs +++ b/src/ownership/codeowners_query.rs @@ -11,7 +11,7 @@ pub(crate) fn team_for_file_from_codeowners( file_path: &Path, ) -> Result, String> { let relative_file_path = if file_path.is_absolute() { - file_path.strip_prefix(project_root).unwrap_or(file_path).to_path_buf() + crate::path_utils::relative_to_buf(project_root, file_path) } else { PathBuf::from(file_path) }; @@ -36,7 +36,7 @@ pub(crate) fn teams_for_files_from_codeowners( .map(Path::new) .map(|path| { if path.is_absolute() { - path.strip_prefix(project_root).unwrap_or(path).to_path_buf() + crate::path_utils::relative_to_buf(project_root, path) } else { path.to_path_buf() } diff --git a/src/ownership/for_file_fast.rs b/src/ownership/file_owner_resolver.rs similarity index 96% rename from src/ownership/for_file_fast.rs rename to src/ownership/file_owner_resolver.rs index c5fe908..fad1f69 100644 --- a/src/ownership/for_file_fast.rs +++ b/src/ownership/file_owner_resolver.rs @@ -17,10 +17,7 @@ pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path) } else { project_root.join(file_path) }; - let relative_file_path = absolute_file_path - .strip_prefix(project_root) - .unwrap_or(&absolute_file_path) - .to_path_buf(); + let relative_file_path = crate::path_utils::relative_to_buf(project_root, &absolute_file_path); let teams = load_teams(project_root, &config.team_file_glob)?; let teams_by_name = build_teams_by_name_map(&teams); @@ -68,7 +65,7 @@ pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path) } for team in &teams { - let team_rel = team.path.strip_prefix(project_root).unwrap_or(&team.path).to_path_buf(); + let team_rel = crate::path_utils::relative_to_buf(project_root, &team.path); if team_rel == relative_file_path { sources_by_team.entry(team.name.clone()).or_default().push(Source::TeamYml); } @@ -77,10 +74,7 @@ pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path) let mut file_owners: Vec = Vec::new(); for (team_name, sources) in sources_by_team.into_iter() { if let Some(team) = teams_by_name.get(&team_name) { - let relative_team_yml_path = team - .path - .strip_prefix(project_root) - .unwrap_or(&team.path) + let relative_team_yml_path = crate::path_utils::relative_to(project_root, &team.path) .to_string_lossy() .to_string(); file_owners.push(FileOwner { @@ -157,9 +151,7 @@ fn most_specific_directory_owner( if let Ok(owner_str) = fs::read_to_string(&codeowner_path) { let owner = owner_str.trim(); if let Some(team) = teams_by_name.get(owner) { - let relative_dir = current - .strip_prefix(project_root) - .unwrap_or(current.as_path()) + let relative_dir = crate::path_utils::relative_to(project_root, current.as_path()) .to_string_lossy() .to_string(); let candidate = (team.name.clone(), Source::Directory(relative_dir)); @@ -191,7 +183,7 @@ fn nearest_package_owner( if !current.pop() { break; } - let parent_rel = current.strip_prefix(project_root).unwrap_or(current.as_path()); + let parent_rel = crate::path_utils::relative_to(project_root, current.as_path()); 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"); diff --git a/src/path_utils.rs b/src/path_utils.rs new file mode 100644 index 0000000..230b1d1 --- /dev/null +++ b/src/path_utils.rs @@ -0,0 +1,49 @@ +use std::path::{Path, PathBuf}; + +/// Return `path` relative to `root` if possible; otherwise return `path` unchanged. +pub fn relative_to<'a>(root: &'a Path, path: &'a Path) -> &'a Path { + path.strip_prefix(root).unwrap_or(path) +} + +/// Like `relative_to`, but returns an owned `PathBuf`. +pub fn relative_to_buf(root: &Path, path: &Path) -> PathBuf { + relative_to(root, path).to_path_buf() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_to_returns_relative_when_under_root() { + let root = Path::new("/a/b"); + let path = Path::new("/a/b/c/d.txt"); + let rel = relative_to(root, path); + assert_eq!(rel, Path::new("c/d.txt")); + } + + #[test] + fn relative_to_returns_input_when_not_under_root() { + let root = Path::new("/a/b"); + let path = Path::new("/x/y/z.txt"); + let rel = relative_to(root, path); + assert_eq!(rel, path); + } + + #[test] + fn relative_to_handles_equal_paths() { + let root = Path::new("/a/b"); + let path = Path::new("/a/b"); + let rel = relative_to(root, path); + assert_eq!(rel, Path::new("")); + } + + #[test] + fn relative_to_buf_matches_relative_to() { + let root = Path::new("/proj"); + let path = Path::new("/proj/src/lib.rs"); + let rel_ref = relative_to(root, path); + let rel_buf = relative_to_buf(root, path); + assert_eq!(rel_ref, rel_buf.as_path()); + } +} diff --git a/src/runner.rs b/src/runner.rs index 00e7f54..0aef0c3 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -176,7 +176,7 @@ impl Runner { } pub fn owners_for_file(&self, file_path: &str) -> Result, Error> { - use crate::ownership::for_file_fast::find_file_owners; + use crate::ownership::file_owner_resolver::find_file_owners; let owners = find_file_owners(&self.run_config.project_root, &self.config, std::path::Path::new(file_path)).map_err(Error::Io)?; Ok(owners) } @@ -215,10 +215,7 @@ impl Runner { pub fn for_file_codeowners_only(&self, file_path: &str) -> RunResult { match team_for_file_from_codeowners(&self.run_config, file_path) { Ok(Some(team)) => { - let relative_team_path = team - .path - .strip_prefix(&self.run_config.project_root) - .unwrap_or(team.path.as_path()) + let relative_team_path = crate::path_utils::relative_to(&self.run_config.project_root, team.path.as_path()) .to_string_lossy() .to_string(); RunResult { diff --git a/src/runner/api.rs b/src/runner/api.rs index 556181d..4a05b0b 100644 --- a/src/runner/api.rs +++ b/src/runner/api.rs @@ -68,9 +68,7 @@ pub fn teams_for_files_from_codeowners( } pub fn team_for_file_from_codeowners(run_config: &RunConfig, file_path: &str) -> error_stack::Result, Error> { - let relative_file_path = Path::new(file_path) - .strip_prefix(&run_config.project_root) - .unwrap_or(Path::new(file_path)); + let relative_file_path = crate::path_utils::relative_to(&run_config.project_root, Path::new(file_path)); let config = config_from_path(&run_config.config_path)?; let res = crate::ownership::codeowners_query::team_for_file_from_codeowners( From 3eac51c99d136aa2e5e14dc33b76c248c3695802 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Thu, 21 Aug 2025 21:53:08 -0500 Subject: [PATCH 09/13] updates to readme --- README.md | 180 +++++++++++++++++++++++------------- src/project_file_builder.rs | 63 +++++-------- src/runner/api.rs | 1 + 3 files changed, 140 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 3e5755c..8e0b880 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ # Codeowners -**Codeowners** is a fast, Rust-based CLI for generating and validating [GitHub `CODEOWNERS` files](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners) in large repositories. +**Codeowners** is a fast, Rust-based CLI for generating and validating [GitHub `CODEOWNERS` files](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners) in large repositories. -Note: For Ruby application, it's usually easier to use `codeowners-rs` via the [code_ownership](https://github.com/rubyatscale/code_ownership) gem. +Note: For Ruby applications, it's usually easier to use `codeowners-rs` via the [code_ownership](https://github.com/rubyatscale/code_ownership) gem. ## 🚀 Quick Start: Generate & Validate -The most common workflow is to **generate and validate your CODEOWNERS file** in a single step: +The most common workflow is to generate and validate in one step: ```sh codeowners gv ``` -- This command will: - - Generate a fresh `CODEOWNERS` file (by default at `.github/CODEOWNERS`) - - Validate that all files are properly owned and that the file is up to date - - Exit with a nonzero code and detailed errors if validation fails +- Generates a fresh `CODEOWNERS` file (default: `.github/CODEOWNERS`) +- Validates ownership and that the file is up to date +- Exits non-zero and prints detailed errors if validation fails ## Table of Contents -- [Quick Start: Generate & Validate](#-quick-start-generate--validate) +- [Quick Start: Generate & Validate](#quick-start-generate--validate) +- [Installation](#installation) - [Getting Started](#getting-started) - [Declaring Ownership](#declaring-ownership) - [Directory-Based Ownership](#1-directory-based-ownership) @@ -27,25 +27,37 @@ codeowners gv - [Package-Based Ownership](#3-package-based-ownership) - [Glob-Based Ownership](#4-glob-based-ownership) - [JavaScript Package Ownership](#5-javascript-package-ownership) -- [Other CLI Commands](#other-cli-commands) +- [CLI Reference](#cli-reference) + - [Global Flags](#global-flags) + - [Commands](#commands) - [Examples](#examples) - - [Find the owner of a file](#find-the-owner-of-a-file) - - [Ownership report for a team](#ownership-report-for-a-team) +- [Configuration](#configuration) +- [Cache](#cache) - [Validation](#validation) +- [Library Usage](#library-usage) - [Development](#development) -- [Configuration](#configuration) -## Getting Started +## Installation + +You can run `codeowners` without installing a platform-specific binary by using DotSlash, or install from source with Cargo. + +### Option A: DotSlash (recommended) -1. **Install DotSlash** - [Install DotSlash](https://dotslash-cli.com/docs/installation/) - Releases include a DotSlash text file that will automatically download and run the correct binary for your system. +1. Install DotSlash: see [https://dotslash-cli.com/docs/installation/](https://dotslash-cli.com/docs/installation/) +2. Download the latest DotSlash text file from a release, for example [https://github.com/rubyatscale/codeowners-rs/releases](https://github.com/rubyatscale/codeowners-rs/releases). +3. Execute the downloaded file with DotSlash; it will fetch and run the correct binary. -2. **Download the Latest DotSlash Text File** - Releases contain a DotSlash text file. Example: [codeowners release v0.2.4](https://github.com/rubyatscale/codeowners-rs/releases/download/v0.2.4/codeowners). - Running this file with DotSlash installed will execute `codeowners`. +### Option B: From source with Cargo + +Requires Rust toolchain. + +```sh +cargo install --git https://github.com/rubyatscale/codeowners-rs codeowners +``` -3. **Configure Ownership** +## Getting Started + +1. **Configure Ownership** Create a `config/code_ownership.yml` file. Example: ```yaml @@ -58,7 +70,7 @@ codeowners gv - frontend/javascripts/**/__generated__/**/* ``` -4. **Declare Teams** +2. **Declare Teams** Example: `config/teams/operations.yml` ```yaml @@ -67,7 +79,7 @@ codeowners gv team: '@my-org/operations-team' ``` -5. **Run the Main Workflow** +3. **Run the Main Workflow** ```sh codeowners gv @@ -92,7 +104,15 @@ Add an annotation at the top of a file: ```ruby # @team MyTeam ``` - +```typescript +// @team MyTeam +``` +```html + +``` +```erb +<%# @team: Foo %> +``` ### 3. Package-Based Ownership In `package.yml` (for Ruby Packwerk): @@ -133,29 +153,27 @@ js_package_paths: - frontend/javascripts/packages/* ``` +## CLI Reference -## Other CLI Commands +### Global Flags -While `codeowners gv` is the main workflow, the CLI also supports: - -```text -Usage: codeowners [OPTIONS] +- `--codeowners-file-path `: Path for the CODEOWNERS file. Default: `./.github/CODEOWNERS` +- `--config-path `: Path to `code_ownership.yml`. Default: `./config/code_ownership.yml` +- `--project-root `: Project root. Default: `.` +- `--no-cache`: Disable on-disk caching (useful in CI) +- `-V, --version`, `-h, --help` -Commands: - for-file Finds the owner of a given file. [aliases: f] - for-team Finds code ownership information for a given team [aliases: t] - generate Generate the CODEOWNERS file [aliases: g] - validate Validate the CODEOWNERS file [aliases: v] - generate-and-validate Chains both `generate` and `validate` [aliases: gv] - help Print this message or the help of the given subcommand(s) +### Commands -Options: - --codeowners-file-path [default: ./.github/CODEOWNERS] - --config-path [default: ./config/code_ownership.yml] - --project-root [default: .] - -h, --help - -V, --version -``` +- `generate` (`g`): Generate the CODEOWNERS file and write it to `--codeowners-file-path`. + - Flags: `--skip-stage, -s` to avoid `git add` after writing +- `validate` (`v`): Validate the CODEOWNERS file and configuration. +- `generate-and-validate` (`gv`): Run `generate` then `validate`. + - Flags: `--skip-stage, -s` +- `for-file ` (`f`): Print the owner of a file. + - Flags: `--from-codeowners` to resolve using only the CODEOWNERS rules +- `for-team ` (`t`): Print ownership report for a team. +- `delete-cache` (`d`): Delete the persisted cache. ### Examples @@ -171,37 +189,54 @@ codeowners for-file path/to/file.rb codeowners for-team Payroll ``` -## Validation +#### Generate but do not stage the file -`codeowners validate` (or `codeowners gv`) ensures: +```sh +codeowners generate --skip-stage +``` -1. Only one mechanism defines ownership for any file. -2. All referenced teams are valid. -3. All files in `owned_globs` are owned, unless in `unowned_globs`. -4. The `CODEOWNERS` file is up to date. +#### Run without using the cache -## Development +```sh +codeowners gv --no-cache +``` -- Written in Rust for speed and reliability. -- To build locally, install Rust: +## Configuration - ```sh - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - ``` +`config/code_ownership.yml` keys and defaults: -- Please update `CHANGELOG.md` and this `README.md` when making changes. +- `owned_globs` (required): Glob patterns that must be owned. +- `ruby_package_paths` (default: `['packs/**/*', 'components/**']`) +- `js_package_paths` / `javascript_package_paths` (default: `['frontend/**/*']`) +- `team_file_glob` (default: `['config/teams/**/*.yml']`) +- `unowned_globs` (default: `['frontend/**/node_modules/**/*', 'frontend/**/__generated__/**/*']`) +- `vendored_gems_path` (default: `'vendored/'`) +- `cache_directory` (default: `'tmp/cache/codeowners'`) +- `ignore_dirs` (default includes: `.git`, `node_modules`, `tmp`, etc.) -### Module layout (for library users) +See examples in `tests/fixtures/**/config/` for reference setups. -- `src/runner.rs`: public façade re-exporting the API and types. -- `src/runner/api.rs`: externally available functions used by the CLI and other crates. -- `src/runner/types.rs`: `RunConfig`, `RunResult`, and runner `Error`. -- `src/ownership/`: all ownership logic (parsing, mapping, validation, generation). -- `src/ownership/codeowners_query.rs`: CODEOWNERS-only queries consumed by the façade. +## Cache -Import public APIs from `codeowners::runner::*`. +By default, cache is stored under `tmp/cache/codeowners` relative to the project root. This speeds up repeated runs. -### Library usage example +- Disable cache for a run: add the global flag `--no-cache` +- Clear all cache: `codeowners delete-cache` + +## Validation + +`codeowners validate` (or `codeowners gv`) ensures: + +1. Only one mechanism defines ownership for any file. +2. All referenced teams are valid. +3. All files in `owned_globs` are owned, unless matched by `unowned_globs`. +4. The generated `CODEOWNERS` file is up to date. + +Exit status is non-zero on errors. + +## Library Usage + +Import public APIs from `codeowners::runner::*`. ```rust use codeowners::runner::{RunConfig, for_file, teams_for_files_from_codeowners}; @@ -231,3 +266,22 @@ fn main() { } } ``` + +## Development + +- Written in Rust for speed and reliability. +- To build locally, install Rust: + + ```sh + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + ``` + +- Please update `CHANGELOG.md` and this `README.md` when making changes. + +### Module layout + +- `src/runner.rs`: public façade re-exporting the API and types. +- `src/runner/api.rs`: externally available functions used by the CLI and other crates. +- `src/runner/types.rs`: `RunConfig`, `RunResult`, and runner `Error`. +- `src/ownership/`: all ownership logic (parsing, mapping, validation, generation). +- `src/ownership/codeowners_query.rs`: CODEOWNERS-only queries consumed by the façade. diff --git a/src/project_file_builder.rs b/src/project_file_builder.rs index fc69cc3..712f8fb 100644 --- a/src/project_file_builder.rs +++ b/src/project_file_builder.rs @@ -77,50 +77,31 @@ pub(crate) fn build_project_file_without_cache(path: &PathBuf) -> ProjectFile { #[cfg(test)] mod tests { + use std::collections::HashMap; + use super::*; + type FirstLine = &'static str; + type Owner = &'static str; #[test] fn test_team_regex() { - let owner = TEAM_REGEX - .captures("// @team Foo") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("// @team Foo") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("// @team: Foo") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("# @team: Foo") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("<%# @team: Foo %>") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); - - let owner = TEAM_REGEX - .captures("") - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()); - assert_eq!(owner, Some("Foo".to_string())); + let mut map: HashMap = HashMap::new(); + map.insert("// @team Foo", "Foo"); + map.insert("// @team Foo Bar", "Foo Bar"); + map.insert("// @team Zoo", "Zoo"); + map.insert("// @team: Zoo Foo", "Zoo Foo"); + map.insert("# @team: Bap", "Bap"); + map.insert("# @team: Bap Hap", "Bap Hap"); + map.insert("", "Zoink"); + map.insert("", "Zoink Err"); + map.insert("<%# @team: Zap %>", "Zap"); + map.insert("<%# @team: Zap Zip%>", "Zap Zip"); + map.insert("", "Blast"); + map.insert("", "Blast Off"); + + for (key, value) in map { + let owner = TEAM_REGEX.captures(key).and_then(|cap| cap.get(1)).map(|m| m.as_str()); + assert_eq!(owner, Some(value)); + } } } diff --git a/src/runner/api.rs b/src/runner/api.rs index 4a05b0b..ea5fcaa 100644 --- a/src/runner/api.rs +++ b/src/runner/api.rs @@ -40,6 +40,7 @@ pub fn crosscheck_owners(run_config: &RunConfig) -> RunResult { run(run_config, |runner| runner.crosscheck_owners()) } +// Returns the highest priority owner for a file. More to come here. pub fn file_owner_for_file(run_config: &RunConfig, file_path: &str) -> error_stack::Result, Error> { let runner = Runner::new(run_config)?; let owners = runner.owners_for_file(file_path)?; From 332f21c58d8b4514705d3b04cc66343f65a4812c Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Fri, 22 Aug 2025 06:15:12 -0500 Subject: [PATCH 10/13] rename to annotated file mapper --- src/ownership/mapper.rs | 4 ++-- .../mapper/{team_file_mapper.rs => annotated_file_mapper.rs} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src/ownership/mapper/{team_file_mapper.rs => annotated_file_mapper.rs} (100%) diff --git a/src/ownership/mapper.rs b/src/ownership/mapper.rs index b36a492..d04c4ee 100644 --- a/src/ownership/mapper.rs +++ b/src/ownership/mapper.rs @@ -5,18 +5,18 @@ use std::{ path::{Path, PathBuf}, }; +mod annotated_file_mapper; pub(crate) mod directory_mapper; mod escaper; mod package_mapper; -mod team_file_mapper; mod team_gem_mapper; mod team_glob_mapper; mod team_yml_mapper; +pub use annotated_file_mapper::TeamFileMapper; pub use directory_mapper::DirectoryMapper; pub use package_mapper::JavascriptPackageMapper; pub use package_mapper::RubyPackageMapper; -pub use team_file_mapper::TeamFileMapper; pub use team_gem_mapper::TeamGemMapper; pub use team_glob_mapper::TeamGlobMapper; pub use team_yml_mapper::TeamYmlMapper; diff --git a/src/ownership/mapper/team_file_mapper.rs b/src/ownership/mapper/annotated_file_mapper.rs similarity index 100% rename from src/ownership/mapper/team_file_mapper.rs rename to src/ownership/mapper/annotated_file_mapper.rs From 0622e67aec072bba931dd5ead71ba0860a3e512c Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Fri, 22 Aug 2025 06:40:53 -0500 Subject: [PATCH 11/13] js project example --- .../javascript_only_project/.github/CODEOWNERS | 8 ++++++++ tests/fixtures/javascript_only_project/.keep | 2 ++ .../javascript_only_project/config/code_ownership.yml | 11 +++++++++++ .../javascript_only_project/config/teams/design.yml | 6 ++++++ .../javascript_only_project/config/teams/frontend.yml | 5 +++++ .../frontend/apps/public/index.tsx | 5 +++++ .../frontend/packages/dashboard/package.json | 8 ++++++++ .../frontend/packages/dashboard/src/index.tsx | 5 +++++ .../frontend/packages/ui-kit/.codeowner | 2 ++ .../frontend/packages/ui-kit/src/button.tsx | 4 ++++ 10 files changed, 56 insertions(+) create mode 100644 tests/fixtures/javascript_only_project/.github/CODEOWNERS create mode 100644 tests/fixtures/javascript_only_project/.keep create mode 100644 tests/fixtures/javascript_only_project/config/code_ownership.yml create mode 100644 tests/fixtures/javascript_only_project/config/teams/design.yml create mode 100644 tests/fixtures/javascript_only_project/config/teams/frontend.yml create mode 100644 tests/fixtures/javascript_only_project/frontend/apps/public/index.tsx create mode 100644 tests/fixtures/javascript_only_project/frontend/packages/dashboard/package.json create mode 100644 tests/fixtures/javascript_only_project/frontend/packages/dashboard/src/index.tsx create mode 100644 tests/fixtures/javascript_only_project/frontend/packages/ui-kit/.codeowner create mode 100644 tests/fixtures/javascript_only_project/frontend/packages/ui-kit/src/button.tsx diff --git a/tests/fixtures/javascript_only_project/.github/CODEOWNERS b/tests/fixtures/javascript_only_project/.github/CODEOWNERS new file mode 100644 index 0000000..f8fe621 --- /dev/null +++ b/tests/fixtures/javascript_only_project/.github/CODEOWNERS @@ -0,0 +1,8 @@ +# 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 + diff --git a/tests/fixtures/javascript_only_project/.keep b/tests/fixtures/javascript_only_project/.keep new file mode 100644 index 0000000..8cb7cb9 --- /dev/null +++ b/tests/fixtures/javascript_only_project/.keep @@ -0,0 +1,2 @@ + + diff --git a/tests/fixtures/javascript_only_project/config/code_ownership.yml b/tests/fixtures/javascript_only_project/config/code_ownership.yml new file mode 100644 index 0000000..21591b5 --- /dev/null +++ b/tests/fixtures/javascript_only_project/config/code_ownership.yml @@ -0,0 +1,11 @@ +owned_globs: + - "{config,frontend}/**/*.{js,jsx,ts,tsx,json,yml}" +javascript_package_paths: + - frontend/packages/** +team_file_glob: + - config/teams/**/*.yml +unbuilt_gems_path: gems +unowned_globs: + - frontend/**/node_modules/**/* + - frontend/**/__generated__/**/* + diff --git a/tests/fixtures/javascript_only_project/config/teams/design.yml b/tests/fixtures/javascript_only_project/config/teams/design.yml new file mode 100644 index 0000000..419e727 --- /dev/null +++ b/tests/fixtures/javascript_only_project/config/teams/design.yml @@ -0,0 +1,6 @@ +name: Design +github: + team: '@DesignTeam' +owned_globs: + - frontend/apps/public/**/* + diff --git a/tests/fixtures/javascript_only_project/config/teams/frontend.yml b/tests/fixtures/javascript_only_project/config/teams/frontend.yml new file mode 100644 index 0000000..b2adb17 --- /dev/null +++ b/tests/fixtures/javascript_only_project/config/teams/frontend.yml @@ -0,0 +1,5 @@ +name: Frontend +github: + team: '@FrontendTeam' +owned_globs: [] + diff --git a/tests/fixtures/javascript_only_project/frontend/apps/public/index.tsx b/tests/fixtures/javascript_only_project/frontend/apps/public/index.tsx new file mode 100644 index 0000000..6b058f6 --- /dev/null +++ b/tests/fixtures/javascript_only_project/frontend/apps/public/index.tsx @@ -0,0 +1,5 @@ +// @team: Design +export default function Public() { + return
Public
; +} + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/dashboard/package.json b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/package.json new file mode 100644 index 0000000..055d462 --- /dev/null +++ b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/package.json @@ -0,0 +1,8 @@ +{ + "name": "@acme/dashboard", + "version": "1.0.0", + "metadata": { + "owner": "Frontend" + } +} + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/dashboard/src/index.tsx b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/src/index.tsx new file mode 100644 index 0000000..46a0f31 --- /dev/null +++ b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/src/index.tsx @@ -0,0 +1,5 @@ +// @team Frontend +export const App = () => { + return
Dashboard
; +}; + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/.codeowner b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/.codeowner new file mode 100644 index 0000000..8f15af1 --- /dev/null +++ b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/.codeowner @@ -0,0 +1,2 @@ +Design + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/src/button.tsx b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/src/button.tsx new file mode 100644 index 0000000..834f3cf --- /dev/null +++ b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/src/button.tsx @@ -0,0 +1,4 @@ +export const Button = () => { + return ; +}; + From 8aef846a39464fb5726c4739515fcb99d15904d3 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Fri, 22 Aug 2025 06:41:25 -0500 Subject: [PATCH 12/13] js example --- src/ownership/file_owner_resolver.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ownership/file_owner_resolver.rs b/src/ownership/file_owner_resolver.rs index fad1f69..cac884a 100644 --- a/src/ownership/file_owner_resolver.rs +++ b/src/ownership/file_owner_resolver.rs @@ -125,8 +125,6 @@ fn load_teams(project_root: &Path, team_file_globs: &[String]) -> std::result::R Ok(teams) } -// no regex: parse cheaply with ASCII-aware checks - fn read_top_of_file_team(path: &Path) -> Option { let project_file = build_project_file_without_cache(&path.to_path_buf()); if let Some(owner) = project_file.owner { From 880de35c78a65b45a0c9af184d49f568d2bc2a47 Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Fri, 22 Aug 2025 07:06:45 -0500 Subject: [PATCH 13/13] js example --- tests/fixtures/javascript_only_project/.github/CODEOWNERS | 1 + tests/fixtures/javascript_only_project/.keep | 1 + tests/fixtures/javascript_only_project/config/code_ownership.yml | 1 + tests/fixtures/javascript_only_project/config/teams/design.yml | 1 + tests/fixtures/javascript_only_project/config/teams/frontend.yml | 1 + .../javascript_only_project/frontend/apps/public/index.tsx | 1 + .../frontend/packages/dashboard/package.json | 1 + .../frontend/packages/dashboard/src/index.tsx | 1 + .../javascript_only_project/frontend/packages/ui-kit/.codeowner | 1 + .../frontend/packages/ui-kit/src/button.tsx | 1 + 10 files changed, 10 insertions(+) diff --git a/tests/fixtures/javascript_only_project/.github/CODEOWNERS b/tests/fixtures/javascript_only_project/.github/CODEOWNERS index f8fe621..b589987 100644 --- a/tests/fixtures/javascript_only_project/.github/CODEOWNERS +++ b/tests/fixtures/javascript_only_project/.github/CODEOWNERS @@ -6,3 +6,4 @@ # code/file owner is notified. Reference GitHub docs for more details: # https://help.github.com/en/articles/about-code-owners + diff --git a/tests/fixtures/javascript_only_project/.keep b/tests/fixtures/javascript_only_project/.keep index 8cb7cb9..92371a0 100644 --- a/tests/fixtures/javascript_only_project/.keep +++ b/tests/fixtures/javascript_only_project/.keep @@ -1,2 +1,3 @@ + diff --git a/tests/fixtures/javascript_only_project/config/code_ownership.yml b/tests/fixtures/javascript_only_project/config/code_ownership.yml index 21591b5..50206a3 100644 --- a/tests/fixtures/javascript_only_project/config/code_ownership.yml +++ b/tests/fixtures/javascript_only_project/config/code_ownership.yml @@ -9,3 +9,4 @@ unowned_globs: - frontend/**/node_modules/**/* - frontend/**/__generated__/**/* + diff --git a/tests/fixtures/javascript_only_project/config/teams/design.yml b/tests/fixtures/javascript_only_project/config/teams/design.yml index 419e727..2c745d2 100644 --- a/tests/fixtures/javascript_only_project/config/teams/design.yml +++ b/tests/fixtures/javascript_only_project/config/teams/design.yml @@ -4,3 +4,4 @@ github: owned_globs: - frontend/apps/public/**/* + diff --git a/tests/fixtures/javascript_only_project/config/teams/frontend.yml b/tests/fixtures/javascript_only_project/config/teams/frontend.yml index b2adb17..a6c5bbd 100644 --- a/tests/fixtures/javascript_only_project/config/teams/frontend.yml +++ b/tests/fixtures/javascript_only_project/config/teams/frontend.yml @@ -3,3 +3,4 @@ github: team: '@FrontendTeam' owned_globs: [] + diff --git a/tests/fixtures/javascript_only_project/frontend/apps/public/index.tsx b/tests/fixtures/javascript_only_project/frontend/apps/public/index.tsx index 6b058f6..4977341 100644 --- a/tests/fixtures/javascript_only_project/frontend/apps/public/index.tsx +++ b/tests/fixtures/javascript_only_project/frontend/apps/public/index.tsx @@ -3,3 +3,4 @@ export default function Public() { return
Public
; } + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/dashboard/package.json b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/package.json index 055d462..57863e3 100644 --- a/tests/fixtures/javascript_only_project/frontend/packages/dashboard/package.json +++ b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/package.json @@ -6,3 +6,4 @@ } } + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/dashboard/src/index.tsx b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/src/index.tsx index 46a0f31..9a3792d 100644 --- a/tests/fixtures/javascript_only_project/frontend/packages/dashboard/src/index.tsx +++ b/tests/fixtures/javascript_only_project/frontend/packages/dashboard/src/index.tsx @@ -3,3 +3,4 @@ export const App = () => { return
Dashboard
; }; + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/.codeowner b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/.codeowner index 8f15af1..122a46e 100644 --- a/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/.codeowner +++ b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/.codeowner @@ -1,2 +1,3 @@ Design + diff --git a/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/src/button.tsx b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/src/button.tsx index 834f3cf..be3cdf5 100644 --- a/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/src/button.tsx +++ b/tests/fixtures/javascript_only_project/frontend/packages/ui-kit/src/button.tsx @@ -2,3 +2,4 @@ export const Button = () => { return ; }; +