use console::style; 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, }) } 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.noteable_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.noteable_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 { let iso = ms_to_iso(ms); iso.split('T').next().unwrap_or(&iso).to_string() } fn wrap_text(text: &str, width: usize, indent: &str) -> String { let mut result = String::new(); let mut current_line = String::new(); for word in text.split_whitespace() { if current_line.is_empty() { current_line = word.to_string(); } else if current_line.len() + 1 + word.len() <= width { current_line.push(' '); current_line.push_str(word); } else { if !result.is_empty() { result.push('\n'); result.push_str(indent); } result.push_str(¤t_line); current_line = word.to_string(); } } if !current_line.is_empty() { if !result.is_empty() { result.push('\n'); result.push_str(indent); } result.push_str(¤t_line); } result } pub fn print_show_issue(issue: &IssueDetail) { let header = format!("Issue #{}: {}", issue.iid, issue.title); println!("{}", style(&header).bold()); println!("{}", "━".repeat(header.len().min(80))); println!(); println!("Ref: {}", style(&issue.references_full).dim()); println!("Project: {}", style(&issue.project_path).cyan()); let state_styled = if issue.state == "opened" { style(&issue.state).green() } else { style(&issue.state).dim() }; println!("State: {}", state_styled); if issue.confidential { println!(" {}", style("CONFIDENTIAL").red().bold()); } if let Some(status) = &issue.status_name { println!( "Status: {}", style_with_hex(status, issue.status_color.as_deref()) ); } println!("Author: @{}", issue.author_username); if !issue.assignees.is_empty() { let label = if issue.assignees.len() > 1 { "Assignees" } else { "Assignee" }; println!( "{}:{} {}", label, " ".repeat(10 - label.len()), issue .assignees .iter() .map(|a| format!("@{}", a)) .collect::>() .join(", ") ); } println!("Created: {}", format_date(issue.created_at)); println!("Updated: {}", format_date(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: {}", style("(none)").dim()); } else { println!("Labels: {}", issue.labels.join(", ")); } if !issue.closing_merge_requests.is_empty() { println!(); println!("{}", style("Development:").bold()); for mr in &issue.closing_merge_requests { let state_indicator = match mr.state.as_str() { "merged" => style(&mr.state).green(), "opened" => style(&mr.state).cyan(), "closed" => style(&mr.state).red(), _ => style(&mr.state).dim(), }; println!(" !{} {} ({})", mr.iid, mr.title, state_indicator); } } if let Some(url) = &issue.web_url { println!("URL: {}", style(url).dim()); } println!(); println!("{}", style("Description:").bold()); if let Some(desc) = &issue.description { let wrapped = wrap_text(desc, 76, " "); println!(" {}", wrapped); } else { println!(" {}", style("(no description)").dim()); } println!(); 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!("{}", style("Discussions: (none)").dim()); } else { println!( "{}", style(format!("Discussions ({}):", user_discussions.len())).bold() ); println!(); 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!( " {} ({}):", style(format!("@{}", first_note.author_username)).cyan(), format_date(first_note.created_at) ); let wrapped = wrap_text(&first_note.body, 72, " "); println!(" {}", wrapped); println!(); for reply in user_notes.iter().skip(1) { println!( " {} ({}):", style(format!("@{}", reply.author_username)).cyan(), format_date(reply.created_at) ); let wrapped = wrap_text(&reply.body, 68, " "); println!(" {}", wrapped); println!(); } } } } } pub fn print_show_mr(mr: &MrDetail) { let draft_prefix = if mr.draft { "[Draft] " } else { "" }; let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title); println!("{}", style(&header).bold()); println!("{}", "━".repeat(header.len().min(80))); println!(); println!("Project: {}", style(&mr.project_path).cyan()); let state_styled = match mr.state.as_str() { "opened" => style(&mr.state).green(), "merged" => style(&mr.state).magenta(), "closed" => style(&mr.state).red(), _ => style(&mr.state).dim(), }; println!("State: {}", state_styled); println!( "Branches: {} -> {}", style(&mr.source_branch).cyan(), style(&mr.target_branch).yellow() ); 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)); println!("Updated: {}", format_date(mr.updated_at)); if let Some(merged_at) = mr.merged_at { println!("Merged: {}", format_date(merged_at)); } if let Some(closed_at) = mr.closed_at { println!("Closed: {}", format_date(closed_at)); } if mr.labels.is_empty() { println!("Labels: {}", style("(none)").dim()); } else { println!("Labels: {}", mr.labels.join(", ")); } if let Some(url) = &mr.web_url { println!("URL: {}", style(url).dim()); } println!(); println!("{}", style("Description:").bold()); if let Some(desc) = &mr.description { let wrapped = wrap_text(desc, 76, " "); println!(" {}", wrapped); } else { println!(" {}", style("(no description)").dim()); } println!(); 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!("{}", style("Discussions: (none)").dim()); } else { println!( "{}", style(format!("Discussions ({}):", user_discussions.len())).bold() ); println!(); 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!( " {} ({}):", style(format!("@{}", first_note.author_username)).cyan(), format_date(first_note.created_at) ); let wrapped = wrap_text(&first_note.body, 72, " "); println!(" {}", wrapped); println!(); for reply in user_notes.iter().skip(1) { println!( " {} ({}):", style(format!("@{}", reply.author_username)).cyan(), format_date(reply.created_at) ); let wrapped = wrap_text(&reply.body, 68, " "); 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!( " {} {}{}", style("📍").dim(), style(file_path).yellow(), style(line_str).dim() ); } } fn style_with_hex<'a>(text: &'a str, hex: Option<&str>) -> console::StyledObject<&'a str> { let styled = console::style(text); let Some(hex) = hex else { return styled }; let hex = hex.trim_start_matches('#'); if hex.len() != 6 { return styled; } let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else { return styled; }; let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else { return styled; }; let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else { return styled; }; styled.color256(ansi256_from_rgb(r, g, b)) } fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 { let ri = (u16::from(r) * 5 + 127) / 255; let gi = (u16::from(g) * 5 + 127) / 255; let bi = (u16::from(b) * 5 + 127) / 255; (16 + 36 * ri + 6 * gi + bi) as u8 } #[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(); } #[test] fn test_ansi256_from_rgb() { assert_eq!(ansi256_from_rgb(0, 0, 0), 16); assert_eq!(ansi256_from_rgb(255, 255, 255), 231); } #[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")); } }