diff --git a/cli/src/codeowners.rs b/cli/src/codeowners.rs index 6b735b58..75d4943b 100644 --- a/cli/src/codeowners.rs +++ b/cli/src/codeowners.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use codeowners::Owners; +use codeowners::{FromPath, GitHubOwners}; use serde::{Deserialize, Serialize}; use crate::constants::CODEOWNERS_LOCATIONS; @@ -9,7 +9,7 @@ use crate::constants::CODEOWNERS_LOCATIONS; pub struct CodeOwners { pub path: PathBuf, #[serde(skip_serializing, skip_deserializing)] - pub owners: Option, + pub owners: Option, } impl CodeOwners { @@ -30,7 +30,7 @@ impl CodeOwners { all_locations.find_map(|location| locate_codeowners(&repo_root, location)); codeowners_path.map(|path| { - let owners_result = codeowners::from_path(&path); + let owners_result = GitHubOwners::from_path(&path); if let Err(ref err) = owners_result { log::error!( "Found CODEOWNERS file `{}`, but couldn't parse it: {}", diff --git a/cli/src/scanner.rs b/cli/src/scanner.rs index 0d46b433..5365de9b 100644 --- a/cli/src/scanner.rs +++ b/cli/src/scanner.rs @@ -1,6 +1,7 @@ use std::format; use std::time::SystemTime; +use codeowners::OwnersOfPath; use regex::Regex; use serde::{Deserialize, Serialize}; diff --git a/codeowners/src/github.rs b/codeowners/src/github.rs new file mode 100644 index 00000000..0b667483 --- /dev/null +++ b/codeowners/src/github.rs @@ -0,0 +1,354 @@ +use glob::Pattern; +use lazy_static::lazy_static; +use regex::Regex; +use std::{ + fmt, + fs::File, + io::{BufRead, BufReader, Read}, + path::Path, + str::FromStr, +}; + +use crate::{FromPath, FromReader, OwnersOfPath}; + +/// Various types of owners +/// +/// GitHubOwner supports parsing from strings as well as displaying as strings +/// +/// # Examples +/// +/// ```rust +/// let raw = "@org/team"; +/// assert_eq!( +/// raw.parse::().unwrap().to_string(), +/// raw +/// ); +/// ``` +#[derive(Debug, PartialEq, Clone)] +pub enum GitHubOwner { + /// Owner in the form @username + Username(String), + /// Owner in the form @org/Team + Team(String), + /// Owner in the form user@domain.com + Email(String), +} + +impl fmt::Display for GitHubOwner { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let inner = match *self { + GitHubOwner::Username(ref u) => u, + GitHubOwner::Team(ref t) => t, + GitHubOwner::Email(ref e) => e, + }; + f.write_str(inner.as_str()) + } +} + +impl FromStr for GitHubOwner { + type Err = String; + + fn from_str(s: &str) -> Result { + lazy_static! { + static ref TEAM: Regex = Regex::new(r"^@\S+/\S+").unwrap(); + static ref USERNAME: Regex = Regex::new(r"^@\S+").unwrap(); + static ref EMAIL: Regex = Regex::new(r"^\S+@\S+").unwrap(); + } + if TEAM.is_match(s) { + Ok(GitHubOwner::Team(s.into())) + } else if USERNAME.is_match(s) { + Ok(GitHubOwner::Username(s.into())) + } else if EMAIL.is_match(s) { + Ok(GitHubOwner::Email(s.into())) + } else { + Err(String::from("not an owner")) + } + } +} + +/// Mappings of GitHub owners to path patterns +#[derive(Debug, PartialEq, Clone)] +pub struct GitHubOwners { + paths: Vec<(Pattern, Vec)>, +} + +impl OwnersOfPath for GitHubOwners { + type Owner = GitHubOwner; + + fn of

(&self, path: P) -> Option> + where + P: AsRef, + { + self.paths + .iter() + .filter_map(|mapping| { + let (pattern, owners) = mapping; + let opts = glob::MatchOptions { + case_sensitive: false, + require_literal_separator: pattern.as_str().contains('/'), + require_literal_leading_dot: false, + }; + if pattern.matches_path_with(path.as_ref(), opts) { + Some(owners) + } else { + // this pattern is only meant to match + // direct children + if pattern.as_str().ends_with("/*") { + return None; + } + // case of implied owned children + // foo/bar @owner should indicate that foo/bar/baz.rs is + // owned by @owner + let mut p = path.as_ref(); + while let Some(parent) = p.parent() { + if pattern.matches_path_with(parent, opts) { + return Some(owners); + } else { + p = parent; + } + } + None + } + }) + .next() + .cloned() + } +} + +impl FromPath for GitHubOwners { + fn from_path

(path: P) -> anyhow::Result + where + P: AsRef, + { + Self::from_reader(File::open(path)?) + } +} + +impl FromReader for GitHubOwners { + /// Parse a CODEOWNERS file from some readable source + /// This format is defined in + /// [Github's documentation](https://help.github.com/articles/about-codeowners/) + /// The syntax is uses gitgnore + /// [patterns](https://www.kernel.org/pub/software/scm/git/docs/gitignore.html#_pattern_format) + /// followed by an identifier for an owner. More information can be found + /// [here](https://help.github.com/articles/about-codeowners/#codeowners-syntax) + fn from_reader(read: R) -> anyhow::Result + where + R: Read, + { + let mut paths = BufReader::new(read) + .lines() + /* trunk-ignore(clippy/lines_filter_map_ok) */ + .filter_map(Result::ok) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .try_fold(Vec::new(), |mut paths, line| -> anyhow::Result<_> { + let mut elements = line.split_whitespace(); + if let Some(path) = elements.next() { + let owners = elements.fold(Vec::new(), |mut result, owner| { + if let Ok(owner) = owner.parse() { + result.push(owner) + } + result + }); + paths.push((pattern(path)?, owners)) + } + Ok(paths) + })?; + // last match takes precedence + paths.reverse(); + Ok(GitHubOwners { paths }) + } +} + +fn pattern(path: &str) -> anyhow::Result { + // if pattern starts with anchor or explicit wild card, it should + // match any prefix + let prefixed = if path.starts_with('*') || path.starts_with('/') { + path.to_owned() + } else { + format!("**/{}", path) + }; + // if pattern starts with anchor it should only match paths + // relative to root + let mut normalized = prefixed.trim_start_matches('/').to_string(); + // if pattern ends with /, it should match children of that directory + if normalized.ends_with('/') { + normalized.push_str("**"); + } + Pattern::new(&normalized).map_err(anyhow::Error::msg) +} + +#[cfg(test)] +mod tests { + use super::*; + const EXAMPLE: &[u8] = include_bytes!("../test_fixtures/github/codeowners_example"); + + #[test] + fn owner_parses() { + assert!("@user".parse() == Ok(GitHubOwner::Username("@user".into()))); + assert!("@org/team".parse() == Ok(GitHubOwner::Team("@org/team".into()))); + assert!("user@domain.com".parse() == Ok(GitHubOwner::Email("user@domain.com".into()))); + assert!("bogus".parse::() == Err("not an owner".into())); + } + + #[test] + fn owner_displays() { + assert!(GitHubOwner::Username("@user".into()).to_string() == "@user"); + assert!(GitHubOwner::Team("@org/team".into()).to_string() == "@org/team"); + assert!(GitHubOwner::Email("user@domain.com".into()).to_string() == "user@domain.com"); + } + + #[test] + fn from_reader_parses() { + let owners = GitHubOwners::from_reader(EXAMPLE).unwrap(); + assert_eq!( + owners, + GitHubOwners { + paths: vec![ + ( + Pattern::new("docs/**").unwrap(), + vec![GitHubOwner::Username("@doctocat".into())] + ), + ( + Pattern::new("**/apps/**").unwrap(), + vec![GitHubOwner::Username("@octocat".into())] + ), + ( + Pattern::new("**/docs/*").unwrap(), + vec![GitHubOwner::Email("docs@example.com".into())] + ), + ( + Pattern::new("build/logs/**").unwrap(), + vec![GitHubOwner::Username("@doctocat".into())] + ), + ( + Pattern::new("*.go").unwrap(), + vec![GitHubOwner::Email("docs@example.com".into())] + ), + ( + Pattern::new("*.js").unwrap(), + vec![GitHubOwner::Username("@js-owner".into())] + ), + ( + Pattern::new("*").unwrap(), + vec![ + GitHubOwner::Username("@global-owner1".into()), + GitHubOwner::Username("@global-owner2".into()), + ] + ), + ], + } + ) + } + + #[test] + fn owners_owns_wildcard() { + let owners = GitHubOwners::from_reader(EXAMPLE).unwrap(); + assert_eq!( + owners.of("foo.txt"), + Some(vec![ + GitHubOwner::Username("@global-owner1".into()), + GitHubOwner::Username("@global-owner2".into()), + ]) + ); + assert_eq!( + owners.of("foo/bar.txt"), + Some(vec![ + GitHubOwner::Username("@global-owner1".into()), + GitHubOwner::Username("@global-owner2".into()), + ]) + ) + } + + #[test] + fn owners_owns_js_extention() { + let owners = GitHubOwners::from_reader(EXAMPLE).unwrap(); + assert_eq!( + owners.of("foo.js"), + Some(vec![GitHubOwner::Username("@js-owner".into())]) + ); + assert_eq!( + owners.of("foo/bar.js"), + Some(vec![GitHubOwner::Username("@js-owner".into())]) + ) + } + + #[test] + fn owners_owns_go_extention() { + let owners = GitHubOwners::from_reader(EXAMPLE).unwrap(); + assert_eq!( + owners.of("foo.go"), + Some(vec![GitHubOwner::Email("docs@example.com".into())]) + ); + assert_eq!( + owners.of("foo/bar.go"), + Some(vec![GitHubOwner::Email("docs@example.com".into())]) + ) + } + + #[test] + fn owners_owns_anchored_build_logs() { + let owners = GitHubOwners::from_reader(EXAMPLE).unwrap(); + // relative to root + assert_eq!( + owners.of("build/logs/foo.go"), + Some(vec![GitHubOwner::Username("@doctocat".into())]) + ); + assert_eq!( + owners.of("build/logs/foo/bar.go"), + Some(vec![GitHubOwner::Username("@doctocat".into())]) + ); + // not relative to root + assert_eq!( + owners.of("foo/build/logs/foo.go"), + Some(vec![GitHubOwner::Email("docs@example.com".into())]) + ) + } + + #[test] + fn owners_owns_unanchored_docs() { + let owners = GitHubOwners::from_reader(EXAMPLE).unwrap(); + // docs anywhere + assert_eq!( + owners.of("foo/docs/foo.js"), + Some(vec![GitHubOwner::Email("docs@example.com".into())]) + ); + assert_eq!( + owners.of("foo/bar/docs/foo.js"), + Some(vec![GitHubOwner::Email("docs@example.com".into())]) + ); + // but not nested + assert_eq!( + owners.of("foo/bar/docs/foo/foo.js"), + Some(vec![GitHubOwner::Username("@js-owner".into())]) + ) + } + + #[test] + fn owners_owns_unanchored_apps() { + let owners = GitHubOwners::from_reader(EXAMPLE).unwrap(); + assert_eq!( + owners.of("foo/apps/foo.js"), + Some(vec![GitHubOwner::Username("@octocat".into())]) + ) + } + + #[test] + fn owners_owns_anchored_docs() { + let owners = GitHubOwners::from_reader(EXAMPLE).unwrap(); + // relative to root + assert_eq!( + owners.of("docs/foo.js"), + Some(vec![GitHubOwner::Username("@doctocat".into())]) + ) + } + + #[test] + fn implied_children_owners() { + let owners = GitHubOwners::from_reader("foo/bar @doug".as_bytes()).unwrap(); + assert_eq!( + owners.of("foo/bar/baz.rs"), + Some(vec![GitHubOwner::Username("@doug".into())]) + ) + } +} diff --git a/codeowners/src/lib.rs b/codeowners/src/lib.rs index cd07fcc6..40458d7c 100644 --- a/codeowners/src/lib.rs +++ b/codeowners/src/lib.rs @@ -1,455 +1,28 @@ -//! Codeowners provides interfaces for resolving owners of paths within code -//! repositories using -//! Github [CODEOWNERS](https://help.github.com/articles/about-codeowners/) -//! files -//! -//! # Examples -//! -//! Typical use involves resolving a CODEOWNERS file, parsing it, -//! then querying target paths -//! -//! ```no_run -//! extern crate codeowners; -//! use std::env; -//! -//! fn main() { -//! if let (Some(owners_file), Some(path)) = -//! (env::args().nth(1), env::args().nth(2)) { -//! let owners = codeowners::from_path(owners_file).unwrap(); -//! match owners.of(&path) { -//! None => println!("{} is up for adoption", path), -//! Some(owners) => { -//! for owner in owners { -//! println!("{}", owner); -//! } -//! } -//! } -//! } -//! } -//! ``` -#![allow(missing_docs)] +mod github; -#[cfg(test)] -#[macro_use] -extern crate pretty_assertions; +use std::{io::Read, path::Path}; -use glob::Pattern; -use lazy_static::lazy_static; -use regex::Regex; -use std::{ - fmt, - fs::File, - io::{BufRead, BufReader, Read}, - path::{Path, PathBuf}, - str::FromStr, -}; +pub use github::{GitHubOwner, GitHubOwners}; -const CODEOWNERS: &str = "CODEOWNERS"; +pub trait OwnersOfPath { + type Owner; -/// Various types of owners -/// -/// Owners supports parsing from strings as well as displaying as strings -/// -/// # Examples -/// -/// ```rust -/// let raw = "@org/team"; -/// assert_eq!( -/// raw.parse::().unwrap().to_string(), -/// raw -/// ); -/// ``` -#[derive(Debug, PartialEq, Clone)] -pub enum Owner { - /// Owner in the form @username - Username(String), - /// Owner in the form @org/Team - Team(String), - /// Owner in the form user@domain.com - Email(String), -} - -impl fmt::Display for Owner { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let inner = match *self { - Owner::Username(ref u) => u, - Owner::Team(ref t) => t, - Owner::Email(ref e) => e, - }; - f.write_str(inner.as_str()) - } -} - -impl FromStr for Owner { - type Err = String; - - fn from_str(s: &str) -> Result { - lazy_static! { - static ref TEAM: Regex = Regex::new(r"^@\S+/\S+").unwrap(); - static ref USERNAME: Regex = Regex::new(r"^@\S+").unwrap(); - static ref EMAIL: Regex = Regex::new(r"^\S+@\S+").unwrap(); - } - if TEAM.is_match(s) { - Ok(Owner::Team(s.into())) - } else if USERNAME.is_match(s) { - Ok(Owner::Username(s.into())) - } else if EMAIL.is_match(s) { - Ok(Owner::Email(s.into())) - } else { - Err(String::from("not an owner")) - } - } -} - -/// Mappings of owners to path patterns -#[derive(Debug, PartialEq, Clone)] -pub struct Owners { - paths: Vec<(Pattern, Vec)>, -} - -impl Owners { /// Resolve a list of owners matching a given path - pub fn of

(&self, path: P) -> Option<&Vec> + fn of

(&self, path: P) -> Option> where - P: AsRef, - { - self.paths - .iter() - .filter_map(|mapping| { - let (pattern, owners) = mapping; - let opts = glob::MatchOptions { - case_sensitive: false, - require_literal_separator: pattern.as_str().contains('/'), - require_literal_leading_dot: false, - }; - if pattern.matches_path_with(path.as_ref(), opts) { - Some(owners) - } else { - // this pattern is only meant to match - // direct children - if pattern.as_str().ends_with("/*") { - return None; - } - // case of implied owned children - // foo/bar @owner should indicate that foo/bar/baz.rs is - // owned by @owner - let mut p = path.as_ref(); - while let Some(parent) = p.parent() { - if pattern.matches_path_with(parent, opts) { - return Some(owners); - } else { - p = parent; - } - } - None - } - }) - .next() - } + P: AsRef; } -/// Attempts to locate CODEOWNERS file based on common locations relative to -/// a given git repo -/// -/// # Examples -/// -/// ```rust -/// match codeowners::locate(".") { -/// Some(ownersfile) => { -/// println!( -/// "{:#?}", -/// codeowners::from_path(ownersfile) -/// ) -/// }, -/// _ => println!("failed to find CODEOWNERS file") -/// } -/// ``` -pub fn locate

(ctx: P) -> Option -where - P: AsRef, -{ - let root = ctx.as_ref().join(CODEOWNERS); - let github = ctx.as_ref().join(".github").join(CODEOWNERS); - let docs = ctx.as_ref().join("docs").join(CODEOWNERS); - if root.exists() { - Some(root) - } else if github.exists() { - Some(github) - } else if docs.exists() { - Some(docs) - } else { - None - } -} - -/// Parse a CODEOWNERS file existing at a given path -pub fn from_path

(path: P) -> anyhow::Result -where - P: AsRef, -{ - crate::from_reader(File::open(path)?) -} - -/// Parse a CODEOWNERS file from some readable source -/// This format is defined in -/// [Github's documentation](https://help.github.com/articles/about-codeowners/) -/// The syntax is uses gitgnore -/// [patterns](https://www.kernel.org/pub/software/scm/git/docs/gitignore.html#_pattern_format) -/// followed by an identifier for an owner. More information can be found -/// [here](https://help.github.com/articles/about-codeowners/#codeowners-syntax) -pub fn from_reader(read: R) -> anyhow::Result -where - R: Read, -{ - let mut paths = BufReader::new(read) - .lines() - /* trunk-ignore(clippy/lines_filter_map_ok) */ - .filter_map(Result::ok) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .try_fold(Vec::new(), |mut paths, line| -> anyhow::Result<_> { - let mut elements = line.split_whitespace(); - if let Some(path) = elements.next() { - let owners = elements.fold(Vec::new(), |mut result, owner| { - if let Ok(owner) = owner.parse() { - result.push(owner) - } - result - }); - paths.push((pattern(path)?, owners)) - } - Ok(paths) - })?; - // last match takes precedence - paths.reverse(); - Ok(Owners { paths }) -} - -fn pattern(path: &str) -> anyhow::Result { - // if pattern starts with anchor or explicit wild card, it should - // match any prefix - let prefixed = if path.starts_with('*') || path.starts_with('/') { - path.to_owned() - } else { - format!("**/{}", path) - }; - // if pattern starts with anchor it should only match paths - // relative to root - let mut normalized = prefixed.trim_start_matches('/').to_string(); - // if pattern ends with /, it should match children of that directory - if normalized.ends_with('/') { - normalized.push_str("**"); - } - Pattern::new(&normalized).map_err(anyhow::Error::msg) +pub trait FromPath: Sized { + /// Parse a CODEOWNERS file existing at a given path + fn from_path

(path: P) -> anyhow::Result + where + P: AsRef; } -#[cfg(test)] -mod tests { - use super::*; - const EXAMPLE: &str = r"# This is a comment. -# Each line is a file pattern followed by one or more owners. - -# These owners will be the default owners for everything in -# the repo. Unless a later match takes precedence, -# @global-owner1 and @global-owner2 will be requested for -# review when someone opens a pull request. -* @global-owner1 @global-owner2 - -# Order is important; the last matching pattern takes the most -# precedence. When someone opens a pull request that only -# modifies JS files, only @js-owner and not the global -# owner(s) will be requested for a review. -*.js @js-owner - -# You can also use email addresses if you prefer. They'll be -# used to look up users just like we do for commit author -# emails. -*.go docs@example.com - -# In this example, @doctocat owns any files in the build/logs -# directory at the root of the repository and any of its -# subdirectories. -/build/logs/ @doctocat - -# The `docs/*` pattern will match files like -# `docs/getting-started.md` but not further nested files like -# `docs/build-app/troubleshooting.md`. -docs/* docs@example.com - -# In this example, @octocat owns any file in an apps directory -# anywhere in your repository. -apps/ @octocat - -# In this example, @doctocat owns any file in the `/docs` -# directory in the root of your repository. -/docs/ @doctocat -"; - - #[test] - fn owner_parses() { - assert!("@user".parse() == Ok(Owner::Username("@user".into()))); - assert!("@org/team".parse() == Ok(Owner::Team("@org/team".into()))); - assert!("user@domain.com".parse() == Ok(Owner::Email("user@domain.com".into()))); - assert!("bogus".parse::() == Err("not an owner".into())); - } - - #[test] - fn owner_displays() { - assert!(Owner::Username("@user".into()).to_string() == "@user"); - assert!(Owner::Team("@org/team".into()).to_string() == "@org/team"); - assert!(Owner::Email("user@domain.com".into()).to_string() == "user@domain.com"); - } - - #[test] - fn from_reader_parses() { - let owners = from_reader(EXAMPLE.as_bytes()).unwrap(); - assert_eq!( - owners, - Owners { - paths: vec![ - ( - Pattern::new("docs/**").unwrap(), - vec![Owner::Username("@doctocat".into())] - ), - ( - Pattern::new("**/apps/**").unwrap(), - vec![Owner::Username("@octocat".into())] - ), - ( - Pattern::new("**/docs/*").unwrap(), - vec![Owner::Email("docs@example.com".into())] - ), - ( - Pattern::new("build/logs/**").unwrap(), - vec![Owner::Username("@doctocat".into())] - ), - ( - Pattern::new("*.go").unwrap(), - vec![Owner::Email("docs@example.com".into())] - ), - ( - Pattern::new("*.js").unwrap(), - vec![Owner::Username("@js-owner".into())] - ), - ( - Pattern::new("*").unwrap(), - vec![ - Owner::Username("@global-owner1".into()), - Owner::Username("@global-owner2".into()), - ] - ), - ], - } - ) - } - - #[test] - fn owners_owns_wildcard() { - let owners = from_reader(EXAMPLE.as_bytes()).unwrap(); - assert_eq!( - owners.of("foo.txt"), - Some(&vec![ - Owner::Username("@global-owner1".into()), - Owner::Username("@global-owner2".into()), - ]) - ); - assert_eq!( - owners.of("foo/bar.txt"), - Some(&vec![ - Owner::Username("@global-owner1".into()), - Owner::Username("@global-owner2".into()), - ]) - ) - } - - #[test] - fn owners_owns_js_extention() { - let owners = from_reader(EXAMPLE.as_bytes()).unwrap(); - assert_eq!( - owners.of("foo.js"), - Some(&vec![Owner::Username("@js-owner".into())]) - ); - assert_eq!( - owners.of("foo/bar.js"), - Some(&vec![Owner::Username("@js-owner".into())]) - ) - } - - #[test] - fn owners_owns_go_extention() { - let owners = from_reader(EXAMPLE.as_bytes()).unwrap(); - assert_eq!( - owners.of("foo.go"), - Some(&vec![Owner::Email("docs@example.com".into())]) - ); - assert_eq!( - owners.of("foo/bar.go"), - Some(&vec![Owner::Email("docs@example.com".into())]) - ) - } - - #[test] - fn owners_owns_anchored_build_logs() { - let owners = from_reader(EXAMPLE.as_bytes()).unwrap(); - // relative to root - assert_eq!( - owners.of("build/logs/foo.go"), - Some(&vec![Owner::Username("@doctocat".into())]) - ); - assert_eq!( - owners.of("build/logs/foo/bar.go"), - Some(&vec![Owner::Username("@doctocat".into())]) - ); - // not relative to root - assert_eq!( - owners.of("foo/build/logs/foo.go"), - Some(&vec![Owner::Email("docs@example.com".into())]) - ) - } - - #[test] - fn owners_owns_unanchored_docs() { - let owners = from_reader(EXAMPLE.as_bytes()).unwrap(); - // docs anywhere - assert_eq!( - owners.of("foo/docs/foo.js"), - Some(&vec![Owner::Email("docs@example.com".into())]) - ); - assert_eq!( - owners.of("foo/bar/docs/foo.js"), - Some(&vec![Owner::Email("docs@example.com".into())]) - ); - // but not nested - assert_eq!( - owners.of("foo/bar/docs/foo/foo.js"), - Some(&vec![Owner::Username("@js-owner".into())]) - ) - } - - #[test] - fn owners_owns_unanchored_apps() { - let owners = from_reader(EXAMPLE.as_bytes()).unwrap(); - assert_eq!( - owners.of("foo/apps/foo.js"), - Some(&vec![Owner::Username("@octocat".into())]) - ) - } - - #[test] - fn owners_owns_anchored_docs() { - let owners = from_reader(EXAMPLE.as_bytes()).unwrap(); - // relative to root - assert_eq!( - owners.of("docs/foo.js"), - Some(&vec![Owner::Username("@doctocat".into())]) - ) - } - - #[test] - fn implied_children_owners() { - let owners = from_reader("foo/bar @doug".as_bytes()).unwrap(); - assert_eq!( - owners.of("foo/bar/baz.rs"), - Some(&vec![Owner::Username("@doug".into())]) - ) - } +pub trait FromReader: Sized { + /// Parse a CODEOWNERS file from some readable source + fn from_reader(read: R) -> anyhow::Result + where + R: Read; } diff --git a/codeowners/test_fixtures/github/codeowners_example b/codeowners/test_fixtures/github/codeowners_example new file mode 100644 index 00000000..22654f63 --- /dev/null +++ b/codeowners/test_fixtures/github/codeowners_example @@ -0,0 +1,37 @@ +# This is a comment. +# Each line is a file pattern followed by one or more owners. + +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, +# @global-owner1 and @global-owner2 will be requested for +# review when someone opens a pull request. +* @global-owner1 @global-owner2 + +# Order is important; the last matching pattern takes the most +# precedence. When someone opens a pull request that only +# modifies JS files, only @js-owner and not the global +# owner(s) will be requested for a review. +*.js @js-owner + +# You can also use email addresses if you prefer. They'll be +# used to look up users just like we do for commit author +# emails. +*.go docs@example.com + +# In this example, @doctocat owns any files in the build/logs +# directory at the root of the repository and any of its +# subdirectories. +/build/logs/ @doctocat + +# The `docs/*` pattern will match files like +# `docs/getting-started.md` but not further nested files like +# `docs/build-app/troubleshooting.md`. +docs/* docs@example.com + +# In this example, @octocat owns any file in an apps directory +# anywhere in your repository. +apps/ @octocat + +# In this example, @doctocat owns any file in the `/docs` +# directory in the root of your repository. +/docs/ @doctocat