Phase 4 of the UX overhaul. Restructures the show issue and show MR detail displays with consistent section layout, state icons, and improved typography. Issue detail changes: - Replace bold header + box-drawing underline with indented title using Theme::bold() for the title text only - Organize fields into named sections using render::section_divider(): Details, Development, Description, Discussions - Add state icons (Icons::issue_opened/closed) alongside text labels - Add relative time in parentheses next to Created/Updated dates - Switch labels from "Labels: (none)" to only showing when present, using format_labels_bare for clean comma-separated output - Move URL and confidential indicator into Details section - Closing MRs show state-colored icons (merged/opened/closed) - Discussions use section_divider instead of bold text, remove colons from author lines, adjust wrap widths for consistent indentation MR detail changes: - Same section-divider layout: Details, Description, Discussions - State icons for opened/merged/closed using Icons::mr_* helpers - Draft indicator uses Icons::mr_draft() instead of [Draft] text prefix - Relative times added to Created, Updated, Merged, Closed dates - Reviewers and Assignees fields aligned with fixed-width labels - Labels shown only when present, using format_labels_bare - Discussion formatting matches issue detail style Both views use 5-space left indent for field alignment and consistent wrap widths (72 for descriptions, 68/66 for discussion notes/replies). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1545 lines
49 KiB
Rust
1545 lines
49 KiB
Rust
use crate::cli::render::{self, Icons, Theme};
|
|
use rusqlite::Connection;
|
|
use serde::Serialize;
|
|
|
|
use crate::Config;
|
|
use crate::cli::robot::RobotMeta;
|
|
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;
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MrDetail {
|
|
pub id: i64,
|
|
pub iid: i64,
|
|
pub title: String,
|
|
pub description: Option<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,
|
|
pub merged_at: Option<i64>,
|
|
pub closed_at: Option<i64>,
|
|
pub web_url: Option<String>,
|
|
pub project_path: String,
|
|
pub labels: Vec<String>,
|
|
pub assignees: Vec<String>,
|
|
pub reviewers: Vec<String>,
|
|
pub discussions: Vec<MrDiscussionDetail>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MrDiscussionDetail {
|
|
pub notes: Vec<MrNoteDetail>,
|
|
pub individual_note: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MrNoteDetail {
|
|
pub author_username: String,
|
|
pub body: String,
|
|
pub created_at: i64,
|
|
pub is_system: bool,
|
|
pub position: Option<DiffNotePosition>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct DiffNotePosition {
|
|
pub old_path: Option<String>,
|
|
pub new_path: Option<String>,
|
|
pub old_line: Option<i64>,
|
|
pub new_line: Option<i64>,
|
|
pub position_type: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ClosingMrRef {
|
|
pub iid: i64,
|
|
pub title: String,
|
|
pub state: String,
|
|
pub web_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct IssueDetail {
|
|
pub id: i64,
|
|
pub iid: i64,
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub state: String,
|
|
pub author_username: String,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
pub closed_at: Option<String>,
|
|
pub confidential: bool,
|
|
pub web_url: Option<String>,
|
|
pub project_path: String,
|
|
pub references_full: String,
|
|
pub labels: Vec<String>,
|
|
pub assignees: Vec<String>,
|
|
pub due_date: Option<String>,
|
|
pub milestone: Option<String>,
|
|
pub user_notes_count: i64,
|
|
pub merge_requests_count: usize,
|
|
pub closing_merge_requests: Vec<ClosingMrRef>,
|
|
pub discussions: Vec<DiscussionDetail>,
|
|
pub status_name: Option<String>,
|
|
pub status_category: Option<String>,
|
|
pub status_color: Option<String>,
|
|
pub status_icon_name: Option<String>,
|
|
pub status_synced_at: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DiscussionDetail {
|
|
pub notes: Vec<NoteDetail>,
|
|
pub individual_note: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct NoteDetail {
|
|
pub author_username: String,
|
|
pub body: String,
|
|
pub created_at: i64,
|
|
pub is_system: bool,
|
|
}
|
|
|
|
pub fn run_show_issue(
|
|
config: &Config,
|
|
iid: i64,
|
|
project_filter: Option<&str>,
|
|
) -> Result<IssueDetail> {
|
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
|
let conn = create_connection(&db_path)?;
|
|
|
|
let issue = find_issue(&conn, iid, project_filter)?;
|
|
|
|
let labels = get_issue_labels(&conn, issue.id)?;
|
|
|
|
let assignees = get_issue_assignees(&conn, issue.id)?;
|
|
|
|
let closing_mrs = get_closing_mrs(&conn, issue.id)?;
|
|
|
|
let discussions = get_issue_discussions(&conn, issue.id)?;
|
|
|
|
let references_full = format!("{}#{}", issue.project_path, issue.iid);
|
|
let merge_requests_count = closing_mrs.len();
|
|
|
|
Ok(IssueDetail {
|
|
id: issue.id,
|
|
iid: issue.iid,
|
|
title: issue.title,
|
|
description: issue.description,
|
|
state: issue.state,
|
|
author_username: issue.author_username,
|
|
created_at: issue.created_at,
|
|
updated_at: issue.updated_at,
|
|
closed_at: issue.closed_at,
|
|
confidential: issue.confidential,
|
|
web_url: issue.web_url,
|
|
project_path: issue.project_path,
|
|
references_full,
|
|
labels,
|
|
assignees,
|
|
due_date: issue.due_date,
|
|
milestone: issue.milestone_title,
|
|
user_notes_count: issue.user_notes_count,
|
|
merge_requests_count,
|
|
closing_merge_requests: closing_mrs,
|
|
discussions,
|
|
status_name: issue.status_name,
|
|
status_category: issue.status_category,
|
|
status_color: issue.status_color,
|
|
status_icon_name: issue.status_icon_name,
|
|
status_synced_at: issue.status_synced_at,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct IssueRow {
|
|
id: i64,
|
|
iid: i64,
|
|
title: String,
|
|
description: Option<String>,
|
|
state: String,
|
|
author_username: String,
|
|
created_at: i64,
|
|
updated_at: i64,
|
|
closed_at: Option<String>,
|
|
confidential: bool,
|
|
web_url: Option<String>,
|
|
project_path: String,
|
|
due_date: Option<String>,
|
|
milestone_title: Option<String>,
|
|
user_notes_count: i64,
|
|
status_name: Option<String>,
|
|
status_category: Option<String>,
|
|
status_color: Option<String>,
|
|
status_icon_name: Option<String>,
|
|
status_synced_at: Option<i64>,
|
|
}
|
|
|
|
fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<IssueRow> {
|
|
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
|
|
Some(project) => {
|
|
let project_id = resolve_project(conn, project)?;
|
|
(
|
|
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
|
|
i.created_at, i.updated_at, i.closed_at, i.confidential,
|
|
i.web_url, p.path_with_namespace,
|
|
i.due_date, i.milestone_title,
|
|
(SELECT COUNT(*) FROM notes n
|
|
JOIN discussions d ON n.discussion_id = d.id
|
|
WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_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 i.iid = ? AND i.project_id = ?",
|
|
vec![Box::new(iid), Box::new(project_id)],
|
|
)
|
|
}
|
|
None => (
|
|
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
|
|
i.created_at, i.updated_at, i.closed_at, i.confidential,
|
|
i.web_url, p.path_with_namespace,
|
|
i.due_date, i.milestone_title,
|
|
(SELECT COUNT(*) FROM notes n
|
|
JOIN discussions d ON n.discussion_id = d.id
|
|
WHERE d.noteable_type = 'Issue' AND d.issue_id = i.id AND n.is_system = 0) AS user_notes_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 i.iid = ?",
|
|
vec![Box::new(iid)],
|
|
),
|
|
};
|
|
|
|
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
|
|
let mut stmt = conn.prepare(sql)?;
|
|
let issues: Vec<IssueRow> = stmt
|
|
.query_map(param_refs.as_slice(), |row| {
|
|
let confidential_val: i64 = row.get(9)?;
|
|
Ok(IssueRow {
|
|
id: row.get(0)?,
|
|
iid: row.get(1)?,
|
|
title: row.get(2)?,
|
|
description: row.get(3)?,
|
|
state: row.get(4)?,
|
|
author_username: row.get(5)?,
|
|
created_at: row.get(6)?,
|
|
updated_at: row.get(7)?,
|
|
closed_at: row.get(8)?,
|
|
confidential: confidential_val != 0,
|
|
web_url: row.get(10)?,
|
|
project_path: row.get(11)?,
|
|
due_date: row.get(12)?,
|
|
milestone_title: row.get(13)?,
|
|
user_notes_count: row.get(14)?,
|
|
status_name: row.get(15)?,
|
|
status_category: row.get(16)?,
|
|
status_color: row.get(17)?,
|
|
status_icon_name: row.get(18)?,
|
|
status_synced_at: row.get(19)?,
|
|
})
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
match issues.len() {
|
|
0 => Err(LoreError::NotFound(format!("Issue #{} not found", iid))),
|
|
1 => Ok(issues.into_iter().next().unwrap()),
|
|
_ => {
|
|
let projects: Vec<String> = issues.iter().map(|i| i.project_path.clone()).collect();
|
|
Err(LoreError::Ambiguous(format!(
|
|
"Issue #{} exists in multiple projects: {}. Use --project to specify.",
|
|
iid,
|
|
projects.join(", ")
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT l.name FROM labels l
|
|
JOIN issue_labels il ON l.id = il.label_id
|
|
WHERE il.issue_id = ?
|
|
ORDER BY l.name",
|
|
)?;
|
|
|
|
let labels: Vec<String> = stmt
|
|
.query_map([issue_id], |row| row.get(0))?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
Ok(labels)
|
|
}
|
|
|
|
fn get_issue_assignees(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT username FROM issue_assignees
|
|
WHERE issue_id = ?
|
|
ORDER BY username",
|
|
)?;
|
|
|
|
let assignees: Vec<String> = stmt
|
|
.query_map([issue_id], |row| row.get(0))?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
Ok(assignees)
|
|
}
|
|
|
|
fn get_closing_mrs(conn: &Connection, issue_id: i64) -> Result<Vec<ClosingMrRef>> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT mr.iid, mr.title, mr.state, mr.web_url
|
|
FROM entity_references er
|
|
JOIN merge_requests mr ON mr.id = er.source_entity_id
|
|
WHERE er.target_entity_type = 'issue'
|
|
AND er.target_entity_id = ?
|
|
AND er.source_entity_type = 'merge_request'
|
|
AND er.reference_type = 'closes'
|
|
ORDER BY mr.iid",
|
|
)?;
|
|
|
|
let mrs: Vec<ClosingMrRef> = stmt
|
|
.query_map([issue_id], |row| {
|
|
Ok(ClosingMrRef {
|
|
iid: row.get(0)?,
|
|
title: row.get(1)?,
|
|
state: row.get(2)?,
|
|
web_url: row.get(3)?,
|
|
})
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
Ok(mrs)
|
|
}
|
|
|
|
fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<DiscussionDetail>> {
|
|
let mut disc_stmt = conn.prepare(
|
|
"SELECT id, individual_note FROM discussions
|
|
WHERE issue_id = ?
|
|
ORDER BY first_note_at",
|
|
)?;
|
|
|
|
let disc_rows: Vec<(i64, bool)> = disc_stmt
|
|
.query_map([issue_id], |row| {
|
|
let individual: i64 = row.get(1)?;
|
|
Ok((row.get(0)?, individual == 1))
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
let mut note_stmt = conn.prepare(
|
|
"SELECT author_username, body, created_at, is_system
|
|
FROM notes
|
|
WHERE discussion_id = ?
|
|
ORDER BY position",
|
|
)?;
|
|
|
|
let mut discussions = Vec::new();
|
|
for (disc_id, individual_note) in disc_rows {
|
|
let notes: Vec<NoteDetail> = note_stmt
|
|
.query_map([disc_id], |row| {
|
|
let is_system: i64 = row.get(3)?;
|
|
Ok(NoteDetail {
|
|
author_username: row.get(0)?,
|
|
body: row.get(1)?,
|
|
created_at: row.get(2)?,
|
|
is_system: is_system == 1,
|
|
})
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
let has_user_notes = notes.iter().any(|n| !n.is_system);
|
|
if has_user_notes || notes.is_empty() {
|
|
discussions.push(DiscussionDetail {
|
|
notes,
|
|
individual_note,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(discussions)
|
|
}
|
|
|
|
pub fn run_show_mr(config: &Config, iid: i64, project_filter: Option<&str>) -> Result<MrDetail> {
|
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
|
let conn = create_connection(&db_path)?;
|
|
|
|
let mr = find_mr(&conn, iid, project_filter)?;
|
|
|
|
let labels = get_mr_labels(&conn, mr.id)?;
|
|
|
|
let assignees = get_mr_assignees(&conn, mr.id)?;
|
|
|
|
let reviewers = get_mr_reviewers(&conn, mr.id)?;
|
|
|
|
let discussions = get_mr_discussions(&conn, mr.id)?;
|
|
|
|
Ok(MrDetail {
|
|
id: mr.id,
|
|
iid: mr.iid,
|
|
title: mr.title,
|
|
description: mr.description,
|
|
state: mr.state,
|
|
draft: mr.draft,
|
|
author_username: mr.author_username,
|
|
source_branch: mr.source_branch,
|
|
target_branch: mr.target_branch,
|
|
created_at: mr.created_at,
|
|
updated_at: mr.updated_at,
|
|
merged_at: mr.merged_at,
|
|
closed_at: mr.closed_at,
|
|
web_url: mr.web_url,
|
|
project_path: mr.project_path,
|
|
labels,
|
|
assignees,
|
|
reviewers,
|
|
discussions,
|
|
})
|
|
}
|
|
|
|
struct MrRow {
|
|
id: i64,
|
|
iid: i64,
|
|
title: String,
|
|
description: Option<String>,
|
|
state: String,
|
|
draft: bool,
|
|
author_username: String,
|
|
source_branch: String,
|
|
target_branch: String,
|
|
created_at: i64,
|
|
updated_at: i64,
|
|
merged_at: Option<i64>,
|
|
closed_at: Option<i64>,
|
|
web_url: Option<String>,
|
|
project_path: String,
|
|
}
|
|
|
|
fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<MrRow> {
|
|
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
|
|
Some(project) => {
|
|
let project_id = resolve_project(conn, project)?;
|
|
(
|
|
"SELECT m.id, m.iid, m.title, m.description, m.state, m.draft,
|
|
m.author_username, m.source_branch, m.target_branch,
|
|
m.created_at, m.updated_at, m.merged_at, m.closed_at,
|
|
m.web_url, p.path_with_namespace
|
|
FROM merge_requests m
|
|
JOIN projects p ON m.project_id = p.id
|
|
WHERE m.iid = ? AND m.project_id = ?",
|
|
vec![Box::new(iid), Box::new(project_id)],
|
|
)
|
|
}
|
|
None => (
|
|
"SELECT m.id, m.iid, m.title, m.description, m.state, m.draft,
|
|
m.author_username, m.source_branch, m.target_branch,
|
|
m.created_at, m.updated_at, m.merged_at, m.closed_at,
|
|
m.web_url, p.path_with_namespace
|
|
FROM merge_requests m
|
|
JOIN projects p ON m.project_id = p.id
|
|
WHERE m.iid = ?",
|
|
vec![Box::new(iid)],
|
|
),
|
|
};
|
|
|
|
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
|
|
|
let mut stmt = conn.prepare(sql)?;
|
|
let mrs: Vec<MrRow> = stmt
|
|
.query_map(param_refs.as_slice(), |row| {
|
|
let draft_val: i64 = row.get(5)?;
|
|
Ok(MrRow {
|
|
id: row.get(0)?,
|
|
iid: row.get(1)?,
|
|
title: row.get(2)?,
|
|
description: row.get(3)?,
|
|
state: row.get(4)?,
|
|
draft: draft_val == 1,
|
|
author_username: row.get(6)?,
|
|
source_branch: row.get(7)?,
|
|
target_branch: row.get(8)?,
|
|
created_at: row.get(9)?,
|
|
updated_at: row.get(10)?,
|
|
merged_at: row.get(11)?,
|
|
closed_at: row.get(12)?,
|
|
web_url: row.get(13)?,
|
|
project_path: row.get(14)?,
|
|
})
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
match mrs.len() {
|
|
0 => Err(LoreError::NotFound(format!("MR !{} not found", iid))),
|
|
1 => Ok(mrs.into_iter().next().unwrap()),
|
|
_ => {
|
|
let projects: Vec<String> = mrs.iter().map(|m| m.project_path.clone()).collect();
|
|
Err(LoreError::Ambiguous(format!(
|
|
"MR !{} exists in multiple projects: {}. Use --project to specify.",
|
|
iid,
|
|
projects.join(", ")
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_mr_labels(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT l.name FROM labels l
|
|
JOIN mr_labels ml ON l.id = ml.label_id
|
|
WHERE ml.merge_request_id = ?
|
|
ORDER BY l.name",
|
|
)?;
|
|
|
|
let labels: Vec<String> = stmt
|
|
.query_map([mr_id], |row| row.get(0))?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
Ok(labels)
|
|
}
|
|
|
|
fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT username FROM mr_assignees
|
|
WHERE merge_request_id = ?
|
|
ORDER BY username",
|
|
)?;
|
|
|
|
let assignees: Vec<String> = stmt
|
|
.query_map([mr_id], |row| row.get(0))?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
Ok(assignees)
|
|
}
|
|
|
|
fn get_mr_reviewers(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT username FROM mr_reviewers
|
|
WHERE merge_request_id = ?
|
|
ORDER BY username",
|
|
)?;
|
|
|
|
let reviewers: Vec<String> = stmt
|
|
.query_map([mr_id], |row| row.get(0))?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
Ok(reviewers)
|
|
}
|
|
|
|
fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionDetail>> {
|
|
let mut disc_stmt = conn.prepare(
|
|
"SELECT id, individual_note FROM discussions
|
|
WHERE merge_request_id = ?
|
|
ORDER BY first_note_at",
|
|
)?;
|
|
|
|
let disc_rows: Vec<(i64, bool)> = disc_stmt
|
|
.query_map([mr_id], |row| {
|
|
let individual: i64 = row.get(1)?;
|
|
Ok((row.get(0)?, individual == 1))
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
let mut note_stmt = conn.prepare(
|
|
"SELECT author_username, body, created_at, is_system,
|
|
position_old_path, position_new_path, position_old_line,
|
|
position_new_line, position_type
|
|
FROM notes
|
|
WHERE discussion_id = ?
|
|
ORDER BY position",
|
|
)?;
|
|
|
|
let mut discussions = Vec::new();
|
|
for (disc_id, individual_note) in disc_rows {
|
|
let notes: Vec<MrNoteDetail> = note_stmt
|
|
.query_map([disc_id], |row| {
|
|
let is_system: i64 = row.get(3)?;
|
|
let old_path: Option<String> = row.get(4)?;
|
|
let new_path: Option<String> = row.get(5)?;
|
|
let old_line: Option<i64> = row.get(6)?;
|
|
let new_line: Option<i64> = row.get(7)?;
|
|
let position_type: Option<String> = row.get(8)?;
|
|
|
|
let position = if old_path.is_some()
|
|
|| new_path.is_some()
|
|
|| old_line.is_some()
|
|
|| new_line.is_some()
|
|
{
|
|
Some(DiffNotePosition {
|
|
old_path,
|
|
new_path,
|
|
old_line,
|
|
new_line,
|
|
position_type,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(MrNoteDetail {
|
|
author_username: row.get(0)?,
|
|
body: row.get(1)?,
|
|
created_at: row.get(2)?,
|
|
is_system: is_system == 1,
|
|
position,
|
|
})
|
|
})?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
let has_user_notes = notes.iter().any(|n| !n.is_system);
|
|
if has_user_notes || notes.is_empty() {
|
|
discussions.push(MrDiscussionDetail {
|
|
notes,
|
|
individual_note,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(discussions)
|
|
}
|
|
|
|
fn format_date(ms: i64) -> String {
|
|
render::format_date(ms)
|
|
}
|
|
|
|
fn wrap_text(text: &str, width: usize, indent: &str) -> String {
|
|
render::wrap_indent(text, width, indent)
|
|
}
|
|
|
|
pub fn print_show_issue(issue: &IssueDetail) {
|
|
// Title line
|
|
println!(
|
|
" Issue #{}: {}",
|
|
issue.iid,
|
|
Theme::bold().render(&issue.title),
|
|
);
|
|
|
|
// Details section
|
|
println!("{}", render::section_divider("Details"));
|
|
|
|
println!(
|
|
" Ref {}",
|
|
Theme::muted().render(&issue.references_full)
|
|
);
|
|
println!(
|
|
" Project {}",
|
|
Theme::info().render(&issue.project_path)
|
|
);
|
|
|
|
let (icon, state_style) = if issue.state == "opened" {
|
|
(Icons::issue_opened(), Theme::success())
|
|
} else {
|
|
(Icons::issue_closed(), Theme::dim())
|
|
};
|
|
println!(
|
|
" State {}",
|
|
state_style.render(&format!("{icon} {}", issue.state))
|
|
);
|
|
|
|
if let Some(status) = &issue.status_name {
|
|
println!(
|
|
" Status {}",
|
|
render::style_with_hex(status, issue.status_color.as_deref())
|
|
);
|
|
}
|
|
|
|
if issue.confidential {
|
|
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
|
|
}
|
|
|
|
println!(" Author @{}", issue.author_username);
|
|
|
|
if !issue.assignees.is_empty() {
|
|
let label = if issue.assignees.len() > 1 {
|
|
"Assignees"
|
|
} else {
|
|
"Assignee"
|
|
};
|
|
println!(
|
|
" {}{} {}",
|
|
label,
|
|
" ".repeat(12 - label.len()),
|
|
issue
|
|
.assignees
|
|
.iter()
|
|
.map(|a| format!("@{a}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
}
|
|
|
|
println!(
|
|
" Created {} ({})",
|
|
format_date(issue.created_at),
|
|
render::format_relative_time_compact(issue.created_at),
|
|
);
|
|
println!(
|
|
" Updated {} ({})",
|
|
format_date(issue.updated_at),
|
|
render::format_relative_time_compact(issue.updated_at),
|
|
);
|
|
|
|
if let Some(closed_at) = &issue.closed_at {
|
|
println!(" Closed {closed_at}");
|
|
}
|
|
|
|
if let Some(due) = &issue.due_date {
|
|
println!(" Due {due}");
|
|
}
|
|
|
|
if let Some(ms) = &issue.milestone {
|
|
println!(" Milestone {ms}");
|
|
}
|
|
|
|
if !issue.labels.is_empty() {
|
|
println!(
|
|
" Labels {}",
|
|
render::format_labels_bare(&issue.labels, issue.labels.len())
|
|
);
|
|
}
|
|
|
|
if let Some(url) = &issue.web_url {
|
|
println!(" URL {}", Theme::muted().render(url));
|
|
}
|
|
|
|
// Development section
|
|
if !issue.closing_merge_requests.is_empty() {
|
|
println!("{}", render::section_divider("Development"));
|
|
for mr in &issue.closing_merge_requests {
|
|
let (mr_icon, mr_style) = match mr.state.as_str() {
|
|
"merged" => (Icons::mr_merged(), Theme::accent()),
|
|
"opened" => (Icons::mr_opened(), Theme::success()),
|
|
"closed" => (Icons::mr_closed(), Theme::error()),
|
|
_ => (Icons::mr_opened(), Theme::dim()),
|
|
};
|
|
println!(
|
|
" {} !{} {} {}",
|
|
mr_style.render(mr_icon),
|
|
mr.iid,
|
|
mr.title,
|
|
mr_style.render(&mr.state),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Description section
|
|
println!("{}", render::section_divider("Description"));
|
|
if let Some(desc) = &issue.description {
|
|
let wrapped = wrap_text(desc, 72, " ");
|
|
println!(" {wrapped}");
|
|
} else {
|
|
println!(" {}", Theme::muted().render("(no description)"));
|
|
}
|
|
|
|
// Discussions section
|
|
let user_discussions: Vec<&DiscussionDetail> = issue
|
|
.discussions
|
|
.iter()
|
|
.filter(|d| d.notes.iter().any(|n| !n.is_system))
|
|
.collect();
|
|
|
|
if user_discussions.is_empty() {
|
|
println!("\n {}", Theme::muted().render("No discussions"));
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
|
);
|
|
|
|
for discussion in user_discussions {
|
|
let user_notes: Vec<&NoteDetail> =
|
|
discussion.notes.iter().filter(|n| !n.is_system).collect();
|
|
|
|
if let Some(first_note) = user_notes.first() {
|
|
println!(
|
|
" {} {}",
|
|
Theme::info().render(&format!("@{}", first_note.author_username)),
|
|
format_date(first_note.created_at),
|
|
);
|
|
let wrapped = wrap_text(&first_note.body, 68, " ");
|
|
println!(" {wrapped}");
|
|
println!();
|
|
|
|
for reply in user_notes.iter().skip(1) {
|
|
println!(
|
|
" {} {}",
|
|
Theme::info().render(&format!("@{}", reply.author_username)),
|
|
format_date(reply.created_at),
|
|
);
|
|
let wrapped = wrap_text(&reply.body, 66, " ");
|
|
println!(" {wrapped}");
|
|
println!();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn print_show_mr(mr: &MrDetail) {
|
|
// Title line
|
|
let draft_prefix = if mr.draft {
|
|
format!("{} ", Icons::mr_draft())
|
|
} else {
|
|
String::new()
|
|
};
|
|
println!(
|
|
" MR !{}: {}{}",
|
|
mr.iid,
|
|
draft_prefix,
|
|
Theme::bold().render(&mr.title),
|
|
);
|
|
|
|
// Details section
|
|
println!("{}", render::section_divider("Details"));
|
|
|
|
println!(" Project {}", Theme::info().render(&mr.project_path));
|
|
|
|
let (icon, state_style) = match mr.state.as_str() {
|
|
"opened" => (Icons::mr_opened(), Theme::success()),
|
|
"merged" => (Icons::mr_merged(), Theme::accent()),
|
|
"closed" => (Icons::mr_closed(), Theme::error()),
|
|
_ => (Icons::mr_opened(), Theme::dim()),
|
|
};
|
|
println!(
|
|
" State {}",
|
|
state_style.render(&format!("{icon} {}", mr.state))
|
|
);
|
|
|
|
println!(
|
|
" Branches {} -> {}",
|
|
Theme::info().render(&mr.source_branch),
|
|
Theme::warning().render(&mr.target_branch)
|
|
);
|
|
|
|
println!(" Author @{}", mr.author_username);
|
|
|
|
if !mr.assignees.is_empty() {
|
|
println!(
|
|
" Assignees {}",
|
|
mr.assignees
|
|
.iter()
|
|
.map(|a| format!("@{a}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
}
|
|
|
|
if !mr.reviewers.is_empty() {
|
|
println!(
|
|
" Reviewers {}",
|
|
mr.reviewers
|
|
.iter()
|
|
.map(|r| format!("@{r}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
}
|
|
|
|
println!(
|
|
" Created {} ({})",
|
|
format_date(mr.created_at),
|
|
render::format_relative_time_compact(mr.created_at),
|
|
);
|
|
println!(
|
|
" Updated {} ({})",
|
|
format_date(mr.updated_at),
|
|
render::format_relative_time_compact(mr.updated_at),
|
|
);
|
|
|
|
if let Some(merged_at) = mr.merged_at {
|
|
println!(
|
|
" Merged {} ({})",
|
|
format_date(merged_at),
|
|
render::format_relative_time_compact(merged_at),
|
|
);
|
|
}
|
|
|
|
if let Some(closed_at) = mr.closed_at {
|
|
println!(
|
|
" Closed {} ({})",
|
|
format_date(closed_at),
|
|
render::format_relative_time_compact(closed_at),
|
|
);
|
|
}
|
|
|
|
if !mr.labels.is_empty() {
|
|
println!(
|
|
" Labels {}",
|
|
render::format_labels_bare(&mr.labels, mr.labels.len())
|
|
);
|
|
}
|
|
|
|
if let Some(url) = &mr.web_url {
|
|
println!(" URL {}", Theme::muted().render(url));
|
|
}
|
|
|
|
// Description section
|
|
println!("{}", render::section_divider("Description"));
|
|
if let Some(desc) = &mr.description {
|
|
let wrapped = wrap_text(desc, 72, " ");
|
|
println!(" {wrapped}");
|
|
} else {
|
|
println!(" {}", Theme::muted().render("(no description)"));
|
|
}
|
|
|
|
// Discussions section
|
|
let user_discussions: Vec<&MrDiscussionDetail> = mr
|
|
.discussions
|
|
.iter()
|
|
.filter(|d| d.notes.iter().any(|n| !n.is_system))
|
|
.collect();
|
|
|
|
if user_discussions.is_empty() {
|
|
println!("\n {}", Theme::muted().render("No discussions"));
|
|
} else {
|
|
println!(
|
|
"{}",
|
|
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
|
);
|
|
|
|
for discussion in user_discussions {
|
|
let user_notes: Vec<&MrNoteDetail> =
|
|
discussion.notes.iter().filter(|n| !n.is_system).collect();
|
|
|
|
if let Some(first_note) = user_notes.first() {
|
|
if let Some(pos) = &first_note.position {
|
|
print_diff_position(pos);
|
|
}
|
|
|
|
println!(
|
|
" {} {}",
|
|
Theme::info().render(&format!("@{}", first_note.author_username)),
|
|
format_date(first_note.created_at),
|
|
);
|
|
let wrapped = wrap_text(&first_note.body, 68, " ");
|
|
println!(" {wrapped}");
|
|
println!();
|
|
|
|
for reply in user_notes.iter().skip(1) {
|
|
println!(
|
|
" {} {}",
|
|
Theme::info().render(&format!("@{}", reply.author_username)),
|
|
format_date(reply.created_at),
|
|
);
|
|
let wrapped = wrap_text(&reply.body, 66, " ");
|
|
println!(" {wrapped}");
|
|
println!();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_diff_position(pos: &DiffNotePosition) {
|
|
let file = pos.new_path.as_ref().or(pos.old_path.as_ref());
|
|
|
|
if let Some(file_path) = file {
|
|
let line_str = match (pos.old_line, pos.new_line) {
|
|
(Some(old), Some(new)) if old == new => format!(":{}", new),
|
|
(Some(old), Some(new)) => format!(":{}→{}", old, new),
|
|
(None, Some(new)) => format!(":+{}", new),
|
|
(Some(old), None) => format!(":-{}", old),
|
|
(None, None) => String::new(),
|
|
};
|
|
|
|
println!(
|
|
" {} {}{}",
|
|
Theme::dim().render("\u{1f4cd}"),
|
|
Theme::warning().render(file_path),
|
|
Theme::dim().render(&line_str)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct IssueDetailJson {
|
|
pub id: i64,
|
|
pub iid: i64,
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub state: String,
|
|
pub author_username: String,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
pub closed_at: Option<String>,
|
|
pub confidential: bool,
|
|
pub web_url: Option<String>,
|
|
pub project_path: String,
|
|
pub references_full: String,
|
|
pub labels: Vec<String>,
|
|
pub assignees: Vec<String>,
|
|
pub due_date: Option<String>,
|
|
pub milestone: Option<String>,
|
|
pub user_notes_count: i64,
|
|
pub merge_requests_count: usize,
|
|
pub closing_merge_requests: Vec<ClosingMrRefJson>,
|
|
pub discussions: Vec<DiscussionDetailJson>,
|
|
pub status_name: Option<String>,
|
|
#[serde(skip_serializing)]
|
|
pub status_category: Option<String>,
|
|
pub status_color: Option<String>,
|
|
pub status_icon_name: Option<String>,
|
|
pub status_synced_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ClosingMrRefJson {
|
|
pub iid: i64,
|
|
pub title: String,
|
|
pub state: String,
|
|
pub web_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct DiscussionDetailJson {
|
|
pub notes: Vec<NoteDetailJson>,
|
|
pub individual_note: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct NoteDetailJson {
|
|
pub author_username: String,
|
|
pub body: String,
|
|
pub created_at: String,
|
|
pub is_system: bool,
|
|
}
|
|
|
|
impl From<&IssueDetail> for IssueDetailJson {
|
|
fn from(issue: &IssueDetail) -> Self {
|
|
Self {
|
|
id: issue.id,
|
|
iid: issue.iid,
|
|
title: issue.title.clone(),
|
|
description: issue.description.clone(),
|
|
state: issue.state.clone(),
|
|
author_username: issue.author_username.clone(),
|
|
created_at: ms_to_iso(issue.created_at),
|
|
updated_at: ms_to_iso(issue.updated_at),
|
|
closed_at: issue.closed_at.clone(),
|
|
confidential: issue.confidential,
|
|
web_url: issue.web_url.clone(),
|
|
project_path: issue.project_path.clone(),
|
|
references_full: issue.references_full.clone(),
|
|
labels: issue.labels.clone(),
|
|
assignees: issue.assignees.clone(),
|
|
due_date: issue.due_date.clone(),
|
|
milestone: issue.milestone.clone(),
|
|
user_notes_count: issue.user_notes_count,
|
|
merge_requests_count: issue.merge_requests_count,
|
|
closing_merge_requests: issue
|
|
.closing_merge_requests
|
|
.iter()
|
|
.map(|mr| ClosingMrRefJson {
|
|
iid: mr.iid,
|
|
title: mr.title.clone(),
|
|
state: mr.state.clone(),
|
|
web_url: mr.web_url.clone(),
|
|
})
|
|
.collect(),
|
|
discussions: issue.discussions.iter().map(|d| d.into()).collect(),
|
|
status_name: issue.status_name.clone(),
|
|
status_category: issue.status_category.clone(),
|
|
status_color: issue.status_color.clone(),
|
|
status_icon_name: issue.status_icon_name.clone(),
|
|
status_synced_at: issue.status_synced_at.map(ms_to_iso),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&DiscussionDetail> for DiscussionDetailJson {
|
|
fn from(disc: &DiscussionDetail) -> Self {
|
|
Self {
|
|
notes: disc.notes.iter().map(|n| n.into()).collect(),
|
|
individual_note: disc.individual_note,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&NoteDetail> for NoteDetailJson {
|
|
fn from(note: &NoteDetail) -> Self {
|
|
Self {
|
|
author_username: note.author_username.clone(),
|
|
body: note.body.clone(),
|
|
created_at: ms_to_iso(note.created_at),
|
|
is_system: note.is_system,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct MrDetailJson {
|
|
pub id: i64,
|
|
pub iid: i64,
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub state: String,
|
|
pub draft: bool,
|
|
pub author_username: String,
|
|
pub source_branch: String,
|
|
pub target_branch: String,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
pub merged_at: Option<String>,
|
|
pub closed_at: Option<String>,
|
|
pub web_url: Option<String>,
|
|
pub project_path: String,
|
|
pub labels: Vec<String>,
|
|
pub assignees: Vec<String>,
|
|
pub reviewers: Vec<String>,
|
|
pub discussions: Vec<MrDiscussionDetailJson>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct MrDiscussionDetailJson {
|
|
pub notes: Vec<MrNoteDetailJson>,
|
|
pub individual_note: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct MrNoteDetailJson {
|
|
pub author_username: String,
|
|
pub body: String,
|
|
pub created_at: String,
|
|
pub is_system: bool,
|
|
pub position: Option<DiffNotePosition>,
|
|
}
|
|
|
|
impl From<&MrDetail> for MrDetailJson {
|
|
fn from(mr: &MrDetail) -> Self {
|
|
Self {
|
|
id: mr.id,
|
|
iid: mr.iid,
|
|
title: mr.title.clone(),
|
|
description: mr.description.clone(),
|
|
state: mr.state.clone(),
|
|
draft: mr.draft,
|
|
author_username: mr.author_username.clone(),
|
|
source_branch: mr.source_branch.clone(),
|
|
target_branch: mr.target_branch.clone(),
|
|
created_at: ms_to_iso(mr.created_at),
|
|
updated_at: ms_to_iso(mr.updated_at),
|
|
merged_at: mr.merged_at.map(ms_to_iso),
|
|
closed_at: mr.closed_at.map(ms_to_iso),
|
|
web_url: mr.web_url.clone(),
|
|
project_path: mr.project_path.clone(),
|
|
labels: mr.labels.clone(),
|
|
assignees: mr.assignees.clone(),
|
|
reviewers: mr.reviewers.clone(),
|
|
discussions: mr.discussions.iter().map(|d| d.into()).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&MrDiscussionDetail> for MrDiscussionDetailJson {
|
|
fn from(disc: &MrDiscussionDetail) -> Self {
|
|
Self {
|
|
notes: disc.notes.iter().map(|n| n.into()).collect(),
|
|
individual_note: disc.individual_note,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&MrNoteDetail> for MrNoteDetailJson {
|
|
fn from(note: &MrNoteDetail) -> Self {
|
|
Self {
|
|
author_username: note.author_username.clone(),
|
|
body: note.body.clone(),
|
|
created_at: ms_to_iso(note.created_at),
|
|
is_system: note.is_system,
|
|
position: note.position.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn print_show_issue_json(issue: &IssueDetail, elapsed_ms: u64) {
|
|
let json_result = IssueDetailJson::from(issue);
|
|
let meta = RobotMeta { elapsed_ms };
|
|
let output = serde_json::json!({
|
|
"ok": true,
|
|
"data": json_result,
|
|
"meta": meta,
|
|
});
|
|
match serde_json::to_string(&output) {
|
|
Ok(json) => println!("{json}"),
|
|
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
|
}
|
|
}
|
|
|
|
pub fn print_show_mr_json(mr: &MrDetail, elapsed_ms: u64) {
|
|
let json_result = MrDetailJson::from(mr);
|
|
let meta = RobotMeta { elapsed_ms };
|
|
let output = serde_json::json!({
|
|
"ok": true,
|
|
"data": json_result,
|
|
"meta": meta,
|
|
});
|
|
match serde_json::to_string(&output) {
|
|
Ok(json) => println!("{json}"),
|
|
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::core::db::run_migrations;
|
|
use std::path::Path;
|
|
|
|
fn setup_test_db() -> Connection {
|
|
let conn = create_connection(Path::new(":memory:")).unwrap();
|
|
run_migrations(&conn).unwrap();
|
|
conn
|
|
}
|
|
|
|
fn seed_project(conn: &Connection) {
|
|
conn.execute(
|
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
|
VALUES (1, 100, 'group/repo', 'https://gitlab.example.com', 1000, 2000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
fn seed_issue(conn: &Connection) {
|
|
seed_project(conn);
|
|
conn.execute(
|
|
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,
|
|
created_at, updated_at, last_seen_at)
|
|
VALUES (1, 200, 10, 1, 'Test issue', 'opened', 'author', 1000, 2000, 2000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
fn seed_second_project(conn: &Connection) {
|
|
conn.execute(
|
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
|
VALUES (2, 101, 'other/repo', 'https://gitlab.example.com/other', 1000, 2000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
fn seed_discussion_with_notes(
|
|
conn: &Connection,
|
|
issue_id: i64,
|
|
project_id: i64,
|
|
user_notes: usize,
|
|
system_notes: usize,
|
|
) {
|
|
let disc_id: i64 = conn
|
|
.query_row(
|
|
"SELECT COALESCE(MAX(id), 0) + 1 FROM discussions",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, first_note_at, last_note_at, last_seen_at)
|
|
VALUES (?1, ?2, ?3, ?4, 'Issue', 1000, 2000, 2000)",
|
|
rusqlite::params![disc_id, format!("disc-{}", disc_id), project_id, issue_id],
|
|
)
|
|
.unwrap();
|
|
for i in 0..user_notes {
|
|
conn.execute(
|
|
"INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position)
|
|
VALUES (?1, ?2, ?3, 'user1', 'comment', 1000, 2000, 2000, 0, ?4)",
|
|
rusqlite::params![1000 + disc_id * 100 + i as i64, disc_id, project_id, i as i64],
|
|
)
|
|
.unwrap();
|
|
}
|
|
for i in 0..system_notes {
|
|
conn.execute(
|
|
"INSERT INTO notes (gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position)
|
|
VALUES (?1, ?2, ?3, 'system', 'status changed', 1000, 2000, 2000, 1, ?4)",
|
|
rusqlite::params![2000 + disc_id * 100 + i as i64, disc_id, project_id, (user_notes + i) as i64],
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
// --- find_issue tests ---
|
|
|
|
#[test]
|
|
fn test_find_issue_basic() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
let row = find_issue(&conn, 10, None).unwrap();
|
|
assert_eq!(row.iid, 10);
|
|
assert_eq!(row.title, "Test issue");
|
|
assert_eq!(row.state, "opened");
|
|
assert_eq!(row.author_username, "author");
|
|
assert_eq!(row.project_path, "group/repo");
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_issue_with_project_filter() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
let row = find_issue(&conn, 10, Some("group/repo")).unwrap();
|
|
assert_eq!(row.iid, 10);
|
|
assert_eq!(row.project_path, "group/repo");
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_issue_not_found() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
let err = find_issue(&conn, 999, None).unwrap_err();
|
|
assert!(matches!(err, LoreError::NotFound(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_issue_wrong_project_filter() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
seed_second_project(&conn);
|
|
// Issue 10 only exists in project 1, not project 2
|
|
let err = find_issue(&conn, 10, Some("other/repo")).unwrap_err();
|
|
assert!(matches!(err, LoreError::NotFound(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_issue_ambiguous_without_project() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn); // issue iid=10 in project 1
|
|
seed_second_project(&conn);
|
|
conn.execute(
|
|
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,
|
|
created_at, updated_at, last_seen_at)
|
|
VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let err = find_issue(&conn, 10, None).unwrap_err();
|
|
assert!(matches!(err, LoreError::Ambiguous(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_issue_ambiguous_resolved_with_project() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
seed_second_project(&conn);
|
|
conn.execute(
|
|
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,
|
|
created_at, updated_at, last_seen_at)
|
|
VALUES (2, 201, 10, 2, 'Same iid different project', 'opened', 'author', 1000, 2000, 2000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let row = find_issue(&conn, 10, Some("other/repo")).unwrap();
|
|
assert_eq!(row.title, "Same iid different project");
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_issue_user_notes_count_zero() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
let row = find_issue(&conn, 10, None).unwrap();
|
|
assert_eq!(row.user_notes_count, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_issue_user_notes_count_excludes_system() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
// 2 user notes + 3 system notes = should count only 2
|
|
seed_discussion_with_notes(&conn, 1, 1, 2, 3);
|
|
let row = find_issue(&conn, 10, None).unwrap();
|
|
assert_eq!(row.user_notes_count, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_issue_user_notes_count_across_discussions() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
seed_discussion_with_notes(&conn, 1, 1, 3, 0); // 3 user notes
|
|
seed_discussion_with_notes(&conn, 1, 1, 1, 2); // 1 user note + 2 system
|
|
let row = find_issue(&conn, 10, None).unwrap();
|
|
assert_eq!(row.user_notes_count, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_issue_notes_count_ignores_other_issues() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
// Add a second issue
|
|
conn.execute(
|
|
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,
|
|
created_at, updated_at, last_seen_at)
|
|
VALUES (2, 201, 20, 1, 'Other issue', 'opened', 'author', 1000, 2000, 2000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
// Notes on issue 2, not issue 1
|
|
seed_discussion_with_notes(&conn, 2, 1, 5, 0);
|
|
let row = find_issue(&conn, 10, None).unwrap();
|
|
assert_eq!(row.user_notes_count, 0); // Issue 10 has no notes
|
|
}
|
|
|
|
#[test]
|
|
fn test_ansi256_from_rgb() {
|
|
// Moved to render.rs — keeping basic hex sanity check
|
|
let result = render::style_with_hex("test", Some("#ff0000"));
|
|
assert!(!result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_issue_assignees_empty() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
let result = get_issue_assignees(&conn, 1).unwrap();
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_issue_assignees_single() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
conn.execute(
|
|
"INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'charlie')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let result = get_issue_assignees(&conn, 1).unwrap();
|
|
assert_eq!(result, vec!["charlie"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_issue_assignees_multiple_sorted() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
conn.execute(
|
|
"INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'bob')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'alice')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let result = get_issue_assignees(&conn, 1).unwrap();
|
|
assert_eq!(result, vec!["alice", "bob"]); // alphabetical
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_closing_mrs_empty() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
let result = get_closing_mrs(&conn, 1).unwrap();
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_closing_mrs_single() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
conn.execute(
|
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username,
|
|
source_branch, target_branch, created_at, updated_at, last_seen_at)
|
|
VALUES (1, 300, 5, 1, 'Fix the bug', 'merged', 'dev', 'fix', 'main', 1000, 2000, 2000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id,
|
|
target_entity_type, target_entity_id, reference_type, source_method, created_at)
|
|
VALUES (1, 'merge_request', 1, 'issue', 1, 'closes', 'api', 3000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let result = get_closing_mrs(&conn, 1).unwrap();
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].iid, 5);
|
|
assert_eq!(result[0].title, "Fix the bug");
|
|
assert_eq!(result[0].state, "merged");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_closing_mrs_ignores_mentioned() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
// Add a 'mentioned' reference that should be ignored
|
|
conn.execute(
|
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username,
|
|
source_branch, target_branch, created_at, updated_at, last_seen_at)
|
|
VALUES (1, 300, 5, 1, 'Some MR', 'opened', 'dev', 'feat', 'main', 1000, 2000, 2000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id,
|
|
target_entity_type, target_entity_id, reference_type, source_method, created_at)
|
|
VALUES (1, 'merge_request', 1, 'issue', 1, 'mentioned', 'note_parse', 3000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let result = get_closing_mrs(&conn, 1).unwrap();
|
|
assert!(result.is_empty()); // 'mentioned' refs not included
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_closing_mrs_multiple_sorted() {
|
|
let conn = setup_test_db();
|
|
seed_issue(&conn);
|
|
conn.execute(
|
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username,
|
|
source_branch, target_branch, created_at, updated_at, last_seen_at)
|
|
VALUES (1, 300, 8, 1, 'Second fix', 'opened', 'dev', 'fix2', 'main', 1000, 2000, 2000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username,
|
|
source_branch, target_branch, created_at, updated_at, last_seen_at)
|
|
VALUES (2, 301, 5, 1, 'First fix', 'merged', 'dev', 'fix1', 'main', 1000, 2000, 2000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id,
|
|
target_entity_type, target_entity_id, reference_type, source_method, created_at)
|
|
VALUES (1, 'merge_request', 1, 'issue', 1, 'closes', 'api', 3000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id,
|
|
target_entity_type, target_entity_id, reference_type, source_method, created_at)
|
|
VALUES (1, 'merge_request', 2, 'issue', 1, 'closes', 'api', 3000)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let result = get_closing_mrs(&conn, 1).unwrap();
|
|
assert_eq!(result.len(), 2);
|
|
assert_eq!(result[0].iid, 5); // Lower iid first
|
|
assert_eq!(result[1].iid, 8);
|
|
}
|
|
|
|
#[test]
|
|
fn wrap_text_single_line() {
|
|
assert_eq!(wrap_text("hello world", 80, " "), "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn wrap_text_multiple_lines() {
|
|
let result = wrap_text("one two three four five", 10, " ");
|
|
assert!(result.contains('\n'));
|
|
}
|
|
|
|
#[test]
|
|
fn format_date_extracts_date_part() {
|
|
let ms = 1705276800000;
|
|
let date = format_date(ms);
|
|
assert!(date.starts_with("2024-01-15"));
|
|
}
|
|
}
|