Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 3 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# 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. It supports conventions for Ruby and JavaScript projects, and is a high-performance reimplementation of the [original Ruby CLI](https://github.com/rubyatscale/code_ownership).
**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.

## 🚀 Quick Start: Generate & Validate

Expand All @@ -15,20 +17,6 @@ codeowners gv
- 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

**Why use this tool?**
On large projects, `codeowners gv` is _over 10x faster_ than the legacy Ruby implementation:

```
$ hyperfine 'codeownership validate' 'codeowners validate'
Benchmark 1: codeownership validate (ruby gem)
Time (mean ± σ): 47.991 s ± 1.220 s
Benchmark 2: codeowners gv (this repo)
Time (mean ± σ): 4.263 s ± 0.025 s

Summary
codeowners gv ran 11.26 ± 0.29 times faster than codeownership validate
```

## Table of Contents

- [Quick Start: Generate & Validate](#-quick-start-generate--validate)
Expand Down
2 changes: 1 addition & 1 deletion src/common_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub mod tests {
}};
}

const DEFAULT_CODE_OWNERSHIP_YML: &str = indoc! {"
pub const DEFAULT_CODE_OWNERSHIP_YML: &str = indoc! {"
---
owned_globs:
- \"{app,components,config,frontend,lib,packs,spec,ruby}/**/*.{rb,rake,js,jsx,ts,tsx,json,yml,erb}\"
Expand Down
4 changes: 2 additions & 2 deletions src/ownership/for_file_fast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path)
&& !is_config_unowned
&& let Some(team) = teams_by_name.get(&team_name)
{
sources_by_team.entry(team.name.clone()).or_default().push(Source::TeamFile);
sources_by_team.entry(team.name.clone()).or_default().push(Source::AnnotatedFile);
}
}
}
Expand Down Expand Up @@ -277,7 +277,7 @@ fn vendored_gem_owner(relative_file_path: &Path, config: &Config, teams: &[Team]
fn source_priority(source: &Source) -> u8 {
match source {
// Highest confidence first
Source::TeamFile => 0,
Source::AnnotatedFile => 0,
Source::Directory(_) => 1,
Source::Package(_, _) => 2,
Source::TeamGlob(_) => 3,
Expand Down
6 changes: 3 additions & 3 deletions src/ownership/mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pub type TeamName = String;
#[derive(Debug, PartialEq, Clone)]
pub enum Source {
Directory(String),
TeamFile,
AnnotatedFile,
TeamGem,
TeamGlob(String),
Package(String, String),
Expand All @@ -44,7 +44,7 @@ impl Display for Source {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Source::Directory(path) => write!(f, "Owner specified in `{}/.codeowner`", path),
Source::TeamFile => write!(f, "Owner annotation at the top of the file"),
Source::AnnotatedFile => write!(f, "Owner annotation at the top of the file"),
Source::TeamGem => write!(f, "Owner specified in Team YML's `owned_gems`"),
Source::TeamGlob(glob) => write!(f, "Owner specified in Team YML as an owned_glob `{}`", glob),
Source::Package(package_path, glob) => {
Expand Down Expand Up @@ -200,7 +200,7 @@ mod tests {
Source::Directory("packs/bam".to_string()).to_string(),
"Owner specified in `packs/bam/.codeowner`"
);
assert_eq!(Source::TeamFile.to_string(), "Owner annotation at the top of the file");
assert_eq!(Source::AnnotatedFile.to_string(), "Owner annotation at the top of the file");
assert_eq!(Source::TeamGem.to_string(), "Owner specified in Team YML's `owned_gems`");
assert_eq!(
Source::TeamGlob("a/glob/**".to_string()).to_string(),
Expand Down
4 changes: 2 additions & 2 deletions src/ownership/mapper/team_file_mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ impl Mapper for TeamFileMapper {
}
}

vec![OwnerMatcher::ExactMatches(path_to_team, Source::TeamFile)]
vec![OwnerMatcher::ExactMatches(path_to_team, Source::AnnotatedFile)]
}

fn name(&self) -> String {
Expand Down Expand Up @@ -150,7 +150,7 @@ mod tests {
(PathBuf::from("ruby/app/views/foos/show.html.erb"), "Bar".to_owned()),
(PathBuf::from("ruby/app/views/foos/_row.html.erb"), "Bam".to_owned()),
]),
Source::TeamFile,
Source::AnnotatedFile,
)];
assert_eq!(owner_matchers, expected_owner_matchers);
Ok(())
Expand Down
221 changes: 137 additions & 84 deletions src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,96 +43,17 @@ pub fn for_file(run_config: &RunConfig, file_path: &str, from_codeowners: bool)
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<Option<Team>, Error> {
let config = config_from_path(&run_config.config_path)?;
let relative_file_path = Path::new(file_path)
.strip_prefix(&run_config.project_root)
.unwrap_or(Path::new(file_path));

let parser = crate::ownership::parser::Parser {
project_root: run_config.project_root.clone(),
codeowners_file_path: run_config.codeowners_file_path.clone(),
team_file_globs: config.team_file_glob.clone(),
};
Ok(parser
.team_from_file_path(Path::new(relative_file_path))
.map_err(|e| Error::Io(e.to_string()))?)
}

pub fn team_for_file(run_config: &RunConfig, file_path: &str) -> Result<Option<Team>, Error> {
pub fn file_owners_for_file(run_config: &RunConfig, file_path: &str) -> Result<Vec<FileOwner>, 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().map(|fo| fo.team.clone()))
Ok(owners)
}

// (imports below intentionally trimmed after refactor)

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()
};
}
};

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<String> = 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()
}
pub fn team_for_file(run_config: &RunConfig, file_path: &str) -> Result<Option<Team>, Error> {
let owners = file_owners_for_file(run_config, file_path)?;
Ok(owners.first().map(|fo| fo.team.clone()))
}

pub fn version() -> String {
Expand Down Expand Up @@ -336,12 +257,144 @@ impl Runner {
}
}

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<Option<Team>, Error> {
let config = config_from_path(&run_config.config_path)?;
let relative_file_path = Path::new(file_path)
.strip_prefix(&run_config.project_root)
.unwrap_or(Path::new(file_path));

let parser = crate::ownership::parser::Parser {
project_root: run_config.project_root.clone(),
codeowners_file_path: run_config.codeowners_file_path.clone(),
team_file_globs: config.team_file_glob.clone(),
};
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()
};
}
};

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<String> = 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()
}
}

#[cfg(test)]
mod tests {
use tempfile::tempdir;

use crate::{common_test, ownership::mapper::Source};

use super::*;

#[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_owners = file_owners_for_file(&run_config, "app/consumers/deep/nesting/nestdir/deep_file.rb").unwrap();
assert_eq!(file_owners.len(), 1);
assert_eq!(file_owners[0].team.name, "b");
assert_eq!(file_owners[0].team.github_team, "@b");
assert!(file_owners[0].team.path.to_string_lossy().ends_with("config/teams/b.yml"));
assert_eq!(file_owners[0].sources.len(), 1);
assert_eq!(file_owners[0].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"));
}
}