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>
This commit is contained in:
214
src/cli/commands/who/reviews.rs
Normal file
214
src/cli/commands/who/reviews.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::cli::render::{Icons, Theme};
|
||||
use crate::core::error::Result;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
// ─── Query: Reviews Mode ────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn query_reviews(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
project_id: Option<i64>,
|
||||
since_ms: i64,
|
||||
) -> Result<ReviewsResult> {
|
||||
// Force the partial index on DiffNote queries (same rationale as expert mode).
|
||||
// COUNT + COUNT(DISTINCT) + category extraction all benefit from 26K DiffNote
|
||||
// scan vs 282K notes full scan: measured 25x speedup.
|
||||
let total_sql = "SELECT COUNT(*) FROM notes n
|
||||
INDEXED BY idx_notes_diffnote_path_created
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.author_username = ?1
|
||||
AND n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND (m.author_username IS NULL OR m.author_username != ?1)
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)";
|
||||
|
||||
let total_diffnotes: u32 = conn.query_row(
|
||||
total_sql,
|
||||
rusqlite::params![username, since_ms, project_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
// Count distinct MRs reviewed
|
||||
let mrs_sql = "SELECT COUNT(DISTINCT m.id) FROM notes n
|
||||
INDEXED BY idx_notes_diffnote_path_created
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.author_username = ?1
|
||||
AND n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND (m.author_username IS NULL OR m.author_username != ?1)
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)";
|
||||
|
||||
let mrs_reviewed: u32 = conn.query_row(
|
||||
mrs_sql,
|
||||
rusqlite::params![username, since_ms, project_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
// Extract prefixed categories: body starts with **prefix**
|
||||
let cat_sql = "SELECT
|
||||
SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,
|
||||
COUNT(*) AS cnt
|
||||
FROM notes n INDEXED BY idx_notes_diffnote_path_created
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
WHERE n.author_username = ?1
|
||||
AND n.note_type = 'DiffNote'
|
||||
AND n.is_system = 0
|
||||
AND (m.author_username IS NULL OR m.author_username != ?1)
|
||||
AND ltrim(n.body) LIKE '**%**%'
|
||||
AND n.created_at >= ?2
|
||||
AND (?3 IS NULL OR n.project_id = ?3)
|
||||
GROUP BY raw_prefix
|
||||
ORDER BY cnt DESC";
|
||||
|
||||
let mut stmt = conn.prepare_cached(cat_sql)?;
|
||||
let raw_categories: Vec<(String, u32)> = stmt
|
||||
.query_map(rusqlite::params![username, since_ms, project_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get(1)?))
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Normalize categories: lowercase, strip trailing colon/space,
|
||||
// merge nit/nitpick variants, merge (non-blocking) variants
|
||||
let mut merged: HashMap<String, u32> = HashMap::new();
|
||||
for (raw, count) in &raw_categories {
|
||||
let normalized = normalize_review_prefix(raw);
|
||||
if !normalized.is_empty() {
|
||||
*merged.entry(normalized).or_insert(0) += count;
|
||||
}
|
||||
}
|
||||
|
||||
let categorized_count: u32 = merged.values().sum();
|
||||
|
||||
let mut categories: Vec<ReviewCategory> = merged
|
||||
.into_iter()
|
||||
.map(|(name, count)| {
|
||||
let percentage = if categorized_count > 0 {
|
||||
f64::from(count) / f64::from(categorized_count) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
ReviewCategory {
|
||||
name,
|
||||
count,
|
||||
percentage,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
categories.sort_by(|a, b| b.count.cmp(&a.count));
|
||||
|
||||
Ok(ReviewsResult {
|
||||
username: username.to_string(),
|
||||
total_diffnotes,
|
||||
categorized_count,
|
||||
mrs_reviewed,
|
||||
categories,
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalize a raw review prefix like "Suggestion (non-blocking):" into "suggestion".
|
||||
pub(super) fn normalize_review_prefix(raw: &str) -> String {
|
||||
let s = raw.trim().trim_end_matches(':').trim().to_lowercase();
|
||||
|
||||
// Strip "(non-blocking)" and similar parentheticals
|
||||
let s = if let Some(idx) = s.find('(') {
|
||||
s[..idx].trim().to_string()
|
||||
} else {
|
||||
s
|
||||
};
|
||||
|
||||
// Merge nit/nitpick variants
|
||||
match s.as_str() {
|
||||
"nitpick" | "nit" => "nit".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Human Renderer ─────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn print_reviews_human(r: &ReviewsResult) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!(
|
||||
"{} {} -- Review Patterns",
|
||||
Icons::user(),
|
||||
r.username
|
||||
))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
println!();
|
||||
|
||||
if r.total_diffnotes == 0 {
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("No review comments found for this user.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} DiffNotes across {} MRs ({} categorized)",
|
||||
Theme::bold().render(&r.total_diffnotes.to_string()),
|
||||
Theme::bold().render(&r.mrs_reviewed.to_string()),
|
||||
Theme::bold().render(&r.categorized_count.to_string()),
|
||||
);
|
||||
println!();
|
||||
|
||||
if !r.categories.is_empty() {
|
||||
println!(
|
||||
" {:<16} {:>6} {:>6}",
|
||||
Theme::bold().render("Category"),
|
||||
Theme::bold().render("Count"),
|
||||
Theme::bold().render("%"),
|
||||
);
|
||||
|
||||
for cat in &r.categories {
|
||||
println!(
|
||||
" {:<16} {:>6} {:>5.1}%",
|
||||
Theme::info().render(&cat.name),
|
||||
cat.count,
|
||||
cat.percentage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let uncategorized = r.total_diffnotes - r.categorized_count;
|
||||
if uncategorized > 0 {
|
||||
println!();
|
||||
println!(
|
||||
" {} {} uncategorized (no **prefix** convention)",
|
||||
Theme::dim().render("Note:"),
|
||||
uncategorized,
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
// ─── Robot Renderer ─────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn reviews_to_json(r: &ReviewsResult) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"username": r.username,
|
||||
"total_diffnotes": r.total_diffnotes,
|
||||
"categorized_count": r.categorized_count,
|
||||
"mrs_reviewed": r.mrs_reviewed,
|
||||
"categories": r.categories.iter().map(|c| serde_json::json!({
|
||||
"name": c.name,
|
||||
"count": c.count,
|
||||
"percentage": (c.percentage * 10.0).round() / 10.0,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user