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 <iid>):
- 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 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-01-26 22:46:59 -05:00
parent 7d0d586932
commit 8ddc974b89
6 changed files with 1675 additions and 127 deletions

View File

@@ -2,6 +2,7 @@
use console::style; use console::style;
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize;
use crate::Config; use crate::Config;
use crate::core::db::create_connection; use crate::core::db::create_connection;
@@ -13,6 +14,15 @@ pub struct CountResult {
pub entity: String, pub entity: String,
pub count: i64, pub count: i64,
pub system_count: Option<i64>, // For notes only pub system_count: Option<i64>, // For notes only
pub state_breakdown: Option<StateBreakdown>, // For issues/MRs
}
/// State breakdown for issues or MRs.
pub struct StateBreakdown {
pub opened: i64,
pub closed: i64,
pub merged: Option<i64>, // MRs only
pub locked: Option<i64>, // MRs only
} }
/// Run the count command. /// 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), "issues" => count_issues(&conn),
"discussions" => count_discussions(&conn, type_filter), "discussions" => count_discussions(&conn, type_filter),
"notes" => count_notes(&conn, type_filter), "notes" => count_notes(&conn, type_filter),
"mrs" => { "mrs" => count_mrs(&conn),
// Placeholder for CP2
Ok(CountResult {
entity: "Merge Requests".to_string(),
count: 0,
system_count: None,
})
}
_ => Ok(CountResult { _ => Ok(CountResult {
entity: entity.to_string(), entity: entity.to_string(),
count: 0, count: 0,
system_count: None, system_count: None,
state_breakdown: None,
}), }),
} }
} }
/// Count issues. /// Count issues with state breakdown.
fn count_issues(conn: &Connection) -> Result<CountResult> { fn count_issues(conn: &Connection) -> Result<CountResult> {
let count: i64 = conn.query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))?; 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 { Ok(CountResult {
entity: "Issues".to_string(), entity: "Issues".to_string(),
count, count,
system_count: None, 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<CountResult> {
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<Cou
entity: entity_name.to_string(), entity: entity_name.to_string(),
count, count,
system_count: None, system_count: None,
state_breakdown: None,
}) })
} }
@@ -126,6 +190,7 @@ fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result<CountResu
entity: entity_name.to_string(), entity: entity_name.to_string(),
count: non_system, count: non_system,
system_count: Some(system_count), system_count: Some(system_count),
state_breakdown: None,
}) })
} }
@@ -145,6 +210,55 @@ fn format_number(n: i64) -> String {
result 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<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
breakdown: Option<CountJsonBreakdown>,
}
#[derive(Serialize)]
struct CountJsonBreakdown {
opened: i64,
closed: i64,
#[serde(skip_serializing_if = "Option::is_none")]
merged: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
locked: Option<i64>,
}
/// 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. /// Print count result.
pub fn print_count(result: &CountResult) { pub fn print_count(result: &CountResult) {
let count_str = format_number(result.count); let count_str = format_number(result.count);
@@ -153,7 +267,7 @@ pub fn print_count(result: &CountResult) {
println!( println!(
"{}: {} {}", "{}: {} {}",
style(&result.entity).cyan(), style(&result.entity).cyan(),
style(count_str).bold(), style(&count_str).bold(),
style(format!( style(format!(
"(excluding {} system)", "(excluding {} system)",
format_number(system_count) format_number(system_count)
@@ -164,9 +278,23 @@ pub fn print_count(result: &CountResult) {
println!( println!(
"{}: {}", "{}: {}",
style(&result.entity).cyan(), 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)] #[cfg(test)]

View File

@@ -3,6 +3,7 @@
use console::style; use console::style;
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize;
use crate::Config; use crate::Config;
use crate::core::db::create_connection; 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::lock::{AppLock, LockOptions};
use crate::core::paths::get_db_path; use crate::core::paths::get_db_path;
use crate::gitlab::GitLabClient; 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. /// Result of ingest command for display.
pub struct IngestResult { pub struct IngestResult {
pub resource_type: String,
pub projects_synced: usize, pub projects_synced: usize,
// Issue-specific fields
pub issues_fetched: usize, pub issues_fetched: usize,
pub issues_upserted: 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 labels_created: usize,
pub discussions_fetched: usize, pub discussions_fetched: usize,
pub notes_upserted: usize, pub notes_upserted: usize,
pub issues_synced_discussions: usize,
pub issues_skipped_discussion_sync: usize,
} }
/// Run the ingest command. /// Run the ingest command.
@@ -31,11 +46,12 @@ pub async fn run_ingest(
project_filter: Option<&str>, project_filter: Option<&str>,
force: bool, force: bool,
full: bool, full: bool,
robot_mode: bool,
) -> Result<IngestResult> { ) -> Result<IngestResult> {
// Only issues supported in CP1 // Validate resource type early
if resource_type != "issues" { if resource_type != "issues" && resource_type != "mrs" {
return Err(GiError::Other(format!( return Err(GiError::Other(format!(
"Resource type '{}' not yet implemented. Only 'issues' is supported.", "Invalid resource type '{}'. Valid types: issues, mrs",
resource_type 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 flag is set, reset sync cursors and discussion watermarks for a complete re-fetch
if full { if full {
if !robot_mode {
println!( println!(
"{}", "{}",
style("Full sync: resetting cursors to fetch all data...").yellow() style("Full sync: resetting cursors to fetch all data...").yellow()
); );
}
for (local_project_id, _, path) in &projects { for (local_project_id, _, path) in &projects {
// Reset discussion watermarks first so discussions get re-synced if resource_type == "issues" {
// Reset issue discussion watermarks first so discussions get re-synced
conn.execute( conn.execute(
"UPDATE issues SET discussions_synced_for_updated_at = NULL WHERE project_id = ?", "UPDATE issues SET discussions_synced_for_updated_at = NULL WHERE project_id = ?",
[*local_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 // Then reset sync cursor
conn.execute( conn.execute(
@@ -86,7 +112,7 @@ pub async fn run_ingest(
(*local_project_id, resource_type), (*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 { let mut total = IngestResult {
resource_type: resource_type.to_string(),
projects_synced: 0, projects_synced: 0,
// Issue fields
issues_fetched: 0, issues_fetched: 0,
issues_upserted: 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, labels_created: 0,
discussions_fetched: 0, discussions_fetched: 0,
notes_upserted: 0, notes_upserted: 0,
issues_synced_discussions: 0,
issues_skipped_discussion_sync: 0,
}; };
println!("{}", style("Ingesting issues...").blue()); let type_label = if resource_type == "issues" {
"issues"
} else {
"merge requests"
};
if !robot_mode {
println!("{}", style(format!("Ingesting {type_label}...")).blue());
println!(); println!();
}
// Sync each project // Sync each project
for (local_project_id, gitlab_project_id, path) in &projects { for (local_project_id, gitlab_project_id, path) in &projects {
// Show spinner while fetching issues // Show spinner while fetching (only in interactive mode)
let spinner = ProgressBar::new_spinner(); let spinner = if robot_mode {
spinner.set_style( ProgressBar::hidden()
} else {
let s = ProgressBar::new_spinner();
s.set_style(
ProgressStyle::default_spinner() ProgressStyle::default_spinner()
.template("{spinner:.blue} {msg}") .template("{spinner:.blue} {msg}")
.unwrap(), .unwrap(),
); );
spinner.set_message(format!("Fetching issues from {path}...")); s.set_message(format!("Fetching {type_label} from {path}..."));
spinner.enable_steady_tick(std::time::Duration::from_millis(100)); s.enable_steady_tick(std::time::Duration::from_millis(100));
s
};
// Progress bar for discussion sync (hidden until needed) // Progress bar for discussion sync (hidden until needed, or always hidden in robot mode)
let disc_bar = ProgressBar::new(0); let disc_bar = if robot_mode {
disc_bar.set_style( ProgressBar::hidden()
} else {
let b = ProgressBar::new(0);
b.set_style(
ProgressStyle::default_bar() ProgressStyle::default_bar()
.template(" {spinner:.blue} Syncing discussions [{bar:30.cyan/dim}] {pos}/{len}") .template(" {spinner:.blue} Syncing discussions [{bar:30.cyan/dim}] {pos}/{len}")
.unwrap() .unwrap()
.progress_chars("=> "), .progress_chars("=> "),
); );
b
};
// Create progress callback // Create progress callback (no-op in robot mode)
let spinner_clone = spinner.clone(); let spinner_clone = spinner.clone();
let disc_bar_clone = disc_bar.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 { Box::new(move |event: ProgressEvent| match event {
// Issue events
ProgressEvent::DiscussionSyncStarted { total } => { ProgressEvent::DiscussionSyncStarted { total } => {
spinner_clone.finish_and_clear(); spinner_clone.finish_and_clear();
disc_bar_clone.set_length(total as u64); disc_bar_clone.set_length(total as u64);
@@ -153,9 +210,23 @@ pub async fn run_ingest(
ProgressEvent::DiscussionSyncComplete => { ProgressEvent::DiscussionSyncComplete => {
disc_bar_clone.finish_and_clear(); 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();
}
_ => {} _ => {}
}); })
};
if resource_type == "issues" {
let result = ingest_project_issues_with_progress( let result = ingest_project_issues_with_progress(
&conn, &conn,
&client, &client,
@@ -169,8 +240,10 @@ pub async fn run_ingest(
spinner.finish_and_clear(); spinner.finish_and_clear();
disc_bar.finish_and_clear(); disc_bar.finish_and_clear();
// Print per-project summary // Print per-project summary (only in interactive mode)
print_project_summary(path, &result); if !robot_mode {
print_issue_project_summary(path, &result);
}
// Aggregate totals // Aggregate totals
total.projects_synced += 1; total.projects_synced += 1;
@@ -181,6 +254,39 @@ pub async fn run_ingest(
total.notes_upserted += result.notes_upserted; total.notes_upserted += result.notes_upserted;
total.issues_synced_discussions += result.issues_synced_discussions; total.issues_synced_discussions += result.issues_synced_discussions;
total.issues_skipped_discussion_sync += result.issues_skipped_discussion_sync; 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 // Lock is released on drop
@@ -219,8 +325,8 @@ fn get_projects_to_sync(
Ok(projects) Ok(projects)
} }
/// Print summary for a single project. /// Print summary for a single project (issues).
fn print_project_summary(path: &str, result: &IngestProjectResult) { fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
let labels_str = if result.labels_created > 0 { let labels_str = if result.labels_created > 0 {
format!(", {} new labels", result.labels_created) format!(", {} new labels", result.labels_created)
} else { } else {
@@ -249,9 +355,141 @@ fn print_project_summary(path: &str, result: &IngestProjectResult) {
} }
} }
/// 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!(
" {}: {} MRs fetched{}{}",
style(path).cyan(),
result.mrs_upserted,
labels_str,
assignees_str
);
if result.mrs_synced_discussions > 0 {
let diffnotes_str = if result.diffnotes_count > 0 {
format!(" ({} diff notes)", result.diffnotes_count)
} else {
String::new()
};
println!(
" {} 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<IngestIssueStats>,
#[serde(skip_serializing_if = "Option::is_none")]
merge_requests: Option<IngestMrStats>,
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. /// Print final summary.
pub fn print_ingest_summary(result: &IngestResult) { pub fn print_ingest_summary(result: &IngestResult) {
println!(); println!();
if result.resource_type == "issues" {
println!( println!(
"{}", "{}",
style(format!( style(format!(
@@ -271,4 +509,34 @@ pub fn print_ingest_summary(result: &IngestResult) {
.dim() .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()
);
}
}
} }

View File

@@ -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<String>,
pub project_path: String,
pub labels: Vec<String>,
pub assignees: Vec<String>,
pub reviewers: Vec<String>,
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<String>,
pub assignees: Vec<String>,
pub reviewers: Vec<String>,
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<String>,
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<MrListRow>,
pub total_count: usize,
}
/// JSON output structure for MRs.
#[derive(Serialize)]
pub struct MrListResultJson {
pub mrs: Vec<MrListRowJson>,
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 struct ListFilters<'a> {
pub limit: usize, pub limit: usize,
pub project: Option<&'a str>, pub project: Option<&'a str>,
@@ -106,6 +198,24 @@ pub struct ListFilters<'a> {
pub order: &'a str, 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. /// Run the list issues command.
pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResult> { pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResult> {
let db_path = get_db_path(config.storage.db_path.as_deref()); let db_path = get_db_path(config.storage.db_path.as_deref());
@@ -126,12 +236,12 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
params.push(Box::new(format!("%{project}%"))); params.push(Box::new(format!("%{project}%")));
} }
if let Some(state) = filters.state { if let Some(state) = filters.state
if state != "all" { && state != "all"
{
where_clauses.push("i.state = ?"); where_clauses.push("i.state = ?");
params.push(Box::new(state.to_string())); params.push(Box::new(state.to_string()));
} }
}
// Handle author filter (strip leading @ if present) // Handle author filter (strip leading @ if present)
if let Some(author) = filters.author { if let Some(author) = filters.author {
@@ -151,12 +261,12 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
} }
// Handle since filter // Handle since filter
if let Some(since_str) = filters.since { if let Some(since_str) = filters.since
if let Some(cutoff_ms) = parse_since(since_str) { && let Some(cutoff_ms) = parse_since(since_str)
{
where_clauses.push("i.updated_at >= ?"); where_clauses.push("i.updated_at >= ?");
params.push(Box::new(cutoff_ms)); params.push(Box::new(cutoff_ms));
} }
}
// Handle label filters (AND logic - all labels must be present) // Handle label filters (AND logic - all labels must be present)
if let Some(labels) = filters.labels { if let Some(labels) = filters.labels {
@@ -210,7 +320,11 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
"iid" => "i.iid", "iid" => "i.iid",
_ => "i.updated_at", // default _ => "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 // Get issues with enriched data
let query_sql = format!( let query_sql = format!(
@@ -251,7 +365,7 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&query_sql)?; let mut stmt = conn.prepare(&query_sql)?;
let issues = stmt let issues: Vec<IssueListRow> = stmt
.query_map(param_refs.as_slice(), |row| { .query_map(param_refs.as_slice(), |row| {
let labels_csv: Option<String> = row.get(8)?; let labels_csv: Option<String> = row.get(8)?;
let labels = labels_csv let labels = labels_csv
@@ -278,8 +392,7 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
unresolved_count: row.get(11)?, unresolved_count: row.get(11)?,
}) })
})? })?
.filter_map(|r| r.ok()) .collect::<std::result::Result<Vec<_>, _>>()?;
.collect();
Ok(ListResult { Ok(ListResult {
issues, issues,
@@ -287,6 +400,216 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
}) })
} }
/// Run the list MRs command.
pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result<MrListResult> {
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<MrListResult> {
// Build WHERE clause
let mut where_clauses = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = 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<MrListRow> = stmt
.query_map(param_refs.as_slice(), |row| {
let labels_csv: Option<String> = row.get(11)?;
let labels = labels_csv
.map(|s| s.split(',').map(String::from).collect())
.unwrap_or_default();
let assignees_csv: Option<String> = row.get(12)?;
let assignees = assignees_csv
.map(|s| s.split(',').map(String::from).collect())
.unwrap_or_default();
let reviewers_csv: Option<String> = 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::<std::result::Result<Vec<_>, _>>()?;
Ok(MrListResult { mrs, total_count })
}
/// Format relative time from ms epoch. /// Format relative time from ms epoch.
fn format_relative_time(ms_epoch: i64) -> String { fn format_relative_time(ms_epoch: i64) -> String {
let now = now_ms(); 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. /// Print issues list as a formatted table.
pub fn print_list_issues(result: &ListResult) { pub fn print_list_issues(result: &ListResult) {
if result.issues.is_empty() { if result.issues.is_empty() {
@@ -441,6 +770,96 @@ pub fn open_issue_in_browser(result: &ListResult) -> Option<String> {
} }
} }
/// 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<String> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -10,12 +10,16 @@ pub mod show;
pub mod sync_status; pub mod sync_status;
pub use auth_test::run_auth_test; 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 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 init::{InitInputs, InitOptions, InitResult, run_init};
pub use list::{ 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 show::{
pub use sync_status::{print_sync_status, run_sync_status}; 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};

View File

@@ -2,6 +2,7 @@
use console::style; use console::style;
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize;
use crate::Config; use crate::Config;
use crate::core::db::create_connection; 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::paths::get_db_path;
use crate::core::time::ms_to_iso; 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<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,
pub merged_at: Option<i64>,
pub closed_at: Option<i64>,
pub web_url: Option<String>,
pub project_path: String,
pub labels: Vec<String>,
pub assignees: Vec<String>,
pub reviewers: Vec<String>,
pub discussions: Vec<MrDiscussionDetail>,
}
/// MR discussion detail for display.
#[derive(Debug, Serialize)]
pub struct MrDiscussionDetail {
pub notes: Vec<MrNoteDetail>,
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<DiffNotePosition>,
}
/// DiffNote position context for display.
#[derive(Debug, Clone, Serialize)]
pub struct DiffNotePosition {
pub old_path: Option<String>,
pub new_path: Option<String>,
pub old_line: Option<i64>,
pub new_line: Option<i64>,
pub position_type: Option<String>,
}
/// Issue metadata for display. /// Issue metadata for display.
#[derive(Debug)] #[derive(Debug, Serialize)]
pub struct IssueDetail { pub struct IssueDetail {
pub id: i64, pub id: i64,
pub iid: i64, pub iid: i64,
@@ -27,14 +79,14 @@ pub struct IssueDetail {
} }
/// Discussion detail for display. /// Discussion detail for display.
#[derive(Debug)] #[derive(Debug, Serialize)]
pub struct DiscussionDetail { pub struct DiscussionDetail {
pub notes: Vec<NoteDetail>, pub notes: Vec<NoteDetail>,
pub individual_note: bool, pub individual_note: bool,
} }
/// Note detail for display. /// Note detail for display.
#[derive(Debug)] #[derive(Debug, Serialize)]
pub struct NoteDetail { pub struct NoteDetail {
pub author_username: String, pub author_username: String,
pub body: 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)?, project_path: row.get(9)?,
}) })
})? })?
.filter_map(|r| r.ok()) .collect::<std::result::Result<Vec<_>, _>>()?;
.collect();
match issues.len() { match issues.len() {
0 => Err(GiError::NotFound(format!("Issue #{} not found", iid))), 0 => Err(GiError::NotFound(format!("Issue #{} not found", iid))),
@@ -155,10 +206,9 @@ fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {
ORDER BY l.name", ORDER BY l.name",
)?; )?;
let labels = stmt let labels: Vec<String> = stmt
.query_map([issue_id], |row| row.get(0))? .query_map([issue_id], |row| row.get(0))?
.filter_map(|r| r.ok()) .collect::<std::result::Result<Vec<_>, _>>()?;
.collect();
Ok(labels) Ok(labels)
} }
@@ -177,8 +227,7 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
let individual: i64 = row.get(1)?; let individual: i64 = row.get(1)?;
Ok((row.get(0)?, individual == 1)) Ok((row.get(0)?, individual == 1))
})? })?
.filter_map(|r| r.ok()) .collect::<std::result::Result<Vec<_>, _>>()?;
.collect();
// Then get notes for each discussion // Then get notes for each discussion
let mut note_stmt = conn.prepare( let mut note_stmt = conn.prepare(
@@ -200,8 +249,7 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
is_system: is_system == 1, is_system: is_system == 1,
}) })
})? })?
.filter_map(|r| r.ok()) .collect::<std::result::Result<Vec<_>, _>>()?;
.collect();
// Filter out discussions with only system notes // Filter out discussions with only system notes
let has_user_notes = notes.iter().any(|n| !n.is_system); 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<Vec<Discuss
Ok(discussions) Ok(discussions)
} }
/// Run the show MR command.
pub fn run_show_mr(config: &Config, iid: i64, project_filter: Option<&str>) -> Result<MrDetail> {
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<String>,
state: String,
draft: bool,
author_username: String,
source_branch: String,
target_branch: String,
created_at: i64,
updated_at: i64,
merged_at: Option<i64>,
closed_at: Option<i64>,
web_url: Option<String>,
project_path: String,
}
/// Find MR by iid, optionally filtered by project.
fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<MrRow> {
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = 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<MrRow> = 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::<std::result::Result<Vec<_>, _>>()?;
match mrs.len() {
0 => Err(GiError::NotFound(format!("MR !{} not found", iid))),
1 => Ok(mrs.into_iter().next().unwrap()),
_ => {
let projects: Vec<String> = 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<Vec<String>> {
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<String> = stmt
.query_map([mr_id], |row| row.get(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(labels)
}
/// Get assignees for an MR.
fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT username FROM mr_assignees
WHERE merge_request_id = ?
ORDER BY username",
)?;
let assignees: Vec<String> = stmt
.query_map([mr_id], |row| row.get(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(assignees)
}
/// Get reviewers for an MR.
fn get_mr_reviewers(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT username FROM mr_reviewers
WHERE merge_request_id = ?
ORDER BY username",
)?;
let reviewers: Vec<String> = stmt
.query_map([mr_id], |row| row.get(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(reviewers)
}
/// Get discussions with notes for an MR.
fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionDetail>> {
// 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::<std::result::Result<Vec<_>, _>>()?;
// 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<MrNoteDetail> = note_stmt
.query_map([disc_id], |row| {
let is_system: i64 = row.get(3)?;
let old_path: Option<String> = row.get(4)?;
let new_path: Option<String> = row.get(5)?;
let old_line: Option<i64> = row.get(6)?;
let new_line: Option<i64> = row.get(7)?;
let position_type: Option<String> = 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::<std::result::Result<Vec<_>, _>>()?;
// 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. /// Format date from ms epoch.
fn format_date(ms: i64) -> String { fn format_date(ms: i64) -> String {
let iso = ms_to_iso(ms); 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() 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 { fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len { if s.chars().count() <= max_len {
s.to_string() s.to_string()
} else { } 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::<Vec<_>>()
.join(", ")
);
}
if !mr.reviewers.is_empty() {
println!(
"Reviewers: {}",
mr.reviewers
.iter()
.map(|r| format!("@{}", r))
.collect::<Vec<_>>()
.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<String>,
pub state: String,
pub author_username: String,
pub created_at: String,
pub updated_at: String,
pub web_url: Option<String>,
pub project_path: String,
pub labels: Vec<String>,
pub discussions: Vec<DiscussionDetailJson>,
}
/// JSON output for discussion detail.
#[derive(Serialize)]
pub struct DiscussionDetailJson {
pub notes: Vec<NoteDetailJson>,
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<String>,
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<String>,
pub closed_at: Option<String>,
pub web_url: Option<String>,
pub project_path: String,
pub labels: Vec<String>,
pub assignees: Vec<String>,
pub reviewers: Vec<String>,
pub discussions: Vec<MrDiscussionDetailJson>,
}
/// JSON output for MR discussion detail.
#[derive(Serialize)]
pub struct MrDiscussionDetailJson {
pub notes: Vec<MrNoteDetailJson>,
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<DiffNotePosition>,
}
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -2,6 +2,7 @@
use console::style; use console::style;
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize;
use crate::Config; use crate::Config;
use crate::core::db::create_connection; use crate::core::db::create_connection;
@@ -175,6 +176,95 @@ fn format_number(n: i64) -> String {
result result
} }
/// JSON output structures for robot mode.
#[derive(Serialize)]
struct SyncStatusJsonOutput {
ok: bool,
data: SyncStatusJsonData,
}
#[derive(Serialize)]
struct SyncStatusJsonData {
last_sync: Option<SyncRunJsonInfo>,
cursors: Vec<CursorJsonInfo>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
duration_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
struct CursorJsonInfo {
project: String,
resource_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
updated_at_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tie_breaker_id: Option<i64>,
}
#[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. /// Print sync status result.
pub fn print_sync_status(result: &SyncStatusResult) { pub fn print_sync_status(result: &SyncStatusResult) {
// Last Sync section // Last Sync section