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:
teernisse
2026-02-25 10:02:13 -05:00
parent eac640225f
commit ce5621f3ed
8 changed files with 714 additions and 13 deletions

View File

@@ -8,7 +8,13 @@ use rusqlite::Connection;
use crate::core::error::Result;
use super::types::{ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr};
use regex::Regex;
use std::collections::HashMap;
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr, SinceCheckEvent,
SinceCheckGroup,
};
/// Stale threshold: items with no activity for 30 days are marked "stale".
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;
@@ -464,6 +470,223 @@ pub fn query_activity(
Ok(events)
}
// ─── Since Last Check (cursor-based inbox) ──────────────────────────────────
/// Raw row from the since-last-check UNION query.
struct RawSinceCheckRow {
timestamp: i64,
event_type: String,
entity_type: String,
entity_iid: i64,
entity_title: String,
project_path: String,
actor: Option<String>,
summary: String,
body_preview: Option<String>,
is_mention_source: bool,
mention_body: Option<String>,
}
/// Query actionable events from others since `cursor_ms`.
/// Returns events from three sources:
/// 1. Others' comments on my open items
/// 2. @mentions on any item (not restricted to my items)
/// 3. Assignment/review-request system notes mentioning me
pub fn query_since_last_check(
conn: &Connection,
username: &str,
cursor_ms: i64,
) -> Result<Vec<SinceCheckGroup>> {
// Build the "my items" subquery fragments (reused from activity).
let my_issue_check = "EXISTS (
SELECT 1 FROM issue_assignees ia
JOIN issues i2 ON ia.issue_id = i2.id
WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1 AND i2.state = 'opened'
)";
let my_mr_check = "(
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1 AND mr2.state = 'opened')
OR EXISTS (SELECT 1 FROM mr_reviewers rv
JOIN merge_requests mr3 ON rv.merge_request_id = mr3.id
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened')
)";
// Source 1: Others' comments on my open items
let source1 = format!(
"SELECT n.created_at, 'note',
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
COALESCE(i.title, m.title),
p.path_with_namespace,
n.author_username,
SUBSTR(n.body, 1, 200),
NULL,
0,
NULL
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
JOIN projects p ON d.project_id = p.id
LEFT JOIN issues i ON d.issue_id = i.id
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
WHERE n.is_system = 0
AND n.created_at > ?2
AND n.author_username != ?1
AND (
(d.issue_id IS NOT NULL AND {issue_check})
OR (d.merge_request_id IS NOT NULL AND {mr_check})
)",
issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
);
// Source 2: @mentions on ANY item (not restricted to my items)
// Word-boundary-aware matching to reduce false positives
let source2 = format!(
"SELECT n.created_at, 'mention_note',
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
COALESCE(i.title, m.title),
p.path_with_namespace,
n.author_username,
SUBSTR(n.body, 1, 200),
NULL,
1,
n.body
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
JOIN projects p ON d.project_id = p.id
LEFT JOIN issues i ON d.issue_id = i.id
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
WHERE n.is_system = 0
AND n.created_at > ?2
AND n.author_username != ?1
AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%'
AND NOT (
(d.issue_id IS NOT NULL AND {issue_check})
OR (d.merge_request_id IS NOT NULL AND {mr_check})
)",
issue_check = my_issue_check.replace("{entity_issue_id}", "d.issue_id"),
mr_check = my_mr_check.replace("{entity_mr_id}", "d.merge_request_id"),
);
// Source 3: Assignment/review-request system notes mentioning me
let source3 = "SELECT n.created_at,
CASE
WHEN LOWER(n.body) LIKE '%assigned to @%' THEN 'assign'
WHEN LOWER(n.body) LIKE '%unassigned @%' THEN 'unassign'
WHEN LOWER(n.body) LIKE '%requested review from @%' THEN 'review_request'
ELSE 'assign'
END,
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,
COALESCE(i.iid, m.iid),
COALESCE(i.title, m.title),
p.path_with_namespace,
n.author_username,
n.body,
NULL,
0,
NULL
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
JOIN projects p ON d.project_id = p.id
LEFT JOIN issues i ON d.issue_id = i.id
LEFT JOIN merge_requests m ON d.merge_request_id = m.id
WHERE n.is_system = 1
AND n.created_at > ?2
AND n.author_username != ?1
AND (
LOWER(n.body) LIKE '%assigned to @' || LOWER(?1) || '%'
OR LOWER(n.body) LIKE '%unassigned @' || LOWER(?1) || '%'
OR LOWER(n.body) LIKE '%requested review from @' || LOWER(?1) || '%'
)"
.to_string();
let full_sql = format!(
"{source1}
UNION ALL {source2}
UNION ALL {source3}
ORDER BY 1 DESC
LIMIT 200"
);
let params: Vec<Box<dyn rusqlite::types::ToSql>> =
vec![Box::new(username.to_string()), Box::new(cursor_ms)];
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&full_sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| {
Ok(RawSinceCheckRow {
timestamp: row.get(0)?,
event_type: row.get(1)?,
entity_type: row.get(2)?,
entity_iid: row.get(3)?,
entity_title: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
project_path: row.get(5)?,
actor: row.get(6)?,
summary: row.get::<_, Option<String>>(7)?.unwrap_or_default(),
body_preview: row.get(8)?,
is_mention_source: row.get::<_, i32>(9)? != 0,
mention_body: row.get(10)?,
})
})?;
let mention_re = build_exact_mention_regex(username);
let raw_events: Vec<RawSinceCheckRow> = rows
.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.filter(|row| {
!row.is_mention_source
|| row
.mention_body
.as_deref()
.is_some_and(|body| contains_exact_mention(body, &mention_re))
})
.collect();
Ok(group_since_check_events(raw_events))
}
/// Group flat event rows by entity, sort groups newest-first, events within oldest-first.
fn group_since_check_events(rows: Vec<RawSinceCheckRow>) -> Vec<SinceCheckGroup> {
// Key: (entity_type, entity_iid, project_path)
let mut groups: HashMap<(String, i64, String), SinceCheckGroup> = HashMap::new();
for row in rows {
let key = (
row.entity_type.clone(),
row.entity_iid,
row.project_path.clone(),
);
let group = groups.entry(key).or_insert_with(|| SinceCheckGroup {
entity_type: row.entity_type.clone(),
entity_iid: row.entity_iid,
entity_title: row.entity_title.clone(),
project_path: row.project_path.clone(),
events: Vec::new(),
latest_timestamp: 0,
});
if row.timestamp > group.latest_timestamp {
group.latest_timestamp = row.timestamp;
}
group.events.push(SinceCheckEvent {
timestamp: row.timestamp,
event_type: parse_event_type(&row.event_type),
actor: row.actor,
summary: row.summary,
body_preview: row.body_preview,
});
}
let mut result: Vec<SinceCheckGroup> = groups.into_values().collect();
// Sort groups newest-first
result.sort_by_key(|g| std::cmp::Reverse(g.latest_timestamp));
// Sort events within each group oldest-first (read top-to-bottom)
for group in &mut result {
group.events.sort_by_key(|e| e.timestamp);
}
result
}
// ─── Helpers ────────────────────────────────────────────────────────────────
/// Parse attention state string from SQL CASE result.
@@ -482,6 +705,7 @@ fn parse_attention_state(s: &str) -> AttentionState {
fn parse_event_type(s: &str) -> ActivityEventType {
match s {
"note" => ActivityEventType::Note,
"mention_note" => ActivityEventType::Note,
"status_change" => ActivityEventType::StatusChange,
"label_change" => ActivityEventType::LabelChange,
"assign" => ActivityEventType::Assign,
@@ -492,6 +716,46 @@ fn parse_event_type(s: &str) -> ActivityEventType {
}
}
fn build_exact_mention_regex(username: &str) -> Regex {
let escaped = regex::escape(username);
let pattern = format!(r"(?i)@{escaped}");
Regex::new(&pattern).expect("mention regex must compile")
}
fn contains_exact_mention(body: &str, mention_re: &Regex) -> bool {
for m in mention_re.find_iter(body) {
let start = m.start();
let end = m.end();
let prev = body[..start].chars().next_back();
if prev.is_some_and(is_username_char) {
continue;
}
if let Some(next) = body[end..].chars().next() {
// Reject domain-like continuations such as "@alice.com"
if next == '.' {
let after_dot = body[end + next.len_utf8()..].chars().next();
if after_dot.is_some_and(is_username_char) {
continue;
}
}
if is_username_char(next) {
continue;
}
}
return true;
}
false
}
fn is_username_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
}
/// Build a SQL clause for project ID filtering.
/// `start_idx` is the 1-based parameter index for the first project ID.
/// Returns empty string when no filter is needed (all projects).