use crate::cli::render::{self, Icons, Theme}; use rusqlite::Connection; use serde::Serialize; use crate::Config; use crate::cli::robot::RobotMeta; 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; #[derive(Debug, Serialize)] pub struct MrDetail { pub id: i64, pub iid: i64, pub title: String, pub description: Option, 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, pub merged_at: Option, pub closed_at: Option, pub web_url: Option, pub project_path: String, pub labels: Vec, pub assignees: Vec, pub reviewers: Vec, pub discussions: Vec, } #[derive(Debug, Serialize)] pub struct MrDiscussionDetail { pub notes: Vec, pub individual_note: bool, } #[derive(Debug, Serialize)] pub struct MrNoteDetail { pub author_username: String, pub body: String, pub created_at: i64, pub is_system: bool, pub position: Option, } #[derive(Debug, Clone, Serialize)] pub struct DiffNotePosition { pub old_path: Option, pub new_path: Option, pub old_line: Option, pub new_line: Option, pub position_type: Option, } #[derive(Debug, Clone, Serialize)] pub struct ClosingMrRef { pub iid: i64, pub title: String, pub state: String, pub web_url: Option, } #[derive(Debug, Serialize)] pub struct IssueDetail { pub id: i64, pub iid: i64, pub title: String, pub description: Option, pub state: String, pub author_username: String, pub created_at: i64, pub updated_at: i64, pub closed_at: Option, pub confidential: bool, pub web_url: Option, pub project_path: String, pub references_full: String, pub labels: Vec, pub assignees: Vec, pub due_date: Option, pub milestone: Option, pub user_notes_count: i64, pub merge_requests_count: usize, pub closing_merge_requests: Vec, pub discussions: Vec, pub status_name: Option, pub status_category: Option, pub status_color: Option, pub status_icon_name: Option, pub status_synced_at: Option, } #[derive(Debug, Serialize)] pub struct DiscussionDetail { pub notes: Vec, pub individual_note: bool, } #[derive(Debug, Serialize)] pub struct NoteDetail { pub author_username: String, pub body: String, pub created_at: i64, pub is_system: bool, } pub fn run_show_issue( config: &Config, iid: i64, project_filter: Option<&str>, ) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; let issue = find_issue(&conn, iid, project_filter)?; let labels = get_issue_labels(&conn, issue.id)?; let assignees = get_issue_assignees(&conn, issue.id)?; let closing_mrs = get_closing_mrs(&conn, issue.id)?; let discussions = get_issue_discussions(&conn, issue.id)?; let references_full = format!("{}#{}", issue.project_path, issue.iid); let merge_requests_count = closing_mrs.len(); Ok(IssueDetail { id: issue.id, iid: issue.iid, title: issue.title, description: issue.description, state: issue.state, author_username: issue.author_username, created_at: issue.created_at, updated_at: issue.updated_at, closed_at: issue.closed_at, confidential: issue.confidential, web_url: issue.web_url, project_path: issue.project_path, references_full, labels, assignees, due_date: issue.due_date, milestone: issue.milestone_title, user_notes_count: issue.user_notes_count, merge_requests_count, closing_merge_requests: closing_mrs, discussions, status_name: issue.status_name, status_category: issue.status_category, status_color: issue.status_color, status_icon_name: issue.status_icon_name, status_synced_at: issue.status_synced_at, }) } #[derive(Debug)] struct IssueRow { id: i64, iid: i64, title: String, description: Option, state: String, author_username: String, created_at: i64, updated_at: i64, closed_at: Option, confidential: bool, web_url: Option, project_path: String, due_date: Option, milestone_title: Option, user_notes_count: i64, status_name: Option, status_category: Option, status_color: Option, status_icon_name: Option, status_synced_at: Option, } fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { let (sql, params): (&str, Vec>) = match project_filter { Some(project) => { let project_id = resolve_project(conn, project)?; ( "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, i.created_at, i.updated_at, i.closed_at, i.confidential, i.web_url, p.path_with_namespace, i.due_date, i.milestone_title, (SELECT COUNT(*) FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_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 i.iid = ? AND i.project_id = ?", vec![Box::new(iid), Box::new(project_id)], ) } None => ( "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, i.created_at, i.updated_at, i.closed_at, i.confidential, i.web_url, p.path_with_namespace, i.due_date, i.milestone_title, (SELECT COUNT(*) FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_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 i.iid = ?", vec![Box::new(iid)], ), }; let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(sql)?; let issues: Vec = stmt .query_map(param_refs.as_slice(), |row| { let confidential_val: i64 = row.get(9)?; Ok(IssueRow { id: row.get(0)?, iid: row.get(1)?, title: row.get(2)?, description: row.get(3)?, state: row.get(4)?, author_username: row.get(5)?, created_at: row.get(6)?, updated_at: row.get(7)?, closed_at: row.get(8)?, confidential: confidential_val != 0, web_url: row.get(10)?, project_path: row.get(11)?, due_date: row.get(12)?, milestone_title: row.get(13)?, user_notes_count: row.get(14)?, status_name: row.get(15)?, status_category: row.get(16)?, status_color: row.get(17)?, status_icon_name: row.get(18)?, status_synced_at: row.get(19)?, }) })? .collect::, _>>()?; match issues.len() { 0 => Err(LoreError::NotFound(format!("Issue #{} not found", iid))), 1 => Ok(issues.into_iter().next().unwrap()), _ => { let projects: Vec = issues.iter().map(|i| i.project_path.clone()).collect(); Err(LoreError::Ambiguous(format!( "Issue #{} exists in multiple projects: {}. Use --project to specify.", iid, projects.join(", ") ))) } } } fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT l.name FROM labels l JOIN issue_labels il ON l.id = il.label_id WHERE il.issue_id = ? ORDER BY l.name", )?; let labels: Vec = stmt .query_map([issue_id], |row| row.get(0))? .collect::, _>>()?; Ok(labels) } fn get_issue_assignees(conn: &Connection, issue_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT username FROM issue_assignees WHERE issue_id = ? ORDER BY username", )?; let assignees: Vec = stmt .query_map([issue_id], |row| row.get(0))? .collect::, _>>()?; Ok(assignees) } fn get_closing_mrs(conn: &Connection, issue_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT mr.iid, mr.title, mr.state, mr.web_url FROM entity_references er JOIN merge_requests mr ON mr.id = er.source_entity_id WHERE er.target_entity_type = 'issue' AND er.target_entity_id = ? AND er.source_entity_type = 'merge_request' AND er.reference_type = 'closes' ORDER BY mr.iid", )?; let mrs: Vec = stmt .query_map([issue_id], |row| { Ok(ClosingMrRef { iid: row.get(0)?, title: row.get(1)?, state: row.get(2)?, web_url: row.get(3)?, }) })? .collect::, _>>()?; Ok(mrs) } fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result> { let mut disc_stmt = conn.prepare( "SELECT id, individual_note FROM discussions WHERE issue_id = ? ORDER BY first_note_at", )?; let disc_rows: Vec<(i64, bool)> = disc_stmt .query_map([issue_id], |row| { let individual: i64 = row.get(1)?; Ok((row.get(0)?, individual == 1)) })? .collect::, _>>()?; let mut note_stmt = conn.prepare( "SELECT author_username, body, created_at, is_system FROM notes WHERE discussion_id = ? ORDER BY position", )?; let mut discussions = Vec::new(); for (disc_id, individual_note) in disc_rows { let notes: Vec = note_stmt .query_map([disc_id], |row| { let is_system: i64 = row.get(3)?; Ok(NoteDetail { author_username: row.get(0)?, body: row.get(1)?, created_at: row.get(2)?, is_system: is_system == 1, }) })? .collect::, _>>()?; let has_user_notes = notes.iter().any(|n| !n.is_system); if has_user_notes || notes.is_empty() { discussions.push(DiscussionDetail { notes, individual_note, }); } } Ok(discussions) } pub fn run_show_mr(config: &Config, iid: i64, project_filter: Option<&str>) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; let mr = find_mr(&conn, iid, project_filter)?; let labels = get_mr_labels(&conn, mr.id)?; let assignees = get_mr_assignees(&conn, mr.id)?; let reviewers = get_mr_reviewers(&conn, mr.id)?; let discussions = get_mr_discussions(&conn, mr.id)?; Ok(MrDetail { id: mr.id, iid: mr.iid, title: mr.title, description: mr.description, state: mr.state, draft: mr.draft, author_username: mr.author_username, source_branch: mr.source_branch, target_branch: mr.target_branch, created_at: mr.created_at, updated_at: mr.updated_at, merged_at: mr.merged_at, closed_at: mr.closed_at, web_url: mr.web_url, project_path: mr.project_path, labels, assignees, reviewers, discussions, }) } struct MrRow { id: i64, iid: i64, title: String, description: Option, state: String, draft: bool, author_username: String, source_branch: String, target_branch: String, created_at: i64, updated_at: i64, merged_at: Option, closed_at: Option, web_url: Option, project_path: String, } fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { let (sql, params): (&str, Vec>) = match project_filter { Some(project) => { let project_id = resolve_project(conn, project)?; ( "SELECT m.id, m.iid, m.title, m.description, m.state, m.draft, m.author_username, m.source_branch, m.target_branch, m.created_at, m.updated_at, m.merged_at, m.closed_at, m.web_url, p.path_with_namespace FROM merge_requests m JOIN projects p ON m.project_id = p.id WHERE m.iid = ? AND m.project_id = ?", vec![Box::new(iid), Box::new(project_id)], ) } None => ( "SELECT m.id, m.iid, m.title, m.description, m.state, m.draft, m.author_username, m.source_branch, m.target_branch, m.created_at, m.updated_at, m.merged_at, m.closed_at, m.web_url, p.path_with_namespace FROM merge_requests m JOIN projects p ON m.project_id = p.id WHERE m.iid = ?", vec![Box::new(iid)], ), }; let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(sql)?; let mrs: Vec = stmt .query_map(param_refs.as_slice(), |row| { let draft_val: i64 = row.get(5)?; Ok(MrRow { id: row.get(0)?, iid: row.get(1)?, title: row.get(2)?, description: row.get(3)?, state: row.get(4)?, draft: draft_val == 1, author_username: row.get(6)?, source_branch: row.get(7)?, target_branch: row.get(8)?, created_at: row.get(9)?, updated_at: row.get(10)?, merged_at: row.get(11)?, closed_at: row.get(12)?, web_url: row.get(13)?, project_path: row.get(14)?, }) })? .collect::, _>>()?; match mrs.len() { 0 => Err(LoreError::NotFound(format!("MR !{} not found", iid))), 1 => Ok(mrs.into_iter().next().unwrap()), _ => { let projects: Vec = mrs.iter().map(|m| m.project_path.clone()).collect(); Err(LoreError::Ambiguous(format!( "MR !{} exists in multiple projects: {}. Use --project to specify.", iid, projects.join(", ") ))) } } } fn get_mr_labels(conn: &Connection, mr_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT l.name FROM labels l JOIN mr_labels ml ON l.id = ml.label_id WHERE ml.merge_request_id = ? ORDER BY l.name", )?; let labels: Vec = stmt .query_map([mr_id], |row| row.get(0))? .collect::, _>>()?; Ok(labels) } fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT username FROM mr_assignees WHERE merge_request_id = ? ORDER BY username", )?; let assignees: Vec = stmt .query_map([mr_id], |row| row.get(0))? .collect::, _>>()?; Ok(assignees) } fn get_mr_reviewers(conn: &Connection, mr_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT username FROM mr_reviewers WHERE merge_request_id = ? ORDER BY username", )?; let reviewers: Vec = stmt .query_map([mr_id], |row| row.get(0))? .collect::, _>>()?; Ok(reviewers) } fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result> { let mut disc_stmt = conn.prepare( "SELECT id, individual_note FROM discussions WHERE merge_request_id = ? ORDER BY first_note_at", )?; let disc_rows: Vec<(i64, bool)> = disc_stmt .query_map([mr_id], |row| { let individual: i64 = row.get(1)?; Ok((row.get(0)?, individual == 1)) })? .collect::, _>>()?; let mut note_stmt = conn.prepare( "SELECT author_username, body, created_at, is_system, position_old_path, position_new_path, position_old_line, position_new_line, position_type FROM notes WHERE discussion_id = ? ORDER BY position", )?; let mut discussions = Vec::new(); for (disc_id, individual_note) in disc_rows { let notes: Vec = note_stmt .query_map([disc_id], |row| { let is_system: i64 = row.get(3)?; let old_path: Option = row.get(4)?; let new_path: Option = row.get(5)?; let old_line: Option = row.get(6)?; let new_line: Option = row.get(7)?; let position_type: Option = row.get(8)?; let position = if old_path.is_some() || new_path.is_some() || old_line.is_some() || new_line.is_some() { Some(DiffNotePosition { old_path, new_path, old_line, new_line, position_type, }) } else { None }; Ok(MrNoteDetail { author_username: row.get(0)?, body: row.get(1)?, created_at: row.get(2)?, is_system: is_system == 1, position, }) })? .collect::, _>>()?; let has_user_notes = notes.iter().any(|n| !n.is_system); if has_user_notes || notes.is_empty() { discussions.push(MrDiscussionDetail { notes, individual_note, }); } } Ok(discussions) } fn format_date(ms: i64) -> String { render::format_date(ms) } fn wrap_text(text: &str, width: usize, indent: &str) -> String { render::wrap_indent(text, width, indent) } pub fn print_show_issue(issue: &IssueDetail) { // Title line println!( " Issue #{}: {}", issue.iid, Theme::bold().render(&issue.title), ); // Details section println!("{}", render::section_divider("Details")); println!( " Ref {}", Theme::muted().render(&issue.references_full) ); println!( " Project {}", Theme::info().render(&issue.project_path) ); let (icon, state_style) = if issue.state == "opened" { (Icons::issue_opened(), Theme::success()) } else { (Icons::issue_closed(), Theme::dim()) }; println!( " State {}", state_style.render(&format!("{icon} {}", issue.state)) ); if let Some(status) = &issue.status_name { println!( " Status {}", render::style_with_hex(status, issue.status_color.as_deref()) ); } if issue.confidential { println!(" {}", Theme::error().bold().render("CONFIDENTIAL")); } println!(" Author @{}", issue.author_username); if !issue.assignees.is_empty() { let label = if issue.assignees.len() > 1 { "Assignees" } else { "Assignee" }; println!( " {}{} {}", label, " ".repeat(12 - label.len()), issue .assignees .iter() .map(|a| format!("@{a}")) .collect::>() .join(", ") ); } println!( " Created {} ({})", format_date(issue.created_at), render::format_relative_time_compact(issue.created_at), ); println!( " Updated {} ({})", format_date(issue.updated_at), render::format_relative_time_compact(issue.updated_at), ); if let Some(closed_at) = &issue.closed_at { println!(" Closed {closed_at}"); } if let Some(due) = &issue.due_date { println!(" Due {due}"); } if let Some(ms) = &issue.milestone { println!(" Milestone {ms}"); } if !issue.labels.is_empty() { println!( " Labels {}", render::format_labels_bare(&issue.labels, issue.labels.len()) ); } if let Some(url) = &issue.web_url { println!(" URL {}", Theme::muted().render(url)); } // Development section if !issue.closing_merge_requests.is_empty() { println!("{}", render::section_divider("Development")); for mr in &issue.closing_merge_requests { let (mr_icon, mr_style) = match mr.state.as_str() { "merged" => (Icons::mr_merged(), Theme::accent()), "opened" => (Icons::mr_opened(), Theme::success()), "closed" => (Icons::mr_closed(), Theme::error()), _ => (Icons::mr_opened(), Theme::dim()), }; println!( " {} !{} {} {}", mr_style.render(mr_icon), mr.iid, mr.title, mr_style.render(&mr.state), ); } } // Description section println!("{}", render::section_divider("Description")); if let Some(desc) = &issue.description { let wrapped = wrap_text(desc, 72, " "); println!(" {wrapped}"); } else { println!(" {}", Theme::muted().render("(no description)")); } // Discussions section let user_discussions: Vec<&DiscussionDetail> = issue .discussions .iter() .filter(|d| d.notes.iter().any(|n| !n.is_system)) .collect(); if user_discussions.is_empty() { println!("\n {}", Theme::muted().render("No discussions")); } else { println!( "{}", render::section_divider(&format!("Discussions ({})", user_discussions.len())) ); for discussion in user_discussions { let user_notes: Vec<&NoteDetail> = discussion.notes.iter().filter(|n| !n.is_system).collect(); if let Some(first_note) = user_notes.first() { println!( " {} {}", Theme::info().render(&format!("@{}", first_note.author_username)), format_date(first_note.created_at), ); let wrapped = wrap_text(&first_note.body, 68, " "); println!(" {wrapped}"); println!(); for reply in user_notes.iter().skip(1) { println!( " {} {}", Theme::info().render(&format!("@{}", reply.author_username)), format_date(reply.created_at), ); let wrapped = wrap_text(&reply.body, 66, " "); println!(" {wrapped}"); println!(); } } } } } pub fn print_show_mr(mr: &MrDetail) { // Title line let draft_prefix = if mr.draft { format!("{} ", Icons::mr_draft()) } else { String::new() }; println!( " MR !{}: {}{}", mr.iid, draft_prefix, Theme::bold().render(&mr.title), ); // Details section println!("{}", render::section_divider("Details")); println!(" Project {}", Theme::info().render(&mr.project_path)); let (icon, state_style) = match mr.state.as_str() { "opened" => (Icons::mr_opened(), Theme::success()), "merged" => (Icons::mr_merged(), Theme::accent()), "closed" => (Icons::mr_closed(), Theme::error()), _ => (Icons::mr_opened(), Theme::dim()), }; println!( " State {}", state_style.render(&format!("{icon} {}", mr.state)) ); println!( " Branches {} -> {}", Theme::info().render(&mr.source_branch), Theme::warning().render(&mr.target_branch) ); println!(" Author @{}", mr.author_username); if !mr.assignees.is_empty() { println!( " Assignees {}", mr.assignees .iter() .map(|a| format!("@{a}")) .collect::>() .join(", ") ); } if !mr.reviewers.is_empty() { println!( " Reviewers {}", mr.reviewers .iter() .map(|r| format!("@{r}")) .collect::>() .join(", ") ); } println!( " Created {} ({})", format_date(mr.created_at), render::format_relative_time_compact(mr.created_at), ); println!( " Updated {} ({})", format_date(mr.updated_at), render::format_relative_time_compact(mr.updated_at), ); if let Some(merged_at) = mr.merged_at { println!( " Merged {} ({})", format_date(merged_at), render::format_relative_time_compact(merged_at), ); } if let Some(closed_at) = mr.closed_at { println!( " Closed {} ({})", format_date(closed_at), render::format_relative_time_compact(closed_at), ); } if !mr.labels.is_empty() { println!( " Labels {}", render::format_labels_bare(&mr.labels, mr.labels.len()) ); } if let Some(url) = &mr.web_url { println!(" URL {}", Theme::muted().render(url)); } // Description section println!("{}", render::section_divider("Description")); if let Some(desc) = &mr.description { let wrapped = wrap_text(desc, 72, " "); println!(" {wrapped}"); } else { println!(" {}", Theme::muted().render("(no description)")); } // Discussions section let user_discussions: Vec<&MrDiscussionDetail> = mr .discussions .iter() .filter(|d| d.notes.iter().any(|n| !n.is_system)) .collect(); if user_discussions.is_empty() { println!("\n {}", Theme::muted().render("No discussions")); } else { println!( "{}", render::section_divider(&format!("Discussions ({})", user_discussions.len())) ); for discussion in user_discussions { let user_notes: Vec<&MrNoteDetail> = discussion.notes.iter().filter(|n| !n.is_system).collect(); if let Some(first_note) = user_notes.first() { if let Some(pos) = &first_note.position { print_diff_position(pos); } println!( " {} {}", Theme::info().render(&format!("@{}", first_note.author_username)), format_date(first_note.created_at), ); let wrapped = wrap_text(&first_note.body, 68, " "); println!(" {wrapped}"); println!(); for reply in user_notes.iter().skip(1) { println!( " {} {}", Theme::info().render(&format!("@{}", reply.author_username)), format_date(reply.created_at), ); let wrapped = wrap_text(&reply.body, 66, " "); println!(" {wrapped}"); println!(); } } } } } fn print_diff_position(pos: &DiffNotePosition) { let file = pos.new_path.as_ref().or(pos.old_path.as_ref()); if let Some(file_path) = file { let line_str = match (pos.old_line, pos.new_line) { (Some(old), Some(new)) if old == new => format!(":{}", new), (Some(old), Some(new)) => format!(":{}→{}", old, new), (None, Some(new)) => format!(":+{}", new), (Some(old), None) => format!(":-{}", old), (None, None) => String::new(), }; println!( " {} {}{}", Theme::dim().render("\u{1f4cd}"), Theme::warning().render(file_path), Theme::dim().render(&line_str) ); } } #[derive(Serialize)] pub struct IssueDetailJson { pub id: i64, pub iid: i64, pub title: String, pub description: Option, pub state: String, pub author_username: String, pub created_at: String, pub updated_at: String, pub closed_at: Option, pub confidential: bool, pub web_url: Option, pub project_path: String, pub references_full: String, pub labels: Vec, pub assignees: Vec, pub due_date: Option, pub milestone: Option, pub user_notes_count: i64, pub merge_requests_count: usize, pub closing_merge_requests: Vec, pub discussions: Vec, pub status_name: Option, #[serde(skip_serializing)] pub status_category: Option, pub status_color: Option, pub status_icon_name: Option, pub status_synced_at: Option, } #[derive(Serialize)] pub struct ClosingMrRefJson { pub iid: i64, pub title: String, pub state: String, pub web_url: Option, } #[derive(Serialize)] pub struct DiscussionDetailJson { pub notes: Vec, pub individual_note: bool, } #[derive(Serialize)] pub struct NoteDetailJson { pub author_username: String, pub body: String, pub created_at: String, pub is_system: bool, } impl From<&IssueDetail> for IssueDetailJson { fn from(issue: &IssueDetail) -> Self { Self { id: issue.id, iid: issue.iid, title: issue.title.clone(), description: issue.description.clone(), state: issue.state.clone(), author_username: issue.author_username.clone(), created_at: ms_to_iso(issue.created_at), updated_at: ms_to_iso(issue.updated_at), closed_at: issue.closed_at.clone(), confidential: issue.confidential, web_url: issue.web_url.clone(), project_path: issue.project_path.clone(), references_full: issue.references_full.clone(), labels: issue.labels.clone(), assignees: issue.assignees.clone(), due_date: issue.due_date.clone(), milestone: issue.milestone.clone(), user_notes_count: issue.user_notes_count, merge_requests_count: issue.merge_requests_count, closing_merge_requests: issue .closing_merge_requests .iter() .map(|mr| ClosingMrRefJson { iid: mr.iid, title: mr.title.clone(), state: mr.state.clone(), web_url: mr.web_url.clone(), }) .collect(), discussions: issue.discussions.iter().map(|d| d.into()).collect(), status_name: issue.status_name.clone(), status_category: issue.status_category.clone(), status_color: issue.status_color.clone(), status_icon_name: issue.status_icon_name.clone(), status_synced_at: issue.status_synced_at.map(ms_to_iso), } } } impl From<&DiscussionDetail> for DiscussionDetailJson { fn from(disc: &DiscussionDetail) -> Self { Self { notes: disc.notes.iter().map(|n| n.into()).collect(), individual_note: disc.individual_note, } } } impl From<&NoteDetail> for NoteDetailJson { fn from(note: &NoteDetail) -> Self { Self { author_username: note.author_username.clone(), body: note.body.clone(), created_at: ms_to_iso(note.created_at), is_system: note.is_system, } } } #[derive(Serialize)] pub struct MrDetailJson { pub id: i64, pub iid: i64, pub title: String, pub description: Option, pub state: String, pub draft: bool, pub author_username: String, pub source_branch: String, pub target_branch: String, pub created_at: String, pub updated_at: String, pub merged_at: Option, pub closed_at: Option, pub web_url: Option, pub project_path: String, pub labels: Vec, pub assignees: Vec, pub reviewers: Vec, pub discussions: Vec, } #[derive(Serialize)] pub struct MrDiscussionDetailJson { pub notes: Vec, pub individual_note: bool, } #[derive(Serialize)] pub struct MrNoteDetailJson { pub author_username: String, pub body: String, pub created_at: String, pub is_system: bool, pub position: Option, } impl From<&MrDetail> for MrDetailJson { fn from(mr: &MrDetail) -> Self { Self { id: mr.id, iid: mr.iid, title: mr.title.clone(), description: mr.description.clone(), state: mr.state.clone(), draft: mr.draft, author_username: mr.author_username.clone(), source_branch: mr.source_branch.clone(), target_branch: mr.target_branch.clone(), created_at: ms_to_iso(mr.created_at), updated_at: ms_to_iso(mr.updated_at), merged_at: mr.merged_at.map(ms_to_iso), closed_at: mr.closed_at.map(ms_to_iso), web_url: mr.web_url.clone(), project_path: mr.project_path.clone(), labels: mr.labels.clone(), assignees: mr.assignees.clone(), reviewers: mr.reviewers.clone(), discussions: mr.discussions.iter().map(|d| d.into()).collect(), } } } impl From<&MrDiscussionDetail> for MrDiscussionDetailJson { fn from(disc: &MrDiscussionDetail) -> Self { Self { notes: disc.notes.iter().map(|n| n.into()).collect(), individual_note: disc.individual_note, } } } impl From<&MrNoteDetail> for MrNoteDetailJson { fn from(note: &MrNoteDetail) -> Self { Self { author_username: note.author_username.clone(), body: note.body.clone(), created_at: ms_to_iso(note.created_at), is_system: note.is_system, position: note.position.clone(), } } } pub fn print_show_issue_json(issue: &IssueDetail, elapsed_ms: u64) { let json_result = IssueDetailJson::from(issue); let meta = RobotMeta { elapsed_ms }; let output = serde_json::json!({ "ok": true, "data": json_result, "meta": meta, }); match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } pub fn print_show_mr_json(mr: &MrDetail, elapsed_ms: u64) { let json_result = MrDetailJson::from(mr); let meta = RobotMeta { elapsed_ms }; let output = serde_json::json!({ "ok": true, "data": json_result, "meta": meta, }); match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to JSON: {e}"), } } #[cfg(test)] mod tests { use super::*; use crate::core::db::run_migrations; use std::path::Path; fn setup_test_db() -> Connection { let conn = create_connection(Path::new(":memory:")).unwrap(); run_migrations(&conn).unwrap(); conn } fn seed_project(conn: &Connection) { conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) VALUES (1, 100, 'group/repo', 'https://gitlab.example.com', 1000, 2000)", [], ) .unwrap(); } fn seed_issue(conn: &Connection) { seed_project(conn); conn.execute( "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (1, 200, 10, 1, 'Test issue', 'opened', 'author', 1000, 2000, 2000)", [], ) .unwrap(); } fn seed_second_project(conn: &Connection) { conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) VALUES (2, 101, 'other/repo', 'https://gitlab.example.com/other', 1000, 2000)", [], ) .unwrap(); } fn seed_discussion_with_notes( conn: &Connection, issue_id: i64, project_id: i64, user_notes: usize, system_notes: usize, ) { let disc_id: i64 = conn .query_row( "SELECT COALESCE(MAX(id), 0) + 1 FROM discussions", [], |r| r.get(0), ) .unwrap(); conn.execute( "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, first_note_at, last_note_at, last_seen_at) VALUES (?1, ?2, ?3, ?4, 'Issue', 1000, 2000, 2000)", rusqlite::params![disc_id, format!("disc-{}", disc_id), project_id, issue_id], ) .unwrap(); for i in 0..user_notes { conn.execute( "INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position) VALUES (?1, ?2, ?3, 'user1', 'comment', 1000, 2000, 2000, 0, ?4)", rusqlite::params![1000 + disc_id * 100 + i as i64, disc_id, project_id, i as i64], ) .unwrap(); } for i in 0..system_notes { conn.execute( "INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position) VALUES (?1, ?2, ?3, 'system', 'status changed', 1000, 2000, 2000, 1, ?4)", rusqlite::params![2000 + disc_id * 100 + i as i64, disc_id, project_id, (user_notes + i) as i64], ) .unwrap(); } } // --- find_issue tests --- #[test] fn test_find_issue_basic() { let conn = setup_test_db(); seed_issue(&conn); let row = find_issue(&conn, 10, None).unwrap(); assert_eq!(row.iid, 10); assert_eq!(row.title, "Test issue"); assert_eq!(row.state, "opened"); assert_eq!(row.author_username, "author"); assert_eq!(row.project_path, "group/repo"); } #[test] fn test_find_issue_with_project_filter() { let conn = setup_test_db(); seed_issue(&conn); let row = find_issue(&conn, 10, Some("group/repo")).unwrap(); assert_eq!(row.iid, 10); assert_eq!(row.project_path, "group/repo"); } #[test] fn test_find_issue_not_found() { let conn = setup_test_db(); seed_issue(&conn); let err = find_issue(&conn, 999, None).unwrap_err(); assert!(matches!(err, LoreError::NotFound(_))); } #[test] fn test_find_issue_wrong_project_filter() { let conn = setup_test_db(); seed_issue(&conn); seed_second_project(&conn); // Issue 10 only exists in project 1, not project 2 let err = find_issue(&conn, 10, Some("other/repo")).unwrap_err(); assert!(matches!(err, LoreError::NotFound(_))); } #[test] fn test_find_issue_ambiguous_without_project() { let conn = setup_test_db(); seed_issue(&conn); // issue iid=10 in project 1 seed_second_project(&conn); conn.execute( "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)", [], ) .unwrap(); let err = find_issue(&conn, 10, None).unwrap_err(); assert!(matches!(err, LoreError::Ambiguous(_))); } #[test] fn test_find_issue_ambiguous_resolved_with_project() { let conn = setup_test_db(); seed_issue(&conn); seed_second_project(&conn); conn.execute( "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)", [], ) .unwrap(); let row = find_issue(&conn, 10, Some("other/repo")).unwrap(); assert_eq!(row.title, "Same iid different project"); } #[test] fn test_find_issue_user_notes_count_zero() { let conn = setup_test_db(); seed_issue(&conn); let row = find_issue(&conn, 10, None).unwrap(); assert_eq!(row.user_notes_count, 0); } #[test] fn test_find_issue_user_notes_count_excludes_system() { let conn = setup_test_db(); seed_issue(&conn); // 2 user notes + 3 system notes = should count only 2 seed_discussion_with_notes(&conn, 1, 1, 2, 3); let row = find_issue(&conn, 10, None).unwrap(); assert_eq!(row.user_notes_count, 2); } #[test] fn test_find_issue_user_notes_count_across_discussions() { let conn = setup_test_db(); seed_issue(&conn); seed_discussion_with_notes(&conn, 1, 1, 3, 0); // 3 user notes seed_discussion_with_notes(&conn, 1, 1, 1, 2); // 1 user note + 2 system let row = find_issue(&conn, 10, None).unwrap(); assert_eq!(row.user_notes_count, 4); } #[test] fn test_find_issue_notes_count_ignores_other_issues() { let conn = setup_test_db(); seed_issue(&conn); // Add a second issue conn.execute( "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (2, 201, 20, 1, 'Other issue', 'opened', 'author', 1000, 2000, 2000)", [], ) .unwrap(); // Notes on issue 2, not issue 1 seed_discussion_with_notes(&conn, 2, 1, 5, 0); let row = find_issue(&conn, 10, None).unwrap(); assert_eq!(row.user_notes_count, 0); // Issue 10 has no notes } #[test] fn test_ansi256_from_rgb() { // Moved to render.rs — keeping basic hex sanity check let result = render::style_with_hex("test", Some("#ff0000")); assert!(!result.is_empty()); } #[test] fn test_get_issue_assignees_empty() { let conn = setup_test_db(); seed_issue(&conn); let result = get_issue_assignees(&conn, 1).unwrap(); assert!(result.is_empty()); } #[test] fn test_get_issue_assignees_single() { let conn = setup_test_db(); seed_issue(&conn); conn.execute( "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'charlie')", [], ) .unwrap(); let result = get_issue_assignees(&conn, 1).unwrap(); assert_eq!(result, vec!["charlie"]); } #[test] fn test_get_issue_assignees_multiple_sorted() { let conn = setup_test_db(); seed_issue(&conn); conn.execute( "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'bob')", [], ) .unwrap(); conn.execute( "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'alice')", [], ) .unwrap(); let result = get_issue_assignees(&conn, 1).unwrap(); assert_eq!(result, vec!["alice", "bob"]); // alphabetical } #[test] fn test_get_closing_mrs_empty() { let conn = setup_test_db(); seed_issue(&conn); let result = get_closing_mrs(&conn, 1).unwrap(); assert!(result.is_empty()); } #[test] fn test_get_closing_mrs_single() { let conn = setup_test_db(); seed_issue(&conn); conn.execute( "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, source_branch, target_branch, created_at, updated_at, last_seen_at) VALUES (1, 300, 5, 1, 'Fix the bug', 'merged', 'dev', 'fix', 'main', 1000, 2000, 2000)", [], ) .unwrap(); conn.execute( "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method, created_at) VALUES (1, 'merge_request', 1, 'issue', 1, 'closes', 'api', 3000)", [], ) .unwrap(); let result = get_closing_mrs(&conn, 1).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].iid, 5); assert_eq!(result[0].title, "Fix the bug"); assert_eq!(result[0].state, "merged"); } #[test] fn test_get_closing_mrs_ignores_mentioned() { let conn = setup_test_db(); seed_issue(&conn); // Add a 'mentioned' reference that should be ignored conn.execute( "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, source_branch, target_branch, created_at, updated_at, last_seen_at) VALUES (1, 300, 5, 1, 'Some MR', 'opened', 'dev', 'feat', 'main', 1000, 2000, 2000)", [], ) .unwrap(); conn.execute( "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method, created_at) VALUES (1, 'merge_request', 1, 'issue', 1, 'mentioned', 'note_parse', 3000)", [], ) .unwrap(); let result = get_closing_mrs(&conn, 1).unwrap(); assert!(result.is_empty()); // 'mentioned' refs not included } #[test] fn test_get_closing_mrs_multiple_sorted() { let conn = setup_test_db(); seed_issue(&conn); conn.execute( "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, source_branch, target_branch, created_at, updated_at, last_seen_at) VALUES (1, 300, 8, 1, 'Second fix', 'opened', 'dev', 'fix2', 'main', 1000, 2000, 2000)", [], ) .unwrap(); conn.execute( "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username, source_branch, target_branch, created_at, updated_at, last_seen_at) VALUES (2, 301, 5, 1, 'First fix', 'merged', 'dev', 'fix1', 'main', 1000, 2000, 2000)", [], ) .unwrap(); conn.execute( "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method, created_at) VALUES (1, 'merge_request', 1, 'issue', 1, 'closes', 'api', 3000)", [], ) .unwrap(); conn.execute( "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method, created_at) VALUES (1, 'merge_request', 2, 'issue', 1, 'closes', 'api', 3000)", [], ) .unwrap(); let result = get_closing_mrs(&conn, 1).unwrap(); assert_eq!(result.len(), 2); assert_eq!(result[0].iid, 5); // Lower iid first assert_eq!(result[1].iid, 8); } #[test] fn wrap_text_single_line() { assert_eq!(wrap_text("hello world", 80, " "), "hello world"); } #[test] fn wrap_text_multiple_lines() { let result = wrap_text("one two three four five", 10, " "); assert!(result.contains('\n')); } #[test] fn format_date_extracts_date_part() { let ms = 1705276800000; let date = format_date(ms); assert!(date.starts_with("2024-01-15")); } }