use crate::cli::render::{self, Theme}; use rusqlite::Connection; use serde::Serialize; use crate::Config; use crate::cli::robot::RobotMeta; use crate::core::db::create_connection; use crate::core::error::Result; use crate::core::events_db::{self, EventCounts}; use crate::core::paths::get_db_path; pub struct CountResult { pub entity: String, pub count: i64, pub system_count: Option, pub state_breakdown: Option, } pub struct StateBreakdown { pub opened: i64, pub closed: i64, pub merged: Option, pub locked: Option, } pub fn run_count(config: &Config, entity: &str, type_filter: Option<&str>) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; match entity { "issues" => count_issues(&conn), "discussions" => count_discussions(&conn, type_filter), "notes" => count_notes(&conn, type_filter), "mrs" => count_mrs(&conn), _ => Ok(CountResult { entity: entity.to_string(), count: 0, system_count: None, state_breakdown: None, }), } } fn count_issues(conn: &Connection) -> Result { // Single query with conditional aggregation instead of 3 separate queries let (count, opened, closed): (i64, i64, i64) = conn.query_row( "SELECT COUNT(*), COALESCE(SUM(CASE WHEN state = 'opened' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN state = 'closed' THEN 1 ELSE 0 END), 0) FROM issues", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; Ok(CountResult { entity: "Issues".to_string(), count, system_count: None, state_breakdown: Some(StateBreakdown { opened, closed, merged: None, locked: None, }), }) } fn count_mrs(conn: &Connection) -> Result { // Single query with conditional aggregation instead of 5 separate queries let (count, opened, merged, closed, locked): (i64, i64, i64, i64, i64) = conn.query_row( "SELECT COUNT(*), COALESCE(SUM(CASE WHEN state = 'opened' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN state = 'merged' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN state = 'closed' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN state = 'locked' THEN 1 ELSE 0 END), 0) FROM merge_requests", [], |row| { Ok(( row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, )) }, )?; Ok(CountResult { entity: "Merge Requests".to_string(), count, system_count: None, state_breakdown: Some(StateBreakdown { opened, closed, merged: Some(merged), locked: Some(locked), }), }) } fn count_discussions(conn: &Connection, type_filter: Option<&str>) -> Result { let (count, entity_name) = match type_filter { Some("issue") => { let count: i64 = conn.query_row( "SELECT COUNT(*) FROM discussions WHERE noteable_type = 'Issue'", [], |row| row.get(0), )?; (count, "Issue Discussions") } Some("mr") => { let count: i64 = conn.query_row( "SELECT COUNT(*) FROM discussions WHERE noteable_type = 'MergeRequest'", [], |row| row.get(0), )?; (count, "MR Discussions") } _ => { let count: i64 = conn.query_row("SELECT COUNT(*) FROM discussions", [], |row| row.get(0))?; (count, "Discussions") } }; Ok(CountResult { entity: entity_name.to_string(), count, system_count: None, state_breakdown: None, }) } fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result { let (total, system_count, entity_name) = match type_filter { Some("issue") => { let (total, system): (i64, i64) = conn.query_row( "SELECT COUNT(*), COALESCE(SUM(n.is_system), 0) FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE d.noteable_type = 'Issue'", [], |row| Ok((row.get(0)?, row.get(1)?)), )?; (total, system, "Issue Notes") } Some("mr") => { let (total, system): (i64, i64) = conn.query_row( "SELECT COUNT(*), COALESCE(SUM(n.is_system), 0) FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE d.noteable_type = 'MergeRequest'", [], |row| Ok((row.get(0)?, row.get(1)?)), )?; (total, system, "MR Notes") } _ => { let (total, system): (i64, i64) = conn.query_row( "SELECT COUNT(*), COALESCE(SUM(is_system), 0) FROM notes", [], |row| Ok((row.get(0)?, row.get(1)?)), )?; (total, system, "Notes") } }; let non_system = total - system_count; Ok(CountResult { entity: entity_name.to_string(), count: non_system, system_count: Some(system_count), state_breakdown: None, }) } #[derive(Serialize)] struct CountJsonOutput { ok: bool, data: CountJsonData, meta: RobotMeta, } #[derive(Serialize)] struct CountJsonData { entity: String, count: i64, #[serde(skip_serializing_if = "Option::is_none")] system_excluded: Option, #[serde(skip_serializing_if = "Option::is_none")] breakdown: Option, } #[derive(Serialize)] struct CountJsonBreakdown { opened: i64, closed: i64, #[serde(skip_serializing_if = "Option::is_none")] merged: Option, #[serde(skip_serializing_if = "Option::is_none")] locked: Option, } pub fn run_count_events(config: &Config) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; events_db::count_events(&conn) } #[derive(Serialize)] struct EventCountJsonOutput { ok: bool, data: EventCountJsonData, meta: RobotMeta, } #[derive(Serialize)] struct EventCountJsonData { state_events: EventTypeCounts, label_events: EventTypeCounts, milestone_events: EventTypeCounts, total: usize, } #[derive(Serialize)] struct EventTypeCounts { issue: usize, merge_request: usize, total: usize, } pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) { let output = EventCountJsonOutput { ok: true, data: EventCountJsonData { state_events: EventTypeCounts { issue: counts.state_issue, merge_request: counts.state_mr, total: counts.state_issue + counts.state_mr, }, label_events: EventTypeCounts { issue: counts.label_issue, merge_request: counts.label_mr, total: counts.label_issue + counts.label_mr, }, milestone_events: EventTypeCounts { issue: counts.milestone_issue, merge_request: counts.milestone_mr, total: counts.milestone_issue + counts.milestone_mr, }, total: counts.total(), }, meta: RobotMeta { elapsed_ms }, }; match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } pub fn print_event_count(counts: &EventCounts) { println!( "{:<20} {:>8} {:>8} {:>8}", Theme::info().bold().render("Event Type"), Theme::bold().render("Issues"), Theme::bold().render("MRs"), Theme::bold().render("Total") ); let state_total = counts.state_issue + counts.state_mr; let label_total = counts.label_issue + counts.label_mr; let milestone_total = counts.milestone_issue + counts.milestone_mr; println!( "{:<20} {:>8} {:>8} {:>8}", "State events", render::format_number(counts.state_issue as i64), render::format_number(counts.state_mr as i64), render::format_number(state_total as i64) ); println!( "{:<20} {:>8} {:>8} {:>8}", "Label events", render::format_number(counts.label_issue as i64), render::format_number(counts.label_mr as i64), render::format_number(label_total as i64) ); println!( "{:<20} {:>8} {:>8} {:>8}", "Milestone events", render::format_number(counts.milestone_issue as i64), render::format_number(counts.milestone_mr as i64), render::format_number(milestone_total as i64) ); let total_issues = counts.state_issue + counts.label_issue + counts.milestone_issue; let total_mrs = counts.state_mr + counts.label_mr + counts.milestone_mr; println!( "{:<20} {:>8} {:>8} {:>8}", Theme::bold().render("Total"), render::format_number(total_issues as i64), render::format_number(total_mrs as i64), Theme::bold().render(&render::format_number(counts.total() as i64)) ); } pub fn print_count_json(result: &CountResult, elapsed_ms: u64) { let breakdown = result.state_breakdown.as_ref().map(|b| CountJsonBreakdown { opened: b.opened, closed: b.closed, merged: b.merged, locked: b.locked.filter(|&l| l > 0), }); let output = CountJsonOutput { ok: true, data: CountJsonData { entity: result.entity.to_lowercase().replace(' ', "_"), count: result.count, system_excluded: result.system_count, breakdown, }, meta: RobotMeta { elapsed_ms }, }; match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } pub fn print_count(result: &CountResult) { let count_str = render::format_number(result.count); if let Some(system_count) = result.system_count { println!( "{}: {:>10} {}", Theme::info().render(&result.entity), Theme::bold().render(&count_str), Theme::dim().render(&format!( "(excluding {} system)", render::format_number(system_count) )) ); } else { println!( "{}: {:>10}", Theme::info().render(&result.entity), Theme::bold().render(&count_str) ); } if let Some(breakdown) = &result.state_breakdown { println!(" opened: {:>10}", render::format_number(breakdown.opened)); if let Some(merged) = breakdown.merged { println!(" merged: {:>10}", render::format_number(merged)); } println!(" closed: {:>10}", render::format_number(breakdown.closed)); if let Some(locked) = breakdown.locked && locked > 0 { println!(" locked: {:>10}", render::format_number(locked)); } } } #[cfg(test)] mod tests { use crate::cli::render; #[test] fn format_number_handles_small_numbers() { assert_eq!(render::format_number(0), "0"); assert_eq!(render::format_number(1), "1"); assert_eq!(render::format_number(100), "100"); assert_eq!(render::format_number(999), "999"); } #[test] fn format_number_adds_thousands_separators() { assert_eq!(render::format_number(1000), "1,000"); assert_eq!(render::format_number(12345), "12,345"); assert_eq!(render::format_number(1234567), "1,234,567"); } }