use crate::cli::render::{self, Align, 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::error::{LoreError, Result}; use crate::core::path_resolver::escape_like as note_escape_like; use crate::core::project::resolve_project; use crate::core::time::{iso_to_ms, ms_to_iso, parse_since}; use super::render_helpers::{ format_note_parent, format_note_path, format_note_type, truncate_body, }; #[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 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::new(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}"), } } pub fn query_notes( conn: &Connection, filters: &NoteListFilters, config: &Config, ) -> Result { let mut where_clauses: Vec = Vec::new(); let mut params: Vec> = Vec::new(); 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)); } 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())); } if let Some(ref note_type) = filters.note_type { where_clauses.push("n.note_type = ?".to_string()); params.push(Box::new(note_type.clone())); } if !filters.include_system { where_clauses.push("n.is_system = 0".to_string()); } 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 }; if let Some(ref until_str) = filters.until { let until_ms = if until_str.len() == 10 && until_str.chars().filter(|&c| c == '-').count() == 2 { let iso_full = format!("{until_str}T23:59:59.999Z"); 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 )) })? }; 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)); } 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())); } } 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}%"))); } 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 ))); } } } 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)); } 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)); } if let Some(id) = filters.note_id { where_clauses.push("n.id = ?".to_string()); params.push(Box::new(id)); } if let Some(gitlab_id) = filters.gitlab_note_id { where_clauses.push("n.gitlab_id = ?".to_string()); params.push(Box::new(gitlab_id)); } 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 ")) }; 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))?; 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 }) }