From 8ddc974b89b92ff0e9a5ec076da4650cec4d2ff4 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Mon, 26 Jan 2026 22:46:59 -0500 Subject: [PATCH] feat(cli): Add MR support to list/show/count/ingest commands Extends all data commands to support merge requests alongside issues, with consistent patterns and JSON output for robot mode. List command (gi list mrs): - MR-specific columns: branches, draft status, reviewers - Filters: --state (opened|merged|closed|locked|all), --draft, --no-draft, --reviewer, --target-branch, --source-branch - Discussion count with unresolved indicator (e.g., "5/2!") - JSON output includes full MR metadata Show command (gi show mr ): - MR details with branches, assignees, reviewers, merge status - DiffNote positions showing file:line for code review comments - Full description and discussion bodies (no truncation in JSON) - --json flag for structured output with ISO timestamps Count command (gi count mrs): - MR counting with optional --type filter for discussions/notes - JSON output with breakdown by state Ingest command (gi ingest --type mrs): - Full MR sync with discussion prefetch - Progress output shows MR-specific metrics (diffnotes count) - JSON summary with comprehensive sync statistics All commands respect global --robot mode for auto-JSON output. The pattern "gi list mrs --json | jq '.mrs[] | .iid'" now works for scripted MR processing. Co-Authored-By: Claude Opus 4.5 --- src/cli/commands/count.rs | 152 +++++++- src/cli/commands/ingest.rs | 428 ++++++++++++++++---- src/cli/commands/list.rs | 449 ++++++++++++++++++++- src/cli/commands/mod.rs | 14 +- src/cli/commands/show.rs | 669 +++++++++++++++++++++++++++++++- src/cli/commands/sync_status.rs | 90 +++++ 6 files changed, 1675 insertions(+), 127 deletions(-) diff --git a/src/cli/commands/count.rs b/src/cli/commands/count.rs index 09c9481..42020df 100644 --- a/src/cli/commands/count.rs +++ b/src/cli/commands/count.rs @@ -2,6 +2,7 @@ use console::style; use rusqlite::Connection; +use serde::Serialize; use crate::Config; use crate::core::db::create_connection; @@ -12,7 +13,16 @@ use crate::core::paths::get_db_path; pub struct CountResult { pub entity: String, pub count: i64, - pub system_count: Option, // For notes only + pub system_count: Option, // For notes only + pub state_breakdown: Option, // For issues/MRs +} + +/// State breakdown for issues or MRs. +pub struct StateBreakdown { + pub opened: i64, + pub closed: i64, + pub merged: Option, // MRs only + pub locked: Option, // MRs only } /// Run the count command. @@ -24,30 +34,83 @@ pub fn run_count(config: &Config, entity: &str, type_filter: Option<&str>) -> Re "issues" => count_issues(&conn), "discussions" => count_discussions(&conn, type_filter), "notes" => count_notes(&conn, type_filter), - "mrs" => { - // Placeholder for CP2 - Ok(CountResult { - entity: "Merge Requests".to_string(), - count: 0, - system_count: None, - }) - } + "mrs" => count_mrs(&conn), _ => Ok(CountResult { entity: entity.to_string(), count: 0, system_count: None, + state_breakdown: None, }), } } -/// Count issues. +/// Count issues with state breakdown. fn count_issues(conn: &Connection) -> Result { let count: i64 = conn.query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))?; + let opened: i64 = conn.query_row( + "SELECT COUNT(*) FROM issues WHERE state = 'opened'", + [], + |row| row.get(0), + )?; + + let closed: i64 = conn.query_row( + "SELECT COUNT(*) FROM issues WHERE state = 'closed'", + [], + |row| row.get(0), + )?; + Ok(CountResult { entity: "Issues".to_string(), count, system_count: None, + state_breakdown: Some(StateBreakdown { + opened, + closed, + merged: None, + locked: None, + }), + }) +} + +/// Count merge requests with state breakdown. +fn count_mrs(conn: &Connection) -> Result { + let count: i64 = conn.query_row("SELECT COUNT(*) FROM merge_requests", [], |row| row.get(0))?; + + let opened: i64 = conn.query_row( + "SELECT COUNT(*) FROM merge_requests WHERE state = 'opened'", + [], + |row| row.get(0), + )?; + + let merged: i64 = conn.query_row( + "SELECT COUNT(*) FROM merge_requests WHERE state = 'merged'", + [], + |row| row.get(0), + )?; + + let closed: i64 = conn.query_row( + "SELECT COUNT(*) FROM merge_requests WHERE state = 'closed'", + [], + |row| row.get(0), + )?; + + let locked: i64 = conn.query_row( + "SELECT COUNT(*) FROM merge_requests WHERE state = 'locked'", + [], + |row| row.get(0), + )?; + + Ok(CountResult { + entity: "Merge Requests".to_string(), + count, + system_count: None, + state_breakdown: Some(StateBreakdown { + opened, + closed, + merged: Some(merged), + locked: Some(locked), + }), }) } @@ -81,6 +144,7 @@ fn count_discussions(conn: &Connection, type_filter: Option<&str>) -> Result) -> Result String { result } +/// JSON output structure for count command. +#[derive(Serialize)] +struct CountJsonOutput { + ok: bool, + data: CountJsonData, +} + +#[derive(Serialize)] +struct CountJsonData { + entity: String, + count: i64, + #[serde(skip_serializing_if = "Option::is_none")] + system_excluded: Option, + #[serde(skip_serializing_if = "Option::is_none")] + breakdown: Option, +} + +#[derive(Serialize)] +struct CountJsonBreakdown { + opened: i64, + closed: i64, + #[serde(skip_serializing_if = "Option::is_none")] + merged: Option, + #[serde(skip_serializing_if = "Option::is_none")] + locked: Option, +} + +/// Print count result as JSON (robot mode). +pub fn print_count_json(result: &CountResult) { + let breakdown = result.state_breakdown.as_ref().map(|b| CountJsonBreakdown { + opened: b.opened, + closed: b.closed, + merged: b.merged, + locked: b.locked.filter(|&l| l > 0), + }); + + let output = CountJsonOutput { + ok: true, + data: CountJsonData { + entity: result.entity.to_lowercase().replace(' ', "_"), + count: result.count, + system_excluded: result.system_count, + breakdown, + }, + }; + + println!("{}", serde_json::to_string(&output).unwrap()); +} + /// Print count result. pub fn print_count(result: &CountResult) { let count_str = format_number(result.count); @@ -153,7 +267,7 @@ pub fn print_count(result: &CountResult) { println!( "{}: {} {}", style(&result.entity).cyan(), - style(count_str).bold(), + style(&count_str).bold(), style(format!( "(excluding {} system)", format_number(system_count) @@ -164,9 +278,23 @@ pub fn print_count(result: &CountResult) { println!( "{}: {}", style(&result.entity).cyan(), - style(count_str).bold() + style(&count_str).bold() ); } + + // Print state breakdown if available + if let Some(breakdown) = &result.state_breakdown { + println!(" opened: {}", format_number(breakdown.opened)); + if let Some(merged) = breakdown.merged { + println!(" merged: {}", format_number(merged)); + } + println!(" closed: {}", format_number(breakdown.closed)); + if let Some(locked) = breakdown.locked + && locked > 0 + { + println!(" locked: {}", format_number(locked)); + } + } } #[cfg(test)] diff --git a/src/cli/commands/ingest.rs b/src/cli/commands/ingest.rs index d752e2e..5481390 100644 --- a/src/cli/commands/ingest.rs +++ b/src/cli/commands/ingest.rs @@ -3,6 +3,7 @@ use console::style; use indicatif::{ProgressBar, ProgressStyle}; use rusqlite::Connection; +use serde::Serialize; use crate::Config; use crate::core::db::create_connection; @@ -10,18 +11,32 @@ use crate::core::error::{GiError, Result}; use crate::core::lock::{AppLock, LockOptions}; use crate::core::paths::get_db_path; use crate::gitlab::GitLabClient; -use crate::ingestion::{IngestProjectResult, ProgressEvent, ingest_project_issues_with_progress}; +use crate::ingestion::{ + IngestMrProjectResult, IngestProjectResult, ProgressEvent, ingest_project_issues_with_progress, + ingest_project_merge_requests_with_progress, +}; /// Result of ingest command for display. pub struct IngestResult { + pub resource_type: String, pub projects_synced: usize, + // Issue-specific fields pub issues_fetched: usize, pub issues_upserted: usize, + pub issues_synced_discussions: usize, + pub issues_skipped_discussion_sync: usize, + // MR-specific fields + pub mrs_fetched: usize, + pub mrs_upserted: usize, + pub mrs_synced_discussions: usize, + pub mrs_skipped_discussion_sync: usize, + pub assignees_linked: usize, + pub reviewers_linked: usize, + pub diffnotes_count: usize, + // Shared fields pub labels_created: usize, pub discussions_fetched: usize, pub notes_upserted: usize, - pub issues_synced_discussions: usize, - pub issues_skipped_discussion_sync: usize, } /// Run the ingest command. @@ -31,11 +46,12 @@ pub async fn run_ingest( project_filter: Option<&str>, force: bool, full: bool, + robot_mode: bool, ) -> Result { - // Only issues supported in CP1 - if resource_type != "issues" { + // Validate resource type early + if resource_type != "issues" && resource_type != "mrs" { return Err(GiError::Other(format!( - "Resource type '{}' not yet implemented. Only 'issues' is supported.", + "Invalid resource type '{}'. Valid types: issues, mrs", resource_type ))); } @@ -69,16 +85,26 @@ pub async fn run_ingest( // If --full flag is set, reset sync cursors and discussion watermarks for a complete re-fetch if full { - println!( - "{}", - style("Full sync: resetting cursors to fetch all data...").yellow() - ); + if !robot_mode { + println!( + "{}", + style("Full sync: resetting cursors to fetch all data...").yellow() + ); + } for (local_project_id, _, path) in &projects { - // Reset discussion watermarks first so discussions get re-synced - conn.execute( - "UPDATE issues SET discussions_synced_for_updated_at = NULL WHERE project_id = ?", - [*local_project_id], - )?; + if resource_type == "issues" { + // Reset issue discussion watermarks first so discussions get re-synced + conn.execute( + "UPDATE issues SET discussions_synced_for_updated_at = NULL WHERE project_id = ?", + [*local_project_id], + )?; + } else if resource_type == "mrs" { + // Reset MR discussion watermarks + conn.execute( + "UPDATE merge_requests SET discussions_synced_for_updated_at = NULL WHERE project_id = ?", + [*local_project_id], + )?; + } // Then reset sync cursor conn.execute( @@ -86,7 +112,7 @@ pub async fn run_ingest( (*local_project_id, resource_type), )?; - tracing::info!(project = %path, "Reset sync cursor and discussion watermarks for full re-fetch"); + tracing::info!(project = %path, resource_type, "Reset sync cursor and discussion watermarks for full re-fetch"); } } @@ -103,45 +129,76 @@ pub async fn run_ingest( } let mut total = IngestResult { + resource_type: resource_type.to_string(), projects_synced: 0, + // Issue fields issues_fetched: 0, issues_upserted: 0, + issues_synced_discussions: 0, + issues_skipped_discussion_sync: 0, + // MR fields + mrs_fetched: 0, + mrs_upserted: 0, + mrs_synced_discussions: 0, + mrs_skipped_discussion_sync: 0, + assignees_linked: 0, + reviewers_linked: 0, + diffnotes_count: 0, + // Shared fields labels_created: 0, discussions_fetched: 0, notes_upserted: 0, - issues_synced_discussions: 0, - issues_skipped_discussion_sync: 0, }; - println!("{}", style("Ingesting issues...").blue()); - println!(); + let type_label = if resource_type == "issues" { + "issues" + } else { + "merge requests" + }; + if !robot_mode { + println!("{}", style(format!("Ingesting {type_label}...")).blue()); + println!(); + } // Sync each project for (local_project_id, gitlab_project_id, path) in &projects { - // Show spinner while fetching issues - let spinner = ProgressBar::new_spinner(); - spinner.set_style( - ProgressStyle::default_spinner() - .template("{spinner:.blue} {msg}") - .unwrap(), - ); - spinner.set_message(format!("Fetching issues from {path}...")); - spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + // Show spinner while fetching (only in interactive mode) + let spinner = if robot_mode { + ProgressBar::hidden() + } else { + let s = ProgressBar::new_spinner(); + s.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.blue} {msg}") + .unwrap(), + ); + s.set_message(format!("Fetching {type_label} from {path}...")); + s.enable_steady_tick(std::time::Duration::from_millis(100)); + s + }; - // Progress bar for discussion sync (hidden until needed) - let disc_bar = ProgressBar::new(0); - disc_bar.set_style( - ProgressStyle::default_bar() - .template(" {spinner:.blue} Syncing discussions [{bar:30.cyan/dim}] {pos}/{len}") - .unwrap() - .progress_chars("=> "), - ); + // Progress bar for discussion sync (hidden until needed, or always hidden in robot mode) + let disc_bar = if robot_mode { + ProgressBar::hidden() + } else { + let b = ProgressBar::new(0); + b.set_style( + ProgressStyle::default_bar() + .template(" {spinner:.blue} Syncing discussions [{bar:30.cyan/dim}] {pos}/{len}") + .unwrap() + .progress_chars("=> "), + ); + b + }; - // Create progress callback + // Create progress callback (no-op in robot mode) let spinner_clone = spinner.clone(); let disc_bar_clone = disc_bar.clone(); - let progress_callback: crate::ingestion::ProgressCallback = + let progress_callback: crate::ingestion::ProgressCallback = if robot_mode { + Box::new(|_| {}) + } else { Box::new(move |event: ProgressEvent| match event { + // Issue events ProgressEvent::DiscussionSyncStarted { total } => { spinner_clone.finish_and_clear(); disc_bar_clone.set_length(total as u64); @@ -153,34 +210,83 @@ pub async fn run_ingest( ProgressEvent::DiscussionSyncComplete => { disc_bar_clone.finish_and_clear(); } + // MR events + ProgressEvent::MrDiscussionSyncStarted { total } => { + spinner_clone.finish_and_clear(); + disc_bar_clone.set_length(total as u64); + disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100)); + } + ProgressEvent::MrDiscussionSynced { current, total: _ } => { + disc_bar_clone.set_position(current as u64); + } + ProgressEvent::MrDiscussionSyncComplete => { + disc_bar_clone.finish_and_clear(); + } _ => {} - }); + }) + }; - let result = ingest_project_issues_with_progress( - &conn, - &client, - config, - *local_project_id, - *gitlab_project_id, - Some(progress_callback), - ) - .await?; + if resource_type == "issues" { + let result = ingest_project_issues_with_progress( + &conn, + &client, + config, + *local_project_id, + *gitlab_project_id, + Some(progress_callback), + ) + .await?; - spinner.finish_and_clear(); - disc_bar.finish_and_clear(); + spinner.finish_and_clear(); + disc_bar.finish_and_clear(); - // Print per-project summary - print_project_summary(path, &result); + // Print per-project summary (only in interactive mode) + if !robot_mode { + print_issue_project_summary(path, &result); + } - // Aggregate totals - total.projects_synced += 1; - total.issues_fetched += result.issues_fetched; - total.issues_upserted += result.issues_upserted; - total.labels_created += result.labels_created; - total.discussions_fetched += result.discussions_fetched; - total.notes_upserted += result.notes_upserted; - total.issues_synced_discussions += result.issues_synced_discussions; - total.issues_skipped_discussion_sync += result.issues_skipped_discussion_sync; + // Aggregate totals + total.projects_synced += 1; + total.issues_fetched += result.issues_fetched; + total.issues_upserted += result.issues_upserted; + total.labels_created += result.labels_created; + total.discussions_fetched += result.discussions_fetched; + total.notes_upserted += result.notes_upserted; + total.issues_synced_discussions += result.issues_synced_discussions; + total.issues_skipped_discussion_sync += result.issues_skipped_discussion_sync; + } else { + let result = ingest_project_merge_requests_with_progress( + &conn, + &client, + config, + *local_project_id, + *gitlab_project_id, + full, + Some(progress_callback), + ) + .await?; + + spinner.finish_and_clear(); + disc_bar.finish_and_clear(); + + // Print per-project summary (only in interactive mode) + if !robot_mode { + print_mr_project_summary(path, &result); + } + + // Aggregate totals + total.projects_synced += 1; + total.mrs_fetched += result.mrs_fetched; + total.mrs_upserted += result.mrs_upserted; + total.labels_created += result.labels_created; + total.assignees_linked += result.assignees_linked; + total.reviewers_linked += result.reviewers_linked; + total.discussions_fetched += result.discussions_fetched; + total.notes_upserted += result.notes_upserted; + total.diffnotes_count += result.diffnotes_count; + total.mrs_synced_discussions += result.mrs_synced_discussions; + total.mrs_skipped_discussion_sync += result.mrs_skipped_discussion_sync; + } } // Lock is released on drop @@ -219,8 +325,8 @@ fn get_projects_to_sync( Ok(projects) } -/// Print summary for a single project. -fn print_project_summary(path: &str, result: &IngestProjectResult) { +/// Print summary for a single project (issues). +fn print_issue_project_summary(path: &str, result: &IngestProjectResult) { let labels_str = if result.labels_created > 0 { format!(", {} new labels", result.labels_created) } else { @@ -249,26 +355,188 @@ fn print_project_summary(path: &str, result: &IngestProjectResult) { } } -/// Print final summary. -pub fn print_ingest_summary(result: &IngestResult) { - println!(); +/// Print summary for a single project (merge requests). +fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) { + let labels_str = if result.labels_created > 0 { + format!(", {} new labels", result.labels_created) + } else { + String::new() + }; + + let assignees_str = if result.assignees_linked > 0 || result.reviewers_linked > 0 { + format!( + ", {} assignees, {} reviewers", + result.assignees_linked, result.reviewers_linked + ) + } else { + String::new() + }; + println!( - "{}", - style(format!( - "Total: {} issues, {} discussions, {} notes", - result.issues_upserted, result.discussions_fetched, result.notes_upserted - )) - .green() + " {}: {} MRs fetched{}{}", + style(path).cyan(), + result.mrs_upserted, + labels_str, + assignees_str ); - if result.issues_skipped_discussion_sync > 0 { + if result.mrs_synced_discussions > 0 { + let diffnotes_str = if result.diffnotes_count > 0 { + format!(" ({} diff notes)", result.diffnotes_count) + } else { + String::new() + }; println!( - "{}", - style(format!( - "Skipped discussion sync for {} unchanged issues.", - result.issues_skipped_discussion_sync - )) - .dim() + " {} MRs -> {} discussions, {} notes{}", + result.mrs_synced_discussions, + result.discussions_fetched, + result.notes_upserted, + diffnotes_str + ); + } + + if result.mrs_skipped_discussion_sync > 0 { + println!( + " {} unchanged MRs (discussion sync skipped)", + style(result.mrs_skipped_discussion_sync).dim() ); } } + +/// JSON output structures for robot mode. +#[derive(Serialize)] +struct IngestJsonOutput { + ok: bool, + data: IngestJsonData, +} + +#[derive(Serialize)] +struct IngestJsonData { + resource_type: String, + projects_synced: usize, + #[serde(skip_serializing_if = "Option::is_none")] + issues: Option, + #[serde(skip_serializing_if = "Option::is_none")] + merge_requests: Option, + labels_created: usize, + discussions_fetched: usize, + notes_upserted: usize, +} + +#[derive(Serialize)] +struct IngestIssueStats { + fetched: usize, + upserted: usize, + synced_discussions: usize, + skipped_discussion_sync: usize, +} + +#[derive(Serialize)] +struct IngestMrStats { + fetched: usize, + upserted: usize, + synced_discussions: usize, + skipped_discussion_sync: usize, + assignees_linked: usize, + reviewers_linked: usize, + diffnotes_count: usize, +} + +/// Print final summary as JSON (robot mode). +pub fn print_ingest_summary_json(result: &IngestResult) { + let (issues, merge_requests) = if result.resource_type == "issues" { + ( + Some(IngestIssueStats { + fetched: result.issues_fetched, + upserted: result.issues_upserted, + synced_discussions: result.issues_synced_discussions, + skipped_discussion_sync: result.issues_skipped_discussion_sync, + }), + None, + ) + } else { + ( + None, + Some(IngestMrStats { + fetched: result.mrs_fetched, + upserted: result.mrs_upserted, + synced_discussions: result.mrs_synced_discussions, + skipped_discussion_sync: result.mrs_skipped_discussion_sync, + assignees_linked: result.assignees_linked, + reviewers_linked: result.reviewers_linked, + diffnotes_count: result.diffnotes_count, + }), + ) + }; + + let output = IngestJsonOutput { + ok: true, + data: IngestJsonData { + resource_type: result.resource_type.clone(), + projects_synced: result.projects_synced, + issues, + merge_requests, + labels_created: result.labels_created, + discussions_fetched: result.discussions_fetched, + notes_upserted: result.notes_upserted, + }, + }; + + println!("{}", serde_json::to_string(&output).unwrap()); +} + +/// Print final summary. +pub fn print_ingest_summary(result: &IngestResult) { + println!(); + + if result.resource_type == "issues" { + println!( + "{}", + style(format!( + "Total: {} issues, {} discussions, {} notes", + result.issues_upserted, result.discussions_fetched, result.notes_upserted + )) + .green() + ); + + if result.issues_skipped_discussion_sync > 0 { + println!( + "{}", + style(format!( + "Skipped discussion sync for {} unchanged issues.", + result.issues_skipped_discussion_sync + )) + .dim() + ); + } + } else { + let diffnotes_str = if result.diffnotes_count > 0 { + format!(" ({} diff notes)", result.diffnotes_count) + } else { + String::new() + }; + + println!( + "{}", + style(format!( + "Total: {} MRs, {} discussions, {} notes{}", + result.mrs_upserted, + result.discussions_fetched, + result.notes_upserted, + diffnotes_str + )) + .green() + ); + + if result.mrs_skipped_discussion_sync > 0 { + println!( + "{}", + style(format!( + "Skipped discussion sync for {} unchanged MRs.", + result.mrs_skipped_discussion_sync + )) + .dim() + ); + } + } +} diff --git a/src/cli/commands/list.rs b/src/cli/commands/list.rs index 9bb2198..456c479 100644 --- a/src/cli/commands/list.rs +++ b/src/cli/commands/list.rs @@ -90,7 +90,99 @@ impl From<&ListResult> for ListResultJson { } } -/// Filter options for list query. +/// MR row for display. +#[derive(Debug, Serialize)] +pub struct MrListRow { + pub iid: i64, + pub title: String, + pub state: String, + pub draft: bool, + pub author_username: String, + pub source_branch: String, + pub target_branch: String, + pub created_at: i64, + pub updated_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_url: Option, + pub project_path: String, + pub labels: Vec, + pub assignees: Vec, + pub reviewers: Vec, + pub discussion_count: i64, + pub unresolved_count: i64, +} + +/// Serializable version for JSON output. +#[derive(Serialize)] +pub struct MrListRowJson { + pub iid: i64, + pub title: String, + pub state: String, + pub draft: bool, + pub author_username: String, + pub source_branch: String, + pub target_branch: String, + pub labels: Vec, + pub assignees: Vec, + pub reviewers: Vec, + pub discussion_count: i64, + pub unresolved_count: i64, + pub created_at_iso: String, + pub updated_at_iso: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_url: Option, + pub project_path: String, +} + +impl From<&MrListRow> for MrListRowJson { + fn from(row: &MrListRow) -> Self { + Self { + iid: row.iid, + title: row.title.clone(), + state: row.state.clone(), + draft: row.draft, + author_username: row.author_username.clone(), + source_branch: row.source_branch.clone(), + target_branch: row.target_branch.clone(), + labels: row.labels.clone(), + assignees: row.assignees.clone(), + reviewers: row.reviewers.clone(), + discussion_count: row.discussion_count, + unresolved_count: row.unresolved_count, + created_at_iso: ms_to_iso(row.created_at), + updated_at_iso: ms_to_iso(row.updated_at), + web_url: row.web_url.clone(), + project_path: row.project_path.clone(), + } + } +} + +/// Result of MR list query. +#[derive(Serialize)] +pub struct MrListResult { + pub mrs: Vec, + pub total_count: usize, +} + +/// JSON output structure for MRs. +#[derive(Serialize)] +pub struct MrListResultJson { + pub mrs: Vec, + pub total_count: usize, + pub showing: usize, +} + +impl From<&MrListResult> for MrListResultJson { + fn from(result: &MrListResult) -> Self { + Self { + mrs: result.mrs.iter().map(MrListRowJson::from).collect(), + total_count: result.total_count, + showing: result.mrs.len(), + } + } +} + +/// Filter options for issue list query. pub struct ListFilters<'a> { pub limit: usize, pub project: Option<&'a str>, @@ -106,6 +198,24 @@ pub struct ListFilters<'a> { pub order: &'a str, } +/// Filter options for MR list query. +pub struct MrListFilters<'a> { + pub limit: usize, + pub project: Option<&'a str>, + pub state: Option<&'a str>, + pub author: Option<&'a str>, + pub assignee: Option<&'a str>, + pub reviewer: Option<&'a str>, + pub labels: Option<&'a [String]>, + pub since: Option<&'a str>, + pub draft: bool, + pub no_draft: bool, + pub target_branch: Option<&'a str>, + pub source_branch: Option<&'a str>, + pub sort: &'a str, + pub order: &'a str, +} + /// Run the list issues command. pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); @@ -126,11 +236,11 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result params.push(Box::new(format!("%{project}%"))); } - if let Some(state) = filters.state { - if state != "all" { - where_clauses.push("i.state = ?"); - params.push(Box::new(state.to_string())); - } + if let Some(state) = filters.state + && state != "all" + { + where_clauses.push("i.state = ?"); + params.push(Box::new(state.to_string())); } // Handle author filter (strip leading @ if present) @@ -151,11 +261,11 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result } // Handle since filter - if let Some(since_str) = filters.since { - if let Some(cutoff_ms) = parse_since(since_str) { - where_clauses.push("i.updated_at >= ?"); - params.push(Box::new(cutoff_ms)); - } + if let Some(since_str) = filters.since + && let Some(cutoff_ms) = parse_since(since_str) + { + where_clauses.push("i.updated_at >= ?"); + params.push(Box::new(cutoff_ms)); } // Handle label filters (AND logic - all labels must be present) @@ -210,7 +320,11 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result "iid" => "i.iid", _ => "i.updated_at", // default }; - let order = if filters.order == "asc" { "ASC" } else { "DESC" }; + let order = if filters.order == "asc" { + "ASC" + } else { + "DESC" + }; // Get issues with enriched data let query_sql = format!( @@ -251,7 +365,7 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(&query_sql)?; - let issues = stmt + let issues: Vec = stmt .query_map(param_refs.as_slice(), |row| { let labels_csv: Option = row.get(8)?; let labels = labels_csv @@ -278,8 +392,7 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result unresolved_count: row.get(11)?, }) })? - .filter_map(|r| r.ok()) - .collect(); + .collect::, _>>()?; Ok(ListResult { issues, @@ -287,6 +400,216 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result }) } +/// Run the list MRs command. +pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + let result = query_mrs(&conn, &filters)?; + Ok(result) +} + +/// Query MRs from database with enriched data. +fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result { + // Build WHERE clause + let mut where_clauses = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(project) = filters.project { + where_clauses.push("p.path_with_namespace LIKE ?"); + params.push(Box::new(format!("%{project}%"))); + } + + if let Some(state) = filters.state + && state != "all" + { + where_clauses.push("m.state = ?"); + params.push(Box::new(state.to_string())); + } + + // Handle author filter (strip leading @ if present) + if let Some(author) = filters.author { + let username = author.strip_prefix('@').unwrap_or(author); + where_clauses.push("m.author_username = ?"); + params.push(Box::new(username.to_string())); + } + + // Handle assignee filter (strip leading @ if present) + if let Some(assignee) = filters.assignee { + let username = assignee.strip_prefix('@').unwrap_or(assignee); + where_clauses.push( + "EXISTS (SELECT 1 FROM mr_assignees ma + WHERE ma.merge_request_id = m.id AND ma.username = ?)", + ); + params.push(Box::new(username.to_string())); + } + + // Handle reviewer filter (strip leading @ if present) + if let Some(reviewer) = filters.reviewer { + let username = reviewer.strip_prefix('@').unwrap_or(reviewer); + where_clauses.push( + "EXISTS (SELECT 1 FROM mr_reviewers mr + WHERE mr.merge_request_id = m.id AND mr.username = ?)", + ); + params.push(Box::new(username.to_string())); + } + + // Handle since filter + if let Some(since_str) = filters.since + && let Some(cutoff_ms) = parse_since(since_str) + { + where_clauses.push("m.updated_at >= ?"); + params.push(Box::new(cutoff_ms)); + } + + // Handle label filters (AND logic - all labels must be present) + if let Some(labels) = filters.labels { + for label in labels { + where_clauses.push( + "EXISTS (SELECT 1 FROM mr_labels ml + JOIN labels l ON ml.label_id = l.id + WHERE ml.merge_request_id = m.id AND l.name = ?)", + ); + params.push(Box::new(label.clone())); + } + } + + // Handle draft filter + if filters.draft { + where_clauses.push("m.draft = 1"); + } else if filters.no_draft { + where_clauses.push("m.draft = 0"); + } + + // Handle target branch filter + if let Some(target_branch) = filters.target_branch { + where_clauses.push("m.target_branch = ?"); + params.push(Box::new(target_branch.to_string())); + } + + // Handle source branch filter + if let Some(source_branch) = filters.source_branch { + where_clauses.push("m.source_branch = ?"); + params.push(Box::new(source_branch.to_string())); + } + + let where_sql = if where_clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", where_clauses.join(" AND ")) + }; + + // Get total count + let count_sql = format!( + "SELECT COUNT(*) FROM merge_requests m + JOIN projects p ON m.project_id = p.id + {where_sql}" + ); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; + let total_count = total_count as usize; + + // Build ORDER BY + let sort_column = match filters.sort { + "created" => "m.created_at", + "iid" => "m.iid", + _ => "m.updated_at", // default + }; + let order = if filters.order == "asc" { + "ASC" + } else { + "DESC" + }; + + // Get MRs with enriched data + let query_sql = format!( + "SELECT + m.iid, + m.title, + m.state, + m.draft, + m.author_username, + m.source_branch, + m.target_branch, + m.created_at, + m.updated_at, + m.web_url, + p.path_with_namespace, + (SELECT GROUP_CONCAT(l.name, ',') + FROM mr_labels ml + JOIN labels l ON ml.label_id = l.id + WHERE ml.merge_request_id = m.id) AS labels_csv, + (SELECT GROUP_CONCAT(ma.username, ',') + FROM mr_assignees ma + WHERE ma.merge_request_id = m.id) AS assignees_csv, + (SELECT GROUP_CONCAT(mr.username, ',') + FROM mr_reviewers mr + WHERE mr.merge_request_id = m.id) AS reviewers_csv, + COALESCE(d.total, 0) AS discussion_count, + COALESCE(d.unresolved, 0) AS unresolved_count + FROM merge_requests m + JOIN projects p ON m.project_id = p.id + LEFT JOIN ( + SELECT merge_request_id, + COUNT(*) as total, + SUM(CASE WHEN resolvable = 1 AND resolved = 0 THEN 1 ELSE 0 END) as unresolved + FROM discussions + WHERE merge_request_id IS NOT NULL + GROUP BY merge_request_id + ) d ON d.merge_request_id = m.id + {where_sql} + ORDER BY {sort_column} {order} + LIMIT ?" + ); + + params.push(Box::new(filters.limit as i64)); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn.prepare(&query_sql)?; + let mrs: Vec = stmt + .query_map(param_refs.as_slice(), |row| { + let labels_csv: Option = row.get(11)?; + let labels = labels_csv + .map(|s| s.split(',').map(String::from).collect()) + .unwrap_or_default(); + + let assignees_csv: Option = row.get(12)?; + let assignees = assignees_csv + .map(|s| s.split(',').map(String::from).collect()) + .unwrap_or_default(); + + let reviewers_csv: Option = row.get(13)?; + let reviewers = reviewers_csv + .map(|s| s.split(',').map(String::from).collect()) + .unwrap_or_default(); + + let draft_int: i64 = row.get(3)?; + + Ok(MrListRow { + iid: row.get(0)?, + title: row.get(1)?, + state: row.get(2)?, + draft: draft_int == 1, + author_username: row.get(4)?, + source_branch: row.get(5)?, + target_branch: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + web_url: row.get(9)?, + project_path: row.get(10)?, + labels, + assignees, + reviewers, + discussion_count: row.get(14)?, + unresolved_count: row.get(15)?, + }) + })? + .collect::, _>>()?; + + Ok(MrListResult { mrs, total_count }) +} + /// Format relative time from ms epoch. fn format_relative_time(ms_epoch: i64) -> String { let now = now_ms(); @@ -362,6 +685,12 @@ fn format_discussions(total: i64, unresolved: i64) -> String { } } +/// Format branch info: target <- source +fn format_branches(target: &str, source: &str, max_width: usize) -> String { + let full = format!("{} <- {}", target, source); + truncate_with_ellipsis(&full, max_width) +} + /// Print issues list as a formatted table. pub fn print_list_issues(result: &ListResult) { if result.issues.is_empty() { @@ -441,6 +770,96 @@ pub fn open_issue_in_browser(result: &ListResult) -> Option { } } +/// Print MRs list as a formatted table. +pub fn print_list_mrs(result: &MrListResult) { + if result.mrs.is_empty() { + println!("No merge requests found."); + return; + } + + println!( + "Merge Requests (showing {} of {})\n", + result.mrs.len(), + result.total_count + ); + + let mut table = Table::new(); + table + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + Cell::new("IID").add_attribute(Attribute::Bold), + Cell::new("Title").add_attribute(Attribute::Bold), + Cell::new("State").add_attribute(Attribute::Bold), + Cell::new("Author").add_attribute(Attribute::Bold), + Cell::new("Branches").add_attribute(Attribute::Bold), + Cell::new("Disc").add_attribute(Attribute::Bold), + Cell::new("Updated").add_attribute(Attribute::Bold), + ]); + + for mr in &result.mrs { + // Add [DRAFT] prefix for draft MRs + let title = if mr.draft { + format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38)) + } else { + truncate_with_ellipsis(&mr.title, 45) + }; + + let relative_time = format_relative_time(mr.updated_at); + let branches = format_branches(&mr.target_branch, &mr.source_branch, 25); + let discussions = format_discussions(mr.discussion_count, mr.unresolved_count); + + let state_cell = match mr.state.as_str() { + "opened" => Cell::new(&mr.state).fg(Color::Green), + "merged" => Cell::new(&mr.state).fg(Color::Magenta), + "closed" => Cell::new(&mr.state).fg(Color::Red), + "locked" => Cell::new(&mr.state).fg(Color::Yellow), + _ => Cell::new(&mr.state).fg(Color::DarkGrey), + }; + + table.add_row(vec![ + Cell::new(format!("!{}", mr.iid)).fg(Color::Cyan), + Cell::new(title), + state_cell, + Cell::new(format!( + "@{}", + truncate_with_ellipsis(&mr.author_username, 12) + )) + .fg(Color::Magenta), + Cell::new(branches).fg(Color::Blue), + Cell::new(discussions), + Cell::new(relative_time).fg(Color::DarkGrey), + ]); + } + + println!("{table}"); +} + +/// Print MRs list as JSON. +pub fn print_list_mrs_json(result: &MrListResult) { + let json_result = MrListResultJson::from(result); + match serde_json::to_string_pretty(&json_result) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} + +/// Open MR in browser. Returns the URL that was opened. +pub fn open_mr_in_browser(result: &MrListResult) -> Option { + let first_mr = result.mrs.first()?; + let url = first_mr.web_url.as_ref()?; + + match open::that(url) { + Ok(()) => { + println!("Opened: {url}"); + Some(url.clone()) + } + Err(e) => { + eprintln!("Failed to open browser: {e}"); + None + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index db0ecb6..4609569 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -10,12 +10,16 @@ pub mod show; pub mod sync_status; pub use auth_test::run_auth_test; -pub use count::{print_count, run_count}; +pub use count::{print_count, print_count_json, run_count}; pub use doctor::{print_doctor_results, run_doctor}; -pub use ingest::{print_ingest_summary, run_ingest}; +pub use ingest::{print_ingest_summary, print_ingest_summary_json, run_ingest}; pub use init::{InitInputs, InitOptions, InitResult, run_init}; pub use list::{ - ListFilters, open_issue_in_browser, print_list_issues, print_list_issues_json, run_list_issues, + ListFilters, MrListFilters, open_issue_in_browser, open_mr_in_browser, print_list_issues, + print_list_issues_json, print_list_mrs, print_list_mrs_json, run_list_issues, run_list_mrs, }; -pub use show::{print_show_issue, run_show_issue}; -pub use sync_status::{print_sync_status, run_sync_status}; +pub use show::{ + print_show_issue, print_show_issue_json, print_show_mr, print_show_mr_json, run_show_issue, + run_show_mr, +}; +pub use sync_status::{print_sync_status, print_sync_status_json, run_sync_status}; diff --git a/src/cli/commands/show.rs b/src/cli/commands/show.rs index 1898195..9ba3597 100644 --- a/src/cli/commands/show.rs +++ b/src/cli/commands/show.rs @@ -2,6 +2,7 @@ use console::style; use rusqlite::Connection; +use serde::Serialize; use crate::Config; use crate::core::db::create_connection; @@ -9,8 +10,59 @@ use crate::core::error::{GiError, Result}; use crate::core::paths::get_db_path; use crate::core::time::ms_to_iso; +/// Merge request metadata for display. +#[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, +} + +/// MR discussion detail for display. +#[derive(Debug, Serialize)] +pub struct MrDiscussionDetail { + pub notes: Vec, + pub individual_note: bool, +} + +/// MR note detail for display (includes DiffNote position). +#[derive(Debug, Serialize)] +pub struct MrNoteDetail { + pub author_username: String, + pub body: String, + pub created_at: i64, + pub is_system: bool, + pub position: Option, +} + +/// DiffNote position context for display. +#[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, +} + /// Issue metadata for display. -#[derive(Debug)] +#[derive(Debug, Serialize)] pub struct IssueDetail { pub id: i64, pub iid: i64, @@ -27,14 +79,14 @@ pub struct IssueDetail { } /// Discussion detail for display. -#[derive(Debug)] +#[derive(Debug, Serialize)] pub struct DiscussionDetail { pub notes: Vec, pub individual_note: bool, } /// Note detail for display. -#[derive(Debug)] +#[derive(Debug, Serialize)] pub struct NoteDetail { pub author_username: String, pub body: String, @@ -129,8 +181,7 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu project_path: row.get(9)?, }) })? - .filter_map(|r| r.ok()) - .collect(); + .collect::, _>>()?; match issues.len() { 0 => Err(GiError::NotFound(format!("Issue #{} not found", iid))), @@ -155,10 +206,9 @@ fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result> { ORDER BY l.name", )?; - let labels = stmt + let labels: Vec = stmt .query_map([issue_id], |row| row.get(0))? - .filter_map(|r| r.ok()) - .collect(); + .collect::, _>>()?; Ok(labels) } @@ -177,8 +227,7 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result, _>>()?; // Then get notes for each discussion let mut note_stmt = conn.prepare( @@ -200,8 +249,7 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result, _>>()?; // Filter out discussions with only system notes let has_user_notes = notes.iter().any(|n| !n.is_system); @@ -216,6 +264,255 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result) -> Result { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + // Find the MR + let mr = find_mr(&conn, iid, project_filter)?; + + // Load labels + let labels = get_mr_labels(&conn, mr.id)?; + + // Load assignees + let assignees = get_mr_assignees(&conn, mr.id)?; + + // Load reviewers + let reviewers = get_mr_reviewers(&conn, mr.id)?; + + // Load discussions with notes + 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, + }) +} + +/// Internal MR row from query. +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, +} + +/// Find MR by iid, optionally filtered by project. +fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { + let (sql, params): (&str, Vec>) = match project_filter { + Some(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 p.path_with_namespace LIKE ?", + vec![Box::new(iid), Box::new(format!("%{}%", project))], + ), + 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(GiError::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(GiError::Ambiguous(format!( + "MR !{} exists in multiple projects: {}. Use --project to specify.", + iid, + projects.join(", ") + ))) + } + } +} + +/// Get labels for an MR. +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) +} + +/// Get assignees for an MR. +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) +} + +/// Get reviewers for an MR. +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) +} + +/// Get discussions with notes for an MR. +fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result> { + // First get all discussions + 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::, _>>()?; + + // Then get notes for each discussion (with DiffNote position fields) + 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::, _>>()?; + + // Filter out discussions with only system notes + 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) +} + /// Format date from ms epoch. fn format_date(ms: i64) -> String { let iso = ms_to_iso(ms); @@ -223,12 +520,13 @@ fn format_date(ms: i64) -> String { iso.split('T').next().unwrap_or(&iso).to_string() } -/// Truncate text with ellipsis. +/// Truncate text with ellipsis (character-safe for UTF-8). fn truncate(s: &str, max_len: usize) -> String { - if s.len() <= max_len { + if s.chars().count() <= max_len { s.to_string() } else { - format!("{}...", &s[..max_len.saturating_sub(3)]) + let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect(); + format!("{truncated}...") } } @@ -357,6 +655,347 @@ pub fn print_show_issue(issue: &IssueDetail) { } } +/// Print MR detail. +pub fn print_show_mr(mr: &MrDetail) { + // Header with draft indicator + 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!(); + + // Metadata + 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!(); + + // Description + println!("{}", style("Description:").bold()); + if let Some(desc) = &mr.description { + let truncated = truncate(desc, 500); + let wrapped = wrap_text(&truncated, 76, " "); + println!(" {}", wrapped); + } else { + println!(" {}", style("(no description)").dim()); + } + + println!(); + + // Discussions + 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() { + // Print DiffNote position context if present + if let Some(pos) = &first_note.position { + print_diff_position(pos); + } + + // First note of discussion (not indented) + println!( + " {} ({}):", + style(format!("@{}", first_note.author_username)).cyan(), + format_date(first_note.created_at) + ); + let wrapped = wrap_text(&truncate(&first_note.body, 300), 72, " "); + println!(" {}", wrapped); + println!(); + + // Replies (indented) + for reply in user_notes.iter().skip(1) { + println!( + " {} ({}):", + style(format!("@{}", reply.author_username)).cyan(), + format_date(reply.created_at) + ); + let wrapped = wrap_text(&truncate(&reply.body, 300), 68, " "); + println!(" {}", wrapped); + println!(); + } + } + } + } +} + +/// Print DiffNote position context. +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() + ); + } +} + +// ============================================================================ +// JSON Output Structs (with ISO timestamps for machine consumption) +// ============================================================================ + +/// JSON output for issue detail. +#[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 web_url: Option, + pub project_path: String, + pub labels: Vec, + pub discussions: Vec, +} + +/// JSON output for discussion detail. +#[derive(Serialize)] +pub struct DiscussionDetailJson { + pub notes: Vec, + pub individual_note: bool, +} + +/// JSON output for note detail. +#[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), + web_url: issue.web_url.clone(), + project_path: issue.project_path.clone(), + labels: issue.labels.clone(), + discussions: issue.discussions.iter().map(|d| d.into()).collect(), + } + } +} + +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, + } + } +} + +/// JSON output for MR detail. +#[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, +} + +/// JSON output for MR discussion detail. +#[derive(Serialize)] +pub struct MrDiscussionDetailJson { + pub notes: Vec, + pub individual_note: bool, +} + +/// JSON output for MR note detail. +#[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(), + } + } +} + +/// Print issue detail as JSON. +pub fn print_show_issue_json(issue: &IssueDetail) { + let json_result = IssueDetailJson::from(issue); + match serde_json::to_string_pretty(&json_result) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} + +/// Print MR detail as JSON. +pub fn print_show_mr_json(mr: &MrDetail) { + let json_result = MrDetailJson::from(mr); + match serde_json::to_string_pretty(&json_result) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli/commands/sync_status.rs b/src/cli/commands/sync_status.rs index 3b57382..3291aef 100644 --- a/src/cli/commands/sync_status.rs +++ b/src/cli/commands/sync_status.rs @@ -2,6 +2,7 @@ use console::style; use rusqlite::Connection; +use serde::Serialize; use crate::Config; use crate::core::db::create_connection; @@ -175,6 +176,95 @@ fn format_number(n: i64) -> String { result } +/// JSON output structures for robot mode. +#[derive(Serialize)] +struct SyncStatusJsonOutput { + ok: bool, + data: SyncStatusJsonData, +} + +#[derive(Serialize)] +struct SyncStatusJsonData { + last_sync: Option, + cursors: Vec, + summary: SummaryJsonInfo, +} + +#[derive(Serialize)] +struct SyncRunJsonInfo { + id: i64, + status: String, + command: String, + started_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + duration_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +struct CursorJsonInfo { + project: String, + resource_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + updated_at_cursor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tie_breaker_id: Option, +} + +#[derive(Serialize)] +struct SummaryJsonInfo { + issues: i64, + discussions: i64, + notes: i64, + system_notes: i64, +} + +/// Print sync status as JSON (robot mode). +pub fn print_sync_status_json(result: &SyncStatusResult) { + let last_sync = result.last_run.as_ref().map(|run| { + let duration_ms = run.finished_at.map(|f| f - run.started_at); + SyncRunJsonInfo { + id: run.id, + status: run.status.clone(), + command: run.command.clone(), + started_at: ms_to_iso(run.started_at), + completed_at: run.finished_at.map(ms_to_iso), + duration_ms, + error: run.error.clone(), + } + }); + + let cursors = result + .cursors + .iter() + .map(|c| CursorJsonInfo { + project: c.project_path.clone(), + resource_type: c.resource_type.clone(), + updated_at_cursor: c.updated_at_cursor.filter(|&ts| ts > 0).map(ms_to_iso), + tie_breaker_id: c.tie_breaker_id, + }) + .collect(); + + let output = SyncStatusJsonOutput { + ok: true, + data: SyncStatusJsonData { + last_sync, + cursors, + summary: SummaryJsonInfo { + issues: result.summary.issue_count, + discussions: result.summary.discussion_count, + notes: result.summary.note_count - result.summary.system_note_count, + system_notes: result.summary.system_note_count, + }, + }, + }; + + println!("{}", serde_json::to_string(&output).unwrap()); +} + /// Print sync status result. pub fn print_sync_status(result: &SyncStatusResult) { // Last Sync section