use rusqlite::Connection; use crate::cli::render::{self, Icons, Theme}; use crate::core::error::Result; use crate::core::time::ms_to_iso; use super::types::*; // ─── Query: Workload Mode ─────────────────────────────────────────────────── pub(super) fn query_workload( conn: &Connection, username: &str, project_id: Option, since_ms: Option, limit: usize, include_closed: bool, ) -> Result { let limit_plus_one = (limit + 1) as i64; // Query 1: Open issues assigned to user let issues_sql = "SELECT i.iid, (p.path_with_namespace || '#' || i.iid) AS ref, i.title, p.path_with_namespace, i.updated_at FROM issues i JOIN issue_assignees ia ON ia.issue_id = i.id JOIN projects p ON i.project_id = p.id WHERE ia.username = ?1 AND i.state = 'opened' AND (?2 IS NULL OR i.project_id = ?2) AND (?3 IS NULL OR i.updated_at >= ?3) ORDER BY i.updated_at DESC LIMIT ?4"; let mut stmt = conn.prepare_cached(issues_sql)?; let assigned_issues: Vec = stmt .query_map( rusqlite::params![username, project_id, since_ms, limit_plus_one], |row| { Ok(WorkloadIssue { iid: row.get(0)?, ref_: row.get(1)?, title: row.get(2)?, project_path: row.get(3)?, updated_at: row.get(4)?, }) }, )? .collect::, _>>()?; // Query 2: Open MRs authored let authored_sql = "SELECT m.iid, (p.path_with_namespace || '!' || m.iid) AS ref, m.title, m.draft, p.path_with_namespace, m.updated_at FROM merge_requests m JOIN projects p ON m.project_id = p.id WHERE m.author_username = ?1 AND m.state = 'opened' AND (?2 IS NULL OR m.project_id = ?2) AND (?3 IS NULL OR m.updated_at >= ?3) ORDER BY m.updated_at DESC LIMIT ?4"; let mut stmt = conn.prepare_cached(authored_sql)?; let authored_mrs: Vec = stmt .query_map( rusqlite::params![username, project_id, since_ms, limit_plus_one], |row| { Ok(WorkloadMr { iid: row.get(0)?, ref_: row.get(1)?, title: row.get(2)?, draft: row.get::<_, i32>(3)? != 0, project_path: row.get(4)?, author_username: None, updated_at: row.get(5)?, }) }, )? .collect::, _>>()?; // Query 3: Open MRs where user is reviewer let reviewing_sql = "SELECT m.iid, (p.path_with_namespace || '!' || m.iid) AS ref, m.title, m.draft, p.path_with_namespace, m.author_username, m.updated_at FROM merge_requests m JOIN mr_reviewers r ON r.merge_request_id = m.id JOIN projects p ON m.project_id = p.id WHERE r.username = ?1 AND m.state = 'opened' AND (?2 IS NULL OR m.project_id = ?2) AND (?3 IS NULL OR m.updated_at >= ?3) ORDER BY m.updated_at DESC LIMIT ?4"; let mut stmt = conn.prepare_cached(reviewing_sql)?; let reviewing_mrs: Vec = stmt .query_map( rusqlite::params![username, project_id, since_ms, limit_plus_one], |row| { Ok(WorkloadMr { iid: row.get(0)?, ref_: row.get(1)?, title: row.get(2)?, draft: row.get::<_, i32>(3)? != 0, project_path: row.get(4)?, author_username: row.get(5)?, updated_at: row.get(6)?, }) }, )? .collect::, _>>()?; // Query 4: Unresolved discussions where user participated 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')" }; let disc_sql = format!( "SELECT d.noteable_type, COALESCE(i.iid, m.iid) AS entity_iid, (p.path_with_namespace || CASE WHEN d.noteable_type = 'MergeRequest' THEN '!' ELSE '#' END || COALESCE(i.iid, m.iid)) AS ref, COALESCE(i.title, m.title) AS entity_title, p.path_with_namespace, d.last_note_at FROM discussions d JOIN projects p ON d.project_id = p.id LEFT JOIN issues i ON d.issue_id = i.id LEFT JOIN merge_requests m ON d.merge_request_id = m.id WHERE d.resolvable = 1 AND d.resolved = 0 AND EXISTS ( SELECT 1 FROM notes n WHERE n.discussion_id = d.id AND n.author_username = ?1 AND n.is_system = 0 ) AND (?2 IS NULL OR d.project_id = ?2) AND (?3 IS NULL OR d.last_note_at >= ?3) {state_filter} ORDER BY d.last_note_at DESC LIMIT ?4" ); let mut stmt = conn.prepare_cached(&disc_sql)?; let unresolved_discussions: Vec = stmt .query_map( rusqlite::params![username, project_id, since_ms, limit_plus_one], |row| { let noteable_type: String = row.get(0)?; let entity_type = if noteable_type == "MergeRequest" { "MR" } else { "Issue" }; Ok(WorkloadDiscussion { entity_type: entity_type.to_string(), entity_iid: row.get(1)?, ref_: row.get(2)?, entity_title: row.get(3)?, project_path: row.get(4)?, last_note_at: row.get(5)?, }) }, )? .collect::, _>>()?; // Truncation detection let assigned_issues_truncated = assigned_issues.len() > limit; let authored_mrs_truncated = authored_mrs.len() > limit; let reviewing_mrs_truncated = reviewing_mrs.len() > limit; let unresolved_discussions_truncated = unresolved_discussions.len() > limit; let assigned_issues: Vec = assigned_issues.into_iter().take(limit).collect(); let authored_mrs: Vec = authored_mrs.into_iter().take(limit).collect(); let reviewing_mrs: Vec = reviewing_mrs.into_iter().take(limit).collect(); let unresolved_discussions: Vec = unresolved_discussions.into_iter().take(limit).collect(); Ok(WorkloadResult { username: username.to_string(), assigned_issues, authored_mrs, reviewing_mrs, unresolved_discussions, assigned_issues_truncated, authored_mrs_truncated, reviewing_mrs_truncated, unresolved_discussions_truncated, }) } // ─── Human Renderer: Workload ─────────────────────────────────────────────── pub(super) fn print_workload_human(r: &WorkloadResult) { println!(); println!( "{}", Theme::bold().render(&format!( "{} {} -- Workload Summary", Icons::user(), r.username )) ); println!("{}", "\u{2500}".repeat(60)); if !r.assigned_issues.is_empty() { println!( "{}", render::section_divider(&format!("Assigned Issues ({})", r.assigned_issues.len())) ); for item in &r.assigned_issues { println!( " {} {} {}", Theme::info().render(&item.ref_), render::truncate(&item.title, 40), Theme::dim().render(&render::format_relative_time(item.updated_at)), ); } if r.assigned_issues_truncated { println!( " {}", Theme::dim().render("(truncated; rerun with a higher --limit)") ); } } if !r.authored_mrs.is_empty() { println!( "{}", render::section_divider(&format!("Authored MRs ({})", r.authored_mrs.len())) ); for mr in &r.authored_mrs { let draft = if mr.draft { " [draft]" } else { "" }; println!( " {} {}{} {}", Theme::info().render(&mr.ref_), render::truncate(&mr.title, 35), Theme::dim().render(draft), Theme::dim().render(&render::format_relative_time(mr.updated_at)), ); } if r.authored_mrs_truncated { println!( " {}", Theme::dim().render("(truncated; rerun with a higher --limit)") ); } } if !r.reviewing_mrs.is_empty() { println!( "{}", render::section_divider(&format!("Reviewing MRs ({})", r.reviewing_mrs.len())) ); for mr in &r.reviewing_mrs { let author = mr .author_username .as_deref() .map(|a| format!(" by @{a}")) .unwrap_or_default(); println!( " {} {}{} {}", Theme::info().render(&mr.ref_), render::truncate(&mr.title, 30), Theme::dim().render(&author), Theme::dim().render(&render::format_relative_time(mr.updated_at)), ); } if r.reviewing_mrs_truncated { println!( " {}", Theme::dim().render("(truncated; rerun with a higher --limit)") ); } } if !r.unresolved_discussions.is_empty() { println!( "{}", render::section_divider(&format!( "Unresolved Discussions ({})", r.unresolved_discussions.len() )) ); for disc in &r.unresolved_discussions { println!( " {} {} {} {}", Theme::dim().render(&disc.entity_type), Theme::info().render(&disc.ref_), render::truncate(&disc.entity_title, 35), Theme::dim().render(&render::format_relative_time(disc.last_note_at)), ); } if r.unresolved_discussions_truncated { println!( " {}", Theme::dim().render("(truncated; rerun with a higher --limit)") ); } } if r.assigned_issues.is_empty() && r.authored_mrs.is_empty() && r.reviewing_mrs.is_empty() && r.unresolved_discussions.is_empty() { println!(); println!( " {}", Theme::dim().render("No open work items found for this user.") ); } println!(); } // ─── JSON Renderer: Workload ──────────────────────────────────────────────── pub(super) fn workload_to_json(r: &WorkloadResult) -> serde_json::Value { serde_json::json!({ "username": r.username, "assigned_issues": r.assigned_issues.iter().map(|i| serde_json::json!({ "iid": i.iid, "ref": i.ref_, "title": i.title, "project_path": i.project_path, "updated_at": ms_to_iso(i.updated_at), })).collect::>(), "authored_mrs": r.authored_mrs.iter().map(|m| serde_json::json!({ "iid": m.iid, "ref": m.ref_, "title": m.title, "draft": m.draft, "project_path": m.project_path, "updated_at": ms_to_iso(m.updated_at), })).collect::>(), "reviewing_mrs": r.reviewing_mrs.iter().map(|m| serde_json::json!({ "iid": m.iid, "ref": m.ref_, "title": m.title, "draft": m.draft, "project_path": m.project_path, "author_username": m.author_username, "updated_at": ms_to_iso(m.updated_at), })).collect::>(), "unresolved_discussions": r.unresolved_discussions.iter().map(|d| serde_json::json!({ "entity_type": d.entity_type, "entity_iid": d.entity_iid, "ref": d.ref_, "entity_title": d.entity_title, "project_path": d.project_path, "last_note_at": ms_to_iso(d.last_note_at), })).collect::>(), "summary": { "assigned_issue_count": r.assigned_issues.len(), "authored_mr_count": r.authored_mrs.len(), "reviewing_mr_count": r.reviewing_mrs.len(), "unresolved_discussion_count": r.unresolved_discussions.len(), }, "truncation": { "assigned_issues_truncated": r.assigned_issues_truncated, "authored_mrs_truncated": r.authored_mrs_truncated, "reviewing_mrs_truncated": r.reviewing_mrs_truncated, "unresolved_discussions_truncated": r.unresolved_discussions_truncated, } }) }