Skip to main content

fallow_api/
ownership.rs

1//! Owner-group reach of a changeset for the review brief.
2//!
3//! Maps the changed files and the impact closure to CODEOWNERS owner groups:
4//! how many groups the change reaches, which groups it reaches only through
5//! the closure, and whether each independent slice of the partition has one
6//! owner. Reads the CODEOWNERS file only. It does not read git history, so it
7//! does not depend on the churn walk behind `routing`.
8//!
9//! Advisory brief data; never gates.
10
11use std::path::Path;
12
13pub use fallow_output::{OWNER_GROUP_CAP, OwnerGroupFact, OwnershipFacts, OwnershipSliceFact};
14use rustc_hash::{FxHashMap, FxHashSet};
15
16use fallow_config::ResolvedConfig;
17use fallow_engine::codeowners::{CodeOwners, UNOWNED_LABEL};
18use fallow_engine::module_graph::PartitionOrderPaths;
19
20/// Load the project CODEOWNERS file: the configured `codeowners` path when
21/// set, else the first of the standard locations.
22///
23/// Returns `Ok(None)` when no path is configured and no standard location
24/// holds a file that parses, so the brief omits the section.
25///
26/// # Errors
27///
28/// Returns the reason when a configured `codeowners` path cannot be read or
29/// does not parse. The caller reports it, because the user asked for that
30/// file.
31pub fn load_codeowners(root: &Path, config: &ResolvedConfig) -> Result<Option<CodeOwners>, String> {
32    match config.codeowners.as_deref() {
33        Some(path) => CodeOwners::load(root, Some(path))
34            .map(Some)
35            .map_err(|error| format!("codeowners path `{path}`: {error}")),
36        None => Ok(CodeOwners::discover(root).ok()),
37    }
38}
39
40/// Compute the ownership section.
41///
42/// `changed` and `affected` are root-relative, forward-slashed paths.
43/// `affected` is the full, uncapped impact closure (files affected but not in
44/// the diff), never the serialized sample. `partition` supplies the
45/// independent slices and the changed files of each unit.
46#[must_use]
47pub fn compute_ownership_facts(
48    codeowners: &CodeOwners,
49    changed: &[String],
50    affected: &[String],
51    partition: Option<&PartitionOrderPaths>,
52) -> OwnershipFacts {
53    let owner_of = |path: &str| -> String {
54        codeowners
55            .owner_of(Path::new(path))
56            .unwrap_or(UNOWNED_LABEL)
57            .to_string()
58    };
59
60    let mut counts: FxHashMap<String, (usize, usize)> = FxHashMap::default();
61    for path in changed {
62        counts.entry(owner_of(path)).or_default().0 += 1;
63    }
64    for path in affected {
65        counts.entry(owner_of(path)).or_default().1 += 1;
66    }
67
68    let unowned_direct_count = counts.get(UNOWNED_LABEL).map_or(0, |&(direct, _)| direct);
69    let transitive_only_count = counts.values().filter(|&&(direct, _)| direct == 0).count();
70    let group_count = counts.len();
71
72    let mut groups: Vec<OwnerGroupFact> = counts
73        .into_iter()
74        .map(|(owner, (direct_count, affected_count))| OwnerGroupFact {
75            owner,
76            direct_count,
77            affected_count,
78        })
79        .collect();
80    groups.sort_by(|a, b| {
81        b.direct_count
82            .cmp(&a.direct_count)
83            .then_with(|| b.affected_count.cmp(&a.affected_count))
84            .then_with(|| a.owner.cmp(&b.owner))
85    });
86    let groups_omitted = groups.len().saturating_sub(OWNER_GROUP_CAP);
87    groups.truncate(OWNER_GROUP_CAP);
88
89    OwnershipFacts {
90        group_count,
91        transitive_only_count,
92        unowned_direct_count,
93        groups,
94        groups_omitted,
95        slices: partition.map_or_else(Vec::new, |partition| slice_owners(partition, &owner_of)),
96    }
97}
98
99/// The owner set of each independent slice, aligned by index with
100/// `independent_slices`. Empty when the partition has fewer than two slices,
101/// the same rule that keeps `independent_slices` off the wire.
102///
103/// The owner set of a slice is never empty: the partition builds its slices
104/// from its own units, so each slice directory has a unit with at least one
105/// changed file, and each file has an owner or the unowned label.
106///
107/// The owners come from the changed files of each unit, not from the module
108/// directory, because a CODEOWNERS rule can split a directory.
109fn slice_owners(
110    partition: &PartitionOrderPaths,
111    owner_of: &dyn Fn(&str) -> String,
112) -> Vec<OwnershipSliceFact> {
113    if partition.independent_slices.len() < 2 {
114        return Vec::new();
115    }
116    let files_by_dir: FxHashMap<&str, &[String]> = partition
117        .units
118        .iter()
119        .map(|unit| (unit.module_dir.as_str(), unit.files.as_slice()))
120        .collect();
121    partition
122        .independent_slices
123        .iter()
124        .map(|module_dirs| {
125            let owners: FxHashSet<String> = module_dirs
126                .iter()
127                .filter_map(|dir| files_by_dir.get(dir.as_str()))
128                .flat_map(|files| files.iter())
129                .map(|file| owner_of(file))
130                .collect();
131            let mut owners: Vec<String> = owners.into_iter().collect();
132            owners.sort_unstable();
133            debug_assert!(
134                !owners.is_empty(),
135                "slice {module_dirs:?} has no unit with changed files"
136            );
137            OwnershipSliceFact {
138                module_dirs: module_dirs.clone(),
139                separable: owners.len() == 1,
140                owners,
141            }
142        })
143        .collect()
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use fallow_engine::module_graph::ReviewUnitPaths;
150
151    fn paths(items: &[&str]) -> Vec<String> {
152        items.iter().map(|item| (*item).to_string()).collect()
153    }
154
155    fn owners(content: &str) -> CodeOwners {
156        CodeOwners::parse(content).expect("valid CODEOWNERS")
157    }
158
159    fn group(owner: &str, direct_count: usize, affected_count: usize) -> OwnerGroupFact {
160        OwnerGroupFact {
161            owner: owner.to_string(),
162            direct_count,
163            affected_count,
164        }
165    }
166
167    fn partition(units: &[(&str, &[&str])], slices: &[&[&str]]) -> PartitionOrderPaths {
168        PartitionOrderPaths {
169            units: units
170                .iter()
171                .map(|(dir, files)| ReviewUnitPaths {
172                    module_dir: (*dir).to_string(),
173                    files: paths(files),
174                })
175                .collect(),
176            order: Vec::new(),
177            independent_slices: slices.iter().map(|slice| paths(slice)).collect(),
178        }
179    }
180
181    #[test]
182    fn all_unowned_files_form_one_unowned_group() {
183        let facts = compute_ownership_facts(
184            &owners("docs/ @docs\n"),
185            &paths(&["src/a.ts", "src/b.ts"]),
186            &paths(&["src/c.ts"]),
187            None,
188        );
189        assert_eq!(facts.group_count, 1);
190        assert_eq!(facts.unowned_direct_count, 2);
191        assert_eq!(facts.transitive_only_count, 0);
192        assert_eq!(facts.groups, vec![group(UNOWNED_LABEL, 2, 1)]);
193    }
194
195    #[test]
196    fn one_owner_owns_the_change_and_its_closure() {
197        let facts = compute_ownership_facts(
198            &owners("* @org/web\n"),
199            &paths(&["src/a.ts"]),
200            &paths(&["src/b.ts", "src/c.ts"]),
201            None,
202        );
203        assert_eq!(facts.group_count, 1);
204        assert_eq!(facts.unowned_direct_count, 0);
205        assert_eq!(facts.groups, vec![group("@org/web", 1, 2)]);
206    }
207
208    #[test]
209    fn a_group_reached_only_through_the_closure_is_transitive_only() {
210        let facts = compute_ownership_facts(
211            &owners("src/web/ @org/web\nsrc/tokens/ @org/design\nsrc/api/ @org/api\n"),
212            &paths(&["src/web/a.ts", "src/web/b.ts", "src/tokens/t.ts"]),
213            &paths(&["src/api/x.ts", "src/api/y.ts", "src/web/c.ts"]),
214            None,
215        );
216        assert_eq!(facts.group_count, 3);
217        assert_eq!(facts.transitive_only_count, 1);
218        assert_eq!(
219            facts.groups,
220            vec![
221                group("@org/web", 2, 1),
222                group("@org/design", 1, 0),
223                group("@org/api", 0, 2),
224            ]
225        );
226    }
227
228    #[test]
229    fn a_gitlab_negation_makes_the_file_unowned() {
230        let facts = compute_ownership_facts(
231            &owners("src/ @org/web\n!src/generated/\n"),
232            &paths(&["src/a.ts", "src/generated/types.ts"]),
233            &[],
234            None,
235        );
236        assert_eq!(facts.unowned_direct_count, 1);
237        assert_eq!(
238            facts.groups,
239            vec![group(UNOWNED_LABEL, 1, 0), group("@org/web", 1, 0)]
240        );
241    }
242
243    #[test]
244    fn groups_beyond_the_cap_are_counted_not_dropped_silently() {
245        let rules = (0..OWNER_GROUP_CAP + 3).fold(String::new(), |mut acc, i| {
246            use std::fmt::Write as _;
247            let _ = writeln!(acc, "pkg{i:02}/ @team{i:02}");
248            acc
249        });
250        let changed: Vec<String> = (0..OWNER_GROUP_CAP + 3)
251            .map(|i| format!("pkg{i:02}/index.ts"))
252            .collect();
253        let facts = compute_ownership_facts(&owners(&rules), &changed, &[], None);
254        assert_eq!(facts.group_count, OWNER_GROUP_CAP + 3);
255        assert_eq!(facts.groups.len(), OWNER_GROUP_CAP);
256        assert_eq!(facts.groups_omitted, 3);
257    }
258
259    #[test]
260    fn ties_sort_by_owner_so_the_order_is_deterministic() {
261        let facts = compute_ownership_facts(
262            &owners("c/ @c\na/ @a\nb/ @b\n"),
263            &paths(&["c/x.ts", "b/x.ts", "a/x.ts"]),
264            &[],
265            None,
266        );
267        let order: Vec<&str> = facts.groups.iter().map(|g| g.owner.as_str()).collect();
268        assert_eq!(order, vec!["@a", "@b", "@c"]);
269    }
270
271    #[test]
272    fn slices_align_with_the_partition_and_flag_one_owner_as_separable() {
273        let partition = partition(
274            &[
275                ("src/app", &["src/app/main.ts"]),
276                ("src/core", &["src/core/lib.ts"]),
277                ("src/tools", &["src/tools/cli.ts"]),
278            ],
279            &[&["src/app", "src/core"], &["src/tools"]],
280        );
281        let facts = compute_ownership_facts(
282            &owners("src/app/ @team/app\nsrc/core/ @team/core\nsrc/tools/ @team/tools\n"),
283            &paths(&["src/app/main.ts", "src/core/lib.ts", "src/tools/cli.ts"]),
284            &[],
285            Some(&partition),
286        );
287        assert_eq!(
288            facts.slices,
289            vec![
290                OwnershipSliceFact {
291                    module_dirs: paths(&["src/app", "src/core"]),
292                    owners: paths(&["@team/app", "@team/core"]),
293                    separable: false,
294                },
295                OwnershipSliceFact {
296                    module_dirs: paths(&["src/tools"]),
297                    owners: paths(&["@team/tools"]),
298                    separable: true,
299                },
300            ]
301        );
302    }
303
304    #[test]
305    fn slice_owners_come_from_files_so_a_rule_can_split_a_directory() {
306        let partition = partition(
307            &[
308                ("src/a", &["src/a/x.ts", "src/a/y.ts"]),
309                ("src/b", &["src/b/z.ts"]),
310            ],
311            &[&["src/a"], &["src/b"]],
312        );
313        let facts = compute_ownership_facts(
314            &owners("src/ @team/all\nsrc/a/y.ts @team/y\n"),
315            &paths(&["src/a/x.ts", "src/a/y.ts", "src/b/z.ts"]),
316            &[],
317            Some(&partition),
318        );
319        assert_eq!(facts.slices[0].owners, paths(&["@team/all", "@team/y"]));
320        assert!(!facts.slices[0].separable);
321        assert!(facts.slices[1].separable);
322    }
323
324    #[test]
325    fn an_unowned_slice_counts_the_unowned_label_as_its_owner() {
326        let partition = partition(
327            &[("src/a", &["src/a/x.ts"]), ("src/b", &["src/b/z.ts"])],
328            &[&["src/a"], &["src/b"]],
329        );
330        let facts = compute_ownership_facts(
331            &owners("src/a/ @team/a\n"),
332            &paths(&["src/a/x.ts", "src/b/z.ts"]),
333            &[],
334            Some(&partition),
335        );
336        assert_eq!(facts.slices[1].owners, paths(&[UNOWNED_LABEL]));
337        assert!(facts.slices[1].separable);
338    }
339
340    #[test]
341    fn a_single_slice_emits_no_slice_owners() {
342        let partition = partition(&[("src", &["src/a.ts"])], &[&["src"]]);
343        let facts = compute_ownership_facts(
344            &owners("* @org/web\n"),
345            &paths(&["src/a.ts"]),
346            &[],
347            Some(&partition),
348        );
349        assert!(facts.slices.is_empty());
350    }
351}