Files
gitlore/src/cli/commands/who/overlap.rs
teernisse c953d8e519 refactor(who): split 2598-line who.rs into per-mode modules
Split the monolithic who.rs into a who/ directory module with 7 focused
files. The 5 query modes (expert, workload, reviews, active, overlap) share
no query-level code — only types and a few small helpers — making this a
clean mechanical extraction.

New structure:
  who/types.rs     — all pub result structs/enums (~185 lines)
  who/mod.rs       — dispatch, shared helpers, JSON envelope (~428 lines)
  who/expert.rs    — query + render + json for expert mode (~839 lines)
  who/workload.rs  — query + render + json for workload mode (~370 lines)
  who/reviews.rs   — query + render + json for reviews mode (~214 lines)
  who/active.rs    — query + render + json for active mode (~299 lines)
  who/overlap.rs   — query + render + json for overlap mode (~323 lines)

Token savings: an agent working on any single mode now loads ~400-960 lines
instead of 2,598 (63-85% reduction). Public API unchanged — parent mod.rs
re-exports are identical.

Test re-exports use #[cfg(test)] use (not pub use) to avoid visibility
conflicts with pub(super) items in submodules. All 79 who tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:28:30 -05:00

324 lines
11 KiB
Rust

use std::collections::{HashMap, HashSet};
use rusqlite::Connection;
use crate::cli::render::{self, Icons, Theme};
use crate::core::error::Result;
use crate::core::path_resolver::build_path_query;
use crate::core::time::ms_to_iso;
use super::types::*;
pub(super) fn query_overlap(
conn: &Connection,
path: &str,
project_id: Option<i64>,
since_ms: i64,
limit: usize,
) -> Result<OverlapResult> {
let pq = build_path_query(conn, path, project_id)?;
// Build SQL with 4 signal sources, matching the expert query expansion.
// Each row produces (username, role, mr_id, mr_ref, seen_at) for Rust-side accumulation.
let path_op = if pq.is_prefix {
"LIKE ?1 ESCAPE '\\'"
} else {
"= ?1"
};
// Match both new_path and old_path to capture activity on renamed files.
// INDEXED BY removed to allow OR across path columns; overlap runs once
// per command so the minor plan difference is acceptable.
let sql = format!(
"SELECT username, role, touch_count, last_seen_at, mr_refs FROM (
-- 1. DiffNote reviewer (matches both new_path and old_path)
SELECT
n.author_username AS username,
'reviewer' AS role,
COUNT(DISTINCT m.id) AS touch_count,
MAX(n.created_at) AS last_seen_at,
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
JOIN merge_requests m ON d.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id
WHERE n.note_type = 'DiffNote'
AND (n.position_new_path {path_op}
OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op}))
AND n.is_system = 0
AND n.author_username IS NOT NULL
AND (m.author_username IS NULL OR n.author_username != m.author_username)
AND m.state IN ('opened','merged','closed')
AND n.created_at >= ?2
AND (?3 IS NULL OR n.project_id = ?3)
GROUP BY n.author_username
UNION ALL
-- 2. DiffNote MR author (matches both new_path and old_path)
SELECT
m.author_username AS username,
'author' AS role,
COUNT(DISTINCT m.id) AS touch_count,
MAX(n.created_at) AS last_seen_at,
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
JOIN merge_requests m ON d.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id
WHERE n.note_type = 'DiffNote'
AND (n.position_new_path {path_op}
OR (n.position_old_path IS NOT NULL AND n.position_old_path {path_op}))
AND n.is_system = 0
AND m.state IN ('opened','merged','closed')
AND m.author_username IS NOT NULL
AND n.created_at >= ?2
AND (?3 IS NULL OR n.project_id = ?3)
GROUP BY m.author_username
UNION ALL
-- 3. MR author via file changes (matches both new_path and old_path)
SELECT
m.author_username AS username,
'author' AS role,
COUNT(DISTINCT m.id) AS touch_count,
MAX(m.updated_at) AS last_seen_at,
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
FROM mr_file_changes fc
JOIN merge_requests m ON fc.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id
WHERE m.author_username IS NOT NULL
AND m.state IN ('opened','merged','closed')
AND (fc.new_path {path_op}
OR (fc.old_path IS NOT NULL AND fc.old_path {path_op}))
AND m.updated_at >= ?2
AND (?3 IS NULL OR fc.project_id = ?3)
GROUP BY m.author_username
UNION ALL
-- 4. MR reviewer via file changes + mr_reviewers (matches both new_path and old_path)
SELECT
r.username AS username,
'reviewer' AS role,
COUNT(DISTINCT m.id) AS touch_count,
MAX(m.updated_at) AS last_seen_at,
GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid)) AS mr_refs
FROM mr_file_changes fc
JOIN merge_requests m ON fc.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id
JOIN mr_reviewers r ON r.merge_request_id = m.id
WHERE r.username IS NOT NULL
AND (m.author_username IS NULL OR r.username != m.author_username)
AND m.state IN ('opened','merged','closed')
AND (fc.new_path {path_op}
OR (fc.old_path IS NOT NULL AND fc.old_path {path_op}))
AND m.updated_at >= ?2
AND (?3 IS NULL OR fc.project_id = ?3)
GROUP BY r.username
)"
);
let mut stmt = conn.prepare_cached(&sql)?;
let rows: Vec<(String, String, u32, i64, Option<String>)> = stmt
.query_map(rusqlite::params![pq.value, since_ms, project_id], |row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
// Internal accumulator uses HashSet for MR refs from the start
struct OverlapAcc {
username: String,
author_touch_count: u32,
review_touch_count: u32,
touch_count: u32,
last_seen_at: i64,
mr_refs: HashSet<String>,
}
let mut user_map: HashMap<String, OverlapAcc> = HashMap::new();
for (username, role, count, last_seen, mr_refs_csv) in &rows {
let mr_refs: Vec<String> = mr_refs_csv
.as_deref()
.map(|csv| csv.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let entry = user_map
.entry(username.clone())
.or_insert_with(|| OverlapAcc {
username: username.clone(),
author_touch_count: 0,
review_touch_count: 0,
touch_count: 0,
last_seen_at: 0,
mr_refs: HashSet::new(),
});
entry.touch_count += count;
if role == "author" {
entry.author_touch_count += count;
} else {
entry.review_touch_count += count;
}
if *last_seen > entry.last_seen_at {
entry.last_seen_at = *last_seen;
}
for r in mr_refs {
entry.mr_refs.insert(r);
}
}
// Convert accumulators to output structs
let mut users: Vec<OverlapUser> = user_map
.into_values()
.map(|a| {
let mut mr_refs: Vec<String> = a.mr_refs.into_iter().collect();
mr_refs.sort();
let mr_refs_total = mr_refs.len() as u32;
let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER;
if mr_refs_truncated {
mr_refs.truncate(MAX_MR_REFS_PER_USER);
}
OverlapUser {
username: a.username,
author_touch_count: a.author_touch_count,
review_touch_count: a.review_touch_count,
touch_count: a.touch_count,
last_seen_at: a.last_seen_at,
mr_refs,
mr_refs_total,
mr_refs_truncated,
}
})
.collect();
// Stable sort with full tie-breakers for deterministic output
users.sort_by(|a, b| {
b.touch_count
.cmp(&a.touch_count)
.then_with(|| b.last_seen_at.cmp(&a.last_seen_at))
.then_with(|| a.username.cmp(&b.username))
});
let truncated = users.len() > limit;
users.truncate(limit);
Ok(OverlapResult {
path_query: if pq.is_prefix {
path.trim_end_matches('/').to_string()
} else {
pq.value.clone()
},
path_match: if pq.is_prefix { "prefix" } else { "exact" }.to_string(),
users,
truncated,
})
}
/// Format overlap role for display: "A", "R", or "A+R".
pub(super) fn format_overlap_role(user: &OverlapUser) -> &'static str {
match (user.author_touch_count > 0, user.review_touch_count > 0) {
(true, true) => "A+R",
(true, false) => "A",
(false, true) => "R",
(false, false) => "-",
}
}
pub(super) fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!();
println!(
"{}",
Theme::bold().render(&format!("Overlap for {}", r.path_query))
);
println!("{}", "\u{2500}".repeat(60));
println!(
" {}",
Theme::dim().render(&format!(
"(matching {} {})",
r.path_match,
if r.path_match == "exact" {
"file"
} else {
"directory prefix"
}
))
);
super::print_scope_hint(project_path);
println!();
if r.users.is_empty() {
println!(
" {}",
Theme::dim().render("No overlapping users found for this path.")
);
println!();
return;
}
println!(
" {:<16} {:<6} {:>7} {:<12} {}",
Theme::bold().render("Username"),
Theme::bold().render("Role"),
Theme::bold().render("MRs"),
Theme::bold().render("Last Seen"),
Theme::bold().render("MR Refs"),
);
for user in &r.users {
let mr_str = user
.mr_refs
.iter()
.take(5)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let overflow = if user.mr_refs.len() > 5 {
format!(" +{}", user.mr_refs.len() - 5)
} else {
String::new()
};
println!(
" {:<16} {:<6} {:>7} {:<12} {}{}",
Theme::info().render(&format!("{} {}", Icons::user(), user.username)),
format_overlap_role(user),
user.touch_count,
render::format_relative_time(user.last_seen_at),
mr_str,
overflow,
);
}
if r.truncated {
println!(
" {}",
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
);
}
println!();
}
pub(super) fn overlap_to_json(r: &OverlapResult) -> serde_json::Value {
serde_json::json!({
"path_query": r.path_query,
"path_match": r.path_match,
"truncated": r.truncated,
"users": r.users.iter().map(|u| serde_json::json!({
"username": u.username,
"role": format_overlap_role(u),
"author_touch_count": u.author_touch_count,
"review_touch_count": u.review_touch_count,
"touch_count": u.touch_count,
"last_seen_at": ms_to_iso(u.last_seen_at),
"mr_refs": u.mr_refs,
"mr_refs_total": u.mr_refs_total,
"mr_refs_truncated": u.mr_refs_truncated,
})).collect::<Vec<_>>(),
})
}