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:
@@ -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 limit: usize,
|
||||
pub project: Option<&'a str>,
|
||||
@@ -106,6 +198,24 @@ pub struct ListFilters<'a> {
|
||||
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.
|
||||
pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResult> {
|
||||
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}%")));
|
||||
}
|
||||
|
||||
if let Some(state) = filters.state {
|
||||
if state != "all" {
|
||||
where_clauses.push("i.state = ?");
|
||||
params.push(Box::new(state.to_string()));
|
||||
}
|
||||
if let Some(state) = filters.state
|
||||
&& state != "all"
|
||||
{
|
||||
where_clauses.push("i.state = ?");
|
||||
params.push(Box::new(state.to_string()));
|
||||
}
|
||||
|
||||
// Handle author filter (strip leading @ if present)
|
||||
@@ -151,11 +261,11 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
|
||||
}
|
||||
|
||||
// Handle since filter
|
||||
if let Some(since_str) = filters.since {
|
||||
if let Some(cutoff_ms) = parse_since(since_str) {
|
||||
where_clauses.push("i.updated_at >= ?");
|
||||
params.push(Box::new(cutoff_ms));
|
||||
}
|
||||
if let Some(since_str) = filters.since
|
||||
&& let Some(cutoff_ms) = parse_since(since_str)
|
||||
{
|
||||
where_clauses.push("i.updated_at >= ?");
|
||||
params.push(Box::new(cutoff_ms));
|
||||
}
|
||||
|
||||
// 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",
|
||||
_ => "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
|
||||
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 mut stmt = conn.prepare(&query_sql)?;
|
||||
let issues = stmt
|
||||
let issues: Vec<IssueListRow> = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
let labels_csv: Option<String> = row.get(8)?;
|
||||
let labels = labels_csv
|
||||
@@ -278,8 +392,7 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
|
||||
unresolved_count: row.get(11)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ListResult {
|
||||
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.
|
||||
fn format_relative_time(ms_epoch: i64) -> String {
|
||||
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.
|
||||
pub fn print_list_issues(result: &ListResult) {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user