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, since_ms: i64, limit: usize, ) -> Result { 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)> = 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::, _>>()?; // 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, } let mut user_map: HashMap = HashMap::new(); for (username, role, count, last_seen, mr_refs_csv) in &rows { let mr_refs: Vec = 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 = user_map .into_values() .map(|a| { let mut mr_refs: Vec = 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::>() .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::>(), }) }