Files
gitlore/src/cli/commands/list.rs
teernisse 83cd16c918 feat: implement per-note search and document pipeline
- Add SourceType::Note with extract_note_document() and ParentMetadataCache
- Migration 022: composite indexes for notes queries + author_id column
- Migration 024: table rebuild adding 'note' to CHECK constraints, defense triggers
- Migration 025: backfill existing non-system notes into dirty queue
- Add lore notes CLI command with 17 filter options (author, path, resolution, etc.)
- Support table/json/jsonl/csv output formats with field selection
- Wire note dirty tracking through discussion and MR discussion ingestion
- Fix test_migration_024_preserves_existing_data off-by-one (tested wrong migration)
- Fix upsert_document_inner returning false for label/path-only changes
2026-02-12 13:31:24 -05:00

2932 lines
97 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
}
}
}
// ---------------------------------------------------------------------------
// Note output formatting
// ---------------------------------------------------------------------------
fn truncate_body(body: &str, max_len: usize) -> String {
if body.chars().count() <= max_len {
body.to_string()
} else {
let truncated: String = body.chars().take(max_len).collect();
format!("{truncated}...")
}
}
fn format_note_type(note_type: Option<&str>) -> &str {
match note_type {
Some("DiffNote") => "Diff",
Some("DiscussionNote") => "Disc",
_ => "-",
}
}
fn format_note_path(path: Option<&str>, line: Option<i64>) -> String {
match (path, line) {
(Some(p), Some(l)) => format!("{p}:{l}"),
(Some(p), None) => p.to_string(),
_ => "-".to_string(),
}
}
fn format_note_parent(noteable_type: Option<&str>, parent_iid: Option<i64>) -> String {
match (noteable_type, parent_iid) {
(Some("Issue"), Some(iid)) => format!("Issue #{iid}"),
(Some("MergeRequest"), Some(iid)) => format!("MR !{iid}"),
_ => "-".to_string(),
}
}
pub fn print_list_notes(result: &NoteListResult) {
if result.notes.is_empty() {
println!("No notes found.");
return;
}
println!(
"Notes (showing {} of {})\n",
result.notes.len(),
result.total_count
);
let mut table = Table::new();
table
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("ID").add_attribute(Attribute::Bold),
Cell::new("Author").add_attribute(Attribute::Bold),
Cell::new("Type").add_attribute(Attribute::Bold),
Cell::new("Body").add_attribute(Attribute::Bold),
Cell::new("Path:Line").add_attribute(Attribute::Bold),
Cell::new("Parent").add_attribute(Attribute::Bold),
Cell::new("Created").add_attribute(Attribute::Bold),
]);
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 = format_relative_time(note.created_at);
let note_type = format_note_type(note.note_type.as_deref());
table.add_row(vec![
colored_cell(note.gitlab_id, Color::Cyan),
colored_cell(
format!("@{}", truncate_with_ellipsis(&note.author_username, 12)),
Color::Magenta,
),
Cell::new(note_type),
Cell::new(body),
Cell::new(path),
Cell::new(parent),
colored_cell(relative_time, Color::DarkGrey),
]);
}
println!("{table}");
}
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 print_list_notes_jsonl(result: &NoteListResult) {
for note in &result.notes {
let json_row = NoteListRowJson::from(note);
match serde_json::to_string(&json_row) {
Ok(json) => println!("{json}"),
Err(e) => eprintln!("Error serializing to JSON: {e}"),
}
}
}
/// Escape a field for RFC 4180 CSV: quote fields containing commas, quotes, or newlines.
fn csv_escape(field: &str) -> String {
if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
let escaped = field.replace('"', "\"\"");
format!("\"{escaped}\"")
} else {
field.to_string()
}
}
pub fn print_list_notes_csv(result: &NoteListResult) {
println!(
"id,gitlab_id,author_username,body,note_type,is_system,created_at,updated_at,position_new_path,position_new_line,noteable_type,parent_iid,project_path"
);
for note in &result.notes {
let body = note.body.as_deref().unwrap_or("");
let note_type = note.note_type.as_deref().unwrap_or("");
let path = note.position_new_path.as_deref().unwrap_or("");
let line = note
.position_new_line
.map_or(String::new(), |l| l.to_string());
let noteable = note.noteable_type.as_deref().unwrap_or("");
let parent_iid = note.parent_iid.map_or(String::new(), |i| i.to_string());
println!(
"{},{},{},{},{},{},{},{},{},{},{},{},{}",
note.id,
note.gitlab_id,
csv_escape(&note.author_username),
csv_escape(body),
csv_escape(note_type),
note.is_system,
note.created_at,
note.updated_at,
csv_escape(path),
line,
csv_escape(noteable),
parent_iid,
csv_escape(&note.project_path),
);
}
}
// ---------------------------------------------------------------------------
// Note query layer
// ---------------------------------------------------------------------------
#[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,
}
fn note_escape_like(input: &str) -> String {
input
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
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();
// Project filter
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));
}
// Author filter (case-insensitive, strip leading @)
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()));
}
// Note type filter
if let Some(ref note_type) = filters.note_type {
where_clauses.push("n.note_type = ?".to_string());
params.push(Box::new(note_type.clone()));
}
// System note filter (default: exclude system notes)
if !filters.include_system {
where_clauses.push("n.is_system = 0".to_string());
}
// Since filter
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
};
// Until filter (end of day for date-only input)
if let Some(ref until_str) = filters.until {
let until_ms = if until_str.len() == 10
&& until_str.chars().filter(|&c| c == '-').count() == 2
{
// Date-only: use end of day 23:59:59.999
let iso_full = format!("{until_str}T23:59:59.999Z");
crate::core::time::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
))
})?
};
// Validate since <= until
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));
}
// Path filter (trailing / = prefix match, else exact)
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()));
}
}
// Contains filter (LIKE %term% on body, case-insensitive)
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}%")));
}
// Resolution filter
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
)));
}
}
}
// For-issue-iid filter (requires project context)
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));
}
// For-mr-iid filter (requires project context)
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));
}
// Note ID filter
if let Some(id) = filters.note_id {
where_clauses.push("n.id = ?".to_string());
params.push(Box::new(id));
}
// GitLab note ID filter
if let Some(gitlab_id) = filters.gitlab_note_id {
where_clauses.push("n.gitlab_id = ?".to_string());
params.push(Box::new(gitlab_id));
}
// Discussion ID filter
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 "))
};
// Count query
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))?;
// Sort + order
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 })
}
#[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!");
}
// -----------------------------------------------------------------------
// Note query layer tests
// -----------------------------------------------------------------------
use std::path::Path;
use crate::core::config::{
Config, EmbeddingConfig, GitLabConfig, LoggingConfig, ProjectConfig, ScoringConfig,
StorageConfig, SyncConfig,
};
use crate::core::db::{create_connection, run_migrations};
fn test_config(default_project: Option<&str>) -> Config {
Config {
gitlab: GitLabConfig {
base_url: "https://gitlab.example.com".to_string(),
token_env_var: "GITLAB_TOKEN".to_string(),
},
projects: vec![ProjectConfig {
path: "group/project".to_string(),
}],
default_project: default_project.map(String::from),
sync: SyncConfig::default(),
storage: StorageConfig::default(),
embedding: EmbeddingConfig::default(),
logging: LoggingConfig::default(),
scoring: ScoringConfig::default(),
}
}
fn default_note_filters() -> NoteListFilters {
NoteListFilters {
limit: 50,
project: None,
author: None,
note_type: None,
include_system: false,
for_issue_iid: None,
for_mr_iid: None,
note_id: None,
gitlab_note_id: None,
discussion_id: None,
since: None,
until: None,
path: None,
contains: None,
resolution: None,
sort: "created".to_string(),
order: "desc".to_string(),
}
}
fn setup_note_test_db() -> Connection {
let conn = create_connection(Path::new(":memory:")).unwrap();
run_migrations(&conn).unwrap();
conn
}
fn insert_test_project(conn: &Connection, id: i64, path: &str) {
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![
id,
id * 100,
path,
format!("https://gitlab.example.com/{path}")
],
)
.unwrap();
}
fn insert_test_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, title: &str) {
conn.execute(
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username,
created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, 'opened', 'author', 1000, 2000, 3000)",
rusqlite::params![id, id * 10, project_id, iid, title],
)
.unwrap();
}
fn insert_test_mr(conn: &Connection, id: i64, project_id: i64, iid: i64, title: &str) {
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state,
author_username, source_branch, target_branch,
created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, 'opened', 'author', 'feat', 'main',
1000, 2000, 3000)",
rusqlite::params![id, id * 10, project_id, iid, title],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_test_discussion(
conn: &Connection,
id: i64,
gitlab_disc_id: &str,
project_id: i64,
issue_id: Option<i64>,
mr_id: Option<i64>,
noteable_type: &str,
) {
conn.execute(
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id,
merge_request_id, noteable_type, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1000)",
rusqlite::params![
id,
gitlab_disc_id,
project_id,
issue_id,
mr_id,
noteable_type
],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_test_note(
conn: &Connection,
id: i64,
gitlab_id: i64,
discussion_id: i64,
project_id: i64,
author: &str,
body: &str,
is_system: bool,
created_at: i64,
updated_at: i64,
) {
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username,
body, is_system, created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
id,
gitlab_id,
discussion_id,
project_id,
author,
body,
is_system as i64,
created_at,
updated_at,
updated_at,
],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_note_with_position(
conn: &Connection,
id: i64,
gitlab_id: i64,
discussion_id: i64,
project_id: i64,
author: &str,
body: &str,
created_at: i64,
note_type: Option<&str>,
new_path: Option<&str>,
new_line: Option<i64>,
) {
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username,
body, is_system, created_at, updated_at, last_seen_at,
note_type, position_new_path, position_new_line)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7, ?7, ?7, ?8, ?9, ?10)",
rusqlite::params![
id,
gitlab_id,
discussion_id,
project_id,
author,
body,
created_at,
note_type,
new_path,
new_line,
],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_resolvable_note(
conn: &Connection,
id: i64,
gitlab_id: i64,
discussion_id: i64,
project_id: i64,
author: &str,
body: &str,
created_at: i64,
resolvable: bool,
resolved: bool,
resolved_by: Option<&str>,
) {
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username,
body, is_system, created_at, updated_at, last_seen_at,
resolvable, resolved, resolved_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7, ?7, ?7, ?8, ?9, ?10)",
rusqlite::params![
id,
gitlab_id,
discussion_id,
project_id,
author,
body,
created_at,
resolvable as i64,
resolved as i64,
resolved_by,
],
)
.unwrap();
}
#[test]
fn test_query_notes_empty_db() {
let conn = setup_note_test_db();
let config = test_config(None);
let filters = default_note_filters();
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 0);
assert!(result.notes.is_empty());
}
#[test]
fn test_query_notes_basic() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 42, "Test Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(
&conn,
1,
100,
1,
1,
"alice",
"Hello world",
false,
1000,
2000,
);
let filters = default_note_filters();
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes.len(), 1);
assert_eq!(result.notes[0].author_username, "alice");
assert_eq!(result.notes[0].body.as_deref(), Some("Hello world"));
assert_eq!(result.notes[0].parent_iid, Some(42));
assert_eq!(result.notes[0].parent_title.as_deref(), Some("Test Issue"));
assert_eq!(result.notes[0].project_path, "group/project");
}
#[test]
fn test_query_notes_excludes_system_by_default() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "User note", false, 1000, 1000);
insert_test_note(&conn, 2, 101, 1, 1, "bot", "System note", true, 2000, 2000);
let filters = default_note_filters();
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_include_system() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "User note", false, 1000, 1000);
insert_test_note(&conn, 2, 101, 1, 1, "bot", "System note", true, 2000, 2000);
let mut filters = default_note_filters();
filters.include_system = true;
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 2);
}
#[test]
fn test_query_notes_filter_author() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "Note 1", false, 1000, 1000);
insert_test_note(&conn, 2, 101, 1, 1, "bob", "Note 2", false, 2000, 2000);
let mut filters = default_note_filters();
filters.author = Some("alice".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_filter_author_case_insensitive() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "Alice", "Note", false, 1000, 1000);
let mut filters = default_note_filters();
filters.author = Some("ALICE".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
}
#[test]
fn test_query_notes_filter_author_strips_at() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "Note", false, 1000, 1000);
let mut filters = default_note_filters();
filters.author = Some("@alice".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
}
#[test]
fn test_query_notes_filter_since() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
let ts = crate::core::time::iso_to_ms("2024-01-15T00:00:00Z").unwrap();
insert_test_note(&conn, 1, 100, 1, 1, "alice", "Old note", false, ts, ts);
let ts2 = crate::core::time::iso_to_ms("2024-06-15T00:00:00Z").unwrap();
insert_test_note(&conn, 2, 101, 1, 1, "bob", "New note", false, ts2, ts2);
let mut filters = default_note_filters();
filters.since = Some("2024-03-01".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "bob");
}
#[test]
fn test_query_notes_filter_until() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
let ts = crate::core::time::iso_to_ms("2024-01-15T00:00:00Z").unwrap();
insert_test_note(&conn, 1, 100, 1, 1, "alice", "Old note", false, ts, ts);
let ts2 = crate::core::time::iso_to_ms("2024-06-15T00:00:00Z").unwrap();
insert_test_note(&conn, 2, 101, 1, 1, "bob", "New note", false, ts2, ts2);
let mut filters = default_note_filters();
filters.until = Some("2024-03-01".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_filter_since_until_combined() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
let ts1 = crate::core::time::iso_to_ms("2024-01-15T00:00:00Z").unwrap();
insert_test_note(&conn, 1, 100, 1, 1, "alice", "Old", false, ts1, ts1);
let ts2 = crate::core::time::iso_to_ms("2024-03-15T00:00:00Z").unwrap();
insert_test_note(&conn, 2, 101, 1, 1, "bob", "Middle", false, ts2, ts2);
let ts3 = crate::core::time::iso_to_ms("2024-06-15T00:00:00Z").unwrap();
insert_test_note(&conn, 3, 102, 1, 1, "carol", "New", false, ts3, ts3);
let mut filters = default_note_filters();
filters.since = Some("2024-02-01".to_string());
filters.until = Some("2024-04-01".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "bob");
}
#[test]
fn test_query_notes_invalid_time_window_rejected() {
let conn = setup_note_test_db();
let config = test_config(None);
let mut filters = default_note_filters();
filters.since = Some("2024-06-01".to_string());
filters.until = Some("2024-01-01".to_string());
let result = query_notes(&conn, &filters, &config);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("--since is after --until"),
"Expected time window error, got: {msg}"
);
}
#[test]
fn test_query_notes_until_date_uses_end_of_day() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
let ts = crate::core::time::iso_to_ms("2024-03-01T23:30:00Z").unwrap();
insert_test_note(&conn, 1, 100, 1, 1, "alice", "Late night", false, ts, ts);
let mut filters = default_note_filters();
filters.until = Some("2024-03-01".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(
result.total_count, 1,
"Note at 23:30 should be included when --until is the same date"
);
}
#[test]
fn test_query_notes_filter_contains() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(
&conn,
1,
100,
1,
1,
"alice",
"This has a BUG in it",
false,
1000,
1000,
);
insert_test_note(
&conn,
2,
101,
1,
1,
"bob",
"Everything is fine",
false,
2000,
2000,
);
let mut filters = default_note_filters();
filters.contains = Some("bug".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_filter_contains_escapes_like_wildcards() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "100% done", false, 1000, 1000);
insert_test_note(&conn, 2, 101, 1, 1, "bob", "100 things", false, 2000, 2000);
let mut filters = default_note_filters();
filters.contains = Some("100%".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_filter_path_exact() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_note_with_position(
&conn,
1,
100,
1,
1,
"alice",
"Change here",
1000,
Some("DiffNote"),
Some("src/main.rs"),
Some(42),
);
insert_note_with_position(
&conn,
2,
101,
1,
1,
"bob",
"And here",
2000,
Some("DiffNote"),
Some("src/lib.rs"),
Some(10),
);
let mut filters = default_note_filters();
filters.path = Some("src/main.rs".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_filter_path_prefix() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_note_with_position(
&conn,
1,
100,
1,
1,
"alice",
"In src",
1000,
Some("DiffNote"),
Some("src/main.rs"),
Some(42),
);
insert_note_with_position(
&conn,
2,
101,
1,
1,
"bob",
"Also in src",
2000,
Some("DiffNote"),
Some("src/lib.rs"),
Some(10),
);
insert_note_with_position(
&conn,
3,
102,
1,
1,
"carol",
"In tests",
3000,
Some("DiffNote"),
Some("tests/test.rs"),
Some(1),
);
let mut filters = default_note_filters();
filters.path = Some("src/".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 2);
}
#[test]
fn test_query_notes_filter_for_issue_requires_project() {
let conn = setup_note_test_db();
let config = test_config(None);
let mut filters = default_note_filters();
filters.for_issue_iid = Some(42);
let result = query_notes(&conn, &filters, &config);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("project context"),
"Expected project context error, got: {msg}"
);
}
#[test]
fn test_query_notes_filter_for_mr_requires_project() {
let conn = setup_note_test_db();
let config = test_config(None);
let mut filters = default_note_filters();
filters.for_mr_iid = Some(10);
let result = query_notes(&conn, &filters, &config);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("project context"),
"Expected project context error, got: {msg}"
);
}
#[test]
fn test_query_notes_filter_for_issue_with_project() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 42, "Target Issue");
insert_test_issue(&conn, 2, 1, 43, "Other Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_discussion(&conn, 2, "disc-2", 1, Some(2), None, "Issue");
insert_test_note(
&conn,
1,
100,
1,
1,
"alice",
"On issue 42",
false,
1000,
1000,
);
insert_test_note(&conn, 2, 101, 2, 1, "bob", "On issue 43", false, 2000, 2000);
let mut filters = default_note_filters();
filters.for_issue_iid = Some(42);
filters.project = Some("group/project".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_filter_for_issue_uses_default_project() {
let conn = setup_note_test_db();
let config = test_config(Some("group/project"));
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 42, "Target Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(
&conn,
1,
100,
1,
1,
"alice",
"On issue 42",
false,
1000,
1000,
);
let mut filters = default_note_filters();
filters.for_issue_iid = Some(42);
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
}
#[test]
fn test_query_notes_filter_for_mr_with_project() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_mr(&conn, 1, 1, 10, "Target MR");
insert_test_discussion(&conn, 1, "disc-1", 1, None, Some(1), "MergeRequest");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "On MR 10", false, 1000, 1000);
let mut filters = default_note_filters();
filters.for_mr_iid = Some(10);
filters.project = Some("group/project".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_filter_resolution_unresolved() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_resolvable_note(
&conn,
1,
100,
1,
1,
"alice",
"Unresolved",
1000,
true,
false,
None,
);
insert_resolvable_note(
&conn,
2,
101,
1,
1,
"bob",
"Resolved",
2000,
true,
true,
Some("carol"),
);
insert_test_note(
&conn,
3,
102,
1,
1,
"dave",
"Not resolvable",
false,
3000,
3000,
);
let mut filters = default_note_filters();
filters.resolution = Some("unresolved".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_filter_resolution_resolved() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_resolvable_note(
&conn,
1,
100,
1,
1,
"alice",
"Unresolved",
1000,
true,
false,
None,
);
insert_resolvable_note(
&conn,
2,
101,
1,
1,
"bob",
"Resolved",
2000,
true,
true,
Some("carol"),
);
let mut filters = default_note_filters();
filters.resolution = Some("resolved".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "bob");
assert!(result.notes[0].resolved);
assert_eq!(result.notes[0].resolved_by.as_deref(), Some("carol"));
}
#[test]
fn test_query_notes_sort_created_desc() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "First", false, 1000, 1000);
insert_test_note(&conn, 2, 101, 1, 1, "bob", "Second", false, 2000, 2000);
insert_test_note(&conn, 3, 102, 1, 1, "carol", "Third", false, 3000, 3000);
let filters = default_note_filters();
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.notes[0].author_username, "carol");
assert_eq!(result.notes[1].author_username, "bob");
assert_eq!(result.notes[2].author_username, "alice");
}
#[test]
fn test_query_notes_sort_created_asc() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "First", false, 1000, 1000);
insert_test_note(&conn, 2, 101, 1, 1, "bob", "Second", false, 2000, 2000);
insert_test_note(&conn, 3, 102, 1, 1, "carol", "Third", false, 3000, 3000);
let mut filters = default_note_filters();
filters.order = "asc".to_string();
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.notes[0].author_username, "alice");
assert_eq!(result.notes[1].author_username, "bob");
assert_eq!(result.notes[2].author_username, "carol");
}
#[test]
fn test_query_notes_deterministic_tiebreak() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "A", false, 1000, 1000);
insert_test_note(&conn, 2, 101, 1, 1, "bob", "B", false, 1000, 1000);
insert_test_note(&conn, 3, 102, 1, 1, "carol", "C", false, 1000, 1000);
let filters = default_note_filters();
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.notes[0].id, 3);
assert_eq!(result.notes[1].id, 2);
assert_eq!(result.notes[2].id, 1);
let mut filters = default_note_filters();
filters.order = "asc".to_string();
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.notes[0].id, 1);
assert_eq!(result.notes[1].id, 2);
assert_eq!(result.notes[2].id, 3);
}
#[test]
fn test_query_notes_limit() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
for i in 1..=10 {
insert_test_note(
&conn,
i,
100 + i,
1,
1,
"alice",
&format!("Note {i}"),
false,
i * 1000,
i * 1000,
);
}
let mut filters = default_note_filters();
filters.limit = 3;
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 10);
assert_eq!(result.notes.len(), 3);
}
#[test]
fn test_query_notes_combined_filters() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(
&conn,
1,
100,
1,
1,
"alice",
"Found a bug here",
false,
1000,
1000,
);
insert_test_note(
&conn,
2,
101,
1,
1,
"alice",
"Looks good",
false,
2000,
2000,
);
insert_test_note(
&conn,
3,
102,
1,
1,
"bob",
"Another bug fix",
false,
3000,
3000,
);
let mut filters = default_note_filters();
filters.author = Some("alice".to_string());
filters.contains = Some("bug".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].id, 1);
}
#[test]
fn test_query_notes_filter_note_type() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_note_with_position(
&conn,
1,
100,
1,
1,
"alice",
"Diff comment",
1000,
Some("DiffNote"),
Some("src/main.rs"),
Some(10),
);
insert_test_note(
&conn,
2,
101,
1,
1,
"bob",
"Discussion note",
false,
2000,
2000,
);
let mut filters = default_note_filters();
filters.note_type = Some("DiffNote".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_filter_discussion_id() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-aaa", 1, Some(1), None, "Issue");
insert_test_discussion(&conn, 2, "disc-bbb", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "In disc A", false, 1000, 1000);
insert_test_note(&conn, 2, 101, 2, 1, "bob", "In disc B", false, 2000, 2000);
let mut filters = default_note_filters();
filters.discussion_id = Some("disc-aaa".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_filter_note_id() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "First", false, 1000, 1000);
insert_test_note(&conn, 2, 101, 1, 1, "bob", "Second", false, 2000, 2000);
let mut filters = default_note_filters();
filters.note_id = Some(2);
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "bob");
}
#[test]
fn test_query_notes_filter_gitlab_note_id() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "First", false, 1000, 1000);
insert_test_note(&conn, 2, 200, 1, 1, "bob", "Second", false, 2000, 2000);
let mut filters = default_note_filters();
filters.gitlab_note_id = Some(200);
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "bob");
}
#[test]
fn test_query_notes_filter_project() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project-a");
insert_test_project(&conn, 2, "group/project-b");
insert_test_issue(&conn, 1, 1, 1, "Issue A");
insert_test_issue(&conn, 2, 2, 1, "Issue B");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_discussion(&conn, 2, "disc-2", 2, Some(2), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "In A", false, 1000, 1000);
insert_test_note(&conn, 2, 101, 2, 2, "bob", "In B", false, 2000, 2000);
let mut filters = default_note_filters();
filters.project = Some("group/project-a".to_string());
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.notes[0].author_username, "alice");
}
#[test]
fn test_query_notes_mr_parent() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_mr(&conn, 1, 1, 99, "My MR");
insert_test_discussion(&conn, 1, "disc-1", 1, None, Some(1), "MergeRequest");
insert_test_note(
&conn,
1,
100,
1,
1,
"alice",
"MR comment",
false,
1000,
1000,
);
let filters = default_note_filters();
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(
result.notes[0].noteable_type.as_deref(),
Some("MergeRequest")
);
assert_eq!(result.notes[0].parent_iid, Some(99));
assert_eq!(result.notes[0].parent_title.as_deref(), Some("My MR"));
}
#[test]
fn test_note_list_row_json_conversion() {
let row = NoteListRow {
id: 1,
gitlab_id: 100,
author_username: "alice".to_string(),
body: Some("Test body".to_string()),
note_type: Some("DiffNote".to_string()),
is_system: false,
created_at: 1_705_315_800_000,
updated_at: 1_705_315_800_000,
position_new_path: Some("src/main.rs".to_string()),
position_new_line: Some(42),
position_old_path: None,
position_old_line: None,
resolvable: true,
resolved: false,
resolved_by: None,
noteable_type: Some("Issue".to_string()),
parent_iid: Some(5),
parent_title: Some("Test Issue".to_string()),
project_path: "group/project".to_string(),
};
let json_row = NoteListRowJson::from(&row);
assert_eq!(json_row.id, 1);
assert_eq!(json_row.gitlab_id, 100);
assert_eq!(json_row.author_username, "alice");
assert!(json_row.created_at_iso.contains("2024-01-15"));
assert!(json_row.updated_at_iso.contains("2024-01-15"));
assert_eq!(json_row.position_new_path.as_deref(), Some("src/main.rs"));
assert_eq!(json_row.position_new_line, Some(42));
assert!(!json_row.is_system);
assert!(json_row.resolvable);
assert!(!json_row.resolved);
}
#[test]
fn test_note_list_result_json_conversion() {
let result = NoteListResult {
notes: vec![NoteListRow {
id: 1,
gitlab_id: 100,
author_username: "alice".to_string(),
body: Some("Test".to_string()),
note_type: None,
is_system: false,
created_at: 1000,
updated_at: 2000,
position_new_path: None,
position_new_line: None,
position_old_path: None,
position_old_line: None,
resolvable: false,
resolved: false,
resolved_by: None,
noteable_type: Some("Issue".to_string()),
parent_iid: Some(1),
parent_title: Some("Issue".to_string()),
project_path: "group/project".to_string(),
}],
total_count: 5,
};
let json_result = NoteListResultJson::from(&result);
assert_eq!(json_result.total_count, 5);
assert_eq!(json_result.showing, 1);
assert_eq!(json_result.notes.len(), 1);
}
#[test]
fn test_query_notes_sort_updated() {
let conn = setup_note_test_db();
let config = test_config(None);
insert_test_project(&conn, 1, "group/project");
insert_test_issue(&conn, 1, 1, 1, "Issue");
insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue");
insert_test_note(&conn, 1, 100, 1, 1, "alice", "A", false, 1000, 3000);
insert_test_note(&conn, 2, 101, 1, 1, "bob", "B", false, 2000, 1000);
insert_test_note(&conn, 3, 102, 1, 1, "carol", "C", false, 3000, 2000);
let mut filters = default_note_filters();
filters.sort = "updated".to_string();
filters.order = "desc".to_string();
let result = query_notes(&conn, &filters, &config).unwrap();
assert_eq!(result.notes[0].author_username, "alice");
assert_eq!(result.notes[1].author_username, "carol");
assert_eq!(result.notes[2].author_username, "bob");
}
#[test]
fn test_note_escape_like() {
assert_eq!(note_escape_like("normal/path"), "normal/path");
assert_eq!(note_escape_like("has_underscore"), "has\\_underscore");
assert_eq!(note_escape_like("has%percent"), "has\\%percent");
assert_eq!(note_escape_like("has\\backslash"), "has\\\\backslash");
}
// -----------------------------------------------------------------------
// Note output formatting tests
// -----------------------------------------------------------------------
#[test]
fn test_truncate_note_body() {
let short = "short body";
assert_eq!(truncate_body(short, 60), "short body");
let long: String = "a".repeat(200);
let result = truncate_body(&long, 60);
assert_eq!(result.chars().count(), 63); // 60 chars + "..."
assert!(result.ends_with("..."));
}
#[test]
fn test_csv_escape_basic() {
assert_eq!(csv_escape("simple"), "simple");
assert_eq!(csv_escape("has,comma"), "\"has,comma\"");
assert_eq!(csv_escape("has\"quote"), "\"has\"\"quote\"");
assert_eq!(csv_escape("has\nnewline"), "\"has\nnewline\"");
}
#[test]
fn test_csv_output_basic() {
let result = NoteListResult {
notes: vec![NoteListRow {
id: 1,
gitlab_id: 100,
author_username: "alice".to_string(),
body: Some("Hello, world".to_string()),
note_type: Some("DiffNote".to_string()),
is_system: false,
created_at: 1_000_000,
updated_at: 2_000_000,
position_new_path: Some("src/main.rs".to_string()),
position_new_line: Some(42),
position_old_path: None,
position_old_line: None,
resolvable: true,
resolved: false,
resolved_by: None,
noteable_type: Some("Issue".to_string()),
parent_iid: Some(7),
parent_title: Some("Test issue".to_string()),
project_path: "group/project".to_string(),
}],
total_count: 1,
};
// Verify csv_escape handles the comma in body correctly
let body = result.notes[0].body.as_deref().unwrap();
let escaped = csv_escape(body);
assert_eq!(escaped, "\"Hello, world\"");
// Verify the formatting helpers
assert_eq!(
format_note_type(result.notes[0].note_type.as_deref()),
"Diff"
);
assert_eq!(
format_note_parent(
result.notes[0].noteable_type.as_deref(),
result.notes[0].parent_iid,
),
"Issue #7"
);
}
#[test]
fn test_jsonl_output_one_per_line() {
let result = NoteListResult {
notes: vec![
NoteListRow {
id: 1,
gitlab_id: 100,
author_username: "alice".to_string(),
body: Some("First note".to_string()),
note_type: None,
is_system: false,
created_at: 1_000_000,
updated_at: 2_000_000,
position_new_path: None,
position_new_line: None,
position_old_path: None,
position_old_line: None,
resolvable: false,
resolved: false,
resolved_by: None,
noteable_type: None,
parent_iid: None,
parent_title: None,
project_path: "group/project".to_string(),
},
NoteListRow {
id: 2,
gitlab_id: 101,
author_username: "bob".to_string(),
body: Some("Second note".to_string()),
note_type: Some("DiffNote".to_string()),
is_system: false,
created_at: 3_000_000,
updated_at: 4_000_000,
position_new_path: None,
position_new_line: None,
position_old_path: None,
position_old_line: None,
resolvable: false,
resolved: false,
resolved_by: None,
noteable_type: None,
parent_iid: None,
parent_title: None,
project_path: "group/project".to_string(),
},
],
total_count: 2,
};
// Each note should produce valid JSON when serialized individually
for note in &result.notes {
let json_row = NoteListRowJson::from(note);
let json_str = serde_json::to_string(&json_row).unwrap();
// Verify it parses back as valid JSON
let _: serde_json::Value = serde_json::from_str(&json_str).unwrap();
// Verify no embedded newlines in the JSON line
assert!(!json_str.contains('\n'));
}
}
#[test]
fn test_format_note_parent_variants() {
assert_eq!(format_note_parent(Some("Issue"), Some(42)), "Issue #42");
assert_eq!(format_note_parent(Some("MergeRequest"), Some(99)), "MR !99");
assert_eq!(format_note_parent(None, None), "-");
assert_eq!(format_note_parent(Some("Issue"), None), "-");
}
#[test]
fn test_format_note_type_variants() {
assert_eq!(format_note_type(Some("DiffNote")), "Diff");
assert_eq!(format_note_type(Some("DiscussionNote")), "Disc");
assert_eq!(format_note_type(None), "-");
assert_eq!(format_note_type(Some("Other")), "-");
}
}