use crate::cli::render::{self, Align, Icons, StyledCell, Table as LoreTable, Theme}; use rusqlite::Connection; use serde::Serialize; use crate::Config; use crate::cli::robot::{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, parse_since}; use super::render_helpers::{format_assignees, format_discussions}; #[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(), } } } 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 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 print_list_issues(result: &ListResult) { if result.issues.is_empty() { println!("No issues found."); return; } println!( "{} {} of {}\n", Theme::bold().render("Issues"), result.issues.len(), result.total_count ); let has_any_status = result.issues.iter().any(|i| i.status_name.is_some()); let mut headers = vec!["IID", "Title", "State"]; if has_any_status { headers.push("Status"); } headers.extend(["Assignee", "Labels", "Disc", "Updated"]); let mut table = LoreTable::new().headers(&headers).align(0, Align::Right); for issue in &result.issues { let title = render::truncate(&issue.title, 45); let relative_time = render::format_relative_time_compact(issue.updated_at); let labels = render::format_labels_bare(&issue.labels, 2); let assignee = format_assignees(&issue.assignees); let discussions = format_discussions(issue.discussion_count, issue.unresolved_count); let (icon, state_style) = if issue.state == "opened" { (Icons::issue_opened(), Theme::success()) } else { (Icons::issue_closed(), Theme::dim()) }; let state_cell = StyledCell::styled(format!("{icon} {}", issue.state), state_style); let mut row = vec![ StyledCell::styled(format!("#{}", issue.iid), Theme::info()), StyledCell::plain(title), state_cell, ]; if has_any_status { match &issue.status_name { Some(status) => { row.push(StyledCell::plain(render::style_with_hex( status, issue.status_color.as_deref(), ))); } None => { row.push(StyledCell::plain("")); } } } row.extend([ StyledCell::styled(assignee, Theme::accent()), StyledCell::styled(labels, Theme::warning()), discussions, StyledCell::styled(relative_time, Theme::dim()), ]); table.add_row(row); } println!("{}", table.render()); } 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 } } }