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;
@@ -12,7 +13,16 @@ use crate::core::paths::get_db_path;
pub struct CountResult {
pub entity: String,
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.
@@ -24,30 +34,83 @@ pub fn run_count(config: &Config, entity: &str, type_filter: Option<&str>) -> Re
"issues" => count_issues(&conn),
"discussions" => count_discussions(&conn, type_filter),
"notes" => count_notes(&conn, type_filter),
"mrs" => {
// Placeholder for CP2
Ok(CountResult {
entity: "Merge Requests".to_string(),
count: 0,
system_count: None,
})
}
"mrs" => count_mrs(&conn),
_ => Ok(CountResult {
entity: entity.to_string(),
count: 0,
system_count: None,
state_breakdown: None,
}),
}
}
/// Count issues.
/// Count issues with state breakdown.
fn count_issues(conn: &Connection) -> Result<CountResult> {
let count: i64 = conn.query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))?;
let opened: i64 = conn.query_row(
"SELECT COUNT(*) FROM issues WHERE state = 'opened'",
[],
|row| row.get(0),
)?;
let closed: i64 = conn.query_row(
"SELECT COUNT(*) FROM issues WHERE state = 'closed'",
[],
|row| row.get(0),
)?;
Ok(CountResult {
entity: "Issues".to_string(),
count,
system_count: None,
state_breakdown: Some(StateBreakdown {
opened,
closed,
merged: None,
locked: None,
}),
})
}
/// Count merge requests with state breakdown.
fn count_mrs(conn: &Connection) -> Result<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(),
count,
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(),
count: non_system,
system_count: Some(system_count),
state_breakdown: None,
})
}
@@ -145,6 +210,55 @@ fn format_number(n: i64) -> String {
result
}
/// JSON output structure for count command.
#[derive(Serialize)]
struct CountJsonOutput {
ok: bool,
data: CountJsonData,
}
#[derive(Serialize)]
struct CountJsonData {
entity: String,
count: i64,
#[serde(skip_serializing_if = "Option::is_none")]
system_excluded: Option<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.
pub fn print_count(result: &CountResult) {
let count_str = format_number(result.count);
@@ -153,7 +267,7 @@ pub fn print_count(result: &CountResult) {
println!(
"{}: {} {}",
style(&result.entity).cyan(),
style(count_str).bold(),
style(&count_str).bold(),
style(format!(
"(excluding {} system)",
format_number(system_count)
@@ -164,9 +278,23 @@ pub fn print_count(result: &CountResult) {
println!(
"{}: {}",
style(&result.entity).cyan(),
style(count_str).bold()
style(&count_str).bold()
);
}
// Print state breakdown if available
if let Some(breakdown) = &result.state_breakdown {
println!(" opened: {}", format_number(breakdown.opened));
if let Some(merged) = breakdown.merged {
println!(" merged: {}", format_number(merged));
}
println!(" closed: {}", format_number(breakdown.closed));
if let Some(locked) = breakdown.locked
&& locked > 0
{
println!(" locked: {}", format_number(locked));
}
}
}
#[cfg(test)]