From ce5621f3ed50ce7f6f903958de32474a23a66801 Mon Sep 17 00:00:00 2001 From: teernisse Date: Wed, 25 Feb 2026 10:02:13 -0500 Subject: [PATCH] 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 --- src/cli/autocorrect.rs | 1 + src/cli/commands/me/me_tests.rs | 126 +++++++++++++ src/cli/commands/me/mod.rs | 100 +++++++++-- src/cli/commands/me/queries.rs | 266 +++++++++++++++++++++++++++- src/cli/commands/me/render_human.rs | 107 +++++++++++ src/cli/commands/me/render_robot.rs | 94 ++++++++++ src/cli/commands/me/types.rs | 29 +++ src/cli/mod.rs | 4 + 8 files changed, 714 insertions(+), 13 deletions(-) diff --git a/src/cli/autocorrect.rs b/src/cli/autocorrect.rs index ab6db39..9f84d53 100644 --- a/src/cli/autocorrect.rs +++ b/src/cli/autocorrect.rs @@ -297,6 +297,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[ "--all", "--user", "--fields", + "--reset-cursor", ], ), ]; diff --git a/src/cli/commands/me/me_tests.rs b/src/cli/commands/me/me_tests.rs index b37ef70..54f7808 100644 --- a/src/cli/commands/me/me_tests.rs +++ b/src/cli/commands/me/me_tests.rs @@ -710,6 +710,131 @@ fn activity_review_request_system_note() { assert_eq!(results[0].event_type, ActivityEventType::ReviewRequest); } +// ─── Since-Last-Check Mention Tests ───────────────────────────────────────── + +#[test] +fn since_last_check_detects_mention_with_trailing_comma() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue(&conn, 10, 1, 42, "someone"); + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t = now_ms() - 1000; + insert_note_at( + &conn, + 200, + disc_id, + 1, + "bob", + false, + "please review this @alice, thanks", + t, + ); + + let groups = query_since_last_check(&conn, "alice", 0).unwrap(); + let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); + assert_eq!(total_events, 1, "expected mention with comma to match"); +} + +#[test] +fn since_last_check_ignores_email_like_text() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue(&conn, 10, 1, 42, "someone"); + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t = now_ms() - 1000; + insert_note_at( + &conn, + 200, + disc_id, + 1, + "bob", + false, + "contact alice at foo@alice.com", + t, + ); + + let groups = query_since_last_check(&conn, "alice", 0).unwrap(); + let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); + assert_eq!(total_events, 0, "email text should not count as mention"); +} + +#[test] +fn since_last_check_detects_mention_with_trailing_period() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue(&conn, 10, 1, 42, "someone"); + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t = now_ms() - 1000; + insert_note_at( + &conn, + 200, + disc_id, + 1, + "bob", + false, + "please review this @alice.", + t, + ); + + let groups = query_since_last_check(&conn, "alice", 0).unwrap(); + let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); + assert_eq!(total_events, 1, "expected mention with period to match"); +} + +#[test] +fn since_last_check_detects_mention_inside_parentheses() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue(&conn, 10, 1, 42, "someone"); + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t = now_ms() - 1000; + insert_note_at( + &conn, + 200, + disc_id, + 1, + "bob", + false, + "thanks (@alice) for the update", + t, + ); + + let groups = query_since_last_check(&conn, "alice", 0).unwrap(); + let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); + assert_eq!(total_events, 1, "expected parenthesized mention to match"); +} + +#[test] +fn since_last_check_ignores_domain_like_text() { + let conn = setup_test_db(); + insert_project(&conn, 1, "group/repo"); + insert_issue(&conn, 10, 1, 42, "someone"); + let disc_id = 100; + insert_discussion(&conn, disc_id, 1, None, Some(10)); + let t = now_ms() - 1000; + insert_note_at( + &conn, + 200, + disc_id, + 1, + "bob", + false, + "@alice.com is the old hostname", + t, + ); + + let groups = query_since_last_check(&conn, "alice", 0).unwrap(); + let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); + assert_eq!( + total_events, 0, + "domain-like text should not count as mention" + ); +} + // ─── Helper Tests ────────────────────────────────────────────────────────── #[test] @@ -734,6 +859,7 @@ fn parse_attention_state_all_variants() { #[test] fn parse_event_type_all_variants() { assert_eq!(parse_event_type("note"), ActivityEventType::Note); + assert_eq!(parse_event_type("mention_note"), ActivityEventType::Note); assert_eq!( parse_event_type("status_change"), ActivityEventType::StatusChange diff --git a/src/cli/commands/me/mod.rs b/src/cli/commands/me/mod.rs index 1b4c32e..a7ebceb 100644 --- a/src/cli/commands/me/mod.rs +++ b/src/cli/commands/me/mod.rs @@ -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 = 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 { + // Resolve project IDs to paths for matching + let paths: HashSet = 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, } } diff --git a/src/cli/commands/me/queries.rs b/src/cli/commands/me/queries.rs index 9004f2a..a76de4f 100644 --- a/src/cli/commands/me/queries.rs +++ b/src/cli/commands/me/queries.rs @@ -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, + summary: String, + body_preview: Option, + is_mention_source: bool, + mention_body: Option, +} + +/// 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> { + // 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> = + 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>(4)?.unwrap_or_default(), + project_path: row.get(5)?, + actor: row.get(6)?, + summary: row.get::<_, Option>(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 = rows + .collect::, _>>()? + .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) -> Vec { + // 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 = 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). diff --git a/src/cli/commands/me/render_human.rs b/src/cli/commands/me/render_human.rs index e4517e1..b6cbc8a 100644 --- a/src/cli/commands/me/render_human.rs +++ b/src/cli/commands/me/render_human.rs @@ -2,6 +2,7 @@ use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell use super::types::{ ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary, + SinceLastCheck, }; // ─── Layout Helpers ───────────────────────────────────────────────────────── @@ -475,10 +476,113 @@ fn format_entity_ref(entity_type: &str, iid: i64) -> String { } } +// ─── Since Last Check ──────────────────────────────────────────────────────── + +/// Print the "since last check" section at the top of the dashboard. +pub fn print_since_last_check_section(since: &SinceLastCheck, single_project: bool) { + let relative = render::format_relative_time(since.cursor_ms); + + if since.groups.is_empty() { + println!( + "\n {}", + Theme::dim().render(&format!( + "No new events since {} ({relative})", + render::format_datetime(since.cursor_ms), + )) + ); + return; + } + + println!( + "{}", + render::section_divider(&format!("Since Last Check ({relative})")) + ); + + for group in &since.groups { + // Entity header: !247 Fix race condition... + let ref_str = match group.entity_type.as_str() { + "issue" => format!("#{}", group.entity_iid), + "mr" => format!("!{}", group.entity_iid), + _ => format!("{}:{}", group.entity_type, group.entity_iid), + }; + let ref_style = match group.entity_type.as_str() { + "issue" => Theme::issue_ref(), + "mr" => Theme::mr_ref(), + _ => Theme::bold(), + }; + + println!(); + println!( + " {} {}", + ref_style.render(&ref_str), + Theme::bold().render(&render::truncate(&group.entity_title, title_width(20))), + ); + if !single_project { + println!(" {}", Theme::dim().render(&group.project_path)); + } + + // Sub-events as indented rows + let summary_max = title_width(42); + let mut table = Table::new() + .columns(3) + .indent(6) + .align(2, Align::Right) + .max_width(1, summary_max); + + for event in &group.events { + let badge = activity_badge_label(&event.event_type); + let badge_style = activity_badge_style(&event.event_type); + + let actor_prefix = event + .actor + .as_deref() + .map(|a| format!("@{a} ")) + .unwrap_or_default(); + let clean_summary = event.summary.replace('\n', " "); + let summary_text = format!("{actor_prefix}{clean_summary}"); + + let time = render::format_relative_time_compact(event.timestamp); + + table.add_row(vec![ + StyledCell::styled(badge, badge_style), + StyledCell::plain(summary_text), + StyledCell::styled(time, Theme::dim()), + ]); + } + + let rendered = table.render(); + for (line, event) in rendered.lines().zip(group.events.iter()) { + println!("{line}"); + if let Some(preview) = &event.body_preview + && !preview.is_empty() + { + let truncated = render::truncate(preview, 60); + println!( + " {}", + Theme::dim().render(&format!("\"{truncated}\"")) + ); + } + } + } + + // Footer + println!( + "\n {}", + Theme::dim().render(&format!( + "{} events across {} items", + since.total_event_count, + since.groups.len() + )) + ); +} + // ─── Full Dashboard ────────────────────────────────────────────────────────── /// Render the complete human-mode dashboard. pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) { + if let Some(ref since) = dashboard.since_last_check { + print_since_last_check_section(since, single_project); + } print_summary_header(&dashboard.summary, &dashboard.username); print_issues_section(&dashboard.open_issues, single_project); print_authored_mrs_section(&dashboard.open_mrs_authored, single_project); @@ -495,6 +599,9 @@ pub fn print_me_dashboard_filtered( show_mrs: bool, show_activity: bool, ) { + if let Some(ref since) = dashboard.since_last_check { + print_since_last_check_section(since, single_project); + } print_summary_header(&dashboard.summary, &dashboard.username); if show_issues { diff --git a/src/cli/commands/me/render_robot.rs b/src/cli/commands/me/render_robot.rs index 3a05bb0..df12666 100644 --- a/src/cli/commands/me/render_robot.rs +++ b/src/cli/commands/me/render_robot.rs @@ -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, summary: SummaryJson, + #[serde(skip_serializing_if = "Option::is_none")] + since_last_check: Option, open_issues: Vec, open_mrs_authored: Vec, reviewing_mrs: Vec, @@ -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, +} + +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, +} + +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, + summary: String, + body_preview: Option, +} + +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)); + } } diff --git a/src/cli/commands/me/types.rs b/src/cli/commands/me/types.rs index dfd2edd..39c6254 100644 --- a/src/cli/commands/me/types.rs +++ b/src/cli/commands/me/types.rs @@ -86,6 +86,34 @@ pub struct MeActivityEvent { pub body_preview: Option, } +/// A single actionable event in the "since last check" section. +#[derive(Clone)] +pub struct SinceCheckEvent { + pub timestamp: i64, + pub event_type: ActivityEventType, + pub actor: Option, + pub summary: String, + pub body_preview: Option, +} + +/// Events grouped by entity for the "since last check" section. +#[derive(Clone)] +pub struct SinceCheckGroup { + pub entity_type: String, + pub entity_iid: i64, + pub entity_title: String, + pub project_path: String, + pub events: Vec, + pub latest_timestamp: i64, +} + +/// The complete "since last check" result. +pub struct SinceLastCheck { + pub cursor_ms: i64, + pub groups: Vec, + pub total_event_count: usize, +} + /// The complete dashboard result. pub struct MeDashboard { pub username: String, @@ -95,4 +123,5 @@ pub struct MeDashboard { pub open_mrs_authored: Vec, pub reviewing_mrs: Vec, pub activity: Vec, + pub since_last_check: Option, } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9b21c31..e3d1565 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1095,6 +1095,10 @@ pub struct MeArgs { /// Select output fields (comma-separated, or 'minimal' preset) #[arg(long, help_heading = "Output", value_delimiter = ',')] pub fields: Option>, + + /// Reset the since-last-check cursor (next run shows no new events) + #[arg(long, help_heading = "Output")] + pub reset_cursor: bool, } impl MeArgs {