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:
@@ -56,6 +56,14 @@ pub struct DiffNotePosition {
|
||||
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,
|
||||
@@ -69,6 +77,10 @@ pub struct IssueDetail {
|
||||
pub web_url: Option<String>,
|
||||
pub project_path: 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>,
|
||||
}
|
||||
|
||||
@@ -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<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> {
|
||||
@@ -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::<std::result::Result<Vec<_>, _>>()?;
|
||||
@@ -201,6 +227,46 @@ fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {
|
||||
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
|
||||
@@ -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::<Vec<_>>()
|
||||
.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<String>,
|
||||
pub project_path: 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>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user