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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user