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::codeowners::CodeOwners;
19use fallow_engine::health_ownership::{OwnershipContext, compile_bot_globs, compute_ownership};
20use fallow_engine::{ChurnResult, SinceDuration, analyze_churn};
21
22/// Default churn window for routing: one year of history is enough to identify
23/// the per-file experts without an unbounded `git log`.
24const ROUTING_CHURN_WINDOW: &str = "1 year ago";
25
26/// Compute the routing section for the changed files. Best-effort: returns an
27/// empty `RoutingFacts` when churn is unavailable (non-git repo, shallow clone
28/// with no history). CODEOWNERS is consulted when present.
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 = SinceDuration {
40        git_after: ROUTING_CHURN_WINDOW.to_string(),
41        display: "1 year".to_string(),
42    };
43    let Some(churn_result) = analyze_churn(root, &since) else {
44        return RoutingFacts::default();
45    };
46
47    let ownership_cfg = &config.health.ownership;
48    let Ok(bot_globs) = compile_bot_globs(&ownership_cfg.bot_patterns) else {
49        return RoutingFacts::default();
50    };
51    let codeowners = CodeOwners::load(root, None).ok();
52    let now_secs = std::time::SystemTime::now()
53        .duration_since(std::time::UNIX_EPOCH)
54        .unwrap_or_default()
55        .as_secs();
56    let ctx = OwnershipContext {
57        author_pool: &churn_result.author_pool,
58        bot_globs: &bot_globs,
59        codeowners: codeowners.as_ref(),
60        email_mode: ownership_cfg.email_mode,
61        now_secs,
62    };
63
64    // The current reviewer (git user) is excluded from routing: you do not "ask
65    // yourself". On a solo repo every file routes to the author, so this is what
66    // turns "ask: bart (bus-factor 1)" on every decision into silence.
67    let self_ids = current_user_identities(root);
68
69    let mut units: Vec<RoutingUnit> = changed_files
70        .iter()
71        .filter_map(|abs| route_one(abs, root, &churn_result, &ctx, &self_ids))
72        .collect();
73    units.sort_by(|a, b| a.file.cmp(&b.file));
74    RoutingFacts { units }
75}
76
77/// Identifiers for the current git user (the reviewer). Used to drop self-routing:
78/// the raw `user.email`, its handle (local-part, GitHub no-reply unwrapped), and
79/// `user.name`. Empty when git config is unreadable (best-effort, no exclusion).
80fn current_user_identities(root: &Path) -> Vec<String> {
81    let read = |key: &str| -> Option<String> {
82        let output = std::process::Command::new("git")
83            .arg("-C")
84            .arg(root)
85            .args(["config", "--get", key])
86            .output()
87            .ok()?;
88        if !output.status.success() {
89            return None;
90        }
91        let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
92        (!value.is_empty()).then_some(value)
93    };
94    let mut ids = Vec::new();
95    if let Some(email) = read("user.email") {
96        if let Some((local, _)) = email.split_once('@') {
97            // GitHub no-reply unwrap: `1234+handle@users.noreply.github.com` -> `handle`.
98            ids.push(local.rsplit('+').next().unwrap_or(local).to_string());
99        }
100        ids.push(email);
101    }
102    if let Some(name) = read("user.name") {
103        ids.push(name);
104    }
105    ids
106}
107
108/// True when `expert` names the current reviewer (case-insensitive, `@`-tolerant
109/// so a CODEOWNERS `@handle` matches the bare git handle).
110fn expert_is_self(expert: &str, self_ids: &[String]) -> bool {
111    let normalized = expert.trim_start_matches('@').to_ascii_lowercase();
112    self_ids
113        .iter()
114        .any(|id| id.trim_start_matches('@').to_ascii_lowercase() == normalized)
115}
116
117/// Route a single changed file: resolve its experts and bus-factor flag from the
118/// ownership machinery. Returns `None` when the file has no churn record (no
119/// signal to route on).
120fn route_one(
121    abs: &Path,
122    root: &Path,
123    churn_result: &ChurnResult,
124    ctx: &OwnershipContext<'_>,
125    self_ids: &[String],
126) -> Option<RoutingUnit> {
127    let file_churn = churn_result.files.get(abs)?;
128    let relative = abs.strip_prefix(root).unwrap_or(abs);
129    let metrics = compute_ownership(file_churn, relative, ctx)?;
130
131    // Prefer the declared CODEOWNERS owner; otherwise the top contributor, then
132    // the suggested reviewers. Deduped, capped to keep the routing line tight.
133    let mut expert: Vec<String> = Vec::new();
134    if let Some(owner) = &metrics.declared_owner {
135        expert.push(owner.clone());
136    }
137    if expert.is_empty() {
138        expert.push(metrics.top_contributor.identifier.clone());
139        for reviewer in metrics.suggested_reviewers.iter().take(2) {
140            if !expert.contains(&reviewer.identifier) {
141                expert.push(reviewer.identifier.clone());
142            }
143        }
144    }
145
146    // Drop the current reviewer: there is no one to "ask" if you own it. A unit
147    // whose every expert is the reviewer carries no routing signal and is omitted
148    // (same doctrine as a file with no ownership signal), so a solo repo emits no
149    // routing noise.
150    expert.retain(|e| !expert_is_self(e, self_ids));
151    if expert.is_empty() {
152        return None;
153    }
154
155    Some(RoutingUnit {
156        file: relative.to_string_lossy().replace('\\', "/"),
157        expert,
158        bus_factor_one: metrics.bus_factor == 1,
159    })
160}
161
162#[cfg(test)]
163mod tests {
164    use fallow_output::{
165        ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
166    };
167
168    fn contributor(id: &str) -> ContributorEntry {
169        ContributorEntry {
170            identifier: id.to_string(),
171            format: ContributorIdentifierFormat::Handle,
172            share: 1.0,
173            stale_days: 1,
174            commits: 5,
175        }
176    }
177
178    fn metrics(declared: Option<&str>, bus_factor: u32) -> OwnershipMetrics {
179        OwnershipMetrics {
180            bus_factor,
181            contributor_count: 1,
182            top_contributor: contributor("alice"),
183            recent_contributors: vec![],
184            suggested_reviewers: vec![contributor("bob")],
185            declared_owner: declared.map(str::to_string),
186            unowned: None,
187            ownership_state: OwnershipState::Active,
188            drift: false,
189            drift_reason: None,
190        }
191    }
192
193    #[test]
194    fn current_reviewer_is_excluded_from_routing() {
195        let self_ids = vec![
196            "bart".to_string(),
197            "bart@waardenburg.dev".to_string(),
198            "Bart Waardenburg".to_string(),
199        ];
200        // The reviewer matches by handle, raw email, name, and CODEOWNERS @form,
201        // case-insensitively.
202        assert!(super::expert_is_self("bart", &self_ids));
203        assert!(super::expert_is_self("Bart", &self_ids));
204        assert!(super::expert_is_self("@bart", &self_ids));
205        assert!(super::expert_is_self("bart@waardenburg.dev", &self_ids));
206        // A different contributor is never self.
207        assert!(!super::expert_is_self("alice", &self_ids));
208        assert!(!super::expert_is_self("@team/ui", &self_ids));
209        // No identities -> never self (best-effort: git config unreadable).
210        assert!(!super::expert_is_self("bart", &[]));
211    }
212
213    /// `route_one`'s expert-selection logic, exercised through a small shim that
214    /// mirrors its branching without needing a live git repo.
215    fn select_expert(metrics: &OwnershipMetrics) -> (Vec<String>, bool) {
216        let mut expert: Vec<String> = Vec::new();
217        if let Some(owner) = &metrics.declared_owner {
218            expert.push(owner.clone());
219        }
220        if expert.is_empty() {
221            expert.push(metrics.top_contributor.identifier.clone());
222            for reviewer in metrics.suggested_reviewers.iter().take(2) {
223                if !expert.contains(&reviewer.identifier) {
224                    expert.push(reviewer.identifier.clone());
225                }
226            }
227        }
228        (expert, metrics.bus_factor == 1)
229    }
230
231    #[test]
232    fn declared_owner_wins() {
233        let (expert, _) = select_expert(&metrics(Some("@team/web"), 3));
234        assert_eq!(expert, vec!["@team/web".to_string()]);
235    }
236
237    #[test]
238    fn falls_back_to_git_contributors() {
239        let (expert, _) = select_expert(&metrics(None, 2));
240        assert_eq!(expert, vec!["alice".to_string(), "bob".to_string()]);
241    }
242
243    #[test]
244    fn bus_factor_one_is_flagged() {
245        let (_, bus1) = select_expert(&metrics(None, 1));
246        assert!(bus1);
247        let (_, bus2) = select_expert(&metrics(None, 2));
248        assert!(!bus2);
249    }
250}