Skip to content

Commit 0c79763

Browse files
committed
feat(ownership): fast per-file owner resolution + top-of-file annotation support
1 parent b76e8bd commit 0c79763

File tree

3 files changed

+316
-274
lines changed

3 files changed

+316
-274
lines changed

src/ownership.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod file_owner_finder;
1414
pub(crate) mod mapper;
1515
pub(crate) mod parser;
1616
mod validator;
17+
pub(crate) mod fast;
1718

1819
use crate::{
1920
ownership::mapper::DirectoryMapper,

src/ownership/fast.rs

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
use std::{collections::{HashMap, HashSet}, fs, path::Path};
2+
3+
use fast_glob::glob_match;
4+
use glob::glob;
5+
use lazy_static::lazy_static;
6+
use regex::Regex;
7+
8+
use crate::{config::Config, project::Team};
9+
10+
use super::{FileOwner, mapper::Source};
11+
12+
pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path) -> Vec<FileOwner> {
13+
let absolute_file_path = if file_path.is_absolute() {
14+
file_path.to_path_buf()
15+
} else {
16+
project_root.join(file_path)
17+
};
18+
let relative_file_path = absolute_file_path
19+
.strip_prefix(project_root)
20+
.unwrap_or(&absolute_file_path)
21+
.to_path_buf();
22+
23+
let teams = match load_teams(project_root, &config.team_file_glob) {
24+
Ok(t) => t,
25+
Err(_) => return vec![],
26+
};
27+
let teams_by_name = build_teams_by_name_map(&teams);
28+
29+
let mut sources_by_team: HashMap<String, Vec<Source>> = HashMap::new();
30+
31+
if let Some(team_name) = read_top_of_file_team(&absolute_file_path) {
32+
if let Some(team) = teams_by_name.get(&team_name) {
33+
sources_by_team.entry(team.name.clone()).or_default().push(Source::TeamFile);
34+
}
35+
}
36+
37+
if let Some((owner_team_name, dir_source)) = most_specific_directory_owner(project_root, &relative_file_path, &teams_by_name) {
38+
sources_by_team.entry(owner_team_name).or_default().push(dir_source);
39+
}
40+
41+
if let Some((owner_team_name, package_source)) = nearest_package_owner(project_root, &relative_file_path, config, &teams_by_name) {
42+
sources_by_team.entry(owner_team_name).or_default().push(package_source);
43+
}
44+
45+
if let Some((owner_team_name, gem_source)) = vendored_gem_owner(&relative_file_path, config, &teams) {
46+
sources_by_team.entry(owner_team_name).or_default().push(gem_source);
47+
}
48+
49+
if let Some(rel_str) = relative_file_path.to_str() {
50+
for team in &teams {
51+
let subtracts: HashSet<&str> = team.subtracted_globs.iter().map(|s| s.as_str()).collect();
52+
for owned_glob in &team.owned_globs {
53+
if glob_match(owned_glob, rel_str) && !subtracts.iter().any(|sub| glob_match(sub, rel_str)) {
54+
sources_by_team
55+
.entry(team.name.clone())
56+
.or_default()
57+
.push(Source::TeamGlob(owned_glob.clone()));
58+
}
59+
}
60+
}
61+
}
62+
63+
for team in &teams {
64+
let team_rel = team
65+
.path
66+
.strip_prefix(project_root)
67+
.unwrap_or(&team.path)
68+
.to_path_buf();
69+
if team_rel == relative_file_path {
70+
sources_by_team.entry(team.name.clone()).or_default().push(Source::TeamYml);
71+
}
72+
}
73+
74+
let mut file_owners: Vec<FileOwner> = Vec::new();
75+
for (team_name, sources) in sources_by_team.into_iter() {
76+
if let Some(team) = teams_by_name.get(&team_name) {
77+
let relative_team_yml_path = team
78+
.path
79+
.strip_prefix(project_root)
80+
.unwrap_or(&team.path)
81+
.to_string_lossy()
82+
.to_string();
83+
file_owners.push(FileOwner {
84+
team: team.clone(),
85+
team_config_file_path: relative_team_yml_path,
86+
sources,
87+
});
88+
}
89+
}
90+
91+
if file_owners.len() > 1 {
92+
file_owners.sort_by(|a, b| {
93+
let priority_a = a
94+
.sources
95+
.iter()
96+
.map(|s| source_priority(s))
97+
.min()
98+
.unwrap_or(u8::MAX);
99+
let priority_b = b
100+
.sources
101+
.iter()
102+
.map(|s| source_priority(s))
103+
.min()
104+
.unwrap_or(u8::MAX);
105+
priority_a.cmp(&priority_b).then_with(|| a.team.name.cmp(&b.team.name))
106+
});
107+
}
108+
109+
file_owners
110+
}
111+
112+
fn build_teams_by_name_map(teams: &[Team]) -> HashMap<String, Team> {
113+
let mut map = HashMap::new();
114+
for team in teams {
115+
map.insert(team.name.clone(), team.clone());
116+
map.insert(team.github_team.clone(), team.clone());
117+
}
118+
map
119+
}
120+
121+
fn load_teams(project_root: &Path, team_file_globs: &[String]) -> std::result::Result<Vec<Team>, String> {
122+
let mut teams: Vec<Team> = Vec::new();
123+
for glob_str in team_file_globs {
124+
let absolute_glob = format!("{}/{}", project_root.display(), glob_str);
125+
let paths = glob(&absolute_glob).map_err(|e| e.to_string())?;
126+
for path in paths.flatten() {
127+
match Team::from_team_file_path(path.clone()) {
128+
Ok(team) => teams.push(team),
129+
Err(e) => {
130+
eprintln!("Error parsing team file: {}", e);
131+
continue;
132+
}
133+
}
134+
}
135+
}
136+
Ok(teams)
137+
}
138+
139+
lazy_static! {
140+
static ref TOP_OF_FILE_TEAM_AT_REGEX: Regex = Regex::new(r#"^(?:#|//)\s*@team\s+(.+)$"#)
141+
.expect("error compiling regular expression");
142+
static ref TOP_OF_FILE_TEAM_COLON_REGEX: Regex = Regex::new(r#"(?i)^(?:#|//)\s*team\s*:\s*(.+)$"#)
143+
.expect("error compiling regular expression");
144+
}
145+
146+
fn read_top_of_file_team(path: &Path) -> Option<String> {
147+
let content = fs::read_to_string(path).ok()?;
148+
for line in content.lines().take(15) {
149+
if let Some(cap) = TOP_OF_FILE_TEAM_AT_REGEX.captures(line) {
150+
if let Some(m) = cap.get(1) {
151+
return Some(m.as_str().to_string());
152+
}
153+
}
154+
if let Some(cap) = TOP_OF_FILE_TEAM_COLON_REGEX.captures(line) {
155+
if let Some(m) = cap.get(1) {
156+
return Some(m.as_str().to_string());
157+
}
158+
}
159+
let trimmed = line.trim_start();
160+
if !(trimmed.starts_with('#') || trimmed.starts_with("//") || trimmed.is_empty()) {
161+
break;
162+
}
163+
}
164+
None
165+
}
166+
167+
fn most_specific_directory_owner(
168+
project_root: &Path,
169+
relative_file_path: &Path,
170+
teams_by_name: &HashMap<String, Team>,
171+
) -> Option<(String, Source)> {
172+
let mut current = project_root.join(relative_file_path);
173+
let mut best: Option<(String, Source)> = None;
174+
loop {
175+
let parent_opt = current.parent().map(|p| p.to_path_buf());
176+
let Some(parent) = parent_opt else { break };
177+
let codeowner_path = parent.join(".codeowner");
178+
if let Ok(owner_str) = fs::read_to_string(&codeowner_path) {
179+
let owner = owner_str.trim();
180+
if let Some(team) = teams_by_name.get(owner) {
181+
let relative_dir = parent
182+
.strip_prefix(project_root)
183+
.unwrap_or(parent.as_path())
184+
.to_string_lossy()
185+
.to_string();
186+
let candidate = (team.name.clone(), Source::Directory(relative_dir));
187+
match &best {
188+
None => best = Some(candidate),
189+
Some((_, existing_source)) => {
190+
let existing_len = source_directory_depth(existing_source);
191+
let candidate_len = source_directory_depth(&candidate.1);
192+
if candidate_len > existing_len {
193+
best = Some(candidate);
194+
}
195+
}
196+
}
197+
}
198+
}
199+
if parent == project_root { break; }
200+
current = parent.clone();
201+
}
202+
best
203+
}
204+
205+
fn nearest_package_owner(
206+
project_root: &Path,
207+
relative_file_path: &Path,
208+
config: &Config,
209+
teams_by_name: &HashMap<String, Team>,
210+
) -> Option<(String, Source)> {
211+
let mut current = project_root.join(relative_file_path);
212+
loop {
213+
let parent_opt = current.parent().map(|p| p.to_path_buf());
214+
let Some(parent) = parent_opt else { break };
215+
let parent_rel = parent.strip_prefix(project_root).unwrap_or(parent.as_path());
216+
if let Some(rel_str) = parent_rel.to_str() {
217+
if glob_list_matches(rel_str, &config.ruby_package_paths) {
218+
let pkg_yml = parent.join("package.yml");
219+
if pkg_yml.exists() {
220+
if let Ok(owner) = read_ruby_package_owner(&pkg_yml) {
221+
if let Some(team) = teams_by_name.get(&owner) {
222+
let package_path = parent_rel.join("package.yml");
223+
let package_glob = format!("{}/**/**", rel_str);
224+
return Some((team.name.clone(), Source::Package(
225+
package_path.to_string_lossy().to_string(),
226+
package_glob,
227+
)));
228+
}
229+
}
230+
}
231+
}
232+
if glob_list_matches(rel_str, &config.javascript_package_paths) {
233+
let pkg_json = parent.join("package.json");
234+
if pkg_json.exists() {
235+
if let Ok(owner) = read_js_package_owner(&pkg_json) {
236+
if let Some(team) = teams_by_name.get(&owner) {
237+
let package_path = parent_rel.join("package.json");
238+
let package_glob = format!("{}/**/**", rel_str);
239+
return Some((team.name.clone(), Source::Package(
240+
package_path.to_string_lossy().to_string(),
241+
package_glob,
242+
)));
243+
}
244+
}
245+
}
246+
}
247+
}
248+
if parent == project_root { break; }
249+
current = parent;
250+
}
251+
None
252+
}
253+
254+
fn source_directory_depth(source: &Source) -> usize {
255+
match source {
256+
Source::Directory(path) => path.matches('/').count(),
257+
_ => 0,
258+
}
259+
}
260+
261+
fn glob_list_matches(path: &str, globs: &[String]) -> bool {
262+
globs.iter().any(|g| glob_match(g, path))
263+
}
264+
265+
fn read_ruby_package_owner(path: &Path) -> std::result::Result<String, String> {
266+
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
267+
let deserializer: crate::project::deserializers::RubyPackage = serde_yaml::from_reader(file).map_err(|e| e.to_string())?;
268+
deserializer.owner.ok_or_else(|| "Missing owner".to_string())
269+
}
270+
271+
fn read_js_package_owner(path: &Path) -> std::result::Result<String, String> {
272+
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
273+
let deserializer: crate::project::deserializers::JavascriptPackage = serde_json::from_reader(file).map_err(|e| e.to_string())?;
274+
deserializer
275+
.metadata
276+
.and_then(|m| m.owner)
277+
.ok_or_else(|| "Missing owner".to_string())
278+
}
279+
280+
fn vendored_gem_owner(
281+
relative_file_path: &Path,
282+
config: &Config,
283+
teams: &[Team],
284+
) -> Option<(String, Source)> {
285+
use std::path::Component;
286+
let mut comps = relative_file_path.components();
287+
let first = comps.next()?;
288+
let second = comps.next()?;
289+
let first_str = match first { Component::Normal(s) => s.to_string_lossy(), _ => return None };
290+
if first_str != config.vendored_gems_path { return None; }
291+
let gem_name = match second { Component::Normal(s) => s.to_string_lossy().to_string(), _ => return None };
292+
for team in teams {
293+
if team.owned_gems.iter().any(|g| g == &gem_name) {
294+
return Some((team.name.clone(), Source::TeamGem));
295+
}
296+
}
297+
None
298+
}
299+
300+
fn source_priority(source: &Source) -> u8 {
301+
match source {
302+
// Highest confidence first
303+
Source::TeamFile => 0,
304+
Source::Directory(_) => 1,
305+
Source::Package(_, _) => 2,
306+
Source::TeamGlob(_) => 3,
307+
Source::TeamGem => 4,
308+
Source::TeamYml => 5,
309+
}
310+
}
311+
312+

0 commit comments

Comments
 (0)