(Supersedes empty commit f3788eb — jj auto-snapshot race.)
Three related refinements to how work item status is presented:
1. available_statuses in meta (list.rs, main.rs):
Robot-mode issue list responses now include meta.available_statuses —
a sorted array of all distinct status_name values in the database.
Agents can use this to validate --status filter values or display
valid options without a separate query.
2. Hide status_category from JSON (list.rs, show.rs):
status_category is a GitLab internal classification that duplicates
the state field. Switched to skip_serializing so it never appears
in JSON output while remaining available internally.
3. Simplify human-readable status display (show.rs):
Removed the "(category)" parenthetical from the Status line.
4. robot-docs schema updates (main.rs):
Documented --status filter semantics and meta.available_statuses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1043 lines
32 KiB
Rust
1043 lines
32 KiB
Rust
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
|
|
use rusqlite::Connection;
|
|
use serde::Serialize;
|
|
|
|
use crate::Config;
|
|
use crate::cli::robot::{RobotMeta, expand_fields_preset, filter_fields};
|
|
use crate::core::db::create_connection;
|
|
use crate::core::error::{LoreError, Result};
|
|
use crate::core::paths::get_db_path;
|
|
use crate::core::project::resolve_project;
|
|
use crate::core::time::{ms_to_iso, now_ms, parse_since};
|
|
|
|
fn colored_cell(content: impl std::fmt::Display, color: Color) -> Cell {
|
|
let cell = Cell::new(content);
|
|
if console::colors_enabled() {
|
|
cell.fg(color)
|
|
} else {
|
|
cell
|
|
}
|
|
}
|
|
|
|
fn colored_cell_hex(content: &str, hex: Option<&str>) -> Cell {
|
|
if !console::colors_enabled() {
|
|
return Cell::new(content);
|
|
}
|
|
let Some(hex) = hex else {
|
|
return Cell::new(content);
|
|
};
|
|
let hex = hex.trim_start_matches('#');
|
|
if hex.len() != 6 {
|
|
return Cell::new(content);
|
|
}
|
|
let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else {
|
|
return Cell::new(content);
|
|
};
|
|
let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else {
|
|
return Cell::new(content);
|
|
};
|
|
let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else {
|
|
return Cell::new(content);
|
|
};
|
|
Cell::new(content).fg(Color::Rgb { r, g, b })
|
|
}
|
|
|
|
#[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,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub status_name: Option<String>,
|
|
#[serde(skip_serializing)]
|
|
pub status_category: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub status_color: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub status_icon_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub status_synced_at: Option<i64>,
|
|
}
|
|
|
|
#[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,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub status_name: Option<String>,
|
|
#[serde(skip_serializing)]
|
|
pub status_category: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub status_color: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub status_icon_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub status_synced_at_iso: Option<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(),
|
|
status_name: row.status_name.clone(),
|
|
status_category: row.status_category.clone(),
|
|
status_color: row.status_color.clone(),
|
|
status_icon_name: row.status_icon_name.clone(),
|
|
status_synced_at_iso: row.status_synced_at.map(ms_to_iso),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ListResult {
|
|
pub issues: Vec<IssueListRow>,
|
|
pub total_count: usize,
|
|
pub available_statuses: Vec<String>,
|
|
}
|
|
|
|
#[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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
#[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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct MrListResult {
|
|
pub mrs: Vec<MrListRow>,
|
|
pub total_count: usize,
|
|
}
|
|
|
|
#[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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
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 statuses: &'a [String],
|
|
pub sort: &'a str,
|
|
pub order: &'a str,
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
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 mut result = query_issues(&conn, &filters)?;
|
|
result.available_statuses = query_available_statuses(&conn)?;
|
|
Ok(result)
|
|
}
|
|
|
|
fn query_available_statuses(conn: &Connection) -> Result<Vec<String>> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT DISTINCT status_name FROM issues WHERE status_name IS NOT NULL ORDER BY status_name",
|
|
)?;
|
|
let statuses = stmt
|
|
.query_map([], |row| row.get::<_, String>(0))?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
Ok(statuses)
|
|
}
|
|
|
|
fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult> {
|
|
let mut where_clauses = Vec::new();
|
|
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
|
|
if let Some(project) = filters.project {
|
|
let project_id = resolve_project(conn, project)?;
|
|
where_clauses.push("i.project_id = ?");
|
|
params.push(Box::new(project_id));
|
|
}
|
|
|
|
if let Some(state) = filters.state
|
|
&& state != "all"
|
|
{
|
|
where_clauses.push("i.state = ?");
|
|
params.push(Box::new(state.to_string()));
|
|
}
|
|
|
|
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()));
|
|
}
|
|
|
|
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()));
|
|
}
|
|
|
|
if let Some(since_str) = filters.since {
|
|
let cutoff_ms = parse_since(since_str).ok_or_else(|| {
|
|
LoreError::Other(format!(
|
|
"Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
|
|
since_str
|
|
))
|
|
})?;
|
|
where_clauses.push("i.updated_at >= ?");
|
|
params.push(Box::new(cutoff_ms));
|
|
}
|
|
|
|
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()));
|
|
}
|
|
}
|
|
|
|
if let Some(milestone) = filters.milestone {
|
|
where_clauses.push("i.milestone_title = ?");
|
|
params.push(Box::new(milestone.to_string()));
|
|
}
|
|
|
|
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()));
|
|
}
|
|
|
|
if filters.has_due_date {
|
|
where_clauses.push("i.due_date IS NOT NULL");
|
|
}
|
|
|
|
let status_in_clause;
|
|
if filters.statuses.len() == 1 {
|
|
where_clauses.push("i.status_name = ? COLLATE NOCASE");
|
|
params.push(Box::new(filters.statuses[0].clone()));
|
|
} else if filters.statuses.len() > 1 {
|
|
let placeholders: Vec<&str> = filters.statuses.iter().map(|_| "?").collect();
|
|
status_in_clause = format!(
|
|
"i.status_name COLLATE NOCASE IN ({})",
|
|
placeholders.join(", ")
|
|
);
|
|
where_clauses.push(&status_in_clause);
|
|
for s in filters.statuses {
|
|
params.push(Box::new(s.clone()));
|
|
}
|
|
}
|
|
|
|
let where_sql = if where_clauses.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("WHERE {}", where_clauses.join(" AND "))
|
|
};
|
|
|
|
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;
|
|
|
|
let sort_column = match filters.sort {
|
|
"created" => "i.created_at",
|
|
"iid" => "i.iid",
|
|
_ => "i.updated_at",
|
|
};
|
|
let order = if filters.order == "asc" {
|
|
"ASC"
|
|
} else {
|
|
"DESC"
|
|
};
|
|
|
|
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,
|
|
(SELECT COUNT(*) FROM discussions d
|
|
WHERE d.issue_id = i.id) AS discussion_count,
|
|
(SELECT COUNT(*) FROM discussions d
|
|
WHERE d.issue_id = i.id AND d.resolvable = 1 AND d.resolved = 0) AS unresolved_count,
|
|
i.status_name,
|
|
i.status_category,
|
|
i.status_color,
|
|
i.status_icon_name,
|
|
i.status_synced_at
|
|
FROM issues i
|
|
JOIN projects p ON i.project_id = p.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)?,
|
|
status_name: row.get(12)?,
|
|
status_category: row.get(13)?,
|
|
status_color: row.get(14)?,
|
|
status_icon_name: row.get(15)?,
|
|
status_synced_at: row.get(16)?,
|
|
})
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
Ok(ListResult {
|
|
issues,
|
|
total_count,
|
|
available_statuses: Vec::new(),
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult> {
|
|
let mut where_clauses = Vec::new();
|
|
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
|
|
if let Some(project) = filters.project {
|
|
let project_id = resolve_project(conn, project)?;
|
|
where_clauses.push("m.project_id = ?");
|
|
params.push(Box::new(project_id));
|
|
}
|
|
|
|
if let Some(state) = filters.state
|
|
&& state != "all"
|
|
{
|
|
where_clauses.push("m.state = ?");
|
|
params.push(Box::new(state.to_string()));
|
|
}
|
|
|
|
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()));
|
|
}
|
|
|
|
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()));
|
|
}
|
|
|
|
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()));
|
|
}
|
|
|
|
if let Some(since_str) = filters.since {
|
|
let cutoff_ms = parse_since(since_str).ok_or_else(|| {
|
|
LoreError::Other(format!(
|
|
"Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
|
|
since_str
|
|
))
|
|
})?;
|
|
where_clauses.push("m.updated_at >= ?");
|
|
params.push(Box::new(cutoff_ms));
|
|
}
|
|
|
|
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()));
|
|
}
|
|
}
|
|
|
|
if filters.draft {
|
|
where_clauses.push("m.draft = 1");
|
|
} else if filters.no_draft {
|
|
where_clauses.push("m.draft = 0");
|
|
}
|
|
|
|
if let Some(target_branch) = filters.target_branch {
|
|
where_clauses.push("m.target_branch = ?");
|
|
params.push(Box::new(target_branch.to_string()));
|
|
}
|
|
|
|
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 "))
|
|
};
|
|
|
|
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;
|
|
|
|
let sort_column = match filters.sort {
|
|
"created" => "m.created_at",
|
|
"iid" => "m.iid",
|
|
_ => "m.updated_at",
|
|
};
|
|
let order = if filters.order == "asc" {
|
|
"ASC"
|
|
} else {
|
|
"DESC"
|
|
};
|
|
|
|
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,
|
|
(SELECT COUNT(*) FROM discussions d
|
|
WHERE d.merge_request_id = m.id) AS discussion_count,
|
|
(SELECT COUNT(*) FROM discussions d
|
|
WHERE d.merge_request_id = m.id AND d.resolvable = 1 AND d.resolved = 0) AS unresolved_count
|
|
FROM merge_requests m
|
|
JOIN projects p ON m.project_id = p.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 })
|
|
}
|
|
|
|
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 => {
|
|
let n = d / 3_600_000;
|
|
format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
|
|
}
|
|
d if d < 604_800_000 => {
|
|
let n = d / 86_400_000;
|
|
format!("{n} {} ago", if n == 1 { "day" } else { "days" })
|
|
}
|
|
d if d < 2_592_000_000 => {
|
|
let n = d / 604_800_000;
|
|
format!("{n} {} ago", if n == 1 { "week" } else { "weeks" })
|
|
}
|
|
_ => {
|
|
let n = diff / 2_592_000_000;
|
|
format!("{n} {} ago", if n == 1 { "month" } else { "months" })
|
|
}
|
|
}
|
|
}
|
|
|
|
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}...")
|
|
}
|
|
}
|
|
|
|
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(", "))
|
|
}
|
|
}
|
|
|
|
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(", ")
|
|
}
|
|
}
|
|
|
|
fn format_discussions(total: i64, unresolved: i64) -> String {
|
|
if total == 0 {
|
|
return String::new();
|
|
}
|
|
|
|
if unresolved > 0 {
|
|
format!("{total}/{unresolved}!")
|
|
} else {
|
|
format!("{total}")
|
|
}
|
|
}
|
|
|
|
fn format_branches(target: &str, source: &str, max_width: usize) -> String {
|
|
let full = format!("{} <- {}", target, source);
|
|
truncate_with_ellipsis(&full, max_width)
|
|
}
|
|
|
|
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 has_any_status = result.issues.iter().any(|i| i.status_name.is_some());
|
|
|
|
let mut header = vec![
|
|
Cell::new("IID").add_attribute(Attribute::Bold),
|
|
Cell::new("Title").add_attribute(Attribute::Bold),
|
|
Cell::new("State").add_attribute(Attribute::Bold),
|
|
];
|
|
if has_any_status {
|
|
header.push(Cell::new("Status").add_attribute(Attribute::Bold));
|
|
}
|
|
header.extend([
|
|
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),
|
|
]);
|
|
|
|
let mut table = Table::new();
|
|
table
|
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
|
.set_header(header);
|
|
|
|
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" {
|
|
colored_cell(&issue.state, Color::Green)
|
|
} else {
|
|
colored_cell(&issue.state, Color::DarkGrey)
|
|
};
|
|
|
|
let mut row = vec![
|
|
colored_cell(format!("#{}", issue.iid), Color::Cyan),
|
|
Cell::new(title),
|
|
state_cell,
|
|
];
|
|
if has_any_status {
|
|
match &issue.status_name {
|
|
Some(status) => {
|
|
row.push(colored_cell_hex(status, issue.status_color.as_deref()));
|
|
}
|
|
None => {
|
|
row.push(Cell::new(""));
|
|
}
|
|
}
|
|
}
|
|
row.extend([
|
|
colored_cell(assignee, Color::Magenta),
|
|
colored_cell(labels, Color::Yellow),
|
|
Cell::new(discussions),
|
|
colored_cell(relative_time, Color::DarkGrey),
|
|
]);
|
|
table.add_row(row);
|
|
}
|
|
|
|
println!("{table}");
|
|
}
|
|
|
|
pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
|
let json_result = ListResultJson::from(result);
|
|
let output = serde_json::json!({
|
|
"ok": true,
|
|
"data": json_result,
|
|
"meta": {
|
|
"elapsed_ms": elapsed_ms,
|
|
"available_statuses": result.available_statuses,
|
|
},
|
|
});
|
|
let mut output = output;
|
|
if let Some(f) = fields {
|
|
let expanded = expand_fields_preset(f, "issues");
|
|
filter_fields(&mut output, "issues", &expanded);
|
|
}
|
|
match serde_json::to_string(&output) {
|
|
Ok(json) => println!("{json}"),
|
|
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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" => colored_cell(&mr.state, Color::Green),
|
|
"merged" => colored_cell(&mr.state, Color::Magenta),
|
|
"closed" => colored_cell(&mr.state, Color::Red),
|
|
"locked" => colored_cell(&mr.state, Color::Yellow),
|
|
_ => colored_cell(&mr.state, Color::DarkGrey),
|
|
};
|
|
|
|
table.add_row(vec![
|
|
colored_cell(format!("!{}", mr.iid), Color::Cyan),
|
|
Cell::new(title),
|
|
state_cell,
|
|
colored_cell(
|
|
format!("@{}", truncate_with_ellipsis(&mr.author_username, 12)),
|
|
Color::Magenta,
|
|
),
|
|
colored_cell(branches, Color::Blue),
|
|
Cell::new(discussions),
|
|
colored_cell(relative_time, Color::DarkGrey),
|
|
]);
|
|
}
|
|
|
|
println!("{table}");
|
|
}
|
|
|
|
pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
|
let json_result = MrListResultJson::from(result);
|
|
let meta = RobotMeta { elapsed_ms };
|
|
let output = serde_json::json!({
|
|
"ok": true,
|
|
"data": json_result,
|
|
"meta": meta,
|
|
});
|
|
let mut output = output;
|
|
if let Some(f) = fields {
|
|
let expanded = expand_fields_preset(f, "mrs");
|
|
filter_fields(&mut output, "mrs", &expanded);
|
|
}
|
|
match serde_json::to_string(&output) {
|
|
Ok(json) => println!("{json}"),
|
|
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
|
}
|
|
}
|
|
|
|
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");
|
|
assert_eq!(format_relative_time(now - 120_000), "2 min ago");
|
|
assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago");
|
|
assert_eq!(format_relative_time(now - 172_800_000), "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!");
|
|
}
|
|
}
|