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

@@ -5,6 +5,7 @@ use crate::core::time::ms_to_iso;
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
SinceCheckEvent, SinceCheckGroup, SinceLastCheck,
};
// ─── Robot JSON Output (Task #18) ────────────────────────────────────────────
@@ -43,6 +44,27 @@ pub fn print_me_json(
Ok(())
}
/// Print `--reset-cursor` response using standard robot envelope.
pub fn print_cursor_reset_json(elapsed_ms: u64) -> crate::core::error::Result<()> {
let value = cursor_reset_envelope_json(elapsed_ms);
let json = serde_json::to_string(&value)
.map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?;
println!("{json}");
Ok(())
}
fn cursor_reset_envelope_json(elapsed_ms: u64) -> serde_json::Value {
serde_json::json!({
"ok": true,
"data": {
"cursor_reset": true
},
"meta": {
"elapsed_ms": elapsed_ms
}
})
}
// ─── JSON Envelope ───────────────────────────────────────────────────────────
#[derive(Serialize)]
@@ -57,6 +79,8 @@ struct MeDataJson {
username: String,
since_iso: Option<String>,
summary: SummaryJson,
#[serde(skip_serializing_if = "Option::is_none")]
since_last_check: Option<SinceLastCheckJson>,
open_issues: Vec<IssueJson>,
open_mrs_authored: Vec<MrJson>,
reviewing_mrs: Vec<MrJson>,
@@ -69,6 +93,7 @@ impl MeDataJson {
username: d.username.clone(),
since_iso: d.since_ms.map(ms_to_iso),
summary: SummaryJson::from(&d.summary),
since_last_check: d.since_last_check.as_ref().map(SinceLastCheckJson::from),
open_issues: d.open_issues.iter().map(IssueJson::from).collect(),
open_mrs_authored: d.open_mrs_authored.iter().map(MrJson::from).collect(),
reviewing_mrs: d.reviewing_mrs.iter().map(MrJson::from).collect(),
@@ -197,6 +222,67 @@ impl From<&MeActivityEvent> for ActivityJson {
}
}
// ─── Since Last Check ────────────────────────────────────────────────────────
#[derive(Serialize)]
struct SinceLastCheckJson {
cursor_iso: String,
total_event_count: usize,
groups: Vec<SinceCheckGroupJson>,
}
impl From<&SinceLastCheck> for SinceLastCheckJson {
fn from(s: &SinceLastCheck) -> Self {
Self {
cursor_iso: ms_to_iso(s.cursor_ms),
total_event_count: s.total_event_count,
groups: s.groups.iter().map(SinceCheckGroupJson::from).collect(),
}
}
}
#[derive(Serialize)]
struct SinceCheckGroupJson {
entity_type: String,
entity_iid: i64,
entity_title: String,
project: String,
events: Vec<SinceCheckEventJson>,
}
impl From<&SinceCheckGroup> for SinceCheckGroupJson {
fn from(g: &SinceCheckGroup) -> Self {
Self {
entity_type: g.entity_type.clone(),
entity_iid: g.entity_iid,
entity_title: g.entity_title.clone(),
project: g.project_path.clone(),
events: g.events.iter().map(SinceCheckEventJson::from).collect(),
}
}
}
#[derive(Serialize)]
struct SinceCheckEventJson {
timestamp_iso: String,
event_type: String,
actor: Option<String>,
summary: String,
body_preview: Option<String>,
}
impl From<&SinceCheckEvent> for SinceCheckEventJson {
fn from(e: &SinceCheckEvent) -> Self {
Self {
timestamp_iso: ms_to_iso(e.timestamp),
event_type: event_type_str(&e.event_type),
actor: e.actor.clone(),
summary: e.summary.clone(),
body_preview: e.body_preview.clone(),
}
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/// Convert `AttentionState` to its programmatic string representation.
@@ -331,4 +417,12 @@ mod tests {
assert!(!json.is_own);
assert_eq!(json.body_preview, Some("This looks good".to_string()));
}
#[test]
fn cursor_reset_envelope_includes_meta_elapsed_ms() {
let value = cursor_reset_envelope_json(17);
assert_eq!(value["ok"], serde_json::json!(true));
assert_eq!(value["data"]["cursor_reset"], serde_json::json!(true));
assert_eq!(value["meta"]["elapsed_ms"], serde_json::json!(17));
}
}