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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "codeowners"
version = "0.1.5"
version = "0.2.0"
edition = "2021"

[profile.release]
Expand Down
16 changes: 16 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(())
Expand Down
249 changes: 246 additions & 3 deletions src/ownership.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -32,6 +33,21 @@ pub struct FileOwner {
pub sources: Vec<Source>,
}

#[derive(Debug, Default, Clone, PartialEq)]
pub struct TeamOwnership {
pub heading: String,
pub globs: Vec<String>,
}

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
Expand Down Expand Up @@ -121,6 +137,15 @@ impl Ownership {
.collect())
}

#[instrument(level = "debug", skip_all)]
pub fn for_team(&self, team_name: &str) -> Result<Vec<TeamOwnership>, Box<dyn Error>> {
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");
Expand All @@ -141,12 +166,49 @@ impl Ownership {
}
}

fn parse_for_team(team_name: String, codeowners_file: &str) -> Result<Vec<TeamOwnership>, Box<dyn Error>> {
let mut output = vec![];
let mut current_section: Option<TeamOwnership> = 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<dyn std::error::Error>> {
fn test_for_file_owner() -> Result<(), Box<dyn Error>> {
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);
Expand All @@ -156,10 +218,191 @@ mod tests {
}

#[test]
fn test_for_file_no_owner() -> Result<(), Box<dyn std::error::Error>> {
fn test_for_file_no_owner() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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(())
}
}
55 changes: 55 additions & 0 deletions tests/valid_project_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,58 @@ fn test_for_file() -> Result<(), Box<dyn Error>> {

Ok(())
}

#[test]
fn test_for_team() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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(())
}