From 784fe79b80743fdf89272c794e656130e8fad3a4 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Thu, 5 Feb 2026 11:22:02 -0500 Subject: [PATCH] feat(show): Enrich issue detail with assignees, milestones, and closing MRs Issue detail now includes: - assignees: List of assigned usernames from issue_assignees table - due_date: Issue due date when set - milestone: Milestone title when assigned - closing_merge_requests: MRs that will close this issue when merged Closing MR detection: - Queries entity_references table for 'closes' reference type - Shows MR iid, title, state (with color coding) in terminal output - Full MR metadata included in JSON output Human-readable output: - "Assignees:" line shows comma-separated @usernames - "Development:" section lists closing MRs with state indicator - Green for merged, cyan for opened, red for closed JSON output: - New fields: assignees, due_date, milestone, closing_merge_requests - closing_merge_requests array contains iid, title, state, web_url Test coverage: - get_issue_assignees: empty, single, multiple (alphabetical order) - get_closing_mrs: empty, single, ignores 'mentioned' references Co-Authored-By: Claude Opus 4.5 --- src/cli/commands/show.rs | 298 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 296 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/show.rs b/src/cli/commands/show.rs index 28e23a4..edc65a0 100644 --- a/src/cli/commands/show.rs +++ b/src/cli/commands/show.rs @@ -56,6 +56,14 @@ pub struct DiffNotePosition { pub position_type: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct ClosingMrRef { + pub iid: i64, + pub title: String, + pub state: String, + pub web_url: Option, +} + #[derive(Debug, Serialize)] pub struct IssueDetail { pub id: i64, @@ -69,6 +77,10 @@ pub struct IssueDetail { pub web_url: Option, pub project_path: String, pub labels: Vec, + pub assignees: Vec, + pub due_date: Option, + pub milestone: Option, + pub closing_merge_requests: Vec, pub discussions: Vec, } @@ -98,6 +110,10 @@ pub fn run_show_issue( 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)?; Ok(IssueDetail { @@ -112,6 +128,10 @@ pub fn run_show_issue( web_url: issue.web_url, project_path: issue.project_path, labels, + assignees, + due_date: issue.due_date, + milestone: issue.milestone_title, + closing_merge_requests: closing_mrs, discussions, }) } @@ -127,6 +147,8 @@ struct IssueRow { updated_at: i64, web_url: Option, project_path: String, + due_date: Option, + milestone_title: Option, } fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { @@ -135,7 +157,8 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu 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.web_url, p.path_with_namespace + i.created_at, i.updated_at, i.web_url, p.path_with_namespace, + i.due_date, i.milestone_title FROM issues i JOIN projects p ON i.project_id = p.id WHERE i.iid = ? AND i.project_id = ?", @@ -144,7 +167,8 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu } None => ( "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, - i.created_at, i.updated_at, i.web_url, p.path_with_namespace + i.created_at, i.updated_at, i.web_url, p.path_with_namespace, + i.due_date, i.milestone_title FROM issues i JOIN projects p ON i.project_id = p.id WHERE i.iid = ?", @@ -168,6 +192,8 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu updated_at: row.get(7)?, web_url: row.get(8)?, project_path: row.get(9)?, + due_date: row.get(10)?, + milestone_title: row.get(11)?, }) })? .collect::, _>>()?; @@ -201,6 +227,46 @@ fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result> { Ok(labels) } +fn get_issue_assignees(conn: &Connection, issue_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT username FROM issue_assignees + WHERE issue_id = ? + ORDER BY username", + )?; + + let assignees: Vec = stmt + .query_map([issue_id], |row| row.get(0))? + .collect::, _>>()?; + + Ok(assignees) +} + +fn get_closing_mrs(conn: &Connection, issue_id: i64) -> Result> { + 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 = 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::, _>>()?; + + Ok(mrs) +} + fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result> { let mut disc_stmt = conn.prepare( "SELECT id, individual_note FROM discussions @@ -546,15 +612,57 @@ pub fn print_show_issue(issue: &IssueDetail) { println!("State: {}", state_styled); println!("Author: @{}", issue.author_username); + + if !issue.assignees.is_empty() { + let label = if issue.assignees.len() > 1 { + "Assignees" + } else { + "Assignee" + }; + println!( + "{}:{} {}", + label, + " ".repeat(10 - label.len()), + issue + .assignees + .iter() + .map(|a| format!("@{}", a)) + .collect::>() + .join(", ") + ); + } + println!("Created: {}", format_date(issue.created_at)); println!("Updated: {}", format_date(issue.updated_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: {}", style("(none)").dim()); } else { println!("Labels: {}", issue.labels.join(", ")); } + if !issue.closing_merge_requests.is_empty() { + println!(); + println!("{}", style("Development:").bold()); + for mr in &issue.closing_merge_requests { + let state_indicator = match mr.state.as_str() { + "merged" => style(&mr.state).green(), + "opened" => style(&mr.state).cyan(), + "closed" => style(&mr.state).red(), + _ => style(&mr.state).dim(), + }; + println!(" !{} {} ({})", mr.iid, mr.title, state_indicator); + } + } + if let Some(url) = &issue.web_url { println!("URL: {}", style(url).dim()); } @@ -779,9 +887,21 @@ pub struct IssueDetailJson { pub web_url: Option, pub project_path: String, pub labels: Vec, + pub assignees: Vec, + pub due_date: Option, + pub milestone: Option, + pub closing_merge_requests: Vec, pub discussions: Vec, } +#[derive(Serialize)] +pub struct ClosingMrRefJson { + pub iid: i64, + pub title: String, + pub state: String, + pub web_url: Option, +} + #[derive(Serialize)] pub struct DiscussionDetailJson { pub notes: Vec, @@ -810,6 +930,19 @@ impl From<&IssueDetail> for IssueDetailJson { web_url: issue.web_url.clone(), project_path: issue.project_path.clone(), labels: issue.labels.clone(), + assignees: issue.assignees.clone(), + due_date: issue.due_date.clone(), + milestone: issue.milestone.clone(), + 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(), } } @@ -939,6 +1072,167 @@ pub fn print_show_mr_json(mr: &MrDetail) { #[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(); + } + + #[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 truncate_leaves_short_strings() {