feat(me): add "since last check" cursor-based inbox to dashboard
Implements a cursor-based notification inbox that surfaces actionable events from others since the user's last `lore me` invocation. This addresses the core UX need: "what happened while I was away?" Event Sources (three-way UNION query): 1. Others' comments on user's open issues/MRs 2. @mentions on ANY item (not restricted to owned items) 3. Assignment/review-request system notes mentioning user Mention Detection: - SQL LIKE pre-filter for performance, then regex validation - Word-boundary-aware: rejects "alice" in "@alice-bot" or "alice@corp.com" - Domain rejection: "@alice.com" not matched (prevents email false positives) - Punctuation tolerance: "@alice," "@alice." "(@ alice)" all match Cursor Watermark Pattern: - Global watermark computed from ALL projects before --project filtering - Ensures --project display filter doesn't permanently skip events - Cursor advances only after successful render (no data loss on errors) - First run establishes baseline (no inbox shown), subsequent runs show delta Output: - Human: color-coded event badges, grouped by entity, actor + timestamp - Robot: standard envelope with since_last_check object containing cursor_iso, total_event_count, and groups array with nested events CLI additions: - --reset-cursor flag: clears cursor (next run shows no new events) - Autocorrect: --reset-cursor added to known me command flags Tests cover: - Mention with trailing comma/period/parentheses (should match) - Email-like text "@alice.com" (should NOT match) - Domain-like text "@alice.example" (should NOT match) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -710,6 +710,131 @@ fn activity_review_request_system_note() {
|
||||
assert_eq!(results[0].event_type, ActivityEventType::ReviewRequest);
|
||||
}
|
||||
|
||||
// ─── Since-Last-Check Mention Tests ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn since_last_check_detects_mention_with_trailing_comma() {
|
||||
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,
|
||||
"please review this @alice, thanks",
|
||||
t,
|
||||
);
|
||||
|
||||
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
|
||||
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
|
||||
assert_eq!(total_events, 1, "expected mention with comma to match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_last_check_ignores_email_like_text() {
|
||||
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,
|
||||
"contact alice at foo@alice.com",
|
||||
t,
|
||||
);
|
||||
|
||||
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
|
||||
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
|
||||
assert_eq!(total_events, 0, "email text should not count as mention");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_last_check_detects_mention_with_trailing_period() {
|
||||
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,
|
||||
"please review this @alice.",
|
||||
t,
|
||||
);
|
||||
|
||||
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
|
||||
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
|
||||
assert_eq!(total_events, 1, "expected mention with period to match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_last_check_detects_mention_inside_parentheses() {
|
||||
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,
|
||||
"thanks (@alice) for the update",
|
||||
t,
|
||||
);
|
||||
|
||||
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
|
||||
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
|
||||
assert_eq!(total_events, 1, "expected parenthesized mention to match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn since_last_check_ignores_domain_like_text() {
|
||||
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.com is the old hostname",
|
||||
t,
|
||||
);
|
||||
|
||||
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
|
||||
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
|
||||
assert_eq!(
|
||||
total_events, 0,
|
||||
"domain-like text should not count as mention"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helper Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -734,6 +859,7 @@ fn parse_attention_state_all_variants() {
|
||||
#[test]
|
||||
fn parse_event_type_all_variants() {
|
||||
assert_eq!(parse_event_type("note"), ActivityEventType::Note);
|
||||
assert_eq!(parse_event_type("mention_note"), ActivityEventType::Note);
|
||||
assert_eq!(
|
||||
parse_event_type("status_change"),
|
||||
ActivityEventType::StatusChange
|
||||
|
||||
Reference in New Issue
Block a user