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>
947 lines
29 KiB
Rust
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!");
|
|
}
|
|
}
|