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>
324 lines
11 KiB
Rust
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<_>>(),
|
|
})
|
|
}
|