feat(cli): Add MR support to list/show/count/ingest commands

Extends all data commands to support merge requests alongside issues,
with consistent patterns and JSON output for robot mode.

List command (gi list mrs):
- MR-specific columns: branches, draft status, reviewers
- Filters: --state (opened|merged|closed|locked|all), --draft,
  --no-draft, --reviewer, --target-branch, --source-branch
- Discussion count with unresolved indicator (e.g., "5/2!")
- JSON output includes full MR metadata

Show command (gi show mr <iid>):
- MR details with branches, assignees, reviewers, merge status
- DiffNote positions showing file:line for code review comments
- Full description and discussion bodies (no truncation in JSON)
- --json flag for structured output with ISO timestamps

Count command (gi count mrs):
- MR counting with optional --type filter for discussions/notes
- JSON output with breakdown by state

Ingest command (gi ingest --type mrs):
- Full MR sync with discussion prefetch
- Progress output shows MR-specific metrics (diffnotes count)
- JSON summary with comprehensive sync statistics

All commands respect global --robot mode for auto-JSON output.
The pattern "gi list mrs --json | jq '.mrs[] | .iid'" now works
for scripted MR processing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-01-26 22:46:59 -05:00
parent 7d0d586932
commit 8ddc974b89
6 changed files with 1675 additions and 127 deletions

View File

@@ -2,6 +2,7 @@
use console::style;
use rusqlite::Connection;
use serde::Serialize;
use crate::Config;
use crate::core::db::create_connection;
@@ -175,6 +176,95 @@ fn format_number(n: i64) -> String {
result
}
/// JSON output structures for robot mode.
#[derive(Serialize)]
struct SyncStatusJsonOutput {
ok: bool,
data: SyncStatusJsonData,
}
#[derive(Serialize)]
struct SyncStatusJsonData {
last_sync: Option<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.
pub fn print_sync_status(result: &SyncStatusResult) {
// Last Sync section