Skip to content

Commit 8e4cafe

Browse files
authored
Feat/add for team subcommand (#39)
* Add `for_team` CLI command * Release `0.2.0`
1 parent e865db5 commit 8e4cafe

File tree

5 files changed

+319
-5
lines changed

5 files changed

+319
-5
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "codeowners"
3-
version = "0.1.5"
3+
version = "0.2.0"
44
edition = "2021"
55

66
[profile.release]

src/main.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ enum Command {
2121
#[clap(about = "Finds the owner of a given file.", visible_alias = "f")]
2222
ForFile { name: String },
2323

24+
#[clap(about = "Finds code ownership information for a given team ", visible_alias = "t")]
25+
ForTeam { name: String },
26+
2427
#[clap(
2528
about = "Generate the CODEOWNERS file and save it to '--codeowners-file-path'.",
2629
visible_alias = "g"
@@ -135,6 +138,19 @@ fn cli() -> Result<(), Error> {
135138
}
136139
}
137140
}
141+
Command::ForTeam { name } => match ownership.for_team(&name) {
142+
Ok(team_ownerships) => {
143+
println!("# Code Ownership Report for `{}` Team", name);
144+
for team_ownership in team_ownerships {
145+
println!("\n#{}", team_ownership.heading);
146+
match team_ownership.globs.len() {
147+
0 => println!("This team owns nothing in this category."),
148+
_ => println!("{}", team_ownership.globs.join("\n")),
149+
}
150+
}
151+
}
152+
Err(err) => println!("{}", err),
153+
},
138154
}
139155

140156
Ok(())

src/ownership.rs

Lines changed: 246 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use file_owner_finder::FileOwnerFinder;
22
use mapper::{OwnerMatcher, Source, TeamName};
33
use std::{
4+
error::Error,
45
fmt::{self, Display},
56
path::Path,
67
sync::Arc,
@@ -32,6 +33,21 @@ pub struct FileOwner {
3233
pub sources: Vec<Source>,
3334
}
3435

36+
#[derive(Debug, Default, Clone, PartialEq)]
37+
pub struct TeamOwnership {
38+
pub heading: String,
39+
pub globs: Vec<String>,
40+
}
41+
42+
impl TeamOwnership {
43+
fn new(heading: String) -> Self {
44+
Self {
45+
heading,
46+
..Default::default()
47+
}
48+
}
49+
}
50+
3551
impl Display for FileOwner {
3652
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3753
let sources = self
@@ -121,6 +137,15 @@ impl Ownership {
121137
.collect())
122138
}
123139

140+
#[instrument(level = "debug", skip_all)]
141+
pub fn for_team(&self, team_name: &str) -> Result<Vec<TeamOwnership>, Box<dyn Error>> {
142+
info!("getting team ownership for {}", team_name);
143+
let team = self.project.get_team(team_name).ok_or("Team not found")?;
144+
let codeowners_file = self.project.get_codeowners_file()?;
145+
146+
parse_for_team(team.github_team, &codeowners_file)
147+
}
148+
124149
#[instrument(level = "debug", skip_all)]
125150
pub fn generate_file(&self) -> String {
126151
info!("generating codeowners file");
@@ -141,12 +166,49 @@ impl Ownership {
141166
}
142167
}
143168

169+
fn parse_for_team(team_name: String, codeowners_file: &str) -> Result<Vec<TeamOwnership>, Box<dyn Error>> {
170+
let mut output = vec![];
171+
let mut current_section: Option<TeamOwnership> = None;
172+
let input: String = codeowners_file.replace(&FileGenerator::disclaimer().join("\n"), "");
173+
let error_message = "CODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file";
174+
175+
for line in input.trim_start().lines() {
176+
match line {
177+
comment if comment.starts_with("#") => {
178+
if let Some(section) = current_section.take() {
179+
output.push(section);
180+
}
181+
current_section = Some(TeamOwnership::new(comment.to_string()));
182+
}
183+
"" => {
184+
if let Some(section) = current_section.take() {
185+
output.push(section);
186+
}
187+
}
188+
team_line if team_line.ends_with(&team_name) => {
189+
let section = current_section.as_mut().ok_or(error_message)?;
190+
191+
let glob = line.split_once(' ').ok_or(error_message)?.0.to_string();
192+
section.globs.push(glob);
193+
}
194+
_ => {}
195+
}
196+
}
197+
198+
if let Some(cs) = current_section {
199+
output.push(cs.clone());
200+
}
201+
202+
Ok(output)
203+
}
204+
144205
#[cfg(test)]
145206
mod tests {
146-
use crate::common_test::tests::build_ownership_with_all_mappers;
207+
use super::*;
208+
use crate::common_test::tests::{build_ownership_with_all_mappers, vecs_match};
147209

148210
#[test]
149-
fn test_for_file_owner() -> Result<(), Box<dyn std::error::Error>> {
211+
fn test_for_file_owner() -> Result<(), Box<dyn Error>> {
150212
let ownership = build_ownership_with_all_mappers()?;
151213
let file_owners = ownership.for_file("app/consumers/directory_owned.rb").unwrap();
152214
assert_eq!(file_owners.len(), 1);
@@ -156,10 +218,191 @@ mod tests {
156218
}
157219

158220
#[test]
159-
fn test_for_file_no_owner() -> Result<(), Box<dyn std::error::Error>> {
221+
fn test_for_file_no_owner() -> Result<(), Box<dyn Error>> {
160222
let ownership = build_ownership_with_all_mappers()?;
161223
let file_owners = ownership.for_file("app/madeup/foo.rb").unwrap();
162224
assert_eq!(file_owners.len(), 0);
163225
Ok(())
164226
}
227+
228+
#[test]
229+
fn test_for_team() -> Result<(), Box<dyn Error>> {
230+
let ownership = build_ownership_with_all_mappers()?;
231+
let team_ownership = ownership.for_team("Bar");
232+
assert!(team_ownership.is_ok());
233+
Ok(())
234+
}
235+
236+
#[test]
237+
fn test_for_team_not_found() -> Result<(), Box<dyn Error>> {
238+
let ownership = build_ownership_with_all_mappers()?;
239+
let team_ownership = ownership.for_team("Nope");
240+
assert!(team_ownership.is_err(), "Team not found");
241+
Ok(())
242+
}
243+
244+
#[test]
245+
fn test_parse_for_team_trims_header() -> Result<(), Box<dyn Error>> {
246+
let codeownership_file = r#"
247+
# STOP! - DO NOT EDIT THIS FILE MANUALLY
248+
# This file was automatically generated by "bin/codeownership validate".
249+
#
250+
# CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub
251+
# teams. This is useful when developers create Pull Requests since the
252+
# code/file owner is notified. Reference GitHub docs for more details:
253+
# https://help.github.com/en/articles/about-code-owners
254+
255+
256+
"#;
257+
258+
let team_ownership = parse_for_team("@Bar".to_string(), codeownership_file)?;
259+
assert!(team_ownership.is_empty());
260+
Ok(())
261+
}
262+
263+
#[test]
264+
fn test_parse_for_team_includes_owned_globs() -> Result<(), Box<dyn Error>> {
265+
let codeownership_file = r#"
266+
# First Section
267+
/path/to/owned @Foo
268+
/path/to/not/owned @Bar
269+
270+
# Last Section
271+
/another/owned/path @Foo
272+
"#;
273+
274+
let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file)?;
275+
vecs_match(
276+
&team_ownership,
277+
&vec![
278+
TeamOwnership {
279+
heading: "# First Section".to_string(),
280+
globs: vec!["/path/to/owned".to_string()],
281+
},
282+
TeamOwnership {
283+
heading: "# Last Section".to_string(),
284+
globs: vec!["/another/owned/path".to_string()],
285+
},
286+
],
287+
);
288+
Ok(())
289+
}
290+
291+
#[test]
292+
fn test_parse_for_team_with_partial_team_match() -> Result<(), Box<dyn Error>> {
293+
let codeownership_file = r#"
294+
# First Section
295+
/path/to/owned @Foo
296+
/path/to/not/owned @FooBar
297+
"#;
298+
299+
let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file)?;
300+
vecs_match(
301+
&team_ownership,
302+
&vec![TeamOwnership {
303+
heading: "# First Section".to_string(),
304+
globs: vec!["/path/to/owned".to_string()],
305+
}],
306+
);
307+
Ok(())
308+
}
309+
310+
#[test]
311+
fn test_parse_for_team_with_trailing_newlines() -> Result<(), Box<dyn Error>> {
312+
let codeownership_file = r#"
313+
# First Section
314+
/path/to/owned @Foo
315+
316+
# Last Section
317+
/another/owned/path @Foo
318+
319+
320+
321+
"#;
322+
323+
let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file)?;
324+
vecs_match(
325+
&team_ownership,
326+
&vec![
327+
TeamOwnership {
328+
heading: "# First Section".to_string(),
329+
globs: vec!["/path/to/owned".to_string()],
330+
},
331+
TeamOwnership {
332+
heading: "# Last Section".to_string(),
333+
globs: vec!["/another/owned/path".to_string()],
334+
},
335+
],
336+
);
337+
Ok(())
338+
}
339+
340+
#[test]
341+
fn test_parse_for_team_without_trailing_newline() -> Result<(), Box<dyn Error>> {
342+
let codeownership_file = r#"
343+
# First Section
344+
/path/to/owned @Foo"#;
345+
346+
let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file)?;
347+
vecs_match(
348+
&team_ownership,
349+
&vec![TeamOwnership {
350+
heading: "# First Section".to_string(),
351+
globs: vec!["/path/to/owned".to_string()],
352+
}],
353+
);
354+
Ok(())
355+
}
356+
357+
#[test]
358+
fn test_parse_for_team_with_missing_section_header() -> Result<(), Box<dyn Error>> {
359+
let codeownership_file = r#"
360+
# First Section
361+
/path/to/owned @Foo
362+
363+
/another/owned/path @Foo
364+
"#;
365+
366+
let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file);
367+
assert!(team_ownership
368+
.is_err_and(|e| e.to_string() == "CODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file"));
369+
Ok(())
370+
}
371+
372+
#[test]
373+
fn test_parse_for_team_with_malformed_team_line() -> Result<(), Box<dyn Error>> {
374+
let codeownership_file = r#"
375+
# First Section
376+
@Foo
377+
"#;
378+
379+
let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file);
380+
assert!(team_ownership
381+
.is_err_and(|e| e.to_string() == "CODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file"));
382+
Ok(())
383+
}
384+
385+
#[test]
386+
fn test_parse_for_team_with_invalid_file() -> Result<(), Box<dyn Error>> {
387+
let codeownership_file = r#"
388+
# First Section
389+
# Second Section
390+
path/to/owned @Foo
391+
"#;
392+
let team_ownership = parse_for_team("@Foo".to_string(), codeownership_file)?;
393+
vecs_match(
394+
&team_ownership,
395+
&vec![
396+
TeamOwnership {
397+
heading: "# First Section".to_string(),
398+
globs: vec![],
399+
},
400+
TeamOwnership {
401+
heading: "# Second Section".to_string(),
402+
globs: vec!["path/to/owned".to_string()],
403+
},
404+
],
405+
);
406+
Ok(())
407+
}
165408
}

tests/valid_project_test.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,58 @@ fn test_for_file() -> Result<(), Box<dyn Error>> {
4747

4848
Ok(())
4949
}
50+
51+
#[test]
52+
fn test_for_team() -> Result<(), Box<dyn Error>> {
53+
let expected_stdout = r#"
54+
# Code Ownership Report for `Payroll` Team
55+
56+
## Annotations at the top of file
57+
/javascript/packages/PayrollFlow/index.tsx
58+
/ruby/app/models/payroll.rb
59+
60+
## Team-specific owned globs
61+
This team owns nothing in this category.
62+
63+
## Owner in .codeowner
64+
/ruby/app/payroll/**/**
65+
66+
## Owner metadata key in package.yml
67+
/ruby/packages/payroll_flow/**/**
68+
69+
## Owner metadata key in package.json
70+
/javascript/packages/PayrollFlow/**/**
71+
72+
## Team YML ownership
73+
/config/teams/payroll.yml
74+
75+
## Team owned gems
76+
/gems/payroll_calculator/**/**
77+
"#
78+
.trim_start();
79+
80+
Command::cargo_bin("codeowners")?
81+
.arg("--project-root")
82+
.arg("tests/fixtures/valid_project")
83+
.arg("for-team")
84+
.arg("Payroll")
85+
.assert()
86+
.success()
87+
.stdout(predicate::eq(expected_stdout));
88+
89+
Ok(())
90+
}
91+
92+
#[test]
93+
fn test_for_missing_team() -> Result<(), Box<dyn Error>> {
94+
Command::cargo_bin("codeowners")?
95+
.arg("--project-root")
96+
.arg("tests/fixtures/valid_project")
97+
.arg("for-team")
98+
.arg("Nope")
99+
.assert()
100+
.success()
101+
.stdout(predicate::str::contains("Team not found"));
102+
103+
Ok(())
104+
}

0 commit comments

Comments
 (0)