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:
@@ -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;
|
||||||
@@ -12,7 +13,16 @@ use crate::core::paths::get_db_path;
|
|||||||
pub struct CountResult {
|
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)]
|
||||||
|
|||||||
@@ -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 {
|
||||||
println!(
|
if !robot_mode {
|
||||||
"{}",
|
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" {
|
||||||
conn.execute(
|
// Reset issue discussion watermarks first so discussions get re-synced
|
||||||
"UPDATE issues SET discussions_synced_for_updated_at = NULL WHERE project_id = ?",
|
conn.execute(
|
||||||
[*local_project_id],
|
"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
|
// 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" {
|
||||||
println!();
|
"issues"
|
||||||
|
} else {
|
||||||
|
"merge requests"
|
||||||
|
};
|
||||||
|
if !robot_mode {
|
||||||
|
println!("{}", style(format!("Ingesting {type_label}...")).blue());
|
||||||
|
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()
|
||||||
ProgressStyle::default_spinner()
|
} else {
|
||||||
.template("{spinner:.blue} {msg}")
|
let s = ProgressBar::new_spinner();
|
||||||
.unwrap(),
|
s.set_style(
|
||||||
);
|
ProgressStyle::default_spinner()
|
||||||
spinner.set_message(format!("Fetching issues from {path}..."));
|
.template("{spinner:.blue} {msg}")
|
||||||
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
|
.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)
|
// 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()
|
||||||
ProgressStyle::default_bar()
|
} else {
|
||||||
.template(" {spinner:.blue} Syncing discussions [{bar:30.cyan/dim}] {pos}/{len}")
|
let b = ProgressBar::new(0);
|
||||||
.unwrap()
|
b.set_style(
|
||||||
.progress_chars("=> "),
|
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 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,34 +210,83 @@ 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();
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
});
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let result = ingest_project_issues_with_progress(
|
if resource_type == "issues" {
|
||||||
&conn,
|
let result = ingest_project_issues_with_progress(
|
||||||
&client,
|
&conn,
|
||||||
config,
|
&client,
|
||||||
*local_project_id,
|
config,
|
||||||
*gitlab_project_id,
|
*local_project_id,
|
||||||
Some(progress_callback),
|
*gitlab_project_id,
|
||||||
)
|
Some(progress_callback),
|
||||||
.await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
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;
|
||||||
total.issues_fetched += result.issues_fetched;
|
total.issues_fetched += result.issues_fetched;
|
||||||
total.issues_upserted += result.issues_upserted;
|
total.issues_upserted += result.issues_upserted;
|
||||||
total.labels_created += result.labels_created;
|
total.labels_created += result.labels_created;
|
||||||
total.discussions_fetched += result.discussions_fetched;
|
total.discussions_fetched += result.discussions_fetched;
|
||||||
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,26 +355,188 @@ fn print_project_summary(path: &str, result: &IngestProjectResult) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print final summary.
|
/// Print summary for a single project (merge requests).
|
||||||
pub fn print_ingest_summary(result: &IngestResult) {
|
fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
|
||||||
println!();
|
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!(
|
println!(
|
||||||
"{}",
|
" {}: {} MRs fetched{}{}",
|
||||||
style(format!(
|
style(path).cyan(),
|
||||||
"Total: {} issues, {} discussions, {} notes",
|
result.mrs_upserted,
|
||||||
result.issues_upserted, result.discussions_fetched, result.notes_upserted
|
labels_str,
|
||||||
))
|
assignees_str
|
||||||
.green()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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!(
|
println!(
|
||||||
"{}",
|
" {} MRs -> {} discussions, {} notes{}",
|
||||||
style(format!(
|
result.mrs_synced_discussions,
|
||||||
"Skipped discussion sync for {} unchanged issues.",
|
result.discussions_fetched,
|
||||||
result.issues_skipped_discussion_sync
|
result.notes_upserted,
|
||||||
))
|
diffnotes_str
|
||||||
.dim()
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,11 +236,11 @@ 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 = ?");
|
{
|
||||||
params.push(Box::new(state.to_string()));
|
where_clauses.push("i.state = ?");
|
||||||
}
|
params.push(Box::new(state.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle author filter (strip leading @ if present)
|
// Handle author filter (strip leading @ if present)
|
||||||
@@ -151,11 +261,11 @@ 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 >= ?");
|
{
|
||||||
params.push(Box::new(cutoff_ms));
|
where_clauses.push("i.updated_at >= ?");
|
||||||
}
|
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)
|
||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user