Files
gitlore/src/cli/commands/list.rs
Taylor Eernisse 753ff46bb4 fix(cli): Correct project filtering and GROUP_CONCAT delimiter
Two SQL correctness issues fixed:

1. Project filter used LIKE '%term%' which caused partial matches
   (e.g. filtering for "foo" matched "group/foobar"). Now uses
   exact match OR suffix match after '/' so "foo" matches
   "group/foo" but not "group/foobar".

2. GROUP_CONCAT used comma as delimiter for labels and assignees,
   which broke parsing when label names themselves contained commas.
   Switched to ASCII unit separator (0x1F) which cannot appear in
   GitLab entity names.

Also adds a guard for negative time deltas in format_relative_time
to handle clock skew gracefully instead of panicking.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:41:56 -05:00

947 lines
29 KiB
Rust

//! List command - display issues/MRs from local database.
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
use rusqlite::Connection;
use serde::Serialize;
use crate::Config;
use crate::core::db::create_connection;
use crate::core::error::Result;
use crate::core::paths::get_db_path;
use crate::core::time::{ms_to_iso, now_ms, parse_since};
/// Issue row for display.
#[derive(Debug, Serialize)]
pub struct IssueListRow {
pub iid: i64,
pub title: String,
pub state: String,
pub author_username: 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 discussion_count: i64,
pub unresolved_count: i64,
}
/// Serializable version for JSON output.
#[derive(Serialize)]
pub struct IssueListRowJson {
pub iid: i64,
pub title: String,
pub state: String,
pub author_username: String,
pub labels: Vec<String>,
pub assignees: 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<&IssueListRow> for IssueListRowJson {
fn from(row: &IssueListRow) -> Self {
Self {
iid: row.iid,
title: row.title.clone(),
state: row.state.clone(),
author_username: row.author_username.clone(),
labels: row.labels.clone(),
assignees: row.assignees.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 list query.
#[derive(Serialize)]
pub struct ListResult {
pub issues: Vec<IssueListRow>,
pub total_count: usize,
}
/// JSON output structure.
#[derive(Serialize)]
pub struct ListResultJson {
pub issues: Vec<IssueListRowJson>,
pub total_count: usize,
pub showing: usize,
}
impl From<&ListResult> for ListResultJson {
fn from(result: &ListResult) -> Self {
Self {
issues: result.issues.iter().map(IssueListRowJson::from).collect(),
total_count: result.total_count,
showing: result.issues.len(),
}
}
}
/// 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>,
pub state: Option<&'a str>,
pub author: Option<&'a str>,
pub assignee: Option<&'a str>,
pub labels: Option<&'a [String]>,
pub milestone: Option<&'a str>,
pub since: Option<&'a str>,
pub due_before: Option<&'a str>,
pub has_due_date: bool,
pub sort: &'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.
pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResult> {
let db_path = get_db_path(config.storage.db_path.as_deref());
let conn = create_connection(&db_path)?;
let result = query_issues(&conn, &filters)?;
Ok(result)
}
/// Query issues from database with enriched data.
fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult> {
// 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 {
// Exact match or suffix match after '/' to avoid partial matches
// e.g. "foo" matches "group/foo" but NOT "group/foobar"
where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)");
params.push(Box::new(project.to_string()));
params.push(Box::new(format!("%/{project}")));
}
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)
if let Some(author) = filters.author {
let username = author.strip_prefix('@').unwrap_or(author);
where_clauses.push("i.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 issue_assignees ia
WHERE ia.issue_id = i.id AND ia.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("i.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 issue_labels il
JOIN labels l ON il.label_id = l.id
WHERE il.issue_id = i.id AND l.name = ?)",
);
params.push(Box::new(label.clone()));
}
}
// Handle milestone filter
if let Some(milestone) = filters.milestone {
where_clauses.push("i.milestone_title = ?");
params.push(Box::new(milestone.to_string()));
}
// Handle due_before filter
if let Some(due_before) = filters.due_before {
where_clauses.push("i.due_date IS NOT NULL AND i.due_date <= ?");
params.push(Box::new(due_before.to_string()));
}
// Handle has_due_date filter
if filters.has_due_date {
where_clauses.push("i.due_date IS NOT NULL");
}
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 issues i
JOIN projects p ON i.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" => "i.created_at",
"iid" => "i.iid",
_ => "i.updated_at", // default
};
let order = if filters.order == "asc" {
"ASC"
} else {
"DESC"
};
// Get issues with enriched data
let query_sql = format!(
"SELECT
i.iid,
i.title,
i.state,
i.author_username,
i.created_at,
i.updated_at,
i.web_url,
p.path_with_namespace,
(SELECT GROUP_CONCAT(l.name, X'1F')
FROM issue_labels il
JOIN labels l ON il.label_id = l.id
WHERE il.issue_id = i.id) AS labels_csv,
(SELECT GROUP_CONCAT(ia.username, X'1F')
FROM issue_assignees ia
WHERE ia.issue_id = i.id) AS assignees_csv,
COALESCE(d.total, 0) AS discussion_count,
COALESCE(d.unresolved, 0) AS unresolved_count
FROM issues i
JOIN projects p ON i.project_id = p.id
LEFT JOIN (
SELECT issue_id,
COUNT(*) as total,
SUM(CASE WHEN resolvable = 1 AND resolved = 0 THEN 1 ELSE 0 END) as unresolved
FROM discussions
WHERE issue_id IS NOT NULL
GROUP BY issue_id
) d ON d.issue_id = i.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 issues: Vec<IssueListRow> = stmt
.query_map(param_refs.as_slice(), |row| {
let labels_csv: Option<String> = row.get(8)?;
let labels = labels_csv
.map(|s| s.split('\x1F').map(String::from).collect())
.unwrap_or_default();
let assignees_csv: Option<String> = row.get(9)?;
let assignees = assignees_csv
.map(|s| s.split('\x1F').map(String::from).collect())
.unwrap_or_default();
Ok(IssueListRow {
iid: row.get(0)?,
title: row.get(1)?,
state: row.get(2)?,
author_username: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
web_url: row.get(6)?,
project_path: row.get(7)?,
labels,
assignees,
discussion_count: row.get(10)?,
unresolved_count: row.get(11)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(ListResult {
issues,
total_count,
})
}
/// 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 {
// Exact match or suffix match after '/' to avoid partial matches
// e.g. "foo" matches "group/foo" but NOT "group/foobar"
where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)");
params.push(Box::new(project.to_string()));
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, X'1F')
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, X'1F')
FROM mr_assignees ma
WHERE ma.merge_request_id = m.id) AS assignees_csv,
(SELECT GROUP_CONCAT(mr.username, X'1F')
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('\x1F').map(String::from).collect())
.unwrap_or_default();
let assignees_csv: Option<String> = row.get(12)?;
let assignees = assignees_csv
.map(|s| s.split('\x1F').map(String::from).collect())
.unwrap_or_default();
let reviewers_csv: Option<String> = row.get(13)?;
let reviewers = reviewers_csv
.map(|s| s.split('\x1F').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();
let diff = now - ms_epoch;
if diff < 0 {
return "in the future".to_string();
}
match diff {
d if d < 60_000 => "just now".to_string(),
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
d if d < 86_400_000 => format!("{} hours ago", d / 3_600_000),
d if d < 604_800_000 => format!("{} days ago", d / 86_400_000),
d if d < 2_592_000_000 => format!("{} weeks ago", d / 604_800_000),
_ => format!("{} months ago", diff / 2_592_000_000),
}
}
/// Truncate string to max width with ellipsis.
fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
if s.chars().count() <= max_width {
s.to_string()
} else {
let truncated: String = s.chars().take(max_width.saturating_sub(3)).collect();
format!("{truncated}...")
}
}
/// Format labels for display: [bug, urgent +2]
fn format_labels(labels: &[String], max_shown: usize) -> String {
if labels.is_empty() {
return String::new();
}
let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect();
let overflow = labels.len().saturating_sub(max_shown);
if overflow > 0 {
format!("[{} +{}]", shown.join(", "), overflow)
} else {
format!("[{}]", shown.join(", "))
}
}
/// Format assignees for display: @user1, @user2 +1
fn format_assignees(assignees: &[String]) -> String {
if assignees.is_empty() {
return "-".to_string();
}
let max_shown = 2;
let shown: Vec<String> = assignees
.iter()
.take(max_shown)
.map(|s| format!("@{}", truncate_with_ellipsis(s, 10)))
.collect();
let overflow = assignees.len().saturating_sub(max_shown);
if overflow > 0 {
format!("{} +{}", shown.join(", "), overflow)
} else {
shown.join(", ")
}
}
/// Format discussion count: "3/1!" (3 total, 1 unresolved)
fn format_discussions(total: i64, unresolved: i64) -> String {
if total == 0 {
return String::new();
}
if unresolved > 0 {
format!("{total}/{unresolved}!")
} else {
format!("{total}")
}
}
/// 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() {
println!("No issues found.");
return;
}
println!(
"Issues (showing {} of {})\n",
result.issues.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("Assignee").add_attribute(Attribute::Bold),
Cell::new("Labels").add_attribute(Attribute::Bold),
Cell::new("Disc").add_attribute(Attribute::Bold),
Cell::new("Updated").add_attribute(Attribute::Bold),
]);
for issue in &result.issues {
let title = truncate_with_ellipsis(&issue.title, 45);
let relative_time = format_relative_time(issue.updated_at);
let labels = format_labels(&issue.labels, 2);
let assignee = format_assignees(&issue.assignees);
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
let state_cell = if issue.state == "opened" {
Cell::new(&issue.state).fg(Color::Green)
} else {
Cell::new(&issue.state).fg(Color::DarkGrey)
};
table.add_row(vec![
Cell::new(format!("#{}", issue.iid)).fg(Color::Cyan),
Cell::new(title),
state_cell,
Cell::new(assignee).fg(Color::Magenta),
Cell::new(labels).fg(Color::Yellow),
Cell::new(discussions),
Cell::new(relative_time).fg(Color::DarkGrey),
]);
}
println!("{table}");
}
/// Print issues list as JSON.
pub fn print_list_issues_json(result: &ListResult) {
let json_result = ListResultJson::from(result);
match serde_json::to_string_pretty(&json_result) {
Ok(json) => println!("{json}"),
Err(e) => eprintln!("Error serializing to JSON: {e}"),
}
}
/// Open issue in browser. Returns the URL that was opened.
pub fn open_issue_in_browser(result: &ListResult) -> Option<String> {
let first_issue = result.issues.first()?;
let url = first_issue.web_url.as_ref()?;
match open::that(url) {
Ok(()) => {
println!("Opened: {url}");
Some(url.clone())
}
Err(e) => {
eprintln!("Failed to open browser: {e}");
None
}
}
}
/// 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::*;
#[test]
fn truncate_leaves_short_strings_alone() {
assert_eq!(truncate_with_ellipsis("short", 10), "short");
}
#[test]
fn truncate_adds_ellipsis_to_long_strings() {
assert_eq!(
truncate_with_ellipsis("this is a very long title", 15),
"this is a ve..."
);
}
#[test]
fn truncate_handles_exact_length() {
assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!");
}
#[test]
fn relative_time_formats_correctly() {
let now = now_ms();
assert_eq!(format_relative_time(now - 30_000), "just now"); // 30s ago
assert_eq!(format_relative_time(now - 120_000), "2 min ago"); // 2 min ago
assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago"); // 2 hours ago
assert_eq!(format_relative_time(now - 172_800_000), "2 days ago"); // 2 days ago
}
#[test]
fn format_labels_empty() {
assert_eq!(format_labels(&[], 2), "");
}
#[test]
fn format_labels_single() {
assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]");
}
#[test]
fn format_labels_multiple() {
let labels = vec!["bug".to_string(), "urgent".to_string()];
assert_eq!(format_labels(&labels, 2), "[bug, urgent]");
}
#[test]
fn format_labels_overflow() {
let labels = vec![
"bug".to_string(),
"urgent".to_string(),
"wip".to_string(),
"blocked".to_string(),
];
assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]");
}
#[test]
fn format_discussions_empty() {
assert_eq!(format_discussions(0, 0), "");
}
#[test]
fn format_discussions_no_unresolved() {
assert_eq!(format_discussions(5, 0), "5");
}
#[test]
fn format_discussions_with_unresolved() {
assert_eq!(format_discussions(5, 2), "5/2!");
}
}