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

@@ -9,14 +9,18 @@ use rusqlite::Connection;
use crate::Config;
use crate::cli::MeArgs;
use crate::core::cursor;
use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::parse_since;
use self::queries::{query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs};
use self::types::{AttentionState, MeDashboard, MeSummary};
use self::queries::{
query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs,
query_since_last_check,
};
use self::types::{AttentionState, MeDashboard, MeSummary, SinceLastCheck};
/// Default activity lookback: 1 day in milliseconds.
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1;
@@ -72,6 +76,20 @@ pub fn resolve_project_scope(
/// summary computation → dashboard assembly → rendering.
pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
let start = std::time::Instant::now();
let username = resolve_username(args, config)?;
// 0. Handle --reset-cursor early return
if args.reset_cursor {
cursor::reset_cursor(username)
.map_err(|e| LoreError::Other(format!("reset cursor: {e}")))?;
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
render_robot::print_cursor_reset_json(elapsed_ms)?;
} else {
println!("Cursor reset for @{username}. Next `lore me` will establish a new baseline.");
}
return Ok(());
}
// 1. Open DB
let db_path = get_db_path(config.storage.db_path.as_deref());
@@ -89,14 +107,11 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
));
}
// 3. Resolve username
let username = resolve_username(args, config)?;
// 4. Resolve project scope
// 3. Resolve project scope
let project_ids = resolve_project_scope(&conn, args, config)?;
let single_project = project_ids.len() == 1;
// 5. Parse --since (default 1d for activity feed)
// 4. Parse --since (default 1d for activity feed)
let since_ms = match args.since.as_deref() {
Some(raw) => parse_since(raw).ok_or_else(|| {
LoreError::Other(format!(
@@ -106,13 +121,13 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
None => crate::core::time::now_ms() - DEFAULT_ACTIVITY_SINCE_DAYS * MS_PER_DAY,
};
// 6. Determine which sections to query
// 5. Determine which sections to query
let show_all = args.show_all_sections();
let want_issues = show_all || args.issues;
let want_mrs = show_all || args.mrs;
let want_activity = show_all || args.activity;
// 7. Run queries for requested sections
// 6. Run queries for requested sections
let open_issues = if want_issues {
query_open_issues(&conn, username, &project_ids)?
} else {
@@ -137,7 +152,32 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
Vec::new()
};
// 8. Compute summary
// 6b. Since-last-check (cursor-based inbox)
let cursor_ms = cursor::read_cursor(username);
// Capture global watermark BEFORE project filtering so --project doesn't
// permanently skip events from other projects.
let mut global_watermark: Option<i64> = None;
let since_last_check = if let Some(prev_cursor) = cursor_ms {
let groups = query_since_last_check(&conn, username, prev_cursor)?;
// Watermark from ALL groups (unfiltered) — this is the true high-water mark
global_watermark = groups.iter().map(|g| g.latest_timestamp).max();
// If --project was passed, filter groups by project for display only
let groups = if !project_ids.is_empty() {
filter_groups_by_project_ids(&conn, &groups, &project_ids)
} else {
groups
};
let total = groups.iter().map(|g| g.events.len()).sum();
Some(SinceLastCheck {
cursor_ms: prev_cursor,
groups,
total_event_count: total,
})
} else {
None // First run — no section shown
};
// 7. Compute summary
let needs_attention_count = open_issues
.iter()
.filter(|i| i.attention_state == AttentionState::NeedsAttention)
@@ -171,7 +211,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
needs_attention_count,
};
// 9. Assemble dashboard
// 8. Assemble dashboard
let dashboard = MeDashboard {
username: username.to_string(),
since_ms: Some(since_ms),
@@ -180,9 +220,10 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
open_mrs_authored,
reviewing_mrs,
activity,
since_last_check,
};
// 10. Render
// 9. Render
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
@@ -200,9 +241,43 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
);
}
// 10. Advance cursor AFTER successful render (watermark pattern)
// Uses max event timestamp from UNFILTERED results so --project filtering
// doesn't permanently skip events from other projects.
let watermark = global_watermark.unwrap_or_else(crate::core::time::now_ms);
cursor::write_cursor(username, watermark)
.map_err(|e| LoreError::Other(format!("write cursor: {e}")))?;
Ok(())
}
/// Filter since-last-check groups to only those matching the given project IDs.
/// Used when --project narrows the display scope (cursor is still global).
fn filter_groups_by_project_ids(
conn: &Connection,
groups: &[types::SinceCheckGroup],
project_ids: &[i64],
) -> Vec<types::SinceCheckGroup> {
// Resolve project IDs to paths for matching
let paths: HashSet<String> = project_ids
.iter()
.filter_map(|pid| {
conn.query_row(
"SELECT path_with_namespace FROM projects WHERE id = ?1",
rusqlite::params![pid],
|row| row.get::<_, String>(0),
)
.ok()
})
.collect();
groups
.iter()
.filter(|g| paths.contains(&g.project_path))
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -243,6 +318,7 @@ mod tests {
all: false,
user: user.map(String::from),
fields: None,
reset_cursor: false,
}
}