core_api/repograph/owners.rs
1//! `owners` — who to ask about a file.
2//!
3//! Four answers, because "who owns this" means four different things. The top
4//! author is who wrote most of it, and the share says whether that is a
5//! majority or a plurality. The `KNOWS` authors are the people whose other
6//! files change when this one does, which finds a reviewer the commit log alone
7//! would not. The last touch dates the file. And the quarters say whether
8//! ownership is where it was a year ago, which is the question behind asking at
9//! all.
10//!
11//! Everything here is read from the graph: `TOP_AUTHOR` and its
12//! `author_counts`, the `KNOWS` edges the co-change rule derives, and the
13//! commits on the file itself.
14
15use crate::db::GraphDb;
16use crate::repograph::facts::{
17 author_counts, author_name, commits_of, int_prop, label_of, neighbors, newest_commit_ts,
18 owner_key, rank, score_of, CommitFact,
19};
20use crate::repograph::render::{quarter_index, quarter_label, sanitize};
21use crate::Direction;
22use core_storage::fs::Fs;
23use serde::Serialize;
24use std::collections::BTreeMap;
25
26/// Quarters of history the report covers, counting back from the one "now"
27/// falls in.
28pub const QUARTERS: i64 = 4;
29/// Authors listed as knowing the file.
30const MAX_KNOWS: usize = 4;
31/// Characters of a sha a digest prints. Seven is what git itself abbreviates
32/// to, and the fixture's shas are distinct in the first seven.
33pub(super) const SHA_LEN: usize = 7;
34
35/// Who has written a file, and when.
36#[derive(Debug, Clone, PartialEq, Serialize)]
37pub struct OwnersReport {
38 pub path: String,
39 /// `(author name, author key, share of the file's commits)`. `None` for a
40 /// file with no `TOP_AUTHOR` — a store built with `--no-structure`, or a
41 /// file with no commits at all.
42 pub top: Option<(String, String, f64)>,
43 /// `(author name, co-change score)` for the authors the `knows` rule links
44 /// to this file, strongest first.
45 pub knows: Vec<(String, f64)>,
46 /// `(abbreviated sha, timestamp, subject)` of the newest commit that
47 /// touched the file.
48 pub last_touch: Option<(String, i64, String)>,
49 /// `(quarter, top author's name, commits)` for the last [`QUARTERS`]
50 /// quarters, oldest first. A quarter in which nothing touched the file is
51 /// left out rather than printed as a zero.
52 pub by_quarter: Vec<(String, String, usize)>,
53}
54
55/// Who wrote `path`.
56///
57/// `now_ts` fixes the end of the quarter window; without one it is the newest
58/// commit on the file, so the answer depends on the store alone and two runs
59/// against an unchanged store agree.
60///
61/// `None` when `path` is not a `File` in this store — including when it names a
62/// node of some other label, which has no owner to report.
63#[must_use]
64pub fn owners<F: Fs>(db: &GraphDb<F>, path: &str, now_ts: Option<i64>) -> Option<OwnersReport> {
65 if label_of(db, path)? != "File" {
66 return None;
67 }
68 let commits = commits_of(db, path);
69 Some(OwnersReport {
70 path: sanitize(path),
71 top: top_author(db, path),
72 knows: knows(db, path),
73 last_touch: commits.first().map(|c| {
74 (
75 c.sha.chars().take(SHA_LEN).collect(),
76 c.ts,
77 sanitize(&c.subject),
78 )
79 }),
80 by_quarter: by_quarter(db, &commits, now_ts),
81 })
82}
83
84/// The `TOP_AUTHOR` and how much of the file is theirs.
85///
86/// The share comes from `author_counts`, the distribution `ingest-git` keeps
87/// beside `n_commits` so that an incremental sync can add to it. A store
88/// written before that prop existed still answers: the commits on the file name
89/// their own authors, and counting those gives the same number for any history
90/// the `commits` list holds in full.
91fn top_author<F: Fs>(db: &GraphDb<F>, path: &str) -> Option<(String, String, f64)> {
92 let key = owner_key(db, path)?;
93 let recorded = author_counts(db, path);
94 let total = int_prop(db, path, "n_commits").unwrap_or(0).max(0) as usize;
95 let (mine, all) = if recorded.is_empty() {
96 let counted = commit_authors(db, path);
97 let all: usize = counted.values().sum();
98 (counted.get(&key).copied().unwrap_or(0), all)
99 } else {
100 let mine = recorded
101 .iter()
102 .find(|(k, _)| *k == key)
103 .map_or(0, |(_, n)| *n);
104 let all = if total > 0 {
105 total
106 } else {
107 recorded.iter().map(|(_, n)| *n).sum()
108 };
109 (mine, all)
110 };
111 let share = if all == 0 {
112 0.0
113 } else {
114 mine as f64 / all as f64
115 };
116 Some((sanitize(&author_name(db, &key)), sanitize(&key), share))
117}
118
119/// How the file's commits split between their authors, counted from the
120/// commits themselves.
121fn commit_authors<F: Fs>(db: &GraphDb<F>, path: &str) -> BTreeMap<String, usize> {
122 let mut out: BTreeMap<String, usize> = BTreeMap::new();
123 for commit in commits_of(db, path) {
124 for author in neighbors(db, &commit.sha, "AUTHOR", Direction::Out) {
125 *out.entry(author).or_default() += 1;
126 }
127 }
128 out
129}
130
131/// The authors the `knows` rule links to this file, by co-change score.
132fn knows<F: Fs>(db: &GraphDb<F>, path: &str) -> Vec<(String, f64)> {
133 let mut out: Vec<(String, f64)> = neighbors(db, path, "KNOWS", Direction::In)
134 .into_iter()
135 .map(|author| {
136 let score = score_of(db, "KNOWS", &author, path).unwrap_or(0.0);
137 (sanitize(&author_name(db, &author)), score)
138 })
139 .collect();
140 rank(&mut out);
141 out.truncate(MAX_KNOWS);
142 out
143}
144
145/// Ownership quarter by quarter, over the window ending at `now_ts`.
146///
147/// The busiest author of a quarter wins it; a tie goes to the author key that
148/// sorts first, which is the same rule `ingest-git` uses to pick a file's top
149/// author, so the two never disagree about a tie.
150fn by_quarter<F: Fs>(
151 db: &GraphDb<F>,
152 commits: &[CommitFact],
153 now_ts: Option<i64>,
154) -> Vec<(String, String, usize)> {
155 // Without a caller-supplied "now" the window ends at the newest commit in
156 // the store, not the newest on this file: a file nobody has touched for a
157 // year should report empty quarters rather than borrow its own last commit
158 // as the present and look busy.
159 let Some(now) = now_ts.or_else(|| newest_commit_ts(db)) else {
160 return Vec::new();
161 };
162 let last = quarter_index(now);
163 let first = last - (QUARTERS - 1);
164
165 let mut counts: BTreeMap<i64, BTreeMap<String, usize>> = BTreeMap::new();
166 for commit in commits {
167 let q = quarter_index(commit.ts);
168 if !(first..=last).contains(&q) {
169 continue;
170 }
171 for author in neighbors(db, &commit.sha, "AUTHOR", Direction::Out) {
172 *counts.entry(q).or_default().entry(author).or_default() += 1;
173 }
174 }
175 counts
176 .into_iter()
177 .map(|(q, by_author)| {
178 let total: usize = by_author.values().sum();
179 let top = by_author
180 .iter()
181 .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))
182 .map(|(key, _)| sanitize(&author_name(db, key)))
183 .unwrap_or_default();
184 (quarter_label(q), top, total)
185 })
186 .collect()
187}