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 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-05 11:22:02 -05:00
parent db750e4fc5
commit 784fe79b80

View File

@@ -56,6 +56,14 @@ pub struct DiffNotePosition {
pub position_type: Option<String>, 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)] #[derive(Debug, Serialize)]
pub struct IssueDetail { pub struct IssueDetail {
pub id: i64, pub id: i64,
@@ -69,6 +77,10 @@ pub struct IssueDetail {
pub web_url: Option<String>, pub web_url: Option<String>,
pub project_path: String, pub project_path: String,
pub labels: Vec<String>, pub labels: Vec<String>,
pub assignees: Vec<String>,
pub due_date: Option<String>,
pub milestone: Option<String>,
pub closing_merge_requests: Vec<ClosingMrRef>,
pub discussions: Vec<DiscussionDetail>, pub discussions: Vec<DiscussionDetail>,
} }
@@ -98,6 +110,10 @@ pub fn run_show_issue(
let labels = get_issue_labels(&conn, issue.id)?; 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 discussions = get_issue_discussions(&conn, issue.id)?;
Ok(IssueDetail { Ok(IssueDetail {
@@ -112,6 +128,10 @@ pub fn run_show_issue(
web_url: issue.web_url, web_url: issue.web_url,
project_path: issue.project_path, project_path: issue.project_path,
labels, labels,
assignees,
due_date: issue.due_date,
milestone: issue.milestone_title,
closing_merge_requests: closing_mrs,
discussions, discussions,
}) })
} }
@@ -127,6 +147,8 @@ struct IssueRow {
updated_at: i64, updated_at: i64,
web_url: Option<String>, web_url: Option<String>,
project_path: String, project_path: String,
due_date: Option<String>,
milestone_title: Option<String>,
} }
fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<IssueRow> { fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<IssueRow> {
@@ -135,7 +157,8 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
let project_id = resolve_project(conn, project)?; let project_id = resolve_project(conn, project)?;
( (
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, "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 FROM issues i
JOIN projects p ON i.project_id = p.id JOIN projects p ON i.project_id = p.id
WHERE i.iid = ? AND i.project_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 => ( None => (
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, "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 FROM issues i
JOIN projects p ON i.project_id = p.id JOIN projects p ON i.project_id = p.id
WHERE i.iid = ?", WHERE i.iid = ?",
@@ -168,6 +192,8 @@ fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Resu
updated_at: row.get(7)?, updated_at: row.get(7)?,
web_url: row.get(8)?, web_url: row.get(8)?,
project_path: row.get(9)?, project_path: row.get(9)?,
due_date: row.get(10)?,
milestone_title: row.get(11)?,
}) })
})? })?
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
@@ -201,6 +227,46 @@ fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {
Ok(labels) 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>> { fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<DiscussionDetail>> {
let mut disc_stmt = conn.prepare( let mut disc_stmt = conn.prepare(
"SELECT id, individual_note FROM discussions "SELECT id, individual_note FROM discussions
@@ -546,15 +612,57 @@ pub fn print_show_issue(issue: &IssueDetail) {
println!("State: {}", state_styled); println!("State: {}", state_styled);
println!("Author: @{}", issue.author_username); 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::<Vec<_>>()
.join(", ")
);
}
println!("Created: {}", format_date(issue.created_at)); println!("Created: {}", format_date(issue.created_at));
println!("Updated: {}", format_date(issue.updated_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() { if issue.labels.is_empty() {
println!("Labels: {}", style("(none)").dim()); println!("Labels: {}", style("(none)").dim());
} else { } else {
println!("Labels: {}", issue.labels.join(", ")); 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 { if let Some(url) = &issue.web_url {
println!("URL: {}", style(url).dim()); println!("URL: {}", style(url).dim());
} }
@@ -779,9 +887,21 @@ pub struct IssueDetailJson {
pub web_url: Option<String>, pub web_url: Option<String>,
pub project_path: String, pub project_path: String,
pub labels: Vec<String>, pub labels: Vec<String>,
pub assignees: Vec<String>,
pub due_date: Option<String>,
pub milestone: Option<String>,
pub closing_merge_requests: Vec<ClosingMrRefJson>,
pub discussions: Vec<DiscussionDetailJson>, pub discussions: Vec<DiscussionDetailJson>,
} }
#[derive(Serialize)]
pub struct ClosingMrRefJson {
pub iid: i64,
pub title: String,
pub state: String,
pub web_url: Option<String>,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct DiscussionDetailJson { pub struct DiscussionDetailJson {
pub notes: Vec<NoteDetailJson>, pub notes: Vec<NoteDetailJson>,
@@ -810,6 +930,19 @@ impl From<&IssueDetail> for IssueDetailJson {
web_url: issue.web_url.clone(), web_url: issue.web_url.clone(),
project_path: issue.project_path.clone(), project_path: issue.project_path.clone(),
labels: issue.labels.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(), discussions: issue.discussions.iter().map(|d| d.into()).collect(),
} }
} }
@@ -939,6 +1072,167 @@ pub fn print_show_mr_json(mr: &MrDetail) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn truncate_leaves_short_strings() { fn truncate_leaves_short_strings() {