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