use crate::cli::render::{self, Align, Icons, StyledCell, Table as LoreTable, Theme}; 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, parse_since}; use super::render_helpers::{format_branches, format_discussions}; #[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, pub project_path: String, pub labels: Vec, pub assignees: Vec, pub reviewers: Vec, 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, pub assignees: Vec, pub reviewers: Vec, 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, 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, pub total_count: usize, } #[derive(Serialize)] pub struct MrListResultJson { pub mrs: Vec, 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 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_mrs(config: &Config, filters: MrListFilters) -> Result { 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 { let mut where_clauses = Vec::new(); let mut params: Vec> = 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 = stmt .query_map(param_refs.as_slice(), |row| { let labels_csv: Option = row.get(11)?; let labels = labels_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); let assignees_csv: Option = row.get(12)?; let assignees = assignees_csv .map(|s| s.split('\x1F').map(String::from).collect()) .unwrap_or_default(); let reviewers_csv: Option = 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::, _>>()?; Ok(MrListResult { mrs, total_count }) } pub fn print_list_mrs(result: &MrListResult) { if result.mrs.is_empty() { println!("No merge requests found."); return; } println!( "{} {} of {}\n", Theme::bold().render("Merge Requests"), result.mrs.len(), result.total_count ); let mut table = LoreTable::new() .headers(&[ "IID", "Title", "State", "Author", "Branches", "Disc", "Updated", ]) .align(0, Align::Right); for mr in &result.mrs { let title = if mr.draft { format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42)) } else { render::truncate(&mr.title, 45) }; let relative_time = render::format_relative_time_compact(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 (icon, style) = match mr.state.as_str() { "opened" => (Icons::mr_opened(), Theme::success()), "merged" => (Icons::mr_merged(), Theme::accent()), "closed" => (Icons::mr_closed(), Theme::error()), "locked" => (Icons::mr_opened(), Theme::warning()), _ => (Icons::mr_opened(), Theme::dim()), }; let state_cell = StyledCell::styled(format!("{icon} {}", mr.state), style); table.add_row(vec![ StyledCell::styled(format!("!{}", mr.iid), Theme::info()), StyledCell::plain(title), state_cell, StyledCell::styled( format!("@{}", render::truncate(&mr.author_username, 12)), Theme::accent(), ), StyledCell::styled(branches, Theme::info()), discussions, StyledCell::styled(relative_time, Theme::dim()), ]); } println!("{}", table.render()); } pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) { let json_result = MrListResultJson::from(result); let meta = RobotMeta::new(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 { 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 } } }