refactor(structure): reorganize codebase into domain-focused modules
This commit is contained in:
470
src/cli/commands/list/notes.rs
Normal file
470
src/cli/commands/list/notes.rs
Normal file
@@ -0,0 +1,470 @@
|
||||
use crate::cli::render::{self, Align, 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::error::{LoreError, Result};
|
||||
use crate::core::path_resolver::escape_like as note_escape_like;
|
||||
use crate::core::project::resolve_project;
|
||||
use crate::core::time::{iso_to_ms, ms_to_iso, parse_since};
|
||||
|
||||
use super::render_helpers::{
|
||||
format_note_parent, format_note_path, format_note_type, truncate_body,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NoteListRow {
|
||||
pub id: i64,
|
||||
pub gitlab_id: i64,
|
||||
pub author_username: String,
|
||||
pub body: Option<String>,
|
||||
pub note_type: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub position_new_path: Option<String>,
|
||||
pub position_new_line: Option<i64>,
|
||||
pub position_old_path: Option<String>,
|
||||
pub position_old_line: Option<i64>,
|
||||
pub resolvable: bool,
|
||||
pub resolved: bool,
|
||||
pub resolved_by: Option<String>,
|
||||
pub noteable_type: Option<String>,
|
||||
pub parent_iid: Option<i64>,
|
||||
pub parent_title: Option<String>,
|
||||
pub project_path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NoteListRowJson {
|
||||
pub id: i64,
|
||||
pub gitlab_id: i64,
|
||||
pub author_username: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub note_type: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub created_at_iso: String,
|
||||
pub updated_at_iso: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position_new_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position_new_line: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position_old_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position_old_line: Option<i64>,
|
||||
pub resolvable: bool,
|
||||
pub resolved: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_by: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub noteable_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_iid: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_title: Option<String>,
|
||||
pub project_path: String,
|
||||
}
|
||||
|
||||
impl From<&NoteListRow> for NoteListRowJson {
|
||||
fn from(row: &NoteListRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
gitlab_id: row.gitlab_id,
|
||||
author_username: row.author_username.clone(),
|
||||
body: row.body.clone(),
|
||||
note_type: row.note_type.clone(),
|
||||
is_system: row.is_system,
|
||||
created_at_iso: ms_to_iso(row.created_at),
|
||||
updated_at_iso: ms_to_iso(row.updated_at),
|
||||
position_new_path: row.position_new_path.clone(),
|
||||
position_new_line: row.position_new_line,
|
||||
position_old_path: row.position_old_path.clone(),
|
||||
position_old_line: row.position_old_line,
|
||||
resolvable: row.resolvable,
|
||||
resolved: row.resolved,
|
||||
resolved_by: row.resolved_by.clone(),
|
||||
noteable_type: row.noteable_type.clone(),
|
||||
parent_iid: row.parent_iid,
|
||||
parent_title: row.parent_title.clone(),
|
||||
project_path: row.project_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NoteListResult {
|
||||
pub notes: Vec<NoteListRow>,
|
||||
pub total_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NoteListResultJson {
|
||||
pub notes: Vec<NoteListRowJson>,
|
||||
pub total_count: i64,
|
||||
pub showing: usize,
|
||||
}
|
||||
|
||||
impl From<&NoteListResult> for NoteListResultJson {
|
||||
fn from(result: &NoteListResult) -> Self {
|
||||
Self {
|
||||
notes: result.notes.iter().map(NoteListRowJson::from).collect(),
|
||||
total_count: result.total_count,
|
||||
showing: result.notes.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoteListFilters {
|
||||
pub limit: usize,
|
||||
pub project: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub note_type: Option<String>,
|
||||
pub include_system: bool,
|
||||
pub for_issue_iid: Option<i64>,
|
||||
pub for_mr_iid: Option<i64>,
|
||||
pub note_id: Option<i64>,
|
||||
pub gitlab_note_id: Option<i64>,
|
||||
pub discussion_id: Option<String>,
|
||||
pub since: Option<String>,
|
||||
pub until: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub contains: Option<String>,
|
||||
pub resolution: Option<String>,
|
||||
pub sort: String,
|
||||
pub order: String,
|
||||
}
|
||||
|
||||
pub fn print_list_notes(result: &NoteListResult) {
|
||||
if result.notes.is_empty() {
|
||||
println!("No notes found.");
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
"{} {} of {}\n",
|
||||
Theme::bold().render("Notes"),
|
||||
result.notes.len(),
|
||||
result.total_count
|
||||
);
|
||||
|
||||
let mut table = LoreTable::new()
|
||||
.headers(&[
|
||||
"ID",
|
||||
"Author",
|
||||
"Type",
|
||||
"Body",
|
||||
"Path:Line",
|
||||
"Parent",
|
||||
"Created",
|
||||
])
|
||||
.align(0, Align::Right);
|
||||
|
||||
for note in &result.notes {
|
||||
let body = note
|
||||
.body
|
||||
.as_deref()
|
||||
.map(|b| truncate_body(b, 60))
|
||||
.unwrap_or_default();
|
||||
let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line);
|
||||
let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid);
|
||||
let relative_time = render::format_relative_time_compact(note.created_at);
|
||||
let note_type = format_note_type(note.note_type.as_deref());
|
||||
|
||||
table.add_row(vec![
|
||||
StyledCell::styled(note.gitlab_id.to_string(), Theme::info()),
|
||||
StyledCell::styled(
|
||||
format!("@{}", render::truncate(¬e.author_username, 12)),
|
||||
Theme::accent(),
|
||||
),
|
||||
StyledCell::plain(note_type),
|
||||
StyledCell::plain(body),
|
||||
StyledCell::plain(path),
|
||||
StyledCell::plain(parent),
|
||||
StyledCell::styled(relative_time, Theme::dim()),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{}", table.render());
|
||||
}
|
||||
|
||||
pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
||||
let json_result = NoteListResultJson::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, "notes");
|
||||
filter_fields(&mut output, "notes", &expanded);
|
||||
}
|
||||
match serde_json::to_string(&output) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_notes(
|
||||
conn: &Connection,
|
||||
filters: &NoteListFilters,
|
||||
config: &Config,
|
||||
) -> Result<NoteListResult> {
|
||||
let mut where_clauses: Vec<String> = Vec::new();
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(ref project) = filters.project {
|
||||
let project_id = resolve_project(conn, project)?;
|
||||
where_clauses.push("n.project_id = ?".to_string());
|
||||
params.push(Box::new(project_id));
|
||||
}
|
||||
|
||||
if let Some(ref author) = filters.author {
|
||||
let username = author.strip_prefix('@').unwrap_or(author);
|
||||
where_clauses.push("n.author_username = ? COLLATE NOCASE".to_string());
|
||||
params.push(Box::new(username.to_string()));
|
||||
}
|
||||
|
||||
if let Some(ref note_type) = filters.note_type {
|
||||
where_clauses.push("n.note_type = ?".to_string());
|
||||
params.push(Box::new(note_type.clone()));
|
||||
}
|
||||
|
||||
if !filters.include_system {
|
||||
where_clauses.push("n.is_system = 0".to_string());
|
||||
}
|
||||
|
||||
let since_ms = if let Some(ref since_str) = filters.since {
|
||||
let 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("n.created_at >= ?".to_string());
|
||||
params.push(Box::new(ms));
|
||||
Some(ms)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ref until_str) = filters.until {
|
||||
let until_ms = if until_str.len() == 10
|
||||
&& until_str.chars().filter(|&c| c == '-').count() == 2
|
||||
{
|
||||
let iso_full = format!("{until_str}T23:59:59.999Z");
|
||||
iso_to_ms(&iso_full).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --until value '{}'. Use YYYY-MM-DD or relative format.",
|
||||
until_str
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
parse_since(until_str).ok_or_else(|| {
|
||||
LoreError::Other(format!(
|
||||
"Invalid --until value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
|
||||
until_str
|
||||
))
|
||||
})?
|
||||
};
|
||||
|
||||
if let Some(s) = since_ms
|
||||
&& s > until_ms
|
||||
{
|
||||
return Err(LoreError::Other(
|
||||
"Invalid time window: --since is after --until.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
where_clauses.push("n.created_at <= ?".to_string());
|
||||
params.push(Box::new(until_ms));
|
||||
}
|
||||
|
||||
if let Some(ref path) = filters.path {
|
||||
if let Some(prefix) = path.strip_suffix('/') {
|
||||
let escaped = note_escape_like(prefix);
|
||||
where_clauses.push("n.position_new_path LIKE ? ESCAPE '\\'".to_string());
|
||||
params.push(Box::new(format!("{escaped}%")));
|
||||
} else {
|
||||
where_clauses.push("n.position_new_path = ?".to_string());
|
||||
params.push(Box::new(path.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref contains) = filters.contains {
|
||||
let escaped = note_escape_like(contains);
|
||||
where_clauses.push("n.body LIKE ? ESCAPE '\\' COLLATE NOCASE".to_string());
|
||||
params.push(Box::new(format!("%{escaped}%")));
|
||||
}
|
||||
|
||||
if let Some(ref resolution) = filters.resolution {
|
||||
match resolution.as_str() {
|
||||
"unresolved" => {
|
||||
where_clauses.push("n.resolvable = 1 AND n.resolved = 0".to_string());
|
||||
}
|
||||
"resolved" => {
|
||||
where_clauses.push("n.resolvable = 1 AND n.resolved = 1".to_string());
|
||||
}
|
||||
other => {
|
||||
return Err(LoreError::Other(format!(
|
||||
"Invalid --resolution value '{}'. Use 'resolved' or 'unresolved'.",
|
||||
other
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(iid) = filters.for_issue_iid {
|
||||
let project_str = filters
|
||||
.project
|
||||
.as_deref()
|
||||
.or(config.default_project.as_deref())
|
||||
.ok_or_else(|| {
|
||||
LoreError::Other(
|
||||
"Cannot filter by issue IID without a project context. Use --project or set defaultProject in config."
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let project_id = resolve_project(conn, project_str)?;
|
||||
where_clauses.push(
|
||||
"d.issue_id = (SELECT id FROM issues WHERE project_id = ? AND iid = ?)".to_string(),
|
||||
);
|
||||
params.push(Box::new(project_id));
|
||||
params.push(Box::new(iid));
|
||||
}
|
||||
|
||||
if let Some(iid) = filters.for_mr_iid {
|
||||
let project_str = filters
|
||||
.project
|
||||
.as_deref()
|
||||
.or(config.default_project.as_deref())
|
||||
.ok_or_else(|| {
|
||||
LoreError::Other(
|
||||
"Cannot filter by MR IID without a project context. Use --project or set defaultProject in config."
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let project_id = resolve_project(conn, project_str)?;
|
||||
where_clauses.push(
|
||||
"d.merge_request_id = (SELECT id FROM merge_requests WHERE project_id = ? AND iid = ?)"
|
||||
.to_string(),
|
||||
);
|
||||
params.push(Box::new(project_id));
|
||||
params.push(Box::new(iid));
|
||||
}
|
||||
|
||||
if let Some(id) = filters.note_id {
|
||||
where_clauses.push("n.id = ?".to_string());
|
||||
params.push(Box::new(id));
|
||||
}
|
||||
|
||||
if let Some(gitlab_id) = filters.gitlab_note_id {
|
||||
where_clauses.push("n.gitlab_id = ?".to_string());
|
||||
params.push(Box::new(gitlab_id));
|
||||
}
|
||||
|
||||
if let Some(ref disc_id) = filters.discussion_id {
|
||||
where_clauses.push("d.gitlab_discussion_id = ?".to_string());
|
||||
params.push(Box::new(disc_id.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 notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN projects p ON n.project_id = p.id
|
||||
LEFT JOIN issues i ON d.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON d.merge_request_id = m.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 sort_column = match filters.sort.as_str() {
|
||||
"updated" => "n.updated_at",
|
||||
_ => "n.created_at",
|
||||
};
|
||||
let order = if filters.order == "asc" {
|
||||
"ASC"
|
||||
} else {
|
||||
"DESC"
|
||||
};
|
||||
|
||||
let query_sql = format!(
|
||||
"SELECT
|
||||
n.id,
|
||||
n.gitlab_id,
|
||||
n.author_username,
|
||||
n.body,
|
||||
n.note_type,
|
||||
n.is_system,
|
||||
n.created_at,
|
||||
n.updated_at,
|
||||
n.position_new_path,
|
||||
n.position_new_line,
|
||||
n.position_old_path,
|
||||
n.position_old_line,
|
||||
n.resolvable,
|
||||
n.resolved,
|
||||
n.resolved_by,
|
||||
d.noteable_type,
|
||||
COALESCE(i.iid, m.iid) AS parent_iid,
|
||||
COALESCE(i.title, m.title) AS parent_title,
|
||||
p.path_with_namespace AS project_path
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN projects p ON n.project_id = p.id
|
||||
LEFT JOIN issues i ON d.issue_id = i.id
|
||||
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
|
||||
{where_sql}
|
||||
ORDER BY {sort_column} {order}, n.id {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 notes: Vec<NoteListRow> = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
let is_system_int: i64 = row.get(5)?;
|
||||
let resolvable_int: i64 = row.get(12)?;
|
||||
let resolved_int: i64 = row.get(13)?;
|
||||
|
||||
Ok(NoteListRow {
|
||||
id: row.get(0)?,
|
||||
gitlab_id: row.get(1)?,
|
||||
author_username: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
|
||||
body: row.get(3)?,
|
||||
note_type: row.get(4)?,
|
||||
is_system: is_system_int == 1,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
position_new_path: row.get(8)?,
|
||||
position_new_line: row.get(9)?,
|
||||
position_old_path: row.get(10)?,
|
||||
position_old_line: row.get(11)?,
|
||||
resolvable: resolvable_int == 1,
|
||||
resolved: resolved_int == 1,
|
||||
resolved_by: row.get(14)?,
|
||||
noteable_type: row.get(15)?,
|
||||
parent_iid: row.get(16)?,
|
||||
parent_title: row.get(17)?,
|
||||
project_path: row.get(18)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(NoteListResult { notes, total_count })
|
||||
}
|
||||
Reference in New Issue
Block a user