From 7e5ffe35d359880bf73494230b8876c6848ede78 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 12 Mar 2026 10:06:54 -0400 Subject: [PATCH] feat(explain): enrich output with project path, thread excerpts, entity state, and timeline metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple improvements to the explain command's data richness: - Add project_path to EntitySummary so consumers can construct URLs from project + entity_type + iid without extra lookups - Include first_note_excerpt (first 200 chars) in open threads so agents and humans get thread context without a separate query - Add state and direction fields to RelatedIssue — consumers now see whether referenced entities are open/closed/merged and whether the reference is incoming or outgoing - Filter out self-references in both outgoing and incoming related entity queries (entity referencing itself via cross-reference extraction) - Wrap timeline excerpt in TimelineExcerpt struct with total_events and truncated fields — consumers know when events were omitted - Keep most recent events (tail) instead of oldest (head) when truncating timeline — recent activity is more actionable - Floor activity summary first_event at entity created_at — label events from bulk operations can predate entity creation - Human output: show project path in header, thread excerpt preview, state badges on related entities, directional arrows, truncation counts --- src/cli/commands/explain.rs | 176 ++++++++++++++++++++++++++++++------ 1 file changed, 148 insertions(+), 28 deletions(-) diff --git a/src/cli/commands/explain.rs b/src/cli/commands/explain.rs index db92fe7..5d405ce 100644 --- a/src/cli/commands/explain.rs +++ b/src/cli/commands/explain.rs @@ -36,7 +36,7 @@ pub struct ExplainResult { #[serde(skip_serializing_if = "Option::is_none")] pub related: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub timeline_excerpt: Option>, + pub timeline_excerpt: Option, } #[derive(Debug, Serialize)] @@ -52,6 +52,7 @@ pub struct EntitySummary { pub created_at: String, pub updated_at: String, pub url: Option, + pub project_path: String, pub status_name: Option, } @@ -80,6 +81,8 @@ pub struct OpenThread { pub started_at: String, pub note_count: usize, pub last_note_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_note_excerpt: Option, } #[derive(Debug, Serialize)] @@ -101,7 +104,16 @@ pub struct RelatedEntityInfo { pub entity_type: String, pub iid: i64, pub title: Option, + pub state: Option, pub reference_type: String, + pub direction: String, +} + +#[derive(Debug, Serialize)] +pub struct TimelineExcerpt { + pub events: Vec, + pub total_events: usize, + pub truncated: bool, } #[derive(Debug, Serialize)] @@ -218,6 +230,7 @@ fn find_explain_issue( created_at: ms_to_iso(r.created_at), updated_at: ms_to_iso(r.updated_at), url: r.web_url, + project_path: project_path.clone(), status_name: r.status_name, }; Ok((summary, local_id, project_path)) @@ -296,6 +309,7 @@ fn find_explain_mr( created_at: ms_to_iso(r.created_at), updated_at: ms_to_iso(r.updated_at), url: r.web_url, + project_path: project_path.clone(), status_name: None, }; Ok((summary, local_id, project_path)) @@ -385,15 +399,17 @@ fn truncate_description(desc: Option<&str>, max_len: usize) -> String { pub fn run_explain(conn: &Connection, params: &ExplainParams) -> Result { let project_filter = params.project.as_deref(); - let (entity_summary, entity_local_id, _project_path, description) = + let (entity_summary, entity_local_id, _project_path, description, created_at_ms) = if params.entity_type == "issues" { let (summary, local_id, path) = find_explain_issue(conn, params.iid, project_filter)?; let desc = get_issue_description(conn, local_id)?; - (summary, local_id, path, desc) + let created_at_ms = get_issue_created_at(conn, local_id)?; + (summary, local_id, path, desc, created_at_ms) } else { let (summary, local_id, path) = find_explain_mr(conn, params.iid, project_filter)?; let desc = get_mr_description(conn, local_id)?; - (summary, local_id, path, desc) + let created_at_ms = get_mr_created_at(conn, local_id)?; + (summary, local_id, path, desc, created_at_ms) }; let description_excerpt = if should_include(¶ms.sections, "description") { @@ -420,6 +436,7 @@ pub fn run_explain(conn: &Connection, params: &ExplainParams) -> Result Result> { Ok(desc) } +fn get_issue_created_at(conn: &Connection, issue_id: i64) -> Result { + let ts: i64 = conn.query_row( + "SELECT created_at FROM issues WHERE id = ?", + [issue_id], + |row| row.get(0), + )?; + Ok(ts) +} + +fn get_mr_created_at(conn: &Connection, mr_id: i64) -> Result { + let ts: i64 = conn.query_row( + "SELECT created_at FROM merge_requests WHERE id = ?", + [mr_id], + |row| row.get(0), + )?; + Ok(ts) +} + // --------------------------------------------------------------------------- // Key-decisions heuristic (Task 2) // --------------------------------------------------------------------------- @@ -664,6 +699,7 @@ fn build_activity_summary( entity_type: &str, entity_id: i64, since: Option, + created_at_ms: i64, ) -> Result { let id_col = id_column_for(entity_type); @@ -702,11 +738,14 @@ fn build_activity_summary( })?; let notes = notes_count as usize; + // Floor first_event at created_at — label events can predate entity creation + // due to bulk operations or API imports let first_event = [state_min, label_min, note_min] .iter() .copied() .flatten() - .min(); + .min() + .map(|ts| ts.max(created_at_ms)); let last_event = [state_max, label_max, note_max] .iter() .copied() @@ -740,7 +779,10 @@ fn fetch_open_threads( WHERE n2.discussion_id = d.id AND n2.is_system = 0) AS note_count, \ (SELECT n3.author_username FROM notes n3 \ WHERE n3.discussion_id = d.id \ - ORDER BY n3.created_at ASC LIMIT 1) AS started_by \ + ORDER BY n3.created_at ASC LIMIT 1) AS started_by, \ + (SELECT SUBSTR(n4.body, 1, 200) FROM notes n4 \ + WHERE n4.discussion_id = d.id AND n4.is_system = 0 \ + ORDER BY n4.created_at ASC LIMIT 1) AS first_note_body \ FROM discussions d \ WHERE d.{id_col} = ?1 \ AND d.resolvable = 1 \ @@ -752,12 +794,14 @@ fn fetch_open_threads( let threads = stmt .query_map([entity_id], |row| { let count: i64 = row.get(3)?; + let first_note_body: Option = row.get(5)?; Ok(OpenThread { discussion_id: row.get(0)?, started_at: ms_to_iso(row.get::<_, i64>(1)?), last_note_at: ms_to_iso(row.get::<_, i64>(2)?), note_count: count as usize, started_by: row.get(4)?, + first_note_excerpt: first_note_body, }) })? .collect::, _>>()?; @@ -813,15 +857,18 @@ fn fetch_related_entities( // Outgoing references (excluding closes, shown above). // Filter out unresolved refs (NULL target_entity_iid) to avoid rusqlite type errors. + // Excludes self-references (same type + same local ID). let mut out_stmt = conn.prepare( "SELECT er.target_entity_type, er.target_entity_iid, er.reference_type, \ - COALESCE(i.title, mr.title) as title \ + COALESCE(i.title, mr.title) as title, \ + COALESCE(i.state, mr.state) as state \ FROM entity_references er \ LEFT JOIN issues i ON er.target_entity_type = 'issue' AND i.id = er.target_entity_id \ LEFT JOIN merge_requests mr ON er.target_entity_type = 'merge_request' AND mr.id = er.target_entity_id \ WHERE er.source_entity_type = ?1 AND er.source_entity_id = ?2 \ AND er.reference_type != 'closes' \ AND er.target_entity_iid IS NOT NULL \ + AND NOT (er.target_entity_type = ?1 AND er.target_entity_id = ?2) \ ORDER BY er.target_entity_type, er.target_entity_iid", )?; @@ -832,21 +879,26 @@ fn fetch_related_entities( iid: row.get(1)?, reference_type: row.get(2)?, title: row.get(3)?, + state: row.get(4)?, + direction: "outgoing".to_string(), }) })? .collect::, _>>()?; // Incoming references (excluding closes). // COALESCE(i.iid, mr.iid) can be NULL if the source entity was deleted; filter those out. + // Excludes self-references (same type + same local ID). let mut in_stmt = conn.prepare( "SELECT er.source_entity_type, COALESCE(i.iid, mr.iid) as iid, er.reference_type, \ - COALESCE(i.title, mr.title) as title \ + COALESCE(i.title, mr.title) as title, \ + COALESCE(i.state, mr.state) as state \ FROM entity_references er \ LEFT JOIN issues i ON er.source_entity_type = 'issue' AND i.id = er.source_entity_id \ LEFT JOIN merge_requests mr ON er.source_entity_type = 'merge_request' AND mr.id = er.source_entity_id \ WHERE er.target_entity_type = ?1 AND er.target_entity_id = ?2 \ AND er.reference_type != 'closes' \ AND COALESCE(i.iid, mr.iid) IS NOT NULL \ + AND NOT (er.source_entity_type = ?1 AND er.source_entity_id = ?2) \ ORDER BY er.source_entity_type, COALESCE(i.iid, mr.iid)", )?; @@ -857,6 +909,8 @@ fn fetch_related_entities( iid: row.get(1)?, reference_type: row.get(2)?, title: row.get(3)?, + state: row.get(4)?, + direction: "incoming".to_string(), }) })? .collect::, _>>()?; @@ -883,11 +937,17 @@ fn build_timeline_excerpt_from_pipeline( conn: &Connection, entity: &EntitySummary, params: &ExplainParams, -) -> Option> { +) -> Option { let timeline_entity_type = match entity.entity_type.as_str() { "issue" => "issue", "merge_request" => "merge_request", - _ => return Some(vec![]), + _ => { + return Some(TimelineExcerpt { + events: vec![], + total_events: 0, + truncated: false, + }); + } }; let project_id = params @@ -900,29 +960,43 @@ fn build_timeline_excerpt_from_pipeline( Ok(result) => result, Err(e) => { tracing::warn!("explain: timeline seed failed: {e}"); - return Some(vec![]); + return Some(TimelineExcerpt { + events: vec![], + total_events: 0, + truncated: false, + }); } }; - let (mut events, _total) = match collect_events( + // Request a generous limit from the pipeline — we'll take the tail (most recent) + let pipeline_limit = 500; + let (events, _total) = match collect_events( conn, &seed_result.seed_entities, &[], &seed_result.evidence_notes, &seed_result.matched_discussions, params.since, - MAX_TIMELINE_EVENTS, + pipeline_limit, ) { Ok(result) => result, Err(e) => { tracing::warn!("explain: timeline collect failed: {e}"); - return Some(vec![]); + return Some(TimelineExcerpt { + events: vec![], + total_events: 0, + truncated: false, + }); } }; - events.truncate(MAX_TIMELINE_EVENTS); + let total_events = events.len(); + let truncated = total_events > MAX_TIMELINE_EVENTS; - let summaries = events + // Keep the MOST RECENT events — events are sorted ASC by collect_events, + // so we skip from the front to keep the tail + let start = total_events.saturating_sub(MAX_TIMELINE_EVENTS); + let summaries = events[start..] .iter() .map(|e| TimelineEventSummary { timestamp: ms_to_iso(e.timestamp), @@ -932,7 +1006,11 @@ fn build_timeline_excerpt_from_pipeline( }) .collect(); - Some(summaries) + Some(TimelineExcerpt { + events: summaries, + total_events, + truncated, + }) } fn timeline_event_type_label(event_type: &crate::timeline::TimelineEventType) -> String { @@ -1065,8 +1143,11 @@ pub fn print_explain(result: &ExplainResult) { Theme::bold().render(&result.entity.title) ); println!( - " State: {} Author: {} Created: {}", - result.entity.state, result.entity.author, result.entity.created_at + " Project: {} State: {} Author: {} Created: {}", + result.entity.project_path, + result.entity.state, + result.entity.author, + result.entity.created_at ); if !result.entity.assignees.is_empty() { println!(" Assignees: {}", result.entity.assignees.join(", ")); @@ -1141,6 +1222,18 @@ pub fn print_explain(result: &ExplainResult) { t.note_count, t.last_note_at ); + if let Some(ref excerpt) = t.first_note_excerpt { + let preview = if excerpt.len() > 100 { + let b = excerpt.floor_char_boundary(100); + format!("{}...", &excerpt[..b]) + } else { + excerpt.clone() + }; + // Show first line only in human output + if let Some(line) = preview.lines().next() { + println!(" {}", Theme::muted().render(line)); + } + } } } @@ -1159,8 +1252,17 @@ pub fn print_explain(result: &ExplainResult) { ); } for ri in &related.related_issues { + let state_str = ri + .state + .as_deref() + .map_or(String::new(), |s| format!(" [{s}]")); + let arrow = if ri.direction == "incoming" { + "<-" + } else { + "->" + }; println!( - " {} {} #{} — {} ({})", + " {} {arrow} {} #{} — {}{state_str} ({})", Icons::info(), ri.entity_type, ri.iid, @@ -1171,16 +1273,25 @@ pub fn print_explain(result: &ExplainResult) { } // Timeline excerpt - if let Some(ref events) = result.timeline_excerpt - && !events.is_empty() + if let Some(ref excerpt) = result.timeline_excerpt + && !excerpt.events.is_empty() { + let truncation_note = if excerpt.truncated { + format!( + " (showing {} of {})", + excerpt.events.len(), + excerpt.total_events + ) + } else { + String::new() + }; println!( - "\n{} {} ({} events)", + "\n{} {}{}", Icons::info(), Theme::bold().render("Timeline"), - events.len() + truncation_note ); - for e in events { + for e in &excerpt.events { let actor_str = e.actor.as_deref().unwrap_or(""); println!( " {} {} {} {}", @@ -1869,7 +1980,8 @@ mod tests { ); } - let activity = build_activity_summary(&conn, "issues", issue_id, None).unwrap(); + let activity = + build_activity_summary(&conn, "issues", issue_id, None, 1_704_067_200_000).unwrap(); assert_eq!(activity.state_changes, 2); assert_eq!(activity.label_changes, 1); @@ -1904,7 +2016,14 @@ mod tests { 5_000_000, ); - let activity = build_activity_summary(&conn, "issues", issue_id, Some(3_000_000)).unwrap(); + let activity = build_activity_summary( + &conn, + "issues", + issue_id, + Some(3_000_000), + 1_704_067_200_000, + ) + .unwrap(); assert_eq!(activity.state_changes, 1, "Only the recent event"); } @@ -1960,7 +2079,8 @@ mod tests { let (conn, project_id) = setup_explain_db(); let issue_id = insert_test_issue(&conn, project_id, 64, None); - let activity = build_activity_summary(&conn, "issues", issue_id, None).unwrap(); + let activity = + build_activity_summary(&conn, "issues", issue_id, None, 1_704_067_200_000).unwrap(); assert_eq!(activity.state_changes, 0); assert_eq!(activity.label_changes, 0); assert_eq!(activity.notes, 0);