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