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