Files
gitlore/src/cli/commands/who/reviews.rs
teernisse ea6e45e43f refactor(who): make --limit optional (unlimited default) and fix clippy sort lints
Change the `who` command's --limit flag from default=20 to optional,
so omitting it returns all results. This matches the behavior users
expect when they want a complete expert/workload/active/overlap listing
without an arbitrary cap.

Also applies clippy-recommended sort improvements:
- who/reviews: sort_by(|a,b| b.count.cmp(&a.count)) -> sort_by_key with Reverse
- drift: same pattern for frequency sorting

Adds Theme::color_icon() helper to DRY the stage-icon coloring pattern
used in sync output (was inline closure, now shared method).
2026-02-18 16:27:59 -05:00

215 lines
7.0 KiB
Rust

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_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::<Vec<_>>(),
})
}