From ffbd1e2dced0dafa72bdfef8c14483c891db1029 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 5 Mar 2026 13:01:55 -0500 Subject: [PATCH] feat(me): add mentions section for @-mentions in dashboard Add a new --mentions flag to the `lore me` command that surfaces items where the user is @-mentioned but NOT already assigned, authoring, or reviewing. This fills an important gap in the personal work dashboard: cross-team requests and callouts that don't show up in the standard issue/MR sections. Implementation details: - query_mentioned_in() scans notes for @username patterns, then filters out entities where the user is already an assignee, author, or reviewer - MentionedInItem type captures entity_type (issue/mr), iid, title, state, project path, attention state, and updated timestamp - Attention state computation marks items as needs_attention when there's recent activity from others - Recency cutoff (7 days) prevents surfacing stale mentions - Both human and robot renderers include the new section The robot mode schema adds mentioned_in array with me_mentions field preset for token-efficient output. Test coverage: - mentioned_in_finds_mention_on_unassigned_issue: basic case - mentioned_in_excludes_assigned_issue: no duplicate surfacing - mentioned_in_excludes_author_on_mr: author already sees in authored MRs - mentioned_in_excludes_reviewer_on_mr: reviewer already sees in reviewing - mentioned_in_uses_recency_cutoff: old mentions filtered - mentioned_in_respects_project_filter: scoping works Co-Authored-By: Claude Opus 4.5 --- src/cli/commands/me/me_tests.rs | 317 ++++++++++++++++++++++++++++ src/cli/commands/me/mod.rs | 23 +- src/cli/commands/me/queries.rs | 299 +++++++++++++++++++++++++- src/cli/commands/me/render_human.rs | 65 +++++- src/cli/commands/me/render_robot.rs | 59 +++++- src/cli/commands/me/types.rs | 19 ++ src/cli/mod.rs | 6 +- src/cli/robot.rs | 26 ++- 8 files changed, 795 insertions(+), 19 deletions(-) diff --git a/src/cli/commands/me/me_tests.rs b/src/cli/commands/me/me_tests.rs index 54f7808..4e4ed58 100644 --- a/src/cli/commands/me/me_tests.rs +++ b/src/cli/commands/me/me_tests.rs @@ -835,6 +835,262 @@ fn since_last_check_ignores_domain_like_text() { ); } +// ─── Mentioned In Tests ───────────────────────────────────────────────────── + +#[test] +fn mentioned_in_finds_mention_on_unassigned_issue() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue(&conn, 10, 1, 42, "someone"); + // alice is NOT assigned to issue 42 + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t = now_ms() - 1000; + insert_note_at( + &conn, + 200, + disc_id, + 1, + "bob", + false, + "hey @alice can you look?", + t, + ); + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].entity_type, "issue"); + assert_eq!(results[0].iid, 42); +} + +#[test] +fn mentioned_in_excludes_assigned_issue() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue(&conn, 10, 1, 42, "someone"); + insert_assignee(&conn, 10, "alice"); // alice IS assigned + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t = now_ms() - 1000; + insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t); + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap(); + assert!(results.is_empty(), "should exclude assigned issues"); +} + +#[test] +fn mentioned_in_finds_mention_on_non_authored_mr() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_mr(&conn, 10, 1, 99, "bob", "opened", false); + // alice is NOT author or reviewer + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, Some(10), None); + let t = now_ms() - 1000; + insert_note_at(&conn, 200, disc_id, 1, "bob", false, "cc @alice", t); + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].entity_type, "mr"); + assert_eq!(results[0].iid, 99); +} + +#[test] +fn mentioned_in_excludes_authored_mr() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_mr(&conn, 10, 1, 99, "alice", "opened", false); // alice IS author + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, Some(10), None); + let t = now_ms() - 1000; + insert_note_at(&conn, 200, disc_id, 1, "bob", false, "@alice thoughts?", t); + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap(); + assert!(results.is_empty(), "should exclude authored MRs"); +} + +#[test] +fn mentioned_in_excludes_reviewer_mr() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_mr(&conn, 10, 1, 99, "bob", "opened", false); + insert_reviewer(&conn, 10, "alice"); // alice IS reviewer + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, Some(10), None); + let t = now_ms() - 1000; + insert_note_at(&conn, 200, disc_id, 1, "charlie", false, "@alice fyi", t); + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap(); + assert!( + results.is_empty(), + "should exclude MRs where user is reviewer" + ); +} + +#[test] +fn mentioned_in_includes_recently_closed_issue() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed"); + // Update updated_at to recent (within 7-day window) + conn.execute( + "UPDATE issues SET updated_at = ?1 WHERE id = 10", + rusqlite::params![now_ms() - 2 * 24 * 3600 * 1000], // 2 days ago + ) + .unwrap(); + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t = now_ms() - 1000; + insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t); + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap(); + assert_eq!(results.len(), 1, "recently closed issue should be included"); + assert_eq!(results[0].state, "closed"); +} + +#[test] +fn mentioned_in_excludes_old_closed_issue() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed"); + // Update updated_at to old (outside 7-day window) + conn.execute( + "UPDATE issues SET updated_at = ?1 WHERE id = 10", + rusqlite::params![now_ms() - 30 * 24 * 3600 * 1000], // 30 days ago + ) + .unwrap(); + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t = now_ms() - 1000; + insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t); + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap(); + assert!(results.is_empty(), "old closed issue should be excluded"); +} + +#[test] +fn mentioned_in_attention_needs_attention_when_unreplied() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue(&conn, 10, 1, 42, "someone"); + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t = now_ms() - 1000; + insert_note_at( + &conn, + 200, + disc_id, + 1, + "bob", + false, + "@alice please review", + t, + ); + // alice has NOT replied + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].attention_state, AttentionState::NeedsAttention); +} + +#[test] +fn mentioned_in_attention_awaiting_when_replied() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue(&conn, 10, 1, 42, "someone"); + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t1 = now_ms() - 5000; + let t2 = now_ms() - 1000; + insert_note_at( + &conn, + 200, + disc_id, + 1, + "bob", + false, + "@alice please review", + t1, + ); + insert_note_at(&conn, 201, disc_id, 1, "alice", false, "looks good", t2); + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].attention_state, AttentionState::AwaitingResponse); +} + +#[test] +fn mentioned_in_project_filter() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo-a"); + insert_project(&conn, 2, "group/repo-b"); + insert_issue(&conn, 10, 1, 42, "someone"); + insert_issue(&conn, 11, 2, 43, "someone"); + let disc_a = 100; + let disc_b = 101; + insert_discussion(&conn, disc_a, 1, None, Some(10)); + insert_discussion(&conn, disc_b, 2, None, Some(11)); + let t = now_ms() - 1000; + insert_note_at(&conn, 200, disc_a, 1, "bob", false, "@alice", t); + insert_note_at(&conn, 201, disc_b, 2, "bob", false, "@alice", t); + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[1], recency_cutoff).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].project_path, "group/repo-a"); +} + +#[test] +fn mentioned_in_deduplicates_multiple_mentions_same_entity() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue(&conn, 10, 1, 42, "someone"); + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t1 = now_ms() - 5000; + let t2 = now_ms() - 1000; + // Two different people mention alice on the same issue + insert_note_at(&conn, 200, disc_id, 1, "bob", false, "@alice thoughts?", t1); + insert_note_at(&conn, 201, disc_id, 1, "charlie", false, "@alice +1", t2); + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap(); + assert_eq!(results.len(), 1, "should deduplicate to one entity"); +} + +#[test] +fn mentioned_in_rejects_false_positive_email() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue(&conn, 10, 1, 42, "someone"); + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t = now_ms() - 1000; + insert_note_at( + &conn, + 200, + disc_id, + 1, + "bob", + false, + "email foo@alice.com", + t, + ); + + let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; + let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap(); + assert!(results.is_empty(), "email-like text should not match"); +} + // ─── Helper Tests ────────────────────────────────────────────────────────── #[test] @@ -856,6 +1112,67 @@ fn parse_attention_state_all_variants() { assert_eq!(parse_attention_state("unknown"), AttentionState::NotStarted); } +#[test] +fn format_attention_reason_not_started() { + let reason = format_attention_reason(&AttentionState::NotStarted, None, None, None); + assert_eq!(reason, "No discussion yet"); +} + +#[test] +fn format_attention_reason_not_ready() { + let reason = format_attention_reason(&AttentionState::NotReady, None, None, None); + assert_eq!(reason, "Draft with no reviewers assigned"); +} + +#[test] +fn format_attention_reason_stale_with_timestamp() { + let stale_ts = now_ms() - 35 * 24 * 3600 * 1000; // 35 days ago + let reason = format_attention_reason(&AttentionState::Stale, None, None, Some(stale_ts)); + assert!(reason.starts_with("No activity for"), "got: {reason}"); + // 35 days = 1 month in our duration bucketing + assert!(reason.contains("1 month"), "got: {reason}"); +} + +#[test] +fn format_attention_reason_needs_attention_both_timestamps() { + let my_ts = now_ms() - 2 * 86_400_000; // 2 days ago + let others_ts = now_ms() - 3_600_000; // 1 hour ago + let reason = format_attention_reason( + &AttentionState::NeedsAttention, + Some(my_ts), + Some(others_ts), + Some(others_ts), + ); + assert!(reason.contains("Others replied"), "got: {reason}"); + assert!(reason.contains("you last commented"), "got: {reason}"); +} + +#[test] +fn format_attention_reason_needs_attention_no_self_comment() { + let others_ts = now_ms() - 3_600_000; // 1 hour ago + let reason = format_attention_reason( + &AttentionState::NeedsAttention, + None, + Some(others_ts), + Some(others_ts), + ); + assert!(reason.contains("Others commented"), "got: {reason}"); + assert!(reason.contains("you haven't replied"), "got: {reason}"); +} + +#[test] +fn format_attention_reason_awaiting_response() { + let my_ts = now_ms() - 7_200_000; // 2 hours ago + let reason = format_attention_reason( + &AttentionState::AwaitingResponse, + Some(my_ts), + None, + Some(my_ts), + ); + assert!(reason.contains("You replied"), "got: {reason}"); + assert!(reason.contains("awaiting others"), "got: {reason}"); +} + #[test] fn parse_event_type_all_variants() { assert_eq!(parse_event_type("note"), ActivityEventType::Note); diff --git a/src/cli/commands/me/mod.rs b/src/cli/commands/me/mod.rs index a7ebceb..0e7a8b2 100644 --- a/src/cli/commands/me/mod.rs +++ b/src/cli/commands/me/mod.rs @@ -17,7 +17,7 @@ use crate::core::project::resolve_project; use crate::core::time::parse_since; use self::queries::{ - query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs, + query_activity, query_authored_mrs, query_mentioned_in, query_open_issues, query_reviewing_mrs, query_since_last_check, }; use self::types::{AttentionState, MeDashboard, MeSummary, SinceLastCheck}; @@ -25,6 +25,8 @@ use self::types::{AttentionState, MeDashboard, MeSummary, SinceLastCheck}; /// Default activity lookback: 1 day in milliseconds. const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1; const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000; +/// Recency window for closed/merged items in the "Mentioned In" section: 7 days. +const RECENCY_WINDOW_MS: i64 = 7 * MS_PER_DAY; /// Resolve the effective username from CLI flag or config. /// @@ -126,6 +128,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> { let want_issues = show_all || args.issues; let want_mrs = show_all || args.mrs; let want_activity = show_all || args.activity; + let want_mentions = show_all || args.mentions; // 6. Run queries for requested sections let open_issues = if want_issues { @@ -146,6 +149,13 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> { Vec::new() }; + let mentioned_in = if want_mentions { + let recency_cutoff = crate::core::time::now_ms() - RECENCY_WINDOW_MS; + query_mentioned_in(&conn, username, &project_ids, recency_cutoff)? + } else { + Vec::new() + }; + let activity = if want_activity { query_activity(&conn, username, &project_ids, since_ms)? } else { @@ -187,6 +197,10 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> { .filter(|m| m.attention_state == AttentionState::NeedsAttention) .count() + reviewing_mrs + .iter() + .filter(|m| m.attention_state == AttentionState::NeedsAttention) + .count() + + mentioned_in .iter() .filter(|m| m.attention_state == AttentionState::NeedsAttention) .count(); @@ -202,12 +216,16 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> { for m in &reviewing_mrs { project_paths.insert(&m.project_path); } + for m in &mentioned_in { + project_paths.insert(&m.project_path); + } let summary = MeSummary { project_count: project_paths.len(), open_issue_count: open_issues.len(), authored_mr_count: open_mrs_authored.len(), reviewing_mr_count: reviewing_mrs.len(), + mentioned_in_count: mentioned_in.len(), needs_attention_count, }; @@ -219,6 +237,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> { open_issues, open_mrs_authored, reviewing_mrs, + mentioned_in, activity, since_last_check, }; @@ -237,6 +256,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> { single_project, want_issues, want_mrs, + want_mentions, want_activity, ); } @@ -313,6 +333,7 @@ mod tests { issues: false, mrs: false, activity: false, + mentions: false, since: None, project: None, all: false, diff --git a/src/cli/commands/me/queries.rs b/src/cli/commands/me/queries.rs index a76de4f..4248e12 100644 --- a/src/cli/commands/me/queries.rs +++ b/src/cli/commands/me/queries.rs @@ -12,13 +12,77 @@ use regex::Regex; use std::collections::HashMap; use super::types::{ - ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr, SinceCheckEvent, + ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMention, MeMr, SinceCheckEvent, SinceCheckGroup, }; /// Stale threshold: items with no activity for 30 days are marked "stale". const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000; +// ─── Attention Reason ─────────────────────────────────────────────────────── + +/// Format a human-readable duration from a millisecond epoch to now. +/// Returns e.g. "3 hours", "2 days", "1 week". +fn relative_duration(ms_epoch: i64) -> String { + let diff = crate::core::time::now_ms() - ms_epoch; + if diff < 60_000 { + return "moments".to_string(); + } + let (n, unit) = match diff { + d if d < 3_600_000 => (d / 60_000, "minute"), + d if d < 86_400_000 => (d / 3_600_000, "hour"), + d if d < 604_800_000 => (d / 86_400_000, "day"), + d if d < 2_592_000_000 => (d / 604_800_000, "week"), + d => (d / 2_592_000_000, "month"), + }; + if n == 1 { + format!("1 {unit}") + } else { + format!("{n} {unit}s") + } +} + +/// Build a human-readable reason explaining why the attention state was set. +pub(super) fn format_attention_reason( + state: &AttentionState, + my_ts: Option, + others_ts: Option, + any_ts: Option, +) -> String { + match state { + AttentionState::NotReady => "Draft with no reviewers assigned".to_string(), + AttentionState::Stale => { + if let Some(ts) = any_ts { + format!("No activity for {}", relative_duration(ts)) + } else { + "No activity for over 30 days".to_string() + } + } + AttentionState::NeedsAttention => { + let others_ago = others_ts + .map(|ts| format!("{} ago", relative_duration(ts))) + .unwrap_or_else(|| "recently".to_string()); + if let Some(ts) = my_ts { + format!( + "Others replied {}; you last commented {} ago", + others_ago, + relative_duration(ts) + ) + } else { + format!("Others commented {}; you haven't replied", others_ago) + } + } + AttentionState::AwaitingResponse => { + if let Some(ts) = my_ts { + format!("You replied {} ago; awaiting others", relative_duration(ts)) + } else { + "Awaiting response from others".to_string() + } + } + AttentionState::NotStarted => "No discussion yet".to_string(), + } +} + // ─── Open Issues (AC-5.1, Task #7) ───────────────────────────────────────── /// Query open issues assigned to the user via issue_assignees. @@ -51,7 +115,8 @@ pub fn query_open_issues( WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 'awaiting_response' ELSE 'not_started' - END AS attention_state + END AS attention_state, + nt.my_ts, nt.others_ts, nt.any_ts FROM issues i JOIN issue_assignees ia ON ia.issue_id = i.id JOIN projects p ON i.project_id = p.id @@ -84,6 +149,11 @@ pub fn query_open_issues( let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map(param_refs.as_slice(), |row| { let attention_str: String = row.get(6)?; + let my_ts: Option = row.get(7)?; + let others_ts: Option = row.get(8)?; + let any_ts: Option = row.get(9)?; + let state = parse_attention_state(&attention_str); + let reason = format_attention_reason(&state, my_ts, others_ts, any_ts); Ok(MeIssue { iid: row.get(0)?, title: row.get::<_, Option>(1)?.unwrap_or_default(), @@ -91,7 +161,8 @@ pub fn query_open_issues( status_name: row.get(3)?, updated_at: row.get(4)?, web_url: row.get(5)?, - attention_state: parse_attention_state(&attention_str), + attention_state: state, + attention_reason: reason, labels: Vec::new(), }) })?; @@ -135,7 +206,8 @@ pub fn query_authored_mrs( WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 'awaiting_response' ELSE 'not_started' - END AS attention_state + END AS attention_state, + nt.my_ts, nt.others_ts, nt.any_ts FROM merge_requests m JOIN projects p ON m.project_id = p.id LEFT JOIN note_ts nt ON nt.merge_request_id = m.id @@ -163,6 +235,11 @@ pub fn query_authored_mrs( let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map(param_refs.as_slice(), |row| { let attention_str: String = row.get(7)?; + let my_ts: Option = row.get(8)?; + let others_ts: Option = row.get(9)?; + let any_ts: Option = row.get(10)?; + let state = parse_attention_state(&attention_str); + let reason = format_attention_reason(&state, my_ts, others_ts, any_ts); Ok(MeMr { iid: row.get(0)?, title: row.get::<_, Option>(1)?.unwrap_or_default(), @@ -171,7 +248,8 @@ pub fn query_authored_mrs( detailed_merge_status: row.get(4)?, updated_at: row.get(5)?, web_url: row.get(6)?, - attention_state: parse_attention_state(&attention_str), + attention_state: state, + attention_reason: reason, author_username: None, labels: Vec::new(), }) @@ -214,7 +292,8 @@ pub fn query_reviewing_mrs( WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 'awaiting_response' ELSE 'not_started' - END AS attention_state + END AS attention_state, + nt.my_ts, nt.others_ts, nt.any_ts FROM merge_requests m JOIN mr_reviewers r ON r.merge_request_id = m.id JOIN projects p ON m.project_id = p.id @@ -242,6 +321,11 @@ pub fn query_reviewing_mrs( let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map(param_refs.as_slice(), |row| { let attention_str: String = row.get(8)?; + let my_ts: Option = row.get(9)?; + let others_ts: Option = row.get(10)?; + let any_ts: Option = row.get(11)?; + let state = parse_attention_state(&attention_str); + let reason = format_attention_reason(&state, my_ts, others_ts, any_ts); Ok(MeMr { iid: row.get(0)?, title: row.get::<_, Option>(1)?.unwrap_or_default(), @@ -251,7 +335,8 @@ pub fn query_reviewing_mrs( author_username: row.get(5)?, updated_at: row.get(6)?, web_url: row.get(7)?, - attention_state: parse_attention_state(&attention_str), + attention_state: state, + attention_reason: reason, labels: Vec::new(), }) })?; @@ -687,6 +772,206 @@ fn group_since_check_events(rows: Vec) -> Vec result } +// ─── Mentioned In (issues/MRs where user is @mentioned but not formally associated) + +/// Raw row from the mentioned-in query. +struct RawMentionRow { + entity_type: String, + iid: i64, + title: String, + project_path: String, + state: String, + updated_at: i64, + web_url: Option, + my_ts: Option, + others_ts: Option, + any_ts: Option, + mention_body: String, +} + +/// Query issues and MRs where the user is @mentioned but not assigned/authored/reviewing. +/// +/// Includes open items unconditionally, plus recently-closed/merged items +/// (where `updated_at > recency_cutoff_ms`). +/// +/// Returns deduplicated results sorted by attention priority then recency. +pub fn query_mentioned_in( + conn: &Connection, + username: &str, + project_ids: &[i64], + recency_cutoff_ms: i64, +) -> Result> { + let project_clause = build_project_clause_at("p.id", project_ids, 3); + + // CTE: note timestamps per issue (for attention state computation) + // CTE: note timestamps per MR + // Then UNION ALL of issue mentions + MR mentions + let sql = format!( + "WITH note_ts_issue AS ( + SELECT d.issue_id, + MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts, + MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts, + MAX(n.created_at) AS any_ts + FROM notes n + JOIN discussions d ON n.discussion_id = d.id + WHERE n.is_system = 0 AND d.issue_id IS NOT NULL + GROUP BY d.issue_id + ), + note_ts_mr AS ( + SELECT d.merge_request_id, + MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts, + MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts, + MAX(n.created_at) AS any_ts + FROM notes n + JOIN discussions d ON n.discussion_id = d.id + WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL + GROUP BY d.merge_request_id + ) + -- Issue mentions + SELECT 'issue', i.iid, i.title, p.path_with_namespace, i.state, + i.updated_at, i.web_url, + nt.my_ts, nt.others_ts, nt.any_ts, + n.body + FROM notes n + JOIN discussions d ON n.discussion_id = d.id + JOIN issues i ON d.issue_id = i.id + JOIN projects p ON i.project_id = p.id + LEFT JOIN note_ts_issue nt ON nt.issue_id = i.id + WHERE n.is_system = 0 + AND n.author_username != ?1 + AND d.issue_id IS NOT NULL + AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%' + AND NOT EXISTS ( + SELECT 1 FROM issue_assignees ia + WHERE ia.issue_id = d.issue_id AND ia.username = ?1 + ) + AND (i.state = 'opened' OR (i.state = 'closed' AND i.updated_at > ?2)) + {project_clause} + UNION ALL + -- MR mentions + SELECT 'mr', m.iid, m.title, p.path_with_namespace, m.state, + m.updated_at, m.web_url, + nt.my_ts, nt.others_ts, nt.any_ts, + n.body + FROM notes n + JOIN discussions d ON n.discussion_id = d.id + JOIN merge_requests m ON d.merge_request_id = m.id + JOIN projects p ON m.project_id = p.id + LEFT JOIN note_ts_mr nt ON nt.merge_request_id = m.id + WHERE n.is_system = 0 + AND n.author_username != ?1 + AND d.merge_request_id IS NOT NULL + AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%' + AND m.author_username != ?1 + AND NOT EXISTS ( + SELECT 1 FROM mr_reviewers rv + WHERE rv.merge_request_id = d.merge_request_id AND rv.username = ?1 + ) + AND (m.state = 'opened' + OR (m.state IN ('merged', 'closed') AND m.updated_at > ?2)) + {project_clause} + ORDER BY 6 DESC + LIMIT 500", + ); + + let mut params: Vec> = Vec::new(); + params.push(Box::new(username.to_string())); + params.push(Box::new(recency_cutoff_ms)); + for &pid in project_ids { + params.push(Box::new(pid)); + } + let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mention_re = build_exact_mention_regex(username); + + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(param_refs.as_slice(), |row| { + Ok(RawMentionRow { + entity_type: row.get(0)?, + iid: row.get(1)?, + title: row.get::<_, Option>(2)?.unwrap_or_default(), + project_path: row.get(3)?, + state: row.get(4)?, + updated_at: row.get(5)?, + web_url: row.get(6)?, + my_ts: row.get(7)?, + others_ts: row.get(8)?, + any_ts: row.get(9)?, + mention_body: row.get::<_, Option>(10)?.unwrap_or_default(), + }) + })?; + + let raw: Vec = rows.collect::, _>>()?; + + // Post-filter with exact mention regex and deduplicate by entity + let mut seen: HashMap<(String, i64, String), RawMentionRow> = HashMap::new(); + for row in raw { + if !contains_exact_mention(&row.mention_body, &mention_re) { + continue; + } + let key = (row.entity_type.clone(), row.iid, row.project_path.clone()); + // Keep the first occurrence (most recent due to ORDER BY updated_at DESC) + seen.entry(key).or_insert(row); + } + + let mut mentions: Vec = seen + .into_values() + .map(|row| { + let state = compute_mention_attention(row.my_ts, row.others_ts, row.any_ts); + let reason = format_attention_reason(&state, row.my_ts, row.others_ts, row.any_ts); + MeMention { + entity_type: row.entity_type, + iid: row.iid, + title: row.title, + project_path: row.project_path, + state: row.state, + attention_state: state, + attention_reason: reason, + updated_at: row.updated_at, + web_url: row.web_url, + } + }) + .collect(); + + // Sort by attention priority (needs_attention first), then by updated_at DESC + mentions.sort_by(|a, b| { + a.attention_state + .cmp(&b.attention_state) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + }); + + Ok(mentions) +} + +/// Compute attention state for a mentioned-in item. +/// Same logic as the other sections, but without the not_ready variant +/// since it's less relevant for mention-only items. +fn compute_mention_attention( + my_ts: Option, + others_ts: Option, + any_ts: Option, +) -> AttentionState { + // Stale check + if let Some(ts) = any_ts + && ts < crate::core::time::now_ms() - STALE_THRESHOLD_MS + { + return AttentionState::Stale; + } + // Others commented after me (or I never engaged but others have) + if let Some(ots) = others_ts + && my_ts.is_none_or(|mts| ots > mts) + { + return AttentionState::NeedsAttention; + } + // I replied and my reply is >= others' latest + if let Some(mts) = my_ts + && mts >= others_ts.unwrap_or(0) + { + return AttentionState::AwaitingResponse; + } + AttentionState::NotStarted +} + // ─── Helpers ──────────────────────────────────────────────────────────────── /// Parse attention state string from SQL CASE result. diff --git a/src/cli/commands/me/render_human.rs b/src/cli/commands/me/render_human.rs index b6cbc8a..be2f260 100644 --- a/src/cli/commands/me/render_human.rs +++ b/src/cli/commands/me/render_human.rs @@ -1,8 +1,8 @@ use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell, Table, Theme}; use super::types::{ - ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary, - SinceLastCheck, + ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMention, MeMr, + MeSummary, SinceLastCheck, }; // ─── Layout Helpers ───────────────────────────────────────────────────────── @@ -164,12 +164,19 @@ pub fn print_summary_header(summary: &MeSummary, username: &str) { Theme::dim().render("0 need attention") }; + let mentioned = if summary.mentioned_in_count > 0 { + format!(" {} mentioned", summary.mentioned_in_count) + } else { + String::new() + }; + println!( - " {} projects {} issues {} authored MRs {} reviewing MRs {}", + " {} projects {} issues {} authored MRs {} reviewing MRs{} {}", summary.project_count, summary.open_issue_count, summary.authored_mr_count, summary.reviewing_mr_count, + mentioned, needs, ); @@ -342,6 +349,53 @@ pub fn print_reviewing_mrs_section(mrs: &[MeMr], single_project: bool) { } } +// ─── Mentioned In Section ──────────────────────────────────────────────── + +/// Print the "Mentioned In" section for items where user is @mentioned but +/// not assigned, authored, or reviewing. +pub fn print_mentioned_in_section(mentions: &[MeMention], single_project: bool) { + if mentions.is_empty() { + return; + } + + println!( + "{}", + render::section_divider(&format!("Mentioned In ({})", mentions.len())) + ); + + for item in mentions { + let attn = styled_attention(&item.attention_state); + let ref_str = match item.entity_type.as_str() { + "issue" => format!("#{}", item.iid), + "mr" => format!("!{}", item.iid), + _ => format!("{}:{}", item.entity_type, item.iid), + }; + let ref_style = match item.entity_type.as_str() { + "issue" => Theme::issue_ref(), + "mr" => Theme::mr_ref(), + _ => Theme::bold(), + }; + let state_tag = match item.state.as_str() { + "opened" => String::new(), + other => format!(" [{}]", other), + }; + let time = render::format_relative_time(item.updated_at); + + println!( + " {} {} {}{} {}", + attn, + ref_style.render(&ref_str), + render::truncate(&item.title, title_width(43)), + Theme::dim().render(&state_tag), + Theme::dim().render(&time), + ); + + if !single_project { + println!(" {}", Theme::dim().render(&item.project_path)); + } + } +} + // ─── Activity Feed ─────────────────────────────────────────────────────────── /// Print the activity feed section (Task #17). @@ -587,6 +641,7 @@ pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) { print_issues_section(&dashboard.open_issues, single_project); print_authored_mrs_section(&dashboard.open_mrs_authored, single_project); print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project); + print_mentioned_in_section(&dashboard.mentioned_in, single_project); print_activity_section(&dashboard.activity, single_project); println!(); } @@ -597,6 +652,7 @@ pub fn print_me_dashboard_filtered( single_project: bool, show_issues: bool, show_mrs: bool, + show_mentions: bool, show_activity: bool, ) { if let Some(ref since) = dashboard.since_last_check { @@ -611,6 +667,9 @@ pub fn print_me_dashboard_filtered( print_authored_mrs_section(&dashboard.open_mrs_authored, single_project); print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project); } + if show_mentions { + print_mentioned_in_section(&dashboard.mentioned_in, single_project); + } if show_activity { print_activity_section(&dashboard.activity, single_project); } diff --git a/src/cli/commands/me/render_robot.rs b/src/cli/commands/me/render_robot.rs index df12666..042ea58 100644 --- a/src/cli/commands/me/render_robot.rs +++ b/src/cli/commands/me/render_robot.rs @@ -4,8 +4,8 @@ use crate::cli::robot::RobotMeta; use crate::core::time::ms_to_iso; use super::types::{ - ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary, - SinceCheckEvent, SinceCheckGroup, SinceLastCheck, + ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMention, MeMr, + MeSummary, SinceCheckEvent, SinceCheckGroup, SinceLastCheck, }; // ─── Robot JSON Output (Task #18) ──────────────────────────────────────────── @@ -28,11 +28,15 @@ pub fn print_me_json( // Apply --fields filtering (Task #19) if let Some(f) = fields { let expanded = crate::cli::robot::expand_fields_preset(f, "me_items"); - // Filter all item arrays + // Filter issue/MR arrays with the items preset for key in &["open_issues", "open_mrs_authored", "reviewing_mrs"] { crate::cli::robot::filter_fields(&mut value, key, &expanded); } + // Mentioned-in gets its own preset (needs entity_type + state to disambiguate) + let mentions_expanded = crate::cli::robot::expand_fields_preset(f, "me_mentions"); + crate::cli::robot::filter_fields(&mut value, "mentioned_in", &mentions_expanded); + // Activity gets its own minimal preset let activity_expanded = crate::cli::robot::expand_fields_preset(f, "me_activity"); crate::cli::robot::filter_fields(&mut value, "activity", &activity_expanded); @@ -84,6 +88,7 @@ struct MeDataJson { open_issues: Vec, open_mrs_authored: Vec, reviewing_mrs: Vec, + mentioned_in: Vec, activity: Vec, } @@ -97,6 +102,7 @@ impl MeDataJson { open_issues: d.open_issues.iter().map(IssueJson::from).collect(), open_mrs_authored: d.open_mrs_authored.iter().map(MrJson::from).collect(), reviewing_mrs: d.reviewing_mrs.iter().map(MrJson::from).collect(), + mentioned_in: d.mentioned_in.iter().map(MentionJson::from).collect(), activity: d.activity.iter().map(ActivityJson::from).collect(), } } @@ -110,6 +116,7 @@ struct SummaryJson { open_issue_count: usize, authored_mr_count: usize, reviewing_mr_count: usize, + mentioned_in_count: usize, needs_attention_count: usize, } @@ -120,6 +127,7 @@ impl From<&MeSummary> for SummaryJson { open_issue_count: s.open_issue_count, authored_mr_count: s.authored_mr_count, reviewing_mr_count: s.reviewing_mr_count, + mentioned_in_count: s.mentioned_in_count, needs_attention_count: s.needs_attention_count, } } @@ -134,6 +142,7 @@ struct IssueJson { title: String, state: String, attention_state: String, + attention_reason: String, status_name: Option, labels: Vec, updated_at_iso: String, @@ -148,6 +157,7 @@ impl From<&MeIssue> for IssueJson { title: i.title.clone(), state: "opened".to_string(), attention_state: attention_state_str(&i.attention_state), + attention_reason: i.attention_reason.clone(), status_name: i.status_name.clone(), labels: i.labels.clone(), updated_at_iso: ms_to_iso(i.updated_at), @@ -165,6 +175,7 @@ struct MrJson { title: String, state: String, attention_state: String, + attention_reason: String, draft: bool, detailed_merge_status: Option, author_username: Option, @@ -181,6 +192,7 @@ impl From<&MeMr> for MrJson { title: m.title.clone(), state: "opened".to_string(), attention_state: attention_state_str(&m.attention_state), + attention_reason: m.attention_reason.clone(), draft: m.draft, detailed_merge_status: m.detailed_merge_status.clone(), author_username: m.author_username.clone(), @@ -191,6 +203,37 @@ impl From<&MeMr> for MrJson { } } +// ─── Mention ───────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct MentionJson { + entity_type: String, + project: String, + iid: i64, + title: String, + state: String, + attention_state: String, + attention_reason: String, + updated_at_iso: String, + web_url: Option, +} + +impl From<&MeMention> for MentionJson { + fn from(m: &MeMention) -> Self { + Self { + entity_type: m.entity_type.clone(), + project: m.project_path.clone(), + iid: m.iid, + title: m.title.clone(), + state: m.state.clone(), + attention_state: attention_state_str(&m.attention_state), + attention_reason: m.attention_reason.clone(), + updated_at_iso: ms_to_iso(m.updated_at), + web_url: m.web_url.clone(), + } + } +} + // ─── Activity ──────────────────────────────────────────────────────────────── #[derive(Serialize)] @@ -365,6 +408,7 @@ mod tests { title: "Fix auth bug".to_string(), project_path: "group/repo".to_string(), attention_state: AttentionState::NeedsAttention, + attention_reason: "Others commented recently; you haven't replied".to_string(), status_name: Some("In progress".to_string()), labels: vec!["bug".to_string()], updated_at: 1_700_000_000_000, @@ -373,6 +417,10 @@ mod tests { let json = IssueJson::from(&issue); assert_eq!(json.iid, 42); assert_eq!(json.attention_state, "needs_attention"); + assert_eq!( + json.attention_reason, + "Others commented recently; you haven't replied" + ); assert_eq!(json.state, "opened"); assert_eq!(json.status_name, Some("In progress".to_string())); } @@ -384,6 +432,7 @@ mod tests { title: "Add feature".to_string(), project_path: "group/repo".to_string(), attention_state: AttentionState::AwaitingResponse, + attention_reason: "You replied moments ago; awaiting others".to_string(), draft: true, detailed_merge_status: Some("mergeable".to_string()), author_username: Some("alice".to_string()), @@ -394,6 +443,10 @@ mod tests { let json = MrJson::from(&mr); assert_eq!(json.iid, 99); assert_eq!(json.attention_state, "awaiting_response"); + assert_eq!( + json.attention_reason, + "You replied moments ago; awaiting others" + ); assert!(json.draft); assert_eq!(json.author_username, Some("alice".to_string())); } diff --git a/src/cli/commands/me/types.rs b/src/cli/commands/me/types.rs index 39c6254..b0466e4 100644 --- a/src/cli/commands/me/types.rs +++ b/src/cli/commands/me/types.rs @@ -44,6 +44,7 @@ pub struct MeSummary { pub open_issue_count: usize, pub authored_mr_count: usize, pub reviewing_mr_count: usize, + pub mentioned_in_count: usize, pub needs_attention_count: usize, } @@ -53,6 +54,7 @@ pub struct MeIssue { pub title: String, pub project_path: String, pub attention_state: AttentionState, + pub attention_reason: String, pub status_name: Option, pub labels: Vec, pub updated_at: i64, @@ -65,6 +67,7 @@ pub struct MeMr { pub title: String, pub project_path: String, pub attention_state: AttentionState, + pub attention_reason: String, pub draft: bool, pub detailed_merge_status: Option, pub author_username: Option, @@ -114,6 +117,21 @@ pub struct SinceLastCheck { pub total_event_count: usize, } +/// An issue or MR where the user is @mentioned but not formally associated. +pub struct MeMention { + /// "issue" or "mr" + pub entity_type: String, + pub iid: i64, + pub title: String, + pub project_path: String, + /// "opened", "closed", or "merged" + pub state: String, + pub attention_state: AttentionState, + pub attention_reason: String, + pub updated_at: i64, + pub web_url: Option, +} + /// The complete dashboard result. pub struct MeDashboard { pub username: String, @@ -122,6 +140,7 @@ pub struct MeDashboard { pub open_issues: Vec, pub open_mrs_authored: Vec, pub reviewing_mrs: Vec, + pub mentioned_in: Vec, pub activity: Vec, pub since_last_check: Option, } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9547ec7..9d0cfe5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1103,6 +1103,10 @@ pub struct MeArgs { #[arg(long, help_heading = "Sections")] pub activity: bool, + /// Show items you're @mentioned in (not assigned/authored/reviewing) + #[arg(long, help_heading = "Sections")] + pub mentions: bool, + /// Activity window (e.g. 7d, 2w, 30d). Default: 30d. Only affects activity section. #[arg(long, help_heading = "Filters")] pub since: Option, @@ -1131,7 +1135,7 @@ pub struct MeArgs { impl MeArgs { /// Returns true if no section flags were passed (show all sections). pub fn show_all_sections(&self) -> bool { - !self.issues && !self.mrs && !self.activity + !self.issues && !self.mrs && !self.activity && !self.mentions } } diff --git a/src/cli/robot.rs b/src/cli/robot.rs index 6da8a72..676261c 100644 --- a/src/cli/robot.rs +++ b/src/cli/robot.rs @@ -68,10 +68,28 @@ pub fn expand_fields_preset(fields: &[String], entity: &str) -> Vec { .iter() .map(|s| (*s).to_string()) .collect(), - "me_items" => ["iid", "title", "attention_state", "updated_at_iso"] - .iter() - .map(|s| (*s).to_string()) - .collect(), + "me_items" => [ + "iid", + "title", + "attention_state", + "attention_reason", + "updated_at_iso", + ] + .iter() + .map(|s| (*s).to_string()) + .collect(), + "me_mentions" => [ + "entity_type", + "iid", + "title", + "state", + "attention_state", + "attention_reason", + "updated_at_iso", + ] + .iter() + .map(|s| (*s).to_string()) + .collect(), "me_activity" => ["timestamp_iso", "event_type", "entity_iid", "actor"] .iter() .map(|s| (*s).to_string())