Skip to main content

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