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>,
|
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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user