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