use std::collections::HashMap; 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) } // --------------------------------------------------------------------------- // References count // --------------------------------------------------------------------------- #[derive(Debug, Serialize)] pub struct ReferenceCountResult { pub total: i64, pub by_type: HashMap, pub by_method: HashMap, pub unresolved: i64, } pub fn run_count_references(config: &Config) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; count_references(&conn) } fn count_references(conn: &Connection) -> Result { let (total, closes, mentioned, related, api, note_parse, desc_parse, unresolved): ( i64, i64, i64, i64, i64, i64, i64, i64, ) = conn.query_row( "SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN reference_type = 'closes' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN reference_type = 'mentioned' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN reference_type = 'related' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN source_method = 'api' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN source_method = 'note_parse' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN source_method = 'description_parse' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN target_entity_id IS NULL THEN 1 ELSE 0 END), 0) FROM entity_references", [], |row| { Ok(( row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?, )) }, )?; let mut by_type = HashMap::new(); by_type.insert("closes".to_string(), closes); by_type.insert("mentioned".to_string(), mentioned); by_type.insert("related".to_string(), related); let mut by_method = HashMap::new(); by_method.insert("api".to_string(), api); by_method.insert("note_parse".to_string(), note_parse); by_method.insert("description_parse".to_string(), desc_parse); Ok(ReferenceCountResult { total, by_type, by_method, unresolved, }) } #[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 }, }; println!("{}", serde_json::to_string(&output).unwrap()); } 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 }, }; println!("{}", serde_json::to_string(&output).unwrap()); } 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)); } } } // --------------------------------------------------------------------------- // References output // --------------------------------------------------------------------------- pub fn print_reference_count(result: &ReferenceCountResult) { println!( "{}: {:>10}", Theme::info().render("References"), Theme::bold().render(&render::format_number(result.total)) ); println!(" By type:"); for key in &["closes", "mentioned", "related"] { let val = result.by_type.get(*key).copied().unwrap_or(0); println!(" {:<20} {:>10}", key, render::format_number(val)); } println!(" By source:"); for key in &["api", "note_parse", "description_parse"] { let val = result.by_method.get(*key).copied().unwrap_or(0); println!(" {:<20} {:>10}", key, render::format_number(val)); } let pct = if result.total > 0 { format!( " ({:.1}%)", result.unresolved as f64 / result.total as f64 * 100.0 ) } else { String::new() }; println!( " Unresolved: {:>10}{}", render::format_number(result.unresolved), Theme::dim().render(&pct) ); } #[derive(Serialize)] struct RefCountJsonOutput { ok: bool, data: RefCountJsonData, meta: RobotMeta, } #[derive(Serialize)] struct RefCountJsonData { entity: String, total: i64, by_type: HashMap, by_method: HashMap, unresolved: i64, } pub fn print_reference_count_json(result: &ReferenceCountResult, elapsed_ms: u64) { let output = RefCountJsonOutput { ok: true, data: RefCountJsonData { entity: "references".to_string(), total: result.total, by_type: result.by_type.clone(), by_method: result.by_method.clone(), unresolved: result.unresolved, }, meta: RobotMeta { elapsed_ms }, }; println!("{}", serde_json::to_string(&output).unwrap_or_default()); } #[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"); } #[test] fn test_count_references_query() { use std::path::Path; use crate::core::db::{create_connection, run_migrations}; use super::count_references; let conn = create_connection(Path::new(":memory:")).unwrap(); run_migrations(&conn).unwrap(); // Insert 3 entity_references rows with varied types/methods. // First need a project row to satisfy FK. conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) VALUES (1, 100, 'g/test', 'https://git.example.com/g/test')", [], ) .unwrap(); // Need source entities for the FK. conn.execute( "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at) VALUES (1, 200, 1, 1, 'Issue 1', 'opened', 0, 0, 0)", [], ) .unwrap(); // Row 1: closes / api / resolved (target_entity_id = 1) conn.execute( "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method, created_at) VALUES (1, 'issue', 1, 'issue', 1, 'closes', 'api', 1000)", [], ) .unwrap(); // Row 2: mentioned / note_parse / unresolved (target_entity_id = NULL) conn.execute( "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, target_project_path, target_entity_iid, reference_type, source_method, created_at) VALUES (1, 'issue', 1, 'merge_request', NULL, 'other/proj', 42, 'mentioned', 'note_parse', 2000)", [], ) .unwrap(); // Row 3: related / api / unresolved (target_entity_id = NULL) conn.execute( "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, target_project_path, target_entity_iid, reference_type, source_method, created_at) VALUES (1, 'issue', 1, 'issue', NULL, 'other/proj2', 99, 'related', 'api', 3000)", [], ) .unwrap(); let result = count_references(&conn).unwrap(); assert_eq!(result.total, 3); assert_eq!(*result.by_type.get("closes").unwrap(), 1); assert_eq!(*result.by_type.get("mentioned").unwrap(), 1); assert_eq!(*result.by_type.get("related").unwrap(), 1); assert_eq!(*result.by_method.get("api").unwrap(), 2); assert_eq!(*result.by_method.get("note_parse").unwrap(), 1); assert_eq!(*result.by_method.get("description_parse").unwrap(), 0); assert_eq!(result.unresolved, 2); } #[test] fn test_count_references_empty_table() { use std::path::Path; use crate::core::db::{create_connection, run_migrations}; use super::count_references; let conn = create_connection(Path::new(":memory:")).unwrap(); run_migrations(&conn).unwrap(); let result = count_references(&conn).unwrap(); assert_eq!(result.total, 0); assert_eq!(*result.by_type.get("closes").unwrap(), 0); assert_eq!(*result.by_type.get("mentioned").unwrap(), 0); assert_eq!(*result.by_type.get("related").unwrap(), 0); assert_eq!(*result.by_method.get("api").unwrap(), 0); assert_eq!(*result.by_method.get("note_parse").unwrap(), 0); assert_eq!(*result.by_method.get("description_parse").unwrap(), 0); assert_eq!(result.unresolved, 0); } }