diff --git a/Cargo.lock b/Cargo.lock index 308f1ba..92ef2d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -162,7 +162,7 @@ checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" [[package]] name = "codeowners" -version = "0.1.5" +version = "0.2.0" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index c4c423e..1f57b29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeowners" -version = "0.1.5" +version = "0.2.0" edition = "2021" [profile.release] diff --git a/src/main.rs b/src/main.rs index ad5a365..de53aa6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,9 @@ enum Command { #[clap(about = "Finds the owner of a given file.", visible_alias = "f")] ForFile { name: String }, + #[clap(about = "Finds code ownership information for a given team ", visible_alias = "t")] + ForTeam { name: String }, + #[clap( about = "Generate the CODEOWNERS file and save it to '--codeowners-file-path'.", visible_alias = "g" @@ -135,6 +138,19 @@ fn cli() -> Result<(), Error> { } } } + Command::ForTeam { name } => match ownership.for_team(&name) { + Ok(team_ownerships) => { + println!("# Code Ownership Report for `{}` Team", name); + for team_ownership in team_ownerships { + println!("\n#{}", team_ownership.heading); + match team_ownership.globs.len() { + 0 => println!("This team owns nothing in this category."), + _ => println!("{}", team_ownership.globs.join("\n")), + } + } + } + Err(err) => println!("{}", err), + }, } Ok(()) diff --git a/src/ownership.rs b/src/ownership.rs index eb6e9c1..c4d093a 100644 --- a/src/ownership.rs +++ b/src/ownership.rs @@ -1,6 +1,7 @@ use file_owner_finder::FileOwnerFinder; use mapper::{OwnerMatcher, Source, TeamName}; use std::{ + error::Error, fmt::{self, Display}, path::Path, sync::Arc, @@ -32,6 +33,21 @@ pub struct FileOwner { pub sources: Vec, } +#[derive(Debug, Default, Clone, PartialEq)] +pub struct TeamOwnership { + pub heading: String, + pub globs: Vec, +} + +impl TeamOwnership { + fn new(heading: String) -> Self { + Self { + heading, + ..Default::default() + } + } +} + impl Display for FileOwner { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let sources = self @@ -121,6 +137,15 @@ impl Ownership { .collect()) } + #[instrument(level = "debug", skip_all)] + pub fn for_team(&self, team_name: &str) -> Result, Box> { + info!("getting team ownership for {}", team_name); + let team = self.project.get_team(team_name).ok_or("Team not found")?; + let codeowners_file = self.project.get_codeowners_file()?; + + parse_for_team(team.github_team, &codeowners_file) + } + #[instrument(level = "debug", skip_all)] pub fn generate_file(&self) -> String { info!("generating codeowners file"); @@ -141,12 +166,49 @@ impl Ownership { } } +fn parse_for_team(team_name: String, codeowners_file: &str) -> Result, Box> { + let mut output = vec![]; + let mut current_section: Option = None; + let input: String = codeowners_file.replace(&FileGenerator::disclaimer().join("\n"), ""); + let error_message = "CODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file"; + + for line in input.trim_start().lines() { + match line { + comment if comment.starts_with("#") => { + if let Some(section) = current_section.take() { + output.push(section); + } + current_section = Some(TeamOwnership::new(comment.to_string())); + } + "" => { + if let Some(section) = current_section.take() { + output.push(section); + } + } + team_line if team_line.ends_with(&team_name) => { + let section = current_section.as_mut().ok_or(error_message)?; + + let glob = line.split_once(' ').ok_or(error_message)?.0.to_string(); + section.globs.push(glob); + } + _ => {} + } + } + + if let Some(cs) = current_section { + output.push(cs.clone()); + } + + Ok(output) +} + #[cfg(test)] mod tests { - use crate::common_test::tests::build_ownership_with_all_mappers; + use super::*; + use crate::common_test::tests::{build_ownership_with_all_mappers, vecs_match}; #[test] - fn test_for_file_owner() -> Result<(), Box> { + fn test_for_file_owner() -> Result<(), Box> { let ownership = build_ownership_with_all_mappers()?; let file_owners = ownership.for_file("app/consumers/directory_owned.rb").unwrap(); assert_eq!(file_owners.len(), 1); @@ -156,10 +218,191 @@ mod tests { } #[test] - fn test_for_file_no_owner() -> Result<(), Box> { + fn test_for_file_no_owner() -> Result<(), Box> { let ownership = build_ownership_with_all_mappers()?; let file_owners = ownership.for_file("app/madeup/foo.rb").unwrap(); assert_eq!(file_owners.len(), 0); Ok(()) } + + #[test] + fn test_for_team() -> Result<(), Box> { + let ownership = build_ownership_with_all_mappers()?; + let team_ownership = ownership.for_team("Bar"); + assert!(team_ownership.is_ok()); + Ok(()) + } + + #[test] + fn test_for_team_not_found() -> Result<(), Box> { + let ownership = build_ownership_with_all_mappers()?; + let team_ownership = ownership.for_team("Nope"); + assert!(team_ownership.is_err(), "Team not found"); + Ok(()) + } + + #[test] + fn test_parse_for_team_trims_header() -> Result<(), Box> { + let codeownership_file = r#" +# 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 + + +"#; + + let team_ownership = parse_for_team("@Bar".to_string(), codeownership_file)?; + assert!(team_ownership.is_empty()); + Ok(()) + } + + #[test] + fn test_parse_for_team_includes_owned_globs() -> Result<(), Box> { + let codeownership_file = r#" +# First Section +/path/to/owned @Foo +/path/to/not/owned @Bar + +# Last Section +/another/owned/path @Foo +"#; + + let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file)?; + vecs_match( + &team_ownership, + &vec![ + TeamOwnership { + heading: "# First Section".to_string(), + globs: vec!["/path/to/owned".to_string()], + }, + TeamOwnership { + heading: "# Last Section".to_string(), + globs: vec!["/another/owned/path".to_string()], + }, + ], + ); + Ok(()) + } + + #[test] + fn test_parse_for_team_with_partial_team_match() -> Result<(), Box> { + let codeownership_file = r#" +# First Section +/path/to/owned @Foo +/path/to/not/owned @FooBar +"#; + + let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file)?; + vecs_match( + &team_ownership, + &vec![TeamOwnership { + heading: "# First Section".to_string(), + globs: vec!["/path/to/owned".to_string()], + }], + ); + Ok(()) + } + + #[test] + fn test_parse_for_team_with_trailing_newlines() -> Result<(), Box> { + let codeownership_file = r#" +# First Section +/path/to/owned @Foo + +# Last Section +/another/owned/path @Foo + + + +"#; + + let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file)?; + vecs_match( + &team_ownership, + &vec![ + TeamOwnership { + heading: "# First Section".to_string(), + globs: vec!["/path/to/owned".to_string()], + }, + TeamOwnership { + heading: "# Last Section".to_string(), + globs: vec!["/another/owned/path".to_string()], + }, + ], + ); + Ok(()) + } + + #[test] + fn test_parse_for_team_without_trailing_newline() -> Result<(), Box> { + let codeownership_file = r#" +# First Section +/path/to/owned @Foo"#; + + let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file)?; + vecs_match( + &team_ownership, + &vec![TeamOwnership { + heading: "# First Section".to_string(), + globs: vec!["/path/to/owned".to_string()], + }], + ); + Ok(()) + } + + #[test] + fn test_parse_for_team_with_missing_section_header() -> Result<(), Box> { + let codeownership_file = r#" +# First Section +/path/to/owned @Foo + +/another/owned/path @Foo +"#; + + let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file); + assert!(team_ownership + .is_err_and(|e| e.to_string() == "CODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file")); + Ok(()) + } + + #[test] + fn test_parse_for_team_with_malformed_team_line() -> Result<(), Box> { + let codeownership_file = r#" +# First Section +@Foo +"#; + + let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file); + assert!(team_ownership + .is_err_and(|e| e.to_string() == "CODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file")); + Ok(()) + } + + #[test] + fn test_parse_for_team_with_invalid_file() -> Result<(), Box> { + let codeownership_file = r#" +# First Section +# Second Section +path/to/owned @Foo +"#; + let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file)?; + vecs_match( + &team_ownership, + &vec![ + TeamOwnership { + heading: "# First Section".to_string(), + globs: vec![], + }, + TeamOwnership { + heading: "# Second Section".to_string(), + globs: vec!["path/to/owned".to_string()], + }, + ], + ); + Ok(()) + } } diff --git a/tests/valid_project_test.rs b/tests/valid_project_test.rs index aab5892..61838e2 100644 --- a/tests/valid_project_test.rs +++ b/tests/valid_project_test.rs @@ -47,3 +47,58 @@ fn test_for_file() -> Result<(), Box> { Ok(()) } + +#[test] +fn test_for_team() -> Result<(), Box> { + let expected_stdout = r#" +# Code Ownership Report for `Payroll` Team + +## Annotations at the top of file +/javascript/packages/PayrollFlow/index.tsx +/ruby/app/models/payroll.rb + +## Team-specific owned globs +This team owns nothing in this category. + +## Owner in .codeowner +/ruby/app/payroll/**/** + +## Owner metadata key in package.yml +/ruby/packages/payroll_flow/**/** + +## Owner metadata key in package.json +/javascript/packages/PayrollFlow/**/** + +## Team YML ownership +/config/teams/payroll.yml + +## Team owned gems +/gems/payroll_calculator/**/** +"# + .trim_start(); + + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg("tests/fixtures/valid_project") + .arg("for-team") + .arg("Payroll") + .assert() + .success() + .stdout(predicate::eq(expected_stdout)); + + Ok(()) +} + +#[test] +fn test_for_missing_team() -> Result<(), Box> { + Command::cargo_bin("codeowners")? + .arg("--project-root") + .arg("tests/fixtures/valid_project") + .arg("for-team") + .arg("Nope") + .assert() + .success() + .stdout(predicate::str::contains("Team not found")); + + Ok(()) +}