use rusqlite::Connection; use crate::cli::render::{self, Theme}; use crate::core::error::Result; use crate::core::time::ms_to_iso; use super::types::*; pub(super) fn query_active( conn: &Connection, project_id: Option, since_ms: i64, limit: usize, include_closed: bool, ) -> Result { let limit_plus_one = (limit + 1) as i64; // State filter for open-entities-only (default behavior) let state_joins = if include_closed { "" } else { " LEFT JOIN issues i ON d.issue_id = i.id LEFT JOIN merge_requests m ON d.merge_request_id = m.id" }; let state_filter = if include_closed { "" } else { " AND (i.id IS NULL OR i.state = 'opened') AND (m.id IS NULL OR m.state = 'opened')" }; // Total unresolved count -- conditionally built let total_sql_global = format!( "SELECT COUNT(*) FROM discussions d {state_joins} WHERE d.resolvable = 1 AND d.resolved = 0 AND d.last_note_at >= ?1 {state_filter}" ); let total_sql_scoped = format!( "SELECT COUNT(*) FROM discussions d {state_joins} WHERE d.resolvable = 1 AND d.resolved = 0 AND d.last_note_at >= ?1 AND d.project_id = ?2 {state_filter}" ); let total_unresolved_in_window: u32 = match project_id { None => conn.query_row(&total_sql_global, rusqlite::params![since_ms], |row| { row.get(0) })?, Some(pid) => { conn.query_row(&total_sql_scoped, rusqlite::params![since_ms, pid], |row| { row.get(0) })? } }; // Active discussions with context -- conditionally built SQL let sql_global = format!( " WITH picked AS ( SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id, d.project_id, d.last_note_at FROM discussions d {state_joins} WHERE d.resolvable = 1 AND d.resolved = 0 AND d.last_note_at >= ?1 {state_filter} ORDER BY d.last_note_at DESC LIMIT ?2 ), note_counts AS ( SELECT n.discussion_id, COUNT(*) AS note_count FROM notes n JOIN picked p ON p.id = n.discussion_id WHERE n.is_system = 0 GROUP BY n.discussion_id ), participants AS ( SELECT x.discussion_id, GROUP_CONCAT(x.author_username, X'1F') AS participants FROM ( SELECT DISTINCT n.discussion_id, n.author_username FROM notes n JOIN picked p ON p.id = n.discussion_id WHERE n.is_system = 0 AND n.author_username IS NOT NULL ) x GROUP BY x.discussion_id ) SELECT p.id AS discussion_id, p.noteable_type, COALESCE(i.iid, m.iid) AS entity_iid, COALESCE(i.title, m.title) AS entity_title, proj.path_with_namespace, p.last_note_at, COALESCE(nc.note_count, 0) AS note_count, COALESCE(pa.participants, '') AS participants FROM picked p JOIN projects proj ON p.project_id = proj.id LEFT JOIN issues i ON p.issue_id = i.id LEFT JOIN merge_requests m ON p.merge_request_id = m.id LEFT JOIN note_counts nc ON nc.discussion_id = p.id LEFT JOIN participants pa ON pa.discussion_id = p.id ORDER BY p.last_note_at DESC " ); let sql_scoped = format!( " WITH picked AS ( SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id, d.project_id, d.last_note_at FROM discussions d {state_joins} WHERE d.resolvable = 1 AND d.resolved = 0 AND d.last_note_at >= ?1 AND d.project_id = ?2 {state_filter} ORDER BY d.last_note_at DESC LIMIT ?3 ), note_counts AS ( SELECT n.discussion_id, COUNT(*) AS note_count FROM notes n JOIN picked p ON p.id = n.discussion_id WHERE n.is_system = 0 GROUP BY n.discussion_id ), participants AS ( SELECT x.discussion_id, GROUP_CONCAT(x.author_username, X'1F') AS participants FROM ( SELECT DISTINCT n.discussion_id, n.author_username FROM notes n JOIN picked p ON p.id = n.discussion_id WHERE n.is_system = 0 AND n.author_username IS NOT NULL ) x GROUP BY x.discussion_id ) SELECT p.id AS discussion_id, p.noteable_type, COALESCE(i.iid, m.iid) AS entity_iid, COALESCE(i.title, m.title) AS entity_title, proj.path_with_namespace, p.last_note_at, COALESCE(nc.note_count, 0) AS note_count, COALESCE(pa.participants, '') AS participants FROM picked p JOIN projects proj ON p.project_id = proj.id LEFT JOIN issues i ON p.issue_id = i.id LEFT JOIN merge_requests m ON p.merge_request_id = m.id LEFT JOIN note_counts nc ON nc.discussion_id = p.id LEFT JOIN participants pa ON pa.discussion_id = p.id ORDER BY p.last_note_at DESC " ); // Row-mapping closure shared between both variants let map_row = |row: &rusqlite::Row| -> rusqlite::Result { let noteable_type: String = row.get(1)?; let entity_type = if noteable_type == "MergeRequest" { "MR" } else { "Issue" }; let participants_csv: Option = row.get(7)?; // Sort participants for deterministic output -- GROUP_CONCAT order is undefined let mut participants: Vec = participants_csv .as_deref() .filter(|s| !s.is_empty()) .map(|csv| csv.split('\x1F').map(String::from).collect()) .unwrap_or_default(); participants.sort(); const MAX_PARTICIPANTS: usize = 50; let participants_total = participants.len() as u32; let participants_truncated = participants.len() > MAX_PARTICIPANTS; if participants_truncated { participants.truncate(MAX_PARTICIPANTS); } Ok(ActiveDiscussion { discussion_id: row.get(0)?, entity_type: entity_type.to_string(), entity_iid: row.get(2)?, entity_title: row.get(3)?, project_path: row.get(4)?, last_note_at: row.get(5)?, note_count: row.get(6)?, participants, participants_total, participants_truncated, }) }; // Select variant first, then prepare exactly one statement let discussions: Vec = match project_id { None => { let mut stmt = conn.prepare_cached(&sql_global)?; stmt.query_map(rusqlite::params![since_ms, limit_plus_one], &map_row)? .collect::, _>>()? } Some(pid) => { let mut stmt = conn.prepare_cached(&sql_scoped)?; stmt.query_map(rusqlite::params![since_ms, pid, limit_plus_one], &map_row)? .collect::, _>>()? } }; let truncated = discussions.len() > limit; let discussions: Vec = discussions.into_iter().take(limit).collect(); Ok(ActiveResult { discussions, total_unresolved_in_window, truncated, }) } pub(super) fn print_active_human(r: &ActiveResult, project_path: Option<&str>) { println!(); println!( "{}", Theme::bold().render(&format!( "Active Discussions ({} unresolved in window)", r.total_unresolved_in_window )) ); println!("{}", "\u{2500}".repeat(60)); super::print_scope_hint(project_path); println!(); if r.discussions.is_empty() { println!( " {}", Theme::dim().render("No active unresolved discussions in this time window.") ); println!(); return; } for disc in &r.discussions { let prefix = if disc.entity_type == "MR" { "!" } else { "#" }; let participants_str = disc .participants .iter() .map(|p| format!("@{p}")) .collect::>() .join(", "); println!( " {} {} {} {} notes {}", Theme::info().render(&format!("{prefix}{}", disc.entity_iid)), render::truncate(&disc.entity_title, 40), Theme::dim().render(&render::format_relative_time(disc.last_note_at)), disc.note_count, Theme::dim().render(&disc.project_path), ); if !participants_str.is_empty() { println!(" {}", Theme::dim().render(&participants_str)); } } if r.truncated { println!( " {}", Theme::dim().render("(showing first -n; rerun with a higher --limit)") ); } println!(); } pub(super) fn active_to_json(r: &ActiveResult) -> serde_json::Value { serde_json::json!({ "total_unresolved_in_window": r.total_unresolved_in_window, "truncated": r.truncated, "discussions": r.discussions.iter().map(|d| serde_json::json!({ "discussion_id": d.discussion_id, "entity_type": d.entity_type, "entity_iid": d.entity_iid, "entity_title": d.entity_title, "project_path": d.project_path, "last_note_at": ms_to_iso(d.last_note_at), "note_count": d.note_count, "participants": d.participants, "participants_total": d.participants_total, "participants_truncated": d.participants_truncated, })).collect::>(), }) }