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}"), } }