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 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-03-05 13:01:55 -05:00
parent 571c304031
commit ffbd1e2dce
8 changed files with 795 additions and 19 deletions

View File

@@ -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);