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, since_ms: i64, ) -> Result { // 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::, _>>()?; // Normalize categories: lowercase, strip trailing colon/space, // merge nit/nitpick variants, merge (non-blocking) variants let mut merged: HashMap = 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 = 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_key(|b| std::cmp::Reverse(b.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::>(), }) }