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_cached};
19use fallow_engine::health::ownership::{OwnershipContext, compile_bot_globs, compute_ownership};
20
21/// Churn window of the audit brief, in months: enough history to name the
22/// per-file experts and to find hidden change coupling. It matches the
23/// default change coupling window of `fallow health`, so both commands share
24/// one churn cache entry.
25const AUDIT_CHURN_WINDOW_MONTHS: u64 = 12;
26
27/// Read the audit brief churn through the shared churn cache. `None` when git
28/// history is unavailable (not a repository, or a branch without commits).
29#[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/// Compute the routing section for the changed files. Best-effort: returns an
40/// empty `RoutingFacts` when churn is unavailable (non-git repo, shallow clone
41/// with no history). CODEOWNERS is consulted when present: the configured
42/// `codeowners` path when set, else the standard locations.
43#[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/// [`compute_routing`] over a churn pass that the caller already read with
59/// [`audit_churn`], so the audit brief reads the history once.
60#[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    // The same file the ownership section reads, so both use one owner
76    // vocabulary.
77    let codeowners = crate::ownership::load_codeowners(root, config)
78        .ok()
79        .flatten();
80    // Reuse the churn run's clock so routing and the health ownership block
81    // agree on "now" and neither flips a staleness threshold between runs.
82    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    // The current reviewer (git user) is excluded from routing: you do not "ask
92    // yourself". On a solo repo every file routes to the author, so this is what
93    // turns "ask: bart (bus-factor 1)" on every decision into silence.
94    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
104/// True when `expert` names the current reviewer (case-insensitive, `@`-tolerant
105/// so a CODEOWNERS `@handle` matches the bare git handle).
106fn 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
113/// Route a single changed file: resolve its experts and bus-factor flag from the
114/// ownership machinery. Returns `None` when the file has no churn record (no
115/// signal to route on).
116fn 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    // Prefer the declared CODEOWNERS owner; otherwise the top contributor, then
128    // the suggested reviewers. Deduped, capped to keep the routing line tight.
129    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    // Drop the current reviewer: there is no one to "ask" if you own it. A unit
143    // whose every expert is the reviewer carries no routing signal and is omitted
144    // (same doctrine as a file with no ownership signal), so a solo repo emits no
145    // routing noise.
146    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        // The reviewer matches by handle, raw email, name, and CODEOWNERS @form,
197        // case-insensitively.
198        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        // A different contributor is never self.
203        assert!(!super::expert_is_self("alice", &self_ids));
204        assert!(!super::expert_is_self("@team/ui", &self_ids));
205        // No identities -> never self (best-effort: git config unreadable).
206        assert!(!super::expert_is_self("bart", &[]));
207    }
208
209    /// `route_one`'s expert-selection logic, exercised through a small shim that
210    /// mirrors its branching without needing a live git repo.
211    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}