issue-report: add transitively-blocks count alongside direct depended-on-by count

This commit is contained in:
damocles 2026-08-31 21:33:46 +02:00 committed by mara
commit 361121c7f6
2 changed files with 101 additions and 8 deletions

View file

@ -69,6 +69,12 @@ pub struct IssueReportRow {
/// dependency — the reverse of `blocked`. Ranks "fix this one to
/// unblock the most other work" highest.
pub depended_on_by_count: u32,
/// How many distinct OTHER issues this issue unblocks, following the
/// same reverse-dependency edges transitively (a depends on b depends
/// on c: fixing c eventually frees both a and b, so c's count includes
/// both). A superset of `depended_on_by_count`, which only counts the
/// direct edge. See [`Client::issue_report`] for how it's walked.
pub transitively_blocks_count: u32,
}
/// The `operators` team, whitelisted for the merge gate on every repo
@ -631,6 +637,12 @@ impl Client {
/// outside this open-issue set, can never accrue a count here — it
/// isn't a row in the report either, so "how many rows depend on this
/// row" would have nothing to point at.
///
/// `transitively_blocks_count` walks the same `reverse_adj` (blocker
/// number → issues naming it as a dependency) from each issue — see
/// [`transitive_reach`] for how the walk itself is made
/// diamond/cycle-safe. `depended_on_by_count` is `reverse_adj[n].len()`;
/// this is everything reachable from `n`, not just the direct edge.
pub async fn issue_report(&self, owner: &str, repo: &str) -> Result<Vec<IssueReportRow>> {
const CONCURRENCY: usize = 8;
@ -672,13 +684,13 @@ impl Client {
.await?;
let open_numbers: HashSet<i64> = issues.iter().filter_map(|i| i.number).collect();
let mut dependent_counts: HashMap<i64, u32> = HashMap::new();
for blockers in deps.values() {
let mut reverse_adj: HashMap<i64, Vec<i64>> = HashMap::new();
for (&number, blockers) in &deps {
for blocker in blockers {
if let Some(n) = blocker.number
&& open_numbers.contains(&n)
{
*dependent_counts.entry(n).or_insert(0) += 1;
reverse_adj.entry(n).or_default().push(number);
}
}
}
@ -709,7 +721,15 @@ impl Client {
.collect(),
html_url: issue.html_url.map(|u| u.to_string()),
blocked,
depended_on_by_count: dependent_counts.get(&number).copied().unwrap_or(0),
depended_on_by_count: u32::try_from(
reverse_adj.get(&number).map_or(0, Vec::len),
)
.unwrap_or(u32::MAX),
transitively_blocks_count: u32::try_from(transitive_reach(
number,
&reverse_adj,
))
.unwrap_or(u32::MAX),
})
})
.collect())
@ -889,6 +909,29 @@ impl Client {
}
}
/// Every distinct node reachable from `start` by following `adj` edges
/// (`adj[n]` = the issues that directly depend on `n`), not counting
/// `start` itself. Plain DFS with a `visited` set doubling as the
/// cycle guard — see [`Client::issue_report`]'s doc comment for why a
/// cycle has to be tolerated rather than assumed impossible.
fn transitive_reach(start: i64, adj: &HashMap<i64, Vec<i64>>) -> usize {
// `start` goes into `visited` up front, pre-empting the one case a
// plain "insert on visit" walk gets wrong: a cycle that loops back to
// `start` would otherwise re-insert it and count it as its own
// descendant. Pre-seeding makes that re-visit a no-op instead, so the
// `- 1` below only ever backs out the seed, never a real node.
let mut visited: HashSet<i64> = HashSet::from([start]);
let mut stack: Vec<i64> = adj.get(&start).cloned().unwrap_or_default();
while let Some(node) = stack.pop() {
if visited.insert(node)
&& let Some(next) = adj.get(&node)
{
stack.extend(next);
}
}
visited.len() - 1
}
/// Where a hook lives. The knowledge hook is repo-scoped and the config-PR
/// hook is org-scoped, mirroring exactly where the per-hive registrars put
/// theirs — a hook on the wrong scope would never fire, and forgejo would
@ -1041,4 +1084,30 @@ mod tests {
std::env::remove_var(URL_ENV);
}
}
#[test]
fn transitive_reach_walks_a_chain_not_just_the_direct_edge() {
// 3 depends on 2 depends on 1: reverse_adj is "who depends on me",
// so 1 -> [2], 2 -> [3]. Fixing 1 eventually frees both 2 and 3.
let adj = HashMap::from([(1, vec![2]), (2, vec![3])]);
assert_eq!(transitive_reach(1, &adj), 2);
assert_eq!(transitive_reach(2, &adj), 1);
assert_eq!(transitive_reach(3, &adj), 0);
}
#[test]
fn transitive_reach_counts_a_diamond_descendant_once() {
// 1 -> [2, 3], both 2 and 3 -> [4]: 4 is reachable via two paths
// but must only be counted once.
let adj = HashMap::from([(1, vec![2, 3]), (2, vec![4]), (3, vec![4])]);
assert_eq!(transitive_reach(1, &adj), 3);
}
#[test]
fn transitive_reach_terminates_on_a_cycle() {
// 1 -> [2] -> [1]: nothing forge-side prevents a dependency cycle,
// so the walk has to survive one instead of looping forever.
let adj = HashMap::from([(1, vec![2]), (2, vec![1])]);
assert_eq!(transitive_reach(1, &adj), 1);
}
}