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).
215 lines
7.0 KiB
Rust
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<_>>(),
|
|
})
|
|
}
|