1use std::path::{Path, PathBuf};
13
14pub use fallow_output::{RoutingFacts, RoutingUnit};
15use rustc_hash::FxHashSet;
16
17use fallow_config::ResolvedConfig;
18use fallow_engine::churn::{ChurnResult, ChurnWindowUnit, SinceDuration, analyze_churn_cached};
19use fallow_engine::health::ownership::{OwnershipContext, compile_bot_globs, compute_ownership};
20
21const AUDIT_CHURN_WINDOW_MONTHS: u64 = 12;
26
27#[must_use]
30pub fn audit_churn(root: &Path, config: &ResolvedConfig) -> Option<ChurnResult> {
31 let since = SinceDuration::relative(
32 AUDIT_CHURN_WINDOW_MONTHS,
33 ChurnWindowUnit::Months,
34 format!("{AUDIT_CHURN_WINDOW_MONTHS} months"),
35 );
36 analyze_churn_cached(root, &since, &config.cache_dir, config.no_cache).map(|(churn, _)| churn)
37}
38
39#[must_use]
44#[allow(
45 clippy::implicit_hasher,
46 reason = "callers always pass the audit changed-file FxHashSet; generalizing the hasher adds noise"
47)]
48pub fn compute_routing(
49 root: &Path,
50 config: &ResolvedConfig,
51 changed_files: &FxHashSet<PathBuf>,
52) -> RoutingFacts {
53 audit_churn(root, config).map_or_else(RoutingFacts::default, |churn| {
54 compute_routing_from_churn(root, config, changed_files, &churn)
55 })
56}
57
58#[must_use]
61#[allow(
62 clippy::implicit_hasher,
63 reason = "callers always pass the audit changed-file FxHashSet; generalizing the hasher adds noise"
64)]
65pub fn compute_routing_from_churn(
66 root: &Path,
67 config: &ResolvedConfig,
68 changed_files: &FxHashSet<PathBuf>,
69 churn_result: &ChurnResult,
70) -> RoutingFacts {
71 let ownership_cfg = &config.health.ownership;
72 let Ok(bot_globs) = compile_bot_globs(&ownership_cfg.bot_patterns) else {
73 return RoutingFacts::default();
74 };
75 let codeowners = crate::ownership::load_codeowners(root, config)
78 .ok()
79 .flatten();
80 let now_secs = churn_result.clock.epoch_secs();
83 let ctx = OwnershipContext {
84 author_pool: &churn_result.author_pool,
85 bot_globs: &bot_globs,
86 codeowners: codeowners.as_ref(),
87 email_mode: ownership_cfg.email_mode,
88 now_secs,
89 };
90
91 let self_ids = fallow_engine::repo_refs::current_user_identities(root);
95
96 let mut units: Vec<RoutingUnit> = changed_files
97 .iter()
98 .filter_map(|abs| route_one(abs, root, churn_result, &ctx, &self_ids))
99 .collect();
100 units.sort_by(|a, b| a.file.cmp(&b.file));
101 RoutingFacts { units }
102}
103
104fn expert_is_self(expert: &str, self_ids: &[String]) -> bool {
107 let normalized = expert.trim_start_matches('@').to_ascii_lowercase();
108 self_ids
109 .iter()
110 .any(|id| id.trim_start_matches('@').to_ascii_lowercase() == normalized)
111}
112
113fn route_one(
117 abs: &Path,
118 root: &Path,
119 churn_result: &ChurnResult,
120 ctx: &OwnershipContext<'_>,
121 self_ids: &[String],
122) -> Option<RoutingUnit> {
123 let file_churn = churn_result.files.get(abs)?;
124 let relative = abs.strip_prefix(root).unwrap_or(abs);
125 let metrics = compute_ownership(file_churn, relative, ctx)?;
126
127 let mut expert: Vec<String> = Vec::new();
130 if let Some(owner) = &metrics.declared_owner {
131 expert.push(owner.clone());
132 }
133 if expert.is_empty() {
134 expert.push(metrics.top_contributor.identifier.clone());
135 for reviewer in metrics.suggested_reviewers.iter().take(2) {
136 if !expert.contains(&reviewer.identifier) {
137 expert.push(reviewer.identifier.clone());
138 }
139 }
140 }
141
142 expert.retain(|e| !expert_is_self(e, self_ids));
147 if expert.is_empty() {
148 return None;
149 }
150
151 Some(RoutingUnit {
152 file: relative.to_string_lossy().replace('\\', "/"),
153 expert,
154 bus_factor_one: metrics.bus_factor == 1,
155 })
156}
157
158#[cfg(test)]
159mod tests {
160 use fallow_output::{
161 ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
162 };
163
164 fn contributor(id: &str) -> ContributorEntry {
165 ContributorEntry {
166 identifier: id.to_string(),
167 format: ContributorIdentifierFormat::Handle,
168 share: 1.0,
169 stale_days: 1,
170 commits: 5,
171 }
172 }
173
174 fn metrics(declared: Option<&str>, bus_factor: u32) -> OwnershipMetrics {
175 OwnershipMetrics {
176 bus_factor,
177 contributor_count: 1,
178 top_contributor: contributor("alice"),
179 recent_contributors: vec![],
180 suggested_reviewers: vec![contributor("bob")],
181 declared_owner: declared.map(str::to_string),
182 unowned: None,
183 ownership_state: OwnershipState::Active,
184 drift: false,
185 drift_reason: None,
186 }
187 }
188
189 #[test]
190 fn current_reviewer_is_excluded_from_routing() {
191 let self_ids = vec![
192 "bart".to_string(),
193 "bart@waardenburg.dev".to_string(),
194 "Bart Waardenburg".to_string(),
195 ];
196 assert!(super::expert_is_self("bart", &self_ids));
199 assert!(super::expert_is_self("Bart", &self_ids));
200 assert!(super::expert_is_self("@bart", &self_ids));
201 assert!(super::expert_is_self("bart@waardenburg.dev", &self_ids));
202 assert!(!super::expert_is_self("alice", &self_ids));
204 assert!(!super::expert_is_self("@team/ui", &self_ids));
205 assert!(!super::expert_is_self("bart", &[]));
207 }
208
209 fn select_expert(metrics: &OwnershipMetrics) -> (Vec<String>, bool) {
212 let mut expert: Vec<String> = Vec::new();
213 if let Some(owner) = &metrics.declared_owner {
214 expert.push(owner.clone());
215 }
216 if expert.is_empty() {
217 expert.push(metrics.top_contributor.identifier.clone());
218 for reviewer in metrics.suggested_reviewers.iter().take(2) {
219 if !expert.contains(&reviewer.identifier) {
220 expert.push(reviewer.identifier.clone());
221 }
222 }
223 }
224 (expert, metrics.bus_factor == 1)
225 }
226
227 #[test]
228 fn declared_owner_wins() {
229 let (expert, _) = select_expert(&metrics(Some("@team/web"), 3));
230 assert_eq!(expert, vec!["@team/web".to_string()]);
231 }
232
233 #[test]
234 fn falls_back_to_git_contributors() {
235 let (expert, _) = select_expert(&metrics(None, 2));
236 assert_eq!(expert, vec!["alice".to_string(), "bob".to_string()]);
237 }
238
239 #[test]
240 fn bus_factor_one_is_flagged() {
241 let (_, bus1) = select_expert(&metrics(None, 1));
242 assert!(bus1);
243 let (_, bus2) = select_expert(&metrics(None, 2));
244 assert!(!bus2);
245 }
246}