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::{RobotMeta, expand_fields_preset, filter_fields}; use crate::core::db::create_connection; use crate::core::error::{LoreError, Result}; use crate::core::path_resolver::escape_like as note_escape_like; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; use crate::core::time::{ms_to_iso, parse_since}; #[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_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!("@{}", render::truncate(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) -> StyledCell { if total == 0 { return StyledCell::plain(String::new()); } if unresolved > 0 { let text = format!("{total}/"); let warn = Theme::warning().render(&format!("{unresolved}!")); StyledCell::plain(format!("{text}{warn}")) } else { StyledCell::plain(format!("{total}")) } } fn format_branches(target: &str, source: &str, max_width: usize) -> String { let full = format!("{} <- {}", target, source); render::truncate(&full, max_width) } 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 } } } pub fn print_list_mrs(result: &MrListResult) { if result.mrs.is_empty() { println!("No merge requests found."); return; } println!( "{} {} of {}\n", Theme::bold().render("Merge Requests"), result.mrs.len(), result.total_count ); let mut table = LoreTable::new() .headers(&[ "IID", "Title", "State", "Author", "Branches", "Disc", "Updated", ]) .align(0, Align::Right); for mr in &result.mrs { let title = if mr.draft { format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42)) } else { render::truncate(&mr.title, 45) }; let relative_time = render::format_relative_time_compact(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 (icon, style) = match mr.state.as_str() { "opened" => (Icons::mr_opened(), Theme::success()), "merged" => (Icons::mr_merged(), Theme::accent()), "closed" => (Icons::mr_closed(), Theme::error()), "locked" => (Icons::mr_opened(), Theme::warning()), _ => (Icons::mr_opened(), Theme::dim()), }; let state_cell = StyledCell::styled(format!("{icon} {}", mr.state), style); table.add_row(vec![ StyledCell::styled(format!("!{}", mr.iid), Theme::info()), StyledCell::plain(title), state_cell, StyledCell::styled( format!("@{}", render::truncate(&mr.author_username, 12)), Theme::accent(), ), StyledCell::styled(branches, Theme::info()), discussions, StyledCell::styled(relative_time, Theme::dim()), ]); } println!("{}", table.render()); } 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 } } } // --------------------------------------------------------------------------- // Note output formatting // --------------------------------------------------------------------------- fn truncate_body(body: &str, max_len: usize) -> String { if body.chars().count() <= max_len { body.to_string() } else { let truncated: String = body.chars().take(max_len).collect(); format!("{truncated}...") } } fn format_note_type(note_type: Option<&str>) -> &str { match note_type { Some("DiffNote") => "Diff", Some("DiscussionNote") => "Disc", _ => "-", } } fn format_note_path(path: Option<&str>, line: Option) -> String { match (path, line) { (Some(p), Some(l)) => format!("{p}:{l}"), (Some(p), None) => p.to_string(), _ => "-".to_string(), } } fn format_note_parent(noteable_type: Option<&str>, parent_iid: Option) -> String { match (noteable_type, parent_iid) { (Some("Issue"), Some(iid)) => format!("Issue #{iid}"), (Some("MergeRequest"), Some(iid)) => format!("MR !{iid}"), _ => "-".to_string(), } } pub fn print_list_notes(result: &NoteListResult) { if result.notes.is_empty() { println!("No notes found."); return; } println!( "{} {} of {}\n", Theme::bold().render("Notes"), result.notes.len(), result.total_count ); let mut table = LoreTable::new() .headers(&[ "ID", "Author", "Type", "Body", "Path:Line", "Parent", "Created", ]) .align(0, Align::Right); for note in &result.notes { let body = note .body .as_deref() .map(|b| truncate_body(b, 60)) .unwrap_or_default(); let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line); let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid); let relative_time = render::format_relative_time_compact(note.created_at); let note_type = format_note_type(note.note_type.as_deref()); table.add_row(vec![ StyledCell::styled(note.gitlab_id.to_string(), Theme::info()), StyledCell::styled( format!("@{}", render::truncate(¬e.author_username, 12)), Theme::accent(), ), StyledCell::plain(note_type), StyledCell::plain(body), StyledCell::plain(path), StyledCell::plain(parent), StyledCell::styled(relative_time, Theme::dim()), ]); } println!("{}", table.render()); } pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) { let json_result = NoteListResultJson::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, "notes"); filter_fields(&mut output, "notes", &expanded); } match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } // --------------------------------------------------------------------------- // Note query layer // --------------------------------------------------------------------------- #[derive(Debug, Serialize)] pub struct NoteListRow { pub id: i64, pub gitlab_id: i64, pub author_username: String, pub body: Option, pub note_type: Option, pub is_system: bool, pub created_at: i64, pub updated_at: i64, pub position_new_path: Option, pub position_new_line: Option, pub position_old_path: Option, pub position_old_line: Option, pub resolvable: bool, pub resolved: bool, pub resolved_by: Option, pub noteable_type: Option, pub parent_iid: Option, pub parent_title: Option, pub project_path: String, } #[derive(Serialize)] pub struct NoteListRowJson { pub id: i64, pub gitlab_id: i64, pub author_username: String, #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, #[serde(skip_serializing_if = "Option::is_none")] pub note_type: Option, pub is_system: bool, pub created_at_iso: String, pub updated_at_iso: String, #[serde(skip_serializing_if = "Option::is_none")] pub position_new_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub position_new_line: Option, #[serde(skip_serializing_if = "Option::is_none")] pub position_old_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub position_old_line: Option, pub resolvable: bool, pub resolved: bool, #[serde(skip_serializing_if = "Option::is_none")] pub resolved_by: Option, #[serde(skip_serializing_if = "Option::is_none")] pub noteable_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub parent_iid: Option, #[serde(skip_serializing_if = "Option::is_none")] pub parent_title: Option, pub project_path: String, } impl From<&NoteListRow> for NoteListRowJson { fn from(row: &NoteListRow) -> Self { Self { id: row.id, gitlab_id: row.gitlab_id, author_username: row.author_username.clone(), body: row.body.clone(), note_type: row.note_type.clone(), is_system: row.is_system, created_at_iso: ms_to_iso(row.created_at), updated_at_iso: ms_to_iso(row.updated_at), position_new_path: row.position_new_path.clone(), position_new_line: row.position_new_line, position_old_path: row.position_old_path.clone(), position_old_line: row.position_old_line, resolvable: row.resolvable, resolved: row.resolved, resolved_by: row.resolved_by.clone(), noteable_type: row.noteable_type.clone(), parent_iid: row.parent_iid, parent_title: row.parent_title.clone(), project_path: row.project_path.clone(), } } } #[derive(Debug)] pub struct NoteListResult { pub notes: Vec, pub total_count: i64, } #[derive(Serialize)] pub struct NoteListResultJson { pub notes: Vec, pub total_count: i64, pub showing: usize, } impl From<&NoteListResult> for NoteListResultJson { fn from(result: &NoteListResult) -> Self { Self { notes: result.notes.iter().map(NoteListRowJson::from).collect(), total_count: result.total_count, showing: result.notes.len(), } } } pub struct NoteListFilters { pub limit: usize, pub project: Option, pub author: Option, pub note_type: Option, pub include_system: bool, pub for_issue_iid: Option, pub for_mr_iid: Option, pub note_id: Option, pub gitlab_note_id: Option, pub discussion_id: Option, pub since: Option, pub until: Option, pub path: Option, pub contains: Option, pub resolution: Option, pub sort: String, pub order: String, } pub fn query_notes( conn: &Connection, filters: &NoteListFilters, config: &Config, ) -> Result { let mut where_clauses: Vec = Vec::new(); let mut params: Vec> = Vec::new(); // Project filter if let Some(ref project) = filters.project { let project_id = resolve_project(conn, project)?; where_clauses.push("n.project_id = ?".to_string()); params.push(Box::new(project_id)); } // Author filter (case-insensitive, strip leading @) if let Some(ref author) = filters.author { let username = author.strip_prefix('@').unwrap_or(author); where_clauses.push("n.author_username = ? COLLATE NOCASE".to_string()); params.push(Box::new(username.to_string())); } // Note type filter if let Some(ref note_type) = filters.note_type { where_clauses.push("n.note_type = ?".to_string()); params.push(Box::new(note_type.clone())); } // System note filter (default: exclude system notes) if !filters.include_system { where_clauses.push("n.is_system = 0".to_string()); } // Since filter let since_ms = if let Some(ref since_str) = filters.since { let 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("n.created_at >= ?".to_string()); params.push(Box::new(ms)); Some(ms) } else { None }; // Until filter (end of day for date-only input) if let Some(ref until_str) = filters.until { let until_ms = if until_str.len() == 10 && until_str.chars().filter(|&c| c == '-').count() == 2 { // Date-only: use end of day 23:59:59.999 let iso_full = format!("{until_str}T23:59:59.999Z"); crate::core::time::iso_to_ms(&iso_full).ok_or_else(|| { LoreError::Other(format!( "Invalid --until value '{}'. Use YYYY-MM-DD or relative format.", until_str )) })? } else { parse_since(until_str).ok_or_else(|| { LoreError::Other(format!( "Invalid --until value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.", until_str )) })? }; // Validate since <= until if let Some(s) = since_ms && s > until_ms { return Err(LoreError::Other( "Invalid time window: --since is after --until.".to_string(), )); } where_clauses.push("n.created_at <= ?".to_string()); params.push(Box::new(until_ms)); } // Path filter (trailing / = prefix match, else exact) if let Some(ref path) = filters.path { if let Some(prefix) = path.strip_suffix('/') { let escaped = note_escape_like(prefix); where_clauses.push("n.position_new_path LIKE ? ESCAPE '\\'".to_string()); params.push(Box::new(format!("{escaped}%"))); } else { where_clauses.push("n.position_new_path = ?".to_string()); params.push(Box::new(path.clone())); } } // Contains filter (LIKE %term% on body, case-insensitive) if let Some(ref contains) = filters.contains { let escaped = note_escape_like(contains); where_clauses.push("n.body LIKE ? ESCAPE '\\' COLLATE NOCASE".to_string()); params.push(Box::new(format!("%{escaped}%"))); } // Resolution filter if let Some(ref resolution) = filters.resolution { match resolution.as_str() { "unresolved" => { where_clauses.push("n.resolvable = 1 AND n.resolved = 0".to_string()); } "resolved" => { where_clauses.push("n.resolvable = 1 AND n.resolved = 1".to_string()); } other => { return Err(LoreError::Other(format!( "Invalid --resolution value '{}'. Use 'resolved' or 'unresolved'.", other ))); } } } // For-issue-iid filter (requires project context) if let Some(iid) = filters.for_issue_iid { let project_str = filters.project.as_deref().or(config.default_project.as_deref()).ok_or_else(|| { LoreError::Other( "Cannot filter by issue IID without a project context. Use --project or set defaultProject in config." .to_string(), ) })?; let project_id = resolve_project(conn, project_str)?; where_clauses.push( "d.issue_id = (SELECT id FROM issues WHERE project_id = ? AND iid = ?)".to_string(), ); params.push(Box::new(project_id)); params.push(Box::new(iid)); } // For-mr-iid filter (requires project context) if let Some(iid) = filters.for_mr_iid { let project_str = filters.project.as_deref().or(config.default_project.as_deref()).ok_or_else(|| { LoreError::Other( "Cannot filter by MR IID without a project context. Use --project or set defaultProject in config." .to_string(), ) })?; let project_id = resolve_project(conn, project_str)?; where_clauses.push( "d.merge_request_id = (SELECT id FROM merge_requests WHERE project_id = ? AND iid = ?)" .to_string(), ); params.push(Box::new(project_id)); params.push(Box::new(iid)); } // Note ID filter if let Some(id) = filters.note_id { where_clauses.push("n.id = ?".to_string()); params.push(Box::new(id)); } // GitLab note ID filter if let Some(gitlab_id) = filters.gitlab_note_id { where_clauses.push("n.gitlab_id = ?".to_string()); params.push(Box::new(gitlab_id)); } // Discussion ID filter if let Some(ref disc_id) = filters.discussion_id { where_clauses.push("d.gitlab_discussion_id = ?".to_string()); params.push(Box::new(disc_id.clone())); } let where_sql = if where_clauses.is_empty() { String::new() } else { format!("WHERE {}", where_clauses.join(" AND ")) }; // Count query let count_sql = format!( "SELECT COUNT(*) FROM notes n JOIN discussions d ON n.discussion_id = d.id JOIN projects p ON n.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_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))?; // Sort + order let sort_column = match filters.sort.as_str() { "updated" => "n.updated_at", _ => "n.created_at", }; let order = if filters.order == "asc" { "ASC" } else { "DESC" }; let query_sql = format!( "SELECT n.id, n.gitlab_id, n.author_username, n.body, n.note_type, n.is_system, n.created_at, n.updated_at, n.position_new_path, n.position_new_line, n.position_old_path, n.position_old_line, n.resolvable, n.resolved, n.resolved_by, d.noteable_type, COALESCE(i.iid, m.iid) AS parent_iid, COALESCE(i.title, m.title) AS parent_title, p.path_with_namespace AS project_path FROM notes n JOIN discussions d ON n.discussion_id = d.id JOIN projects p ON n.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_sql} ORDER BY {sort_column} {order}, n.id {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 notes: Vec = stmt .query_map(param_refs.as_slice(), |row| { let is_system_int: i64 = row.get(5)?; let resolvable_int: i64 = row.get(12)?; let resolved_int: i64 = row.get(13)?; Ok(NoteListRow { id: row.get(0)?, gitlab_id: row.get(1)?, author_username: row.get::<_, Option>(2)?.unwrap_or_default(), body: row.get(3)?, note_type: row.get(4)?, is_system: is_system_int == 1, created_at: row.get(6)?, updated_at: row.get(7)?, position_new_path: row.get(8)?, position_new_line: row.get(9)?, position_old_path: row.get(10)?, position_old_line: row.get(11)?, resolvable: resolvable_int == 1, resolved: resolved_int == 1, resolved_by: row.get(14)?, noteable_type: row.get(15)?, parent_iid: row.get(16)?, parent_title: row.get(17)?, project_path: row.get(18)?, }) })? .collect::, _>>()?; Ok(NoteListResult { notes, total_count }) } #[cfg(test)] #[path = "list_tests.rs"] mod tests;