Skip to main content

fallow_api/
routing.rs

1//! Ownership-aware reviewer routing (6.D).
2//!
3//! Per changed file, name the expert(s) to route the review to: CODEOWNERS
4//! declared owner plus git-blame / recency contributors, and flag a bus-factor-1
5//! risk (the only qualified owner is one person). Reuses the health ownership /
6//! bus-factor machinery (`compute_ownership`, `CodeOwners`, churn) rather than a
7//! parallel implementation.
8//!
9//! This is the people-layer of the review direction: it answers "who do I ask?".
10//! Advisory brief data; never gates.
11
12use 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::health::ownership::{OwnershipContext, compile_bot_globs, compute_ownership};
20
21/// Default churn window for routing: one year of history is enough to identify
22/// the per-file experts without an unbounded `git log`.
23const ROUTING_CHURN_WINDOW_YEARS: u64 = 1;
24
25/// Compute the routing section for the changed files. Best-effort: returns an
26/// empty `RoutingFacts` when churn is unavailable (non-git repo, shallow clone
27/// with no history). CODEOWNERS is consulted when present: the configured
28/// `codeowners` path when set, else the standard locations.
29#[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    // The same file the ownership section reads, so both use one owner
50    // vocabulary.
51    let codeowners = crate::ownership::load_codeowners(root, config)
52        .ok()
53        .flatten();
54    // Reuse the churn run's clock so routing and the health ownership block
55    // agree on "now" and neither flips a staleness threshold between runs.
56    let now_secs = churn_result.clock.epoch_secs();
57    let ctx = OwnershipContext {
58        author_pool: &churn_result.author_pool,
59        bot_globs: &bot_globs,
60        codeowners: codeowners.as_ref(),
61        email_mode: ownership_cfg.email_mode,
62        now_secs,
63    };
64
65    // The current reviewer (git user) is excluded from routing: you do not "ask
66    // yourself". On a solo repo every file routes to the author, so this is what
67    // turns "ask: bart (bus-factor 1)" on every decision into silence.
68    let self_ids = fallow_engine::repo_refs::current_user_identities(root);
69
70    let mut units: Vec<RoutingUnit> = changed_files
71        .iter()
72        .filter_map(|abs| route_one(abs, root, &churn_result, &ctx, &self_ids))
73        .collect();
74    units.sort_by(|a, b| a.file.cmp(&b.file));
75    RoutingFacts { units }
76}
77
78/// True when `expert` names the current reviewer (case-insensitive, `@`-tolerant
79/// so a CODEOWNERS `@handle` matches the bare git handle).
80fn expert_is_self(expert: &str, self_ids: &[String]) -> bool {
81    let normalized = expert.trim_start_matches('@').to_ascii_lowercase();
82    self_ids
83        .iter()
84        .any(|id| id.trim_start_matches('@').to_ascii_lowercase() == normalized)
85}
86
87/// Route a single changed file: resolve its experts and bus-factor flag from the
88/// ownership machinery. Returns `None` when the file has no churn record (no
89/// signal to route on).
90fn route_one(
91    abs: &Path,
92    root: &Path,
93    churn_result: &ChurnResult,
94    ctx: &OwnershipContext<'_>,
95    self_ids: &[String],
96) -> Option<RoutingUnit> {
97    let file_churn = churn_result.files.get(abs)?;
98    let relative = abs.strip_prefix(root).unwrap_or(abs);
99    let metrics = compute_ownership(file_churn, relative, ctx)?;
100
101    // Prefer the declared CODEOWNERS owner; otherwise the top contributor, then
102    // the suggested reviewers. Deduped, capped to keep the routing line tight.
103    let mut expert: Vec<String> = Vec::new();
104    if let Some(owner) = &metrics.declared_owner {
105        expert.push(owner.clone());
106    }
107    if expert.is_empty() {
108        expert.push(metrics.top_contributor.identifier.clone());
109        for reviewer in metrics.suggested_reviewers.iter().take(2) {
110            if !expert.contains(&reviewer.identifier) {
111                expert.push(reviewer.identifier.clone());
112            }
113        }
114    }
115
116    // Drop the current reviewer: there is no one to "ask" if you own it. A unit
117    // whose every expert is the reviewer carries no routing signal and is omitted
118    // (same doctrine as a file with no ownership signal), so a solo repo emits no
119    // routing noise.
120    expert.retain(|e| !expert_is_self(e, self_ids));
121    if expert.is_empty() {
122        return None;
123    }
124
125    Some(RoutingUnit {
126        file: relative.to_string_lossy().replace('\\', "/"),
127        expert,
128        bus_factor_one: metrics.bus_factor == 1,
129    })
130}
131
132#[cfg(test)]
133mod tests {
134    use fallow_output::{
135        ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
136    };
137
138    fn contributor(id: &str) -> ContributorEntry {
139        ContributorEntry {
140            identifier: id.to_string(),
141            format: ContributorIdentifierFormat::Handle,
142            share: 1.0,
143            stale_days: 1,
144            commits: 5,
145        }
146    }
147
148    fn metrics(declared: Option<&str>, bus_factor: u32) -> OwnershipMetrics {
149        OwnershipMetrics {
150            bus_factor,
151            contributor_count: 1,
152            top_contributor: contributor("alice"),
153            recent_contributors: vec![],
154            suggested_reviewers: vec![contributor("bob")],
155            declared_owner: declared.map(str::to_string),
156            unowned: None,
157            ownership_state: OwnershipState::Active,
158            drift: false,
159            drift_reason: None,
160        }
161    }
162
163    #[test]
164    fn current_reviewer_is_excluded_from_routing() {
165        let self_ids = vec![
166            "bart".to_string(),
167            "bart@waardenburg.dev".to_string(),
168            "Bart Waardenburg".to_string(),
169        ];
170        // The reviewer matches by handle, raw email, name, and CODEOWNERS @form,
171        // case-insensitively.
172        assert!(super::expert_is_self("bart", &self_ids));
173        assert!(super::expert_is_self("Bart", &self_ids));
174        assert!(super::expert_is_self("@bart", &self_ids));
175        assert!(super::expert_is_self("bart@waardenburg.dev", &self_ids));
176        // A different contributor is never self.
177        assert!(!super::expert_is_self("alice", &self_ids));
178        assert!(!super::expert_is_self("@team/ui", &self_ids));
179        // No identities -> never self (best-effort: git config unreadable).
180        assert!(!super::expert_is_self("bart", &[]));
181    }
182
183    /// `route_one`'s expert-selection logic, exercised through a small shim that
184    /// mirrors its branching without needing a live git repo.
185    fn select_expert(metrics: &OwnershipMetrics) -> (Vec<String>, bool) {
186        let mut expert: Vec<String> = Vec::new();
187        if let Some(owner) = &metrics.declared_owner {
188            expert.push(owner.clone());
189        }
190        if expert.is_empty() {
191            expert.push(metrics.top_contributor.identifier.clone());
192            for reviewer in metrics.suggested_reviewers.iter().take(2) {
193                if !expert.contains(&reviewer.identifier) {
194                    expert.push(reviewer.identifier.clone());
195                }
196            }
197        }
198        (expert, metrics.bus_factor == 1)
199    }
200
201    #[test]
202    fn declared_owner_wins() {
203        let (expert, _) = select_expert(&metrics(Some("@team/web"), 3));
204        assert_eq!(expert, vec!["@team/web".to_string()]);
205    }
206
207    #[test]
208    fn falls_back_to_git_contributors() {
209        let (expert, _) = select_expert(&metrics(None, 2));
210        assert_eq!(expert, vec!["alice".to_string(), "bob".to_string()]);
211    }
212
213    #[test]
214    fn bus_factor_one_is_flagged() {
215        let (_, bus1) = select_expert(&metrics(None, 1));
216        assert!(bus1);
217        let (_, bus2) = select_expert(&metrics(None, 2));
218        assert!(!bus2);
219    }
220}