Skip to main content

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}