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};
19use fallow_engine::codeowners::CodeOwners;
20use fallow_engine::health::ownership::{OwnershipContext, compile_bot_globs, compute_ownership};
21
22const ROUTING_CHURN_WINDOW_YEARS: u64 = 1;
25
26#[must_use]
30#[allow(
31 clippy::implicit_hasher,
32 reason = "callers always pass the audit changed-file FxHashSet; generalizing the hasher adds noise"
33)]
34pub fn compute_routing(
35 root: &Path,
36 config: &ResolvedConfig,
37 changed_files: &FxHashSet<PathBuf>,
38) -> RoutingFacts {
39 let since =
40 SinceDuration::relative(ROUTING_CHURN_WINDOW_YEARS, ChurnWindowUnit::Years, "1 year");
41 let Some(churn_result) = analyze_churn(root, &since) else {
42 return RoutingFacts::default();
43 };
44
45 let ownership_cfg = &config.health.ownership;
46 let Ok(bot_globs) = compile_bot_globs(&ownership_cfg.bot_patterns) else {
47 return RoutingFacts::default();
48 };
49 let codeowners = CodeOwners::load(root, None).ok();
50 let now_secs = churn_result.clock.epoch_secs();
53 let ctx = OwnershipContext {
54 author_pool: &churn_result.author_pool,
55 bot_globs: &bot_globs,
56 codeowners: codeowners.as_ref(),
57 email_mode: ownership_cfg.email_mode,
58 now_secs,
59 };
60
61 let self_ids = fallow_engine::repo_refs::current_user_identities(root);
65
66 let mut units: Vec<RoutingUnit> = changed_files
67 .iter()
68 .filter_map(|abs| route_one(abs, root, &churn_result, &ctx, &self_ids))
69 .collect();
70 units.sort_by(|a, b| a.file.cmp(&b.file));
71 RoutingFacts { units }
72}
73
74fn expert_is_self(expert: &str, self_ids: &[String]) -> bool {
77 let normalized = expert.trim_start_matches('@').to_ascii_lowercase();
78 self_ids
79 .iter()
80 .any(|id| id.trim_start_matches('@').to_ascii_lowercase() == normalized)
81}
82
83fn route_one(
87 abs: &Path,
88 root: &Path,
89 churn_result: &ChurnResult,
90 ctx: &OwnershipContext<'_>,
91 self_ids: &[String],
92) -> Option<RoutingUnit> {
93 let file_churn = churn_result.files.get(abs)?;
94 let relative = abs.strip_prefix(root).unwrap_or(abs);
95 let metrics = compute_ownership(file_churn, relative, ctx)?;
96
97 let mut expert: Vec<String> = Vec::new();
100 if let Some(owner) = &metrics.declared_owner {
101 expert.push(owner.clone());
102 }
103 if expert.is_empty() {
104 expert.push(metrics.top_contributor.identifier.clone());
105 for reviewer in metrics.suggested_reviewers.iter().take(2) {
106 if !expert.contains(&reviewer.identifier) {
107 expert.push(reviewer.identifier.clone());
108 }
109 }
110 }
111
112 expert.retain(|e| !expert_is_self(e, self_ids));
117 if expert.is_empty() {
118 return None;
119 }
120
121 Some(RoutingUnit {
122 file: relative.to_string_lossy().replace('\\', "/"),
123 expert,
124 bus_factor_one: metrics.bus_factor == 1,
125 })
126}
127
128#[cfg(test)]
129mod tests {
130 use fallow_output::{
131 ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
132 };
133
134 fn contributor(id: &str) -> ContributorEntry {
135 ContributorEntry {
136 identifier: id.to_string(),
137 format: ContributorIdentifierFormat::Handle,
138 share: 1.0,
139 stale_days: 1,
140 commits: 5,
141 }
142 }
143
144 fn metrics(declared: Option<&str>, bus_factor: u32) -> OwnershipMetrics {
145 OwnershipMetrics {
146 bus_factor,
147 contributor_count: 1,
148 top_contributor: contributor("alice"),
149 recent_contributors: vec![],
150 suggested_reviewers: vec![contributor("bob")],
151 declared_owner: declared.map(str::to_string),
152 unowned: None,
153 ownership_state: OwnershipState::Active,
154 drift: false,
155 drift_reason: None,
156 }
157 }
158
159 #[test]
160 fn current_reviewer_is_excluded_from_routing() {
161 let self_ids = vec![
162 "bart".to_string(),
163 "bart@waardenburg.dev".to_string(),
164 "Bart Waardenburg".to_string(),
165 ];
166 assert!(super::expert_is_self("bart", &self_ids));
169 assert!(super::expert_is_self("Bart", &self_ids));
170 assert!(super::expert_is_self("@bart", &self_ids));
171 assert!(super::expert_is_self("bart@waardenburg.dev", &self_ids));
172 assert!(!super::expert_is_self("alice", &self_ids));
174 assert!(!super::expert_is_self("@team/ui", &self_ids));
175 assert!(!super::expert_is_self("bart", &[]));
177 }
178
179 fn select_expert(metrics: &OwnershipMetrics) -> (Vec<String>, bool) {
182 let mut expert: Vec<String> = Vec::new();
183 if let Some(owner) = &metrics.declared_owner {
184 expert.push(owner.clone());
185 }
186 if expert.is_empty() {
187 expert.push(metrics.top_contributor.identifier.clone());
188 for reviewer in metrics.suggested_reviewers.iter().take(2) {
189 if !expert.contains(&reviewer.identifier) {
190 expert.push(reviewer.identifier.clone());
191 }
192 }
193 }
194 (expert, metrics.bus_factor == 1)
195 }
196
197 #[test]
198 fn declared_owner_wins() {
199 let (expert, _) = select_expert(&metrics(Some("@team/web"), 3));
200 assert_eq!(expert, vec!["@team/web".to_string()]);
201 }
202
203 #[test]
204 fn falls_back_to_git_contributors() {
205 let (expert, _) = select_expert(&metrics(None, 2));
206 assert_eq!(expert, vec!["alice".to_string(), "bob".to_string()]);
207 }
208
209 #[test]
210 fn bus_factor_one_is_flagged() {
211 let (_, bus1) = select_expert(&metrics(None, 1));
212 assert!(bus1);
213 let (_, bus2) = select_expert(&metrics(None, 2));
214 assert!(!bus2);
215 }
216}