use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table}; use rusqlite::Connection; use serde::Serialize; use crate::Config; use crate::cli::robot::{RobotMeta, expand_fields_preset, filter_fields}; use crate::core::db::create_connection; use crate::core::error::{LoreError, Result}; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; use crate::core::time::{ms_to_iso, now_ms, parse_since}; fn colored_cell(content: impl std::fmt::Display, color: Color) -> Cell { let cell = Cell::new(content); if console::colors_enabled() { cell.fg(color) } else { cell } } fn colored_cell_hex(content: &str, hex: Option<&str>) -> Cell { if !console::colors_enabled() { return Cell::new(content); } let Some(hex) = hex else { return Cell::new(content); }; let hex = hex.trim_start_matches('#'); if hex.len() != 6 { return Cell::new(content); } let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else { return Cell::new(content); }; let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else { return Cell::new(content); }; let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else { return Cell::new(content); }; Cell::new(content).fg(Color::Rgb { r, g, b }) } #[derive(Debug, Serialize)] pub struct IssueListRow { pub iid: i64, pub title: String, pub state: String, pub author_username: String, pub created_at: i64, pub updated_at: i64, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, pub project_path: String, pub labels: Vec, pub assignees: Vec, pub discussion_count: i64, pub unresolved_count: i64, #[serde(skip_serializing_if = "Option::is_none")] pub status_name: Option, #[serde(skip_serializing)] pub status_category: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status_color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status_icon_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status_synced_at: Option, } #[derive(Serialize)] pub struct IssueListRowJson { pub iid: i64, pub title: String, pub state: String, pub author_username: String, pub labels: Vec, pub assignees: Vec, pub discussion_count: i64, pub unresolved_count: i64, pub created_at_iso: String, pub updated_at_iso: String, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, pub project_path: String, #[serde(skip_serializing_if = "Option::is_none")] pub status_name: Option, #[serde(skip_serializing)] pub status_category: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status_color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status_icon_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status_synced_at_iso: Option, } impl From<&IssueListRow> for IssueListRowJson { fn from(row: &IssueListRow) -> Self { Self { iid: row.iid, title: row.title.clone(), state: row.state.clone(), author_username: row.author_username.clone(), labels: row.labels.clone(), assignees: row.assignees.clone(), discussion_count: row.discussion_count, unresolved_count: row.unresolved_count, created_at_iso: ms_to_iso(row.created_at), updated_at_iso: ms_to_iso(row.updated_at), web_url: row.web_url.clone(), project_path: row.project_path.clone(), status_name: row.status_name.clone(), status_category: row.status_category.clone(), status_color: row.status_color.clone(), status_icon_name: row.status_icon_name.clone(), status_synced_at_iso: row.status_synced_at.map(ms_to_iso), } } } #[derive(Serialize)] pub struct ListResult { pub issues: Vec, pub total_count: usize, pub available_statuses: Vec, } #[derive(Serialize)] pub struct ListResultJson { pub issues: Vec, pub total_count: usize, pub showing: usize, } impl From<&ListResult> for ListResultJson { fn from(result: &ListResult) -> Self { Self { issues: result.issues.iter().map(IssueListRowJson::from).collect(), total_count: result.total_count, showing: result.issues.len(), } } } #[derive(Debug, Serialize)] pub struct MrListRow { pub iid: i64, pub title: String, pub state: String, pub draft: bool, pub author_username: String, pub source_branch: String, pub target_branch: String, pub created_at: i64, pub updated_at: i64, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, pub project_path: String, pub labels: Vec, pub assignees: Vec, pub reviewers: Vec, pub discussion_count: i64, pub unresolved_count: i64, } #[derive(Serialize)] pub struct MrListRowJson { pub iid: i64, pub title: String, pub state: String, pub draft: bool, pub author_username: String, pub source_branch: String, pub target_branch: String, pub labels: Vec, pub assignees: Vec, pub reviewers: Vec, pub discussion_count: i64, pub unresolved_count: i64, pub created_at_iso: String, pub updated_at_iso: String, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, pub project_path: String, } impl From<&MrListRow> for MrListRowJson { fn from(row: &MrListRow) -> Self { Self { iid: row.iid, title: row.title.clone(), state: row.state.clone(), draft: row.draft, author_username: row.author_username.clone(), source_branch: row.source_branch.clone(), target_branch: row.target_branch.clone(), labels: row.labels.clone(), assignees: row.assignees.clone(), reviewers: row.reviewers.clone(), discussion_count: row.discussion_count, unresolved_count: row.unresolved_count, created_at_iso: ms_to_iso(row.created_at), updated_at_iso: ms_to_iso(row.updated_at), web_url: row.web_url.clone(), project_path: row.project_path.clone(), } } } #[derive(Serialize)] pub struct MrListResult { pub mrs: Vec, pub total_count: usize, } #[derive(Serialize)] pub struct MrListResultJson { pub mrs: Vec, pub total_count: usize, pub showing: usize, } impl From<&MrListResult> for MrListResultJson { fn from(result: &MrListResult) -> Self { Self { mrs: result.mrs.iter().map(MrListRowJson::from).collect(), total_count: result.total_count, showing: result.mrs.len(), } } } pub struct ListFilters<'a> { pub limit: usize, pub project: Option<&'a str>, pub state: Option<&'a str>, pub author: Option<&'a str>, pub assignee: Option<&'a str>, pub labels: Option<&'a [String]>, pub milestone: Option<&'a str>, pub since: Option<&'a str>, pub due_before: Option<&'a str>, pub has_due_date: bool, pub statuses: &'a [String], pub sort: &'a str, pub order: &'a str, } pub struct MrListFilters<'a> { pub limit: usize, pub project: Option<&'a str>, pub state: Option<&'a str>, pub author: Option<&'a str>, pub assignee: Option<&'a str>, pub reviewer: Option<&'a str>, pub labels: Option<&'a [String]>, pub since: Option<&'a str>, pub draft: bool, pub no_draft: bool, pub target_branch: Option<&'a str>, pub source_branch: Option<&'a str>, pub sort: &'a str, pub order: &'a str, } pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; let mut result = query_issues(&conn, &filters)?; result.available_statuses = query_available_statuses(&conn)?; Ok(result) } fn query_available_statuses(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT DISTINCT status_name FROM issues WHERE status_name IS NOT NULL ORDER BY status_name", )?; let statuses = stmt .query_map([], |row| row.get::<_, String>(0))? .collect::, _>>()?; Ok(statuses) } fn query_issues(conn: &Connection, filters: &ListFilters) -> Result { let mut where_clauses = Vec::new(); let mut params: Vec> = Vec::new(); if let Some(project) = filters.project { let project_id = resolve_project(conn, project)?; where_clauses.push("i.project_id = ?"); params.push(Box::new(project_id)); } if let Some(state) = filters.state && state != "all" { where_clauses.push("i.state = ?"); params.push(Box::new(state.to_string())); } if let Some(author) = filters.author { let username = author.strip_prefix('@').unwrap_or(author); where_clauses.push("i.author_username = ?"); params.push(Box::new(username.to_string())); } if let Some(assignee) = filters.assignee { let username = assignee.strip_prefix('@').unwrap_or(assignee); where_clauses.push( "EXISTS (SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = i.id AND ia.username = ?)", ); params.push(Box::new(username.to_string())); } if let Some(since_str) = filters.since { let cutoff_ms = parse_since(since_str).ok_or_else(|| { LoreError::Other(format!( "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", since_str )) })?; where_clauses.push("i.updated_at >= ?"); params.push(Box::new(cutoff_ms)); } if let Some(labels) = filters.labels { for label in labels { where_clauses.push( "EXISTS (SELECT 1 FROM issue_labels il JOIN labels l ON il.label_id = l.id WHERE il.issue_id = i.id AND l.name = ?)", ); params.push(Box::new(label.clone())); } } if let Some(milestone) = filters.milestone { where_clauses.push("i.milestone_title = ?"); params.push(Box::new(milestone.to_string())); } if let Some(due_before) = filters.due_before { where_clauses.push("i.due_date IS NOT NULL AND i.due_date <= ?"); params.push(Box::new(due_before.to_string())); } if filters.has_due_date { where_clauses.push("i.due_date IS NOT NULL"); } let status_in_clause; if filters.statuses.len() == 1 { where_clauses.push("i.status_name = ? COLLATE NOCASE"); params.push(Box::new(filters.statuses[0].clone())); } else if filters.statuses.len() > 1 { let placeholders: Vec<&str> = filters.statuses.iter().map(|_| "?").collect(); status_in_clause = format!( "i.status_name COLLATE NOCASE IN ({})", placeholders.join(", ") ); where_clauses.push(&status_in_clause); for s in filters.statuses { params.push(Box::new(s.clone())); } } let where_sql = if where_clauses.is_empty() { String::new() } else { format!("WHERE {}", where_clauses.join(" AND ")) }; let count_sql = format!( "SELECT COUNT(*) FROM issues i JOIN projects p ON i.project_id = p.id {where_sql}" ); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; let total_count = total_count as usize; let sort_column = match filters.sort { "created" => "i.created_at", "iid" => "i.iid", _ => "i.updated_at", }; let order = if filters.order == "asc" { "ASC" } else { "DESC" }; let query_sql = format!( "SELECT i.iid, i.title, i.state, i.author_username, i.created_at, i.updated_at, i.web_url, p.path_with_namespace, (SELECT GROUP_CONCAT(l.name, X'1F') FROM issue_labels il JOIN labels l ON il.label_id = l.id WHERE il.issue_id = i.id) AS labels_csv, (SELECT GROUP_CONCAT(ia.username, X'1F') FROM issue_assignees ia WHERE ia.issue_id = i.id) AS assignees_csv, (SELECT COUNT(*) FROM discussions d WHERE d.issue_id = i.id) AS discussion_count, (SELECT COUNT(*) FROM discussions d WHERE d.issue_id = i.id AND d.resolvable = 1 AND d.resolved = 0) AS unresolved_count, i.status_name, i.status_category, i.status_color, i.status_icon_name, i.status_synced_at FROM issues i JOIN projects p ON i.project_id = p.id {where_sql} ORDER BY {sort_column} {order} LIMIT ?" ); params.push(Box::new(filters.limit as i64)); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(&query_sql)?; let issues: Vec = stmt .query_map(param_refs.as_slice(), |row| { let labels_csv: Option = row.get(8)?; let labels = labels_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); let assignees_csv: Option = row.get(9)?; let assignees = assignees_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); Ok(IssueListRow { iid: row.get(0)?, title: row.get(1)?, state: row.get(2)?, author_username: row.get(3)?, created_at: row.get(4)?, updated_at: row.get(5)?, web_url: row.get(6)?, project_path: row.get(7)?, labels, assignees, discussion_count: row.get(10)?, unresolved_count: row.get(11)?, status_name: row.get(12)?, status_category: row.get(13)?, status_color: row.get(14)?, status_icon_name: row.get(15)?, status_synced_at: row.get(16)?, }) })? .collect::, _>>()?; Ok(ListResult { issues, total_count, available_statuses: Vec::new(), }) } pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; let result = query_mrs(&conn, &filters)?; Ok(result) } fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result { let mut where_clauses = Vec::new(); let mut params: Vec> = Vec::new(); if let Some(project) = filters.project { let project_id = resolve_project(conn, project)?; where_clauses.push("m.project_id = ?"); params.push(Box::new(project_id)); } if let Some(state) = filters.state && state != "all" { where_clauses.push("m.state = ?"); params.push(Box::new(state.to_string())); } if let Some(author) = filters.author { let username = author.strip_prefix('@').unwrap_or(author); where_clauses.push("m.author_username = ?"); params.push(Box::new(username.to_string())); } if let Some(assignee) = filters.assignee { let username = assignee.strip_prefix('@').unwrap_or(assignee); where_clauses.push( "EXISTS (SELECT 1 FROM mr_assignees ma WHERE ma.merge_request_id = m.id AND ma.username = ?)", ); params.push(Box::new(username.to_string())); } if let Some(reviewer) = filters.reviewer { let username = reviewer.strip_prefix('@').unwrap_or(reviewer); where_clauses.push( "EXISTS (SELECT 1 FROM mr_reviewers mr WHERE mr.merge_request_id = m.id AND mr.username = ?)", ); params.push(Box::new(username.to_string())); } if let Some(since_str) = filters.since { let cutoff_ms = parse_since(since_str).ok_or_else(|| { LoreError::Other(format!( "Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", since_str )) })?; where_clauses.push("m.updated_at >= ?"); params.push(Box::new(cutoff_ms)); } if let Some(labels) = filters.labels { for label in labels { where_clauses.push( "EXISTS (SELECT 1 FROM mr_labels ml JOIN labels l ON ml.label_id = l.id WHERE ml.merge_request_id = m.id AND l.name = ?)", ); params.push(Box::new(label.clone())); } } if filters.draft { where_clauses.push("m.draft = 1"); } else if filters.no_draft { where_clauses.push("m.draft = 0"); } if let Some(target_branch) = filters.target_branch { where_clauses.push("m.target_branch = ?"); params.push(Box::new(target_branch.to_string())); } if let Some(source_branch) = filters.source_branch { where_clauses.push("m.source_branch = ?"); params.push(Box::new(source_branch.to_string())); } let where_sql = if where_clauses.is_empty() { String::new() } else { format!("WHERE {}", where_clauses.join(" AND ")) }; let count_sql = format!( "SELECT COUNT(*) FROM merge_requests m JOIN projects p ON m.project_id = p.id {where_sql}" ); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; let total_count = total_count as usize; let sort_column = match filters.sort { "created" => "m.created_at", "iid" => "m.iid", _ => "m.updated_at", }; let order = if filters.order == "asc" { "ASC" } else { "DESC" }; let query_sql = format!( "SELECT m.iid, m.title, m.state, m.draft, m.author_username, m.source_branch, m.target_branch, m.created_at, m.updated_at, m.web_url, p.path_with_namespace, (SELECT GROUP_CONCAT(l.name, X'1F') FROM mr_labels ml JOIN labels l ON ml.label_id = l.id WHERE ml.merge_request_id = m.id) AS labels_csv, (SELECT GROUP_CONCAT(ma.username, X'1F') FROM mr_assignees ma WHERE ma.merge_request_id = m.id) AS assignees_csv, (SELECT GROUP_CONCAT(mr.username, X'1F') FROM mr_reviewers mr WHERE mr.merge_request_id = m.id) AS reviewers_csv, (SELECT COUNT(*) FROM discussions d WHERE d.merge_request_id = m.id) AS discussion_count, (SELECT COUNT(*) FROM discussions d WHERE d.merge_request_id = m.id AND d.resolvable = 1 AND d.resolved = 0) AS unresolved_count FROM merge_requests m JOIN projects p ON m.project_id = p.id {where_sql} ORDER BY {sort_column} {order} LIMIT ?" ); params.push(Box::new(filters.limit as i64)); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(&query_sql)?; let mrs: Vec = stmt .query_map(param_refs.as_slice(), |row| { let labels_csv: Option = row.get(11)?; let labels = labels_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); let assignees_csv: Option = row.get(12)?; let assignees = assignees_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); let reviewers_csv: Option = row.get(13)?; let reviewers = reviewers_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); let draft_int: i64 = row.get(3)?; Ok(MrListRow { iid: row.get(0)?, title: row.get(1)?, state: row.get(2)?, draft: draft_int == 1, author_username: row.get(4)?, source_branch: row.get(5)?, target_branch: row.get(6)?, created_at: row.get(7)?, updated_at: row.get(8)?, web_url: row.get(9)?, project_path: row.get(10)?, labels, assignees, reviewers, discussion_count: row.get(14)?, unresolved_count: row.get(15)?, }) })? .collect::, _>>()?; Ok(MrListResult { mrs, total_count }) } fn format_relative_time(ms_epoch: i64) -> String { let now = now_ms(); let diff = now - ms_epoch; if diff < 0 { return "in the future".to_string(); } match diff { d if d < 60_000 => "just now".to_string(), d if d < 3_600_000 => format!("{} min ago", d / 60_000), d if d < 86_400_000 => { let n = d / 3_600_000; format!("{n} {} ago", if n == 1 { "hour" } else { "hours" }) } d if d < 604_800_000 => { let n = d / 86_400_000; format!("{n} {} ago", if n == 1 { "day" } else { "days" }) } d if d < 2_592_000_000 => { let n = d / 604_800_000; format!("{n} {} ago", if n == 1 { "week" } else { "weeks" }) } _ => { let n = diff / 2_592_000_000; format!("{n} {} ago", if n == 1 { "month" } else { "months" }) } } } fn truncate_with_ellipsis(s: &str, max_width: usize) -> String { if s.chars().count() <= max_width { s.to_string() } else { let truncated: String = s.chars().take(max_width.saturating_sub(3)).collect(); format!("{truncated}...") } } fn format_labels(labels: &[String], max_shown: usize) -> String { if labels.is_empty() { return String::new(); } let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect(); let overflow = labels.len().saturating_sub(max_shown); if overflow > 0 { format!("[{} +{}]", shown.join(", "), overflow) } else { format!("[{}]", shown.join(", ")) } } fn format_assignees(assignees: &[String]) -> String { if assignees.is_empty() { return "-".to_string(); } let max_shown = 2; let shown: Vec = assignees .iter() .take(max_shown) .map(|s| format!("@{}", truncate_with_ellipsis(s, 10))) .collect(); let overflow = assignees.len().saturating_sub(max_shown); if overflow > 0 { format!("{} +{}", shown.join(", "), overflow) } else { shown.join(", ") } } fn format_discussions(total: i64, unresolved: i64) -> String { if total == 0 { return String::new(); } if unresolved > 0 { format!("{total}/{unresolved}!") } else { format!("{total}") } } fn format_branches(target: &str, source: &str, max_width: usize) -> String { let full = format!("{} <- {}", target, source); truncate_with_ellipsis(&full, max_width) } pub fn print_list_issues(result: &ListResult) { if result.issues.is_empty() { println!("No issues found."); return; } println!( "Issues (showing {} of {})\n", result.issues.len(), result.total_count ); let has_any_status = result.issues.iter().any(|i| i.status_name.is_some()); let mut header = vec![ Cell::new("IID").add_attribute(Attribute::Bold), Cell::new("Title").add_attribute(Attribute::Bold), Cell::new("State").add_attribute(Attribute::Bold), ]; if has_any_status { header.push(Cell::new("Status").add_attribute(Attribute::Bold)); } header.extend([ Cell::new("Assignee").add_attribute(Attribute::Bold), Cell::new("Labels").add_attribute(Attribute::Bold), Cell::new("Disc").add_attribute(Attribute::Bold), Cell::new("Updated").add_attribute(Attribute::Bold), ]); let mut table = Table::new(); table .set_content_arrangement(ContentArrangement::Dynamic) .set_header(header); for issue in &result.issues { let title = truncate_with_ellipsis(&issue.title, 45); let relative_time = format_relative_time(issue.updated_at); let labels = format_labels(&issue.labels, 2); let assignee = format_assignees(&issue.assignees); let discussions = format_discussions(issue.discussion_count, issue.unresolved_count); let state_cell = if issue.state == "opened" { colored_cell(&issue.state, Color::Green) } else { colored_cell(&issue.state, Color::DarkGrey) }; let mut row = vec![ colored_cell(format!("#{}", issue.iid), Color::Cyan), Cell::new(title), state_cell, ]; if has_any_status { match &issue.status_name { Some(status) => { row.push(colored_cell_hex(status, issue.status_color.as_deref())); } None => { row.push(Cell::new("")); } } } row.extend([ colored_cell(assignee, Color::Magenta), colored_cell(labels, Color::Yellow), Cell::new(discussions), colored_cell(relative_time, Color::DarkGrey), ]); table.add_row(row); } println!("{table}"); } pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) { let json_result = ListResultJson::from(result); let output = serde_json::json!({ "ok": true, "data": json_result, "meta": { "elapsed_ms": elapsed_ms, "available_statuses": result.available_statuses, }, }); let mut output = output; if let Some(f) = fields { let expanded = expand_fields_preset(f, "issues"); filter_fields(&mut output, "issues", &expanded); } match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } pub fn open_issue_in_browser(result: &ListResult) -> Option { let first_issue = result.issues.first()?; let url = first_issue.web_url.as_ref()?; match open::that(url) { Ok(()) => { println!("Opened: {url}"); Some(url.clone()) } Err(e) => { eprintln!("Failed to open browser: {e}"); None } } } pub fn print_list_mrs(result: &MrListResult) { if result.mrs.is_empty() { println!("No merge requests found."); return; } println!( "Merge Requests (showing {} of {})\n", result.mrs.len(), result.total_count ); let mut table = Table::new(); table .set_content_arrangement(ContentArrangement::Dynamic) .set_header(vec![ Cell::new("IID").add_attribute(Attribute::Bold), Cell::new("Title").add_attribute(Attribute::Bold), Cell::new("State").add_attribute(Attribute::Bold), Cell::new("Author").add_attribute(Attribute::Bold), Cell::new("Branches").add_attribute(Attribute::Bold), Cell::new("Disc").add_attribute(Attribute::Bold), Cell::new("Updated").add_attribute(Attribute::Bold), ]); for mr in &result.mrs { let title = if mr.draft { format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38)) } else { truncate_with_ellipsis(&mr.title, 45) }; let relative_time = format_relative_time(mr.updated_at); let branches = format_branches(&mr.target_branch, &mr.source_branch, 25); let discussions = format_discussions(mr.discussion_count, mr.unresolved_count); let state_cell = match mr.state.as_str() { "opened" => colored_cell(&mr.state, Color::Green), "merged" => colored_cell(&mr.state, Color::Magenta), "closed" => colored_cell(&mr.state, Color::Red), "locked" => colored_cell(&mr.state, Color::Yellow), _ => colored_cell(&mr.state, Color::DarkGrey), }; table.add_row(vec![ colored_cell(format!("!{}", mr.iid), Color::Cyan), Cell::new(title), state_cell, colored_cell( format!("@{}", truncate_with_ellipsis(&mr.author_username, 12)), Color::Magenta, ), colored_cell(branches, Color::Blue), Cell::new(discussions), colored_cell(relative_time, Color::DarkGrey), ]); } println!("{table}"); } pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) { let json_result = MrListResultJson::from(result); let meta = RobotMeta { elapsed_ms }; let output = serde_json::json!({ "ok": true, "data": json_result, "meta": meta, }); let mut output = output; if let Some(f) = fields { let expanded = expand_fields_preset(f, "mrs"); filter_fields(&mut output, "mrs", &expanded); } match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } pub fn open_mr_in_browser(result: &MrListResult) -> Option { let first_mr = result.mrs.first()?; let url = first_mr.web_url.as_ref()?; match open::that(url) { Ok(()) => { println!("Opened: {url}"); Some(url.clone()) } Err(e) => { eprintln!("Failed to open browser: {e}"); None } } } #[cfg(test)] mod tests { use super::*; #[test] fn truncate_leaves_short_strings_alone() { assert_eq!(truncate_with_ellipsis("short", 10), "short"); } #[test] fn truncate_adds_ellipsis_to_long_strings() { assert_eq!( truncate_with_ellipsis("this is a very long title", 15), "this is a ve..." ); } #[test] fn truncate_handles_exact_length() { assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!"); } #[test] fn relative_time_formats_correctly() { let now = now_ms(); assert_eq!(format_relative_time(now - 30_000), "just now"); assert_eq!(format_relative_time(now - 120_000), "2 min ago"); assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago"); assert_eq!(format_relative_time(now - 172_800_000), "2 days ago"); } #[test] fn format_labels_empty() { assert_eq!(format_labels(&[], 2), ""); } #[test] fn format_labels_single() { assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]"); } #[test] fn format_labels_multiple() { let labels = vec!["bug".to_string(), "urgent".to_string()]; assert_eq!(format_labels(&labels, 2), "[bug, urgent]"); } #[test] fn format_labels_overflow() { let labels = vec![ "bug".to_string(), "urgent".to_string(), "wip".to_string(), "blocked".to_string(), ]; assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]"); } #[test] fn format_discussions_empty() { assert_eq!(format_discussions(0, 0), ""); } #[test] fn format_discussions_no_unresolved() { assert_eq!(format_discussions(5, 0), "5"); } #[test] fn format_discussions_with_unresolved() { assert_eq!(format_discussions(5, 2), "5/2!"); } }