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:
@@ -297,6 +297,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
|||||||
"--all",
|
"--all",
|
||||||
"--user",
|
"--user",
|
||||||
"--fields",
|
"--fields",
|
||||||
|
"--reset-cursor",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -710,6 +710,131 @@ fn activity_review_request_system_note() {
|
|||||||
assert_eq!(results[0].event_type, ActivityEventType::ReviewRequest);
|
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 ──────────────────────────────────────────────────────────
|
// ─── Helper Tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -734,6 +859,7 @@ fn parse_attention_state_all_variants() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_event_type_all_variants() {
|
fn parse_event_type_all_variants() {
|
||||||
assert_eq!(parse_event_type("note"), ActivityEventType::Note);
|
assert_eq!(parse_event_type("note"), ActivityEventType::Note);
|
||||||
|
assert_eq!(parse_event_type("mention_note"), ActivityEventType::Note);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_event_type("status_change"),
|
parse_event_type("status_change"),
|
||||||
ActivityEventType::StatusChange
|
ActivityEventType::StatusChange
|
||||||
|
|||||||
@@ -9,14 +9,18 @@ use rusqlite::Connection;
|
|||||||
|
|
||||||
use crate::Config;
|
use crate::Config;
|
||||||
use crate::cli::MeArgs;
|
use crate::cli::MeArgs;
|
||||||
|
use crate::core::cursor;
|
||||||
use crate::core::db::create_connection;
|
use crate::core::db::create_connection;
|
||||||
use crate::core::error::{LoreError, Result};
|
use crate::core::error::{LoreError, Result};
|
||||||
use crate::core::paths::get_db_path;
|
use crate::core::paths::get_db_path;
|
||||||
use crate::core::project::resolve_project;
|
use crate::core::project::resolve_project;
|
||||||
use crate::core::time::parse_since;
|
use crate::core::time::parse_since;
|
||||||
|
|
||||||
use self::queries::{query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs};
|
use self::queries::{
|
||||||
use self::types::{AttentionState, MeDashboard, MeSummary};
|
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.
|
/// Default activity lookback: 1 day in milliseconds.
|
||||||
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1;
|
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1;
|
||||||
@@ -72,6 +76,20 @@ pub fn resolve_project_scope(
|
|||||||
/// summary computation → dashboard assembly → rendering.
|
/// summary computation → dashboard assembly → rendering.
|
||||||
pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
||||||
let start = std::time::Instant::now();
|
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
|
// 1. Open DB
|
||||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
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
|
// 3. Resolve project scope
|
||||||
let username = resolve_username(args, config)?;
|
|
||||||
|
|
||||||
// 4. Resolve project scope
|
|
||||||
let project_ids = resolve_project_scope(&conn, args, config)?;
|
let project_ids = resolve_project_scope(&conn, args, config)?;
|
||||||
let single_project = project_ids.len() == 1;
|
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() {
|
let since_ms = match args.since.as_deref() {
|
||||||
Some(raw) => parse_since(raw).ok_or_else(|| {
|
Some(raw) => parse_since(raw).ok_or_else(|| {
|
||||||
LoreError::Other(format!(
|
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,
|
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 show_all = args.show_all_sections();
|
||||||
let want_issues = show_all || args.issues;
|
let want_issues = show_all || args.issues;
|
||||||
let want_mrs = show_all || args.mrs;
|
let want_mrs = show_all || args.mrs;
|
||||||
let want_activity = show_all || args.activity;
|
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 {
|
let open_issues = if want_issues {
|
||||||
query_open_issues(&conn, username, &project_ids)?
|
query_open_issues(&conn, username, &project_ids)?
|
||||||
} else {
|
} else {
|
||||||
@@ -137,7 +152,32 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
|||||||
Vec::new()
|
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
|
let needs_attention_count = open_issues
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|i| i.attention_state == AttentionState::NeedsAttention)
|
.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,
|
needs_attention_count,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 9. Assemble dashboard
|
// 8. Assemble dashboard
|
||||||
let dashboard = MeDashboard {
|
let dashboard = MeDashboard {
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
since_ms: Some(since_ms),
|
since_ms: Some(since_ms),
|
||||||
@@ -180,9 +220,10 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
|||||||
open_mrs_authored,
|
open_mrs_authored,
|
||||||
reviewing_mrs,
|
reviewing_mrs,
|
||||||
activity,
|
activity,
|
||||||
|
since_last_check,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 10. Render
|
// 9. Render
|
||||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
if robot_mode {
|
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(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -243,6 +318,7 @@ mod tests {
|
|||||||
all: false,
|
all: false,
|
||||||
user: user.map(String::from),
|
user: user.map(String::from),
|
||||||
fields: None,
|
fields: None,
|
||||||
|
reset_cursor: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ use rusqlite::Connection;
|
|||||||
|
|
||||||
use crate::core::error::Result;
|
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".
|
/// Stale threshold: items with no activity for 30 days are marked "stale".
|
||||||
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;
|
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000;
|
||||||
@@ -464,6 +470,223 @@ pub fn query_activity(
|
|||||||
Ok(events)
|
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<String>,
|
||||||
|
summary: String,
|
||||||
|
body_preview: Option<String>,
|
||||||
|
is_mention_source: bool,
|
||||||
|
mention_body: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Vec<SinceCheckGroup>> {
|
||||||
|
// 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<Box<dyn rusqlite::types::ToSql>> =
|
||||||
|
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<String>>(4)?.unwrap_or_default(),
|
||||||
|
project_path: row.get(5)?,
|
||||||
|
actor: row.get(6)?,
|
||||||
|
summary: row.get::<_, Option<String>>(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<RawSinceCheckRow> = rows
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||||
|
.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<RawSinceCheckRow>) -> Vec<SinceCheckGroup> {
|
||||||
|
// 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<SinceCheckGroup> = 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 ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Parse attention state string from SQL CASE result.
|
/// 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 {
|
fn parse_event_type(s: &str) -> ActivityEventType {
|
||||||
match s {
|
match s {
|
||||||
"note" => ActivityEventType::Note,
|
"note" => ActivityEventType::Note,
|
||||||
|
"mention_note" => ActivityEventType::Note,
|
||||||
"status_change" => ActivityEventType::StatusChange,
|
"status_change" => ActivityEventType::StatusChange,
|
||||||
"label_change" => ActivityEventType::LabelChange,
|
"label_change" => ActivityEventType::LabelChange,
|
||||||
"assign" => ActivityEventType::Assign,
|
"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.
|
/// Build a SQL clause for project ID filtering.
|
||||||
/// `start_idx` is the 1-based parameter index for the first project ID.
|
/// `start_idx` is the 1-based parameter index for the first project ID.
|
||||||
/// Returns empty string when no filter is needed (all projects).
|
/// Returns empty string when no filter is needed (all projects).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell
|
|||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
||||||
|
SinceLastCheck,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Layout Helpers ─────────────────────────────────────────────────────────
|
// ─── 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 ──────────────────────────────────────────────────────────
|
// ─── Full Dashboard ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Render the complete human-mode dashboard.
|
/// Render the complete human-mode dashboard.
|
||||||
pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) {
|
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_summary_header(&dashboard.summary, &dashboard.username);
|
||||||
print_issues_section(&dashboard.open_issues, single_project);
|
print_issues_section(&dashboard.open_issues, single_project);
|
||||||
print_authored_mrs_section(&dashboard.open_mrs_authored, 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_mrs: bool,
|
||||||
show_activity: 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);
|
print_summary_header(&dashboard.summary, &dashboard.username);
|
||||||
|
|
||||||
if show_issues {
|
if show_issues {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::core::time::ms_to_iso;
|
|||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
||||||
|
SinceCheckEvent, SinceCheckGroup, SinceLastCheck,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Robot JSON Output (Task #18) ────────────────────────────────────────────
|
// ─── Robot JSON Output (Task #18) ────────────────────────────────────────────
|
||||||
@@ -43,6 +44,27 @@ pub fn print_me_json(
|
|||||||
Ok(())
|
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 ───────────────────────────────────────────────────────────
|
// ─── JSON Envelope ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -57,6 +79,8 @@ struct MeDataJson {
|
|||||||
username: String,
|
username: String,
|
||||||
since_iso: Option<String>,
|
since_iso: Option<String>,
|
||||||
summary: SummaryJson,
|
summary: SummaryJson,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
since_last_check: Option<SinceLastCheckJson>,
|
||||||
open_issues: Vec<IssueJson>,
|
open_issues: Vec<IssueJson>,
|
||||||
open_mrs_authored: Vec<MrJson>,
|
open_mrs_authored: Vec<MrJson>,
|
||||||
reviewing_mrs: Vec<MrJson>,
|
reviewing_mrs: Vec<MrJson>,
|
||||||
@@ -69,6 +93,7 @@ impl MeDataJson {
|
|||||||
username: d.username.clone(),
|
username: d.username.clone(),
|
||||||
since_iso: d.since_ms.map(ms_to_iso),
|
since_iso: d.since_ms.map(ms_to_iso),
|
||||||
summary: SummaryJson::from(&d.summary),
|
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_issues: d.open_issues.iter().map(IssueJson::from).collect(),
|
||||||
open_mrs_authored: d.open_mrs_authored.iter().map(MrJson::from).collect(),
|
open_mrs_authored: d.open_mrs_authored.iter().map(MrJson::from).collect(),
|
||||||
reviewing_mrs: d.reviewing_mrs.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 ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Convert `AttentionState` to its programmatic string representation.
|
/// Convert `AttentionState` to its programmatic string representation.
|
||||||
@@ -331,4 +417,12 @@ mod tests {
|
|||||||
assert!(!json.is_own);
|
assert!(!json.is_own);
|
||||||
assert_eq!(json.body_preview, Some("This looks good".to_string()));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,34 @@ pub struct MeActivityEvent {
|
|||||||
pub body_preview: Option<String>,
|
pub body_preview: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
pub summary: String,
|
||||||
|
pub body_preview: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<SinceCheckEvent>,
|
||||||
|
pub latest_timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The complete "since last check" result.
|
||||||
|
pub struct SinceLastCheck {
|
||||||
|
pub cursor_ms: i64,
|
||||||
|
pub groups: Vec<SinceCheckGroup>,
|
||||||
|
pub total_event_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// The complete dashboard result.
|
/// The complete dashboard result.
|
||||||
pub struct MeDashboard {
|
pub struct MeDashboard {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -95,4 +123,5 @@ pub struct MeDashboard {
|
|||||||
pub open_mrs_authored: Vec<MeMr>,
|
pub open_mrs_authored: Vec<MeMr>,
|
||||||
pub reviewing_mrs: Vec<MeMr>,
|
pub reviewing_mrs: Vec<MeMr>,
|
||||||
pub activity: Vec<MeActivityEvent>,
|
pub activity: Vec<MeActivityEvent>,
|
||||||
|
pub since_last_check: Option<SinceLastCheck>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1095,6 +1095,10 @@ pub struct MeArgs {
|
|||||||
/// Select output fields (comma-separated, or 'minimal' preset)
|
/// Select output fields (comma-separated, or 'minimal' preset)
|
||||||
#[arg(long, help_heading = "Output", value_delimiter = ',')]
|
#[arg(long, help_heading = "Output", value_delimiter = ',')]
|
||||||
pub fields: Option<Vec<String>>,
|
pub fields: Option<Vec<String>>,
|
||||||
|
|
||||||
|
/// Reset the since-last-check cursor (next run shows no new events)
|
||||||
|
#[arg(long, help_heading = "Output")]
|
||||||
|
pub reset_cursor: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MeArgs {
|
impl MeArgs {
|
||||||
|
|||||||
Reference in New Issue
Block a user