Skip to content

Commit 8b21640

Browse files
committed
Improve ownership validation to detect multiple TeamYML owners
Detect files owned by multiple teams via overlapping TeamYML sources and report a distinct error to prevent conflicting ownership definitions.
1 parent c3cace6 commit 8b21640

File tree

3 files changed

+117
-23
lines changed

3 files changed

+117
-23
lines changed

src/ownership/validator.rs

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::project::{Project, ProjectFile};
22
use core::fmt;
33
use std::collections::HashSet;
44
use std::fmt::Display;
5+
use std::path::Path;
56
use std::path::PathBuf;
67
use std::sync::Arc;
78

@@ -15,7 +16,7 @@ use tracing::instrument;
1516
use super::file_generator::FileGenerator;
1617
use super::file_owner_finder::FileOwnerFinder;
1718
use super::file_owner_finder::Owner;
18-
use super::mapper::{Mapper, OwnerMatcher, TeamName};
19+
use super::mapper::{Mapper, OwnerMatcher, Source,TeamName};
1920

2021
pub struct Validator {
2122
pub project: Arc<Project>,
@@ -27,7 +28,7 @@ pub struct Validator {
2728
enum Error {
2829
InvalidTeam { name: String, path: PathBuf },
2930
FileWithoutOwner { path: PathBuf },
30-
FileWithMultipleOwners { path: PathBuf, owners: Vec<Owner> },
31+
MultipleTeamYmls { path: PathBuf, owners: Vec<Owner> },
3132
CodeownershipFileIsStale,
3233
}
3334

@@ -113,11 +114,8 @@ impl Validator {
113114

114115
if owners.is_empty() {
115116
validation_errors.push(Error::FileWithoutOwner { path: relative_path })
116-
} else if owners.len() > 1 {
117-
validation_errors.push(Error::FileWithMultipleOwners {
118-
path: relative_path,
119-
owners,
120-
})
117+
} else if let Some(multiple_team_file_owners_error) = multiple_team_file_owners(&owners, &relative_path) {
118+
validation_errors.push(multiple_team_file_owners_error);
121119
}
122120
}
123121

@@ -162,7 +160,7 @@ impl Error {
162160
pub fn category(&self) -> String {
163161
match self {
164162
Error::FileWithoutOwner { path: _ } => "Some files are missing ownership".to_owned(),
165-
Error::FileWithMultipleOwners { path: _, owners: _ } => "Code ownership should only be defined for each file in one way. The following files have declared ownership in multiple ways".to_owned(),
163+
Error::MultipleTeamYmls { path: _, owners: _ } => "Team yml file globs should not overlap".to_owned(),
166164
Error::CodeownershipFileIsStale => {
167165
"CODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file".to_owned()
168166
}
@@ -173,7 +171,7 @@ impl Error {
173171
pub fn messages(&self) -> Vec<String> {
174172
match self {
175173
Error::FileWithoutOwner { path } => vec![format!("- {}", path.to_string_lossy())],
176-
Error::FileWithMultipleOwners { path, owners } => {
174+
Error::MultipleTeamYmls { path, owners } => {
177175
let path_display = path.to_string_lossy();
178176
let mut messages = vec![format!("\n{path_display}")];
179177

@@ -193,6 +191,29 @@ impl Error {
193191
}
194192
}
195193

194+
fn multiple_team_file_owners(owners: &[Owner], relative_path: &Path) -> Option<Error> {
195+
if owners.len() <= 1 {
196+
return None;
197+
}
198+
let team_file_owners: Vec<Owner> = owners
199+
.iter()
200+
.filter(|owner| owner.sources.iter().any(|source| matches!(source, Source::TeamYml)))
201+
.map(|owner| Owner {
202+
sources: owner.sources.clone(),
203+
team_name: owner.team_name.clone(),
204+
})
205+
.collect();
206+
207+
if team_file_owners.len() > 1 {
208+
Some(Error::MultipleTeamYmls {
209+
path: relative_path.to_path_buf(),
210+
owners: team_file_owners,
211+
})
212+
} else {
213+
None
214+
}
215+
}
216+
196217
impl Display for Errors {
197218
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
198219
let grouped_errors = self.0.iter().into_group_map_by(|error| error.category());
@@ -216,3 +237,83 @@ impl Display for Errors {
216237
}
217238

218239
impl Context for Errors {}
240+
241+
#[cfg(test)]
242+
mod tests {
243+
use super::*;
244+
245+
fn owner_with_sources(team: &str, sources: Vec<Source>) -> Owner {
246+
Owner {
247+
team_name: team.to_string(),
248+
sources,
249+
}
250+
}
251+
252+
#[test]
253+
fn multiple_team_file_owners_with_no_owners_returns_none() {
254+
let owners: Vec<Owner> = vec![];
255+
let path = PathBuf::from("app/models/user.rb");
256+
257+
let result = multiple_team_file_owners(&owners, &path);
258+
assert!(result.is_none());
259+
}
260+
261+
#[test]
262+
fn multiple_team_file_owners_with_single_owner_returns_none() {
263+
let owners = vec![owner_with_sources("Foo", vec![Source::TeamYml])];
264+
let path = PathBuf::from("app/models/user.rb");
265+
266+
let result = multiple_team_file_owners(&owners, &path);
267+
assert!(result.is_none());
268+
}
269+
270+
#[test]
271+
fn multiple_team_file_owners_with_multiple_non_teamfile_owners_returns_none() {
272+
let owners = vec![
273+
owner_with_sources("Foo", vec![Source::Directory("app".to_string())]),
274+
owner_with_sources("Bar", vec![Source::TeamGlob("packs/bar/**".to_string())]),
275+
];
276+
let path = PathBuf::from("app/models/user.rb");
277+
278+
let result = multiple_team_file_owners(&owners, &path);
279+
assert!(result.is_none());
280+
}
281+
282+
#[test]
283+
fn multiple_team_file_owners_with_one_teamfile_owner_returns_none() {
284+
let owners = vec![
285+
owner_with_sources("Foo", vec![Source::TeamYml]),
286+
owner_with_sources("Bar", vec![Source::Directory("app/services".to_string())]),
287+
];
288+
let path = PathBuf::from("app/services/service.rb");
289+
290+
let result = multiple_team_file_owners(&owners, &path);
291+
assert!(result.is_none());
292+
}
293+
294+
#[test]
295+
fn multiple_team_file_owners_with_two_teamfile_owners_returns_error() {
296+
let owners = vec![
297+
owner_with_sources("Foo", vec![Source::TeamYml]),
298+
owner_with_sources("Bar", vec![Source::TeamYml]),
299+
];
300+
let path = PathBuf::from("packs/payroll/services/runner.rb");
301+
302+
let result = multiple_team_file_owners(&owners, &path);
303+
match result {
304+
Some(Error::MultipleTeamYmls {
305+
path: p,
306+
owners: conflicting,
307+
}) => {
308+
assert_eq!(p, path);
309+
assert_eq!(conflicting.len(), 2);
310+
let mut names: Vec<&str> = conflicting.iter().map(|o| o.team_name.as_str()).collect();
311+
names.sort_unstable();
312+
assert_eq!(names, vec!["Bar", "Foo"]);
313+
// Ensure sources are preserved as TeamFile for both
314+
assert!(conflicting.iter().all(|o| o.sources.iter().any(|s| matches!(s, Source::TeamYml))));
315+
}
316+
_ => panic!("Expected MultipleTeamYmls error"),
317+
}
318+
}
319+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
1+
# STOP! - DO NOT EDIT THIS FILE MANUALLY
2+
# This file was automatically generated by "bin/codeownership validate".
3+
#
4+
# CODEOWNERS is used for GitHub to suggest code/file owners to various GitHub
5+
# teams. This is useful when developers create Pull Requests since the
6+
# code/file owner is notified. Reference GitHub docs for more details:
7+
# https://help.github.com/en/articles/about-code-owners
18

tests/invalid_project_test.rs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,6 @@ fn test_validate() -> Result<(), Box<dyn Error>> {
1717
1818
CODEOWNERS out of date. Run `codeowners generate` to update the CODEOWNERS file
1919
20-
Code ownership should only be defined for each file in one way. The following files have declared ownership in multiple ways
21-
22-
gems/payroll_calculator/calculator.rb
23-
owner: Payments
24-
- Owner annotation at the top of the file
25-
owner: Payroll
26-
- Owner specified in Team YML's `owned_gems`
27-
28-
ruby/app/services/multi_owned.rb
29-
owner: Payments
30-
- Owner annotation at the top of the file
31-
owner: Payroll
32-
- Owner specified in `ruby/app/services/.codeowner`
33-
3420
Found invalid team annotations
3521
- ruby/app/models/blockchain.rb is referencing an invalid team - 'Web3'
3622

0 commit comments

Comments
 (0)