// ─── Query Functions ──────────────────────────────────────────────────────── // // SQL queries powering the `lore me` dashboard. // Each function takes &Connection, username, optional project scope, // and returns Result>. use rusqlite::Connection; use crate::core::error::Result; use regex::Regex; use std::collections::HashMap; use super::types::{ ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMention, MeMr, SinceCheckEvent, SinceCheckGroup, }; /// Stale threshold: items with no activity for 30 days are marked "stale". const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000; // ─── Attention Reason ─────────────────────────────────────────────────────── /// Format a human-readable duration from a millisecond epoch to now. /// Returns e.g. "3 hours", "2 days", "1 week". fn relative_duration(ms_epoch: i64) -> String { let diff = crate::core::time::now_ms() - ms_epoch; if diff < 60_000 { return "moments".to_string(); } let (n, unit) = match diff { d if d < 3_600_000 => (d / 60_000, "minute"), d if d < 86_400_000 => (d / 3_600_000, "hour"), d if d < 604_800_000 => (d / 86_400_000, "day"), d if d < 2_592_000_000 => (d / 604_800_000, "week"), d => (d / 2_592_000_000, "month"), }; if n == 1 { format!("1 {unit}") } else { format!("{n} {unit}s") } } /// Build a human-readable reason explaining why the attention state was set. pub(super) fn format_attention_reason( state: &AttentionState, my_ts: Option, others_ts: Option, any_ts: Option, ) -> String { match state { AttentionState::NotReady => "Draft with no reviewers assigned".to_string(), AttentionState::Stale => { if let Some(ts) = any_ts { format!("No activity for {}", relative_duration(ts)) } else { "No activity for over 30 days".to_string() } } AttentionState::NeedsAttention => { let others_ago = others_ts .map(|ts| format!("{} ago", relative_duration(ts))) .unwrap_or_else(|| "recently".to_string()); if let Some(ts) = my_ts { format!( "Others replied {}; you last commented {} ago", others_ago, relative_duration(ts) ) } else { format!("Others commented {}; you haven't replied", others_ago) } } AttentionState::AwaitingResponse => { if let Some(ts) = my_ts { format!("You replied {} ago; awaiting others", relative_duration(ts)) } else { "Awaiting response from others".to_string() } } AttentionState::NotStarted => "No discussion yet".to_string(), } } // ─── Open Issues (AC-5.1, Task #7) ───────────────────────────────────────── /// Query open issues assigned to the user via issue_assignees. /// Returns issues sorted by attention state priority, then by most recently updated. /// Attention state is computed inline using CTE-based note timestamp comparison. pub fn query_open_issues( conn: &Connection, username: &str, project_ids: &[i64], ) -> Result> { let project_clause = build_project_clause("i.project_id", project_ids); let sql = format!( "WITH note_ts AS ( SELECT d.issue_id, MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts, MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts, MAX(n.created_at) AS any_ts FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE n.is_system = 0 AND d.issue_id IS NOT NULL GROUP BY d.issue_id ) SELECT i.iid, i.title, p.path_with_namespace, i.status_name, i.updated_at, i.web_url, CASE WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 'stale' WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 'needs_attention' WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 'awaiting_response' ELSE 'not_started' END AS attention_state, nt.my_ts, nt.others_ts, nt.any_ts FROM issues i JOIN issue_assignees ia ON ia.issue_id = i.id JOIN projects p ON i.project_id = p.id LEFT JOIN note_ts nt ON nt.issue_id = i.id WHERE ia.username = ?1 AND i.state = 'opened' AND (i.status_name COLLATE NOCASE IN ('In Progress', 'In Review') OR i.status_name IS NULL) {project_clause} ORDER BY CASE WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 0 WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1 WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 2 WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3 ELSE 1 END, i.updated_at DESC", stale_ms = STALE_THRESHOLD_MS, ); let params = build_params(username, project_ids); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map(param_refs.as_slice(), |row| { let attention_str: String = row.get(6)?; let my_ts: Option = row.get(7)?; let others_ts: Option = row.get(8)?; let any_ts: Option = row.get(9)?; let state = parse_attention_state(&attention_str); let reason = format_attention_reason(&state, my_ts, others_ts, any_ts); Ok(MeIssue { iid: row.get(0)?, title: row.get::<_, Option>(1)?.unwrap_or_default(), project_path: row.get(2)?, status_name: row.get(3)?, updated_at: row.get(4)?, web_url: row.get(5)?, attention_state: state, attention_reason: reason, labels: Vec::new(), }) })?; let mut issues: Vec = rows.collect::, _>>()?; populate_issue_labels(conn, &mut issues)?; Ok(issues) } // ─── Authored MRs (AC-5.2, Task #8) ──────────────────────────────────────── /// Query open MRs authored by the user. pub fn query_authored_mrs( conn: &Connection, username: &str, project_ids: &[i64], ) -> Result> { let project_clause = build_project_clause("m.project_id", project_ids); let sql = format!( "WITH note_ts AS ( SELECT d.merge_request_id, MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts, MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts, MAX(n.created_at) AS any_ts FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL GROUP BY d.merge_request_id ) SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status, m.updated_at, m.web_url, CASE WHEN m.draft = 1 AND NOT EXISTS ( SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id ) THEN 'not_ready' WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 'stale' WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 'needs_attention' WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 'awaiting_response' ELSE 'not_started' END AS attention_state, nt.my_ts, nt.others_ts, nt.any_ts FROM merge_requests m JOIN projects p ON m.project_id = p.id LEFT JOIN note_ts nt ON nt.merge_request_id = m.id WHERE m.author_username = ?1 AND m.state = 'opened' {project_clause} ORDER BY CASE WHEN m.draft = 1 AND NOT EXISTS (SELECT 1 FROM mr_reviewers WHERE merge_request_id = m.id) THEN 4 WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 0 WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1 WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 2 WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3 ELSE 1 END, m.updated_at DESC", stale_ms = STALE_THRESHOLD_MS, ); let params = build_params(username, project_ids); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map(param_refs.as_slice(), |row| { let attention_str: String = row.get(7)?; let my_ts: Option = row.get(8)?; let others_ts: Option = row.get(9)?; let any_ts: Option = row.get(10)?; let state = parse_attention_state(&attention_str); let reason = format_attention_reason(&state, my_ts, others_ts, any_ts); Ok(MeMr { iid: row.get(0)?, title: row.get::<_, Option>(1)?.unwrap_or_default(), project_path: row.get(2)?, draft: row.get::<_, i32>(3)? != 0, detailed_merge_status: row.get(4)?, updated_at: row.get(5)?, web_url: row.get(6)?, attention_state: state, attention_reason: reason, author_username: None, labels: Vec::new(), }) })?; let mut mrs: Vec = rows.collect::, _>>()?; populate_mr_labels(conn, &mut mrs)?; Ok(mrs) } // ─── Reviewing MRs (AC-5.3, Task #9) ─────────────────────────────────────── /// Query open MRs where user is a reviewer. pub fn query_reviewing_mrs( conn: &Connection, username: &str, project_ids: &[i64], ) -> Result> { let project_clause = build_project_clause("m.project_id", project_ids); let sql = format!( "WITH note_ts AS ( SELECT d.merge_request_id, MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts, MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts, MAX(n.created_at) AS any_ts FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE n.is_system = 0 AND d.merge_request_id IS NOT NULL GROUP BY d.merge_request_id ) SELECT m.iid, m.title, p.path_with_namespace, m.draft, m.detailed_merge_status, m.author_username, m.updated_at, m.web_url, CASE -- not_ready is impossible here: JOIN mr_reviewers guarantees a reviewer exists WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 'stale' WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 'needs_attention' WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 'awaiting_response' ELSE 'not_started' END AS attention_state, nt.my_ts, nt.others_ts, nt.any_ts FROM merge_requests m JOIN mr_reviewers r ON r.merge_request_id = m.id JOIN projects p ON m.project_id = p.id LEFT JOIN note_ts nt ON nt.merge_request_id = m.id WHERE r.username = ?1 AND m.state = 'opened' {project_clause} ORDER BY CASE WHEN nt.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 0 WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1 WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) AND (nt.any_ts IS NULL OR nt.any_ts >= (strftime('%s', 'now') * 1000 - {stale_ms})) THEN 2 WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3 ELSE 1 END, m.updated_at DESC", stale_ms = STALE_THRESHOLD_MS, ); let params = build_params(username, project_ids); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map(param_refs.as_slice(), |row| { let attention_str: String = row.get(8)?; let my_ts: Option = row.get(9)?; let others_ts: Option = row.get(10)?; let any_ts: Option = row.get(11)?; let state = parse_attention_state(&attention_str); let reason = format_attention_reason(&state, my_ts, others_ts, any_ts); Ok(MeMr { iid: row.get(0)?, title: row.get::<_, Option>(1)?.unwrap_or_default(), project_path: row.get(2)?, draft: row.get::<_, i32>(3)? != 0, detailed_merge_status: row.get(4)?, author_username: row.get(5)?, updated_at: row.get(6)?, web_url: row.get(7)?, attention_state: state, attention_reason: reason, labels: Vec::new(), }) })?; let mut mrs: Vec = rows.collect::, _>>()?; populate_mr_labels(conn, &mut mrs)?; Ok(mrs) } // ─── Activity Feed (AC-5.4, Tasks #11-13) ────────────────────────────────── /// Query activity events on items currently associated with the user. /// Combines notes, state events, label events, milestone events, and /// assignment/reviewer system notes into a unified feed sorted newest-first. pub fn query_activity( conn: &Connection, username: &str, project_ids: &[i64], since_ms: i64, ) -> Result> { // Build project filter for activity sources. // Activity params: ?1=username, ?2=since_ms, ?3+=project_ids let project_clause = build_project_clause_at("p.id", project_ids, 3); // Build the "my items" subquery fragments for issue/MR association checks. // These ensure we only see activity on items associated with the user, // regardless of state (open, closed, or merged). Comments on merged MRs // and closed issues are still relevant (follow-up discussions, post-merge // questions, etc.). let my_issue_check = "EXISTS ( SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1 )"; let my_mr_check = "( EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1) OR EXISTS (SELECT 1 FROM mr_reviewers rv WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1) )"; // Source 1: Human comments on my items let notes_sql = format!( "SELECT n.created_at, 'note', CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END, COALESCE(i.iid, m.iid), p.path_with_namespace, n.author_username, CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END, SUBSTR(n.body, 1, 200), 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 {project_clause} AND ( (d.issue_id IS NOT NULL AND {issue_check}) OR (d.merge_request_id IS NOT NULL AND {mr_check}) )", project_clause = project_clause, 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: State events let state_sql = format!( "SELECT e.created_at, 'status_change', CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END, COALESCE(i.iid, m.iid), p.path_with_namespace, e.actor_username, CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END, e.state, NULL FROM resource_state_events e JOIN projects p ON e.project_id = p.id LEFT JOIN issues i ON e.issue_id = i.id LEFT JOIN merge_requests m ON e.merge_request_id = m.id WHERE e.created_at >= ?2 {project_clause} AND ( (e.issue_id IS NOT NULL AND {issue_check}) OR (e.merge_request_id IS NOT NULL AND {mr_check}) )", project_clause = project_clause, issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"), mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"), ); // Source 3: Label events let label_sql = format!( "SELECT e.created_at, 'label_change', CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END, COALESCE(i.iid, m.iid), p.path_with_namespace, e.actor_username, CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END, (e.action || ' ' || COALESCE(e.label_name, '(deleted)')), NULL FROM resource_label_events e JOIN projects p ON e.project_id = p.id LEFT JOIN issues i ON e.issue_id = i.id LEFT JOIN merge_requests m ON e.merge_request_id = m.id WHERE e.created_at >= ?2 {project_clause} AND ( (e.issue_id IS NOT NULL AND {issue_check}) OR (e.merge_request_id IS NOT NULL AND {mr_check}) )", project_clause = project_clause, issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"), mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"), ); // Source 4: Milestone events let milestone_sql = format!( "SELECT e.created_at, 'milestone_change', CASE WHEN e.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END, COALESCE(i.iid, m.iid), p.path_with_namespace, e.actor_username, CASE WHEN e.actor_username = ?1 THEN 1 ELSE 0 END, (e.action || ' ' || COALESCE(e.milestone_title, '(deleted)')), NULL FROM resource_milestone_events e JOIN projects p ON e.project_id = p.id LEFT JOIN issues i ON e.issue_id = i.id LEFT JOIN merge_requests m ON e.merge_request_id = m.id WHERE e.created_at >= ?2 {project_clause} AND ( (e.issue_id IS NOT NULL AND {issue_check}) OR (e.merge_request_id IS NOT NULL AND {mr_check}) )", project_clause = project_clause, issue_check = my_issue_check.replace("{entity_issue_id}", "e.issue_id"), mr_check = my_mr_check.replace("{entity_mr_id}", "e.merge_request_id"), ); // Source 5: Assignment/reviewer system notes (AC-12) let assign_sql = format!( "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), p.path_with_namespace, n.author_username, CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END, n.body, 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 {project_clause} 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) || '%' ) AND ( (d.issue_id IS NOT NULL AND {issue_check}) OR (d.merge_request_id IS NOT NULL AND {mr_check}) )", project_clause = project_clause, 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"), ); let full_sql = format!( "{notes_sql} UNION ALL {state_sql} UNION ALL {label_sql} UNION ALL {milestone_sql} UNION ALL {assign_sql} ORDER BY 1 DESC LIMIT 100" ); let mut params: Vec> = Vec::new(); params.push(Box::new(username.to_string())); params.push(Box::new(since_ms)); for &pid in project_ids { params.push(Box::new(pid)); } 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| { let event_type_str: String = row.get(1)?; Ok(MeActivityEvent { timestamp: row.get(0)?, event_type: parse_event_type(&event_type_str), entity_type: row.get(2)?, entity_iid: row.get(3)?, project_path: row.get(4)?, actor: row.get(5)?, is_own: row.get::<_, i32>(6)? != 0, summary: row.get::<_, Option>(7)?.unwrap_or_default(), body_preview: row.get(8)?, }) })?; let events: Vec = rows.collect::, _>>()?; 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 items (any state) /// 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). // No state filter: comments on closed/merged items are still actionable. let my_issue_check = "EXISTS ( SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1 )"; let my_mr_check = "( EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1) OR EXISTS (SELECT 1 FROM mr_reviewers rv WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1) )"; // Source 1: Others' comments on my items (any state) 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 } // ─── Mentioned In (issues/MRs where user is @mentioned but not formally associated) /// Raw row from the mentioned-in query. struct RawMentionRow { entity_type: String, iid: i64, title: String, project_path: String, state: String, updated_at: i64, web_url: Option, my_ts: Option, others_ts: Option, any_ts: Option, mention_body: String, } fn build_mentioned_in_sql(project_clause: &str) -> String { format!( "WITH candidate_issues AS MATERIALIZED ( SELECT i.id, i.iid, i.title, p.path_with_namespace, i.state, i.updated_at, i.web_url FROM issues i JOIN projects p ON i.project_id = p.id WHERE (i.state = 'opened' OR (i.state = 'closed' AND i.updated_at > ?2)) AND (i.author_username IS NULL OR i.author_username != ?1) AND NOT EXISTS ( SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = i.id AND ia.username = ?1 ) {project_clause} ), candidate_mrs AS MATERIALIZED ( SELECT m.id, m.iid, m.title, p.path_with_namespace, m.state, m.updated_at, m.web_url FROM merge_requests m JOIN projects p ON m.project_id = p.id WHERE (m.state = 'opened' OR (m.state IN ('merged', 'closed') AND m.updated_at > ?2)) AND m.author_username != ?1 AND NOT EXISTS ( SELECT 1 FROM mr_reviewers rv WHERE rv.merge_request_id = m.id AND rv.username = ?1 ) {project_clause} ), note_ts_issue AS MATERIALIZED ( SELECT d.issue_id, MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts, MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts, MAX(n.created_at) AS any_ts FROM notes n JOIN discussions d ON n.discussion_id = d.id JOIN candidate_issues ci ON ci.id = d.issue_id WHERE n.is_system = 0 GROUP BY d.issue_id ), note_ts_mr AS MATERIALIZED ( SELECT d.merge_request_id, MAX(CASE WHEN n.author_username = ?1 THEN n.created_at END) AS my_ts, MAX(CASE WHEN n.author_username != ?1 THEN n.created_at END) AS others_ts, MAX(n.created_at) AS any_ts FROM notes n JOIN discussions d ON n.discussion_id = d.id JOIN candidate_mrs cm ON cm.id = d.merge_request_id WHERE n.is_system = 0 GROUP BY d.merge_request_id ) -- Issue mentions (scoped to candidate entities only) SELECT 'issue', ci.iid, ci.title, ci.path_with_namespace, ci.state, ci.updated_at, ci.web_url, nt.my_ts, nt.others_ts, nt.any_ts, n.body FROM notes n JOIN discussions d ON n.discussion_id = d.id JOIN candidate_issues ci ON ci.id = d.issue_id LEFT JOIN note_ts_issue nt ON nt.issue_id = ci.id WHERE n.is_system = 0 AND n.author_username != ?1 AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%' UNION ALL -- MR mentions (scoped to candidate entities only) SELECT 'mr', cm.iid, cm.title, cm.path_with_namespace, cm.state, cm.updated_at, cm.web_url, nt.my_ts, nt.others_ts, nt.any_ts, n.body FROM notes n JOIN discussions d ON n.discussion_id = d.id JOIN candidate_mrs cm ON cm.id = d.merge_request_id LEFT JOIN note_ts_mr nt ON nt.merge_request_id = cm.id WHERE n.is_system = 0 AND n.author_username != ?1 AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%' ORDER BY 6 DESC LIMIT 500", ) } /// Query issues and MRs where the user is @mentioned but not assigned/authored/reviewing. /// /// Includes open items unconditionally, plus recently-closed/merged items /// (where `updated_at > recency_cutoff_ms`). /// /// Returns deduplicated results sorted by attention priority then recency. pub fn query_mentioned_in( conn: &Connection, username: &str, project_ids: &[i64], recency_cutoff_ms: i64, ) -> Result> { let project_clause = build_project_clause_at("p.id", project_ids, 3); // Materialized CTEs avoid pathological query plans for project-scoped mentions. let sql = build_mentioned_in_sql(&project_clause); let mut params: Vec> = Vec::new(); params.push(Box::new(username.to_string())); params.push(Box::new(recency_cutoff_ms)); for &pid in project_ids { params.push(Box::new(pid)); } let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let mention_re = build_exact_mention_regex(username); let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map(param_refs.as_slice(), |row| { Ok(RawMentionRow { entity_type: row.get(0)?, iid: row.get(1)?, title: row.get::<_, Option>(2)?.unwrap_or_default(), project_path: row.get(3)?, state: row.get(4)?, updated_at: row.get(5)?, web_url: row.get(6)?, my_ts: row.get(7)?, others_ts: row.get(8)?, any_ts: row.get(9)?, mention_body: row.get::<_, Option>(10)?.unwrap_or_default(), }) })?; let raw: Vec = rows.collect::, _>>()?; // Post-filter with exact mention regex and deduplicate by entity let mut seen: HashMap<(String, i64, String), RawMentionRow> = HashMap::new(); for row in raw { if !contains_exact_mention(&row.mention_body, &mention_re) { continue; } let key = (row.entity_type.clone(), row.iid, row.project_path.clone()); // Keep the first occurrence (most recent due to ORDER BY updated_at DESC) seen.entry(key).or_insert(row); } let mut mentions: Vec = seen .into_values() .map(|row| { let state = compute_mention_attention(row.my_ts, row.others_ts, row.any_ts); let reason = format_attention_reason(&state, row.my_ts, row.others_ts, row.any_ts); MeMention { entity_type: row.entity_type, iid: row.iid, title: row.title, project_path: row.project_path, state: row.state, attention_state: state, attention_reason: reason, updated_at: row.updated_at, web_url: row.web_url, } }) .collect(); // Sort by attention priority (needs_attention first), then by updated_at DESC mentions.sort_by(|a, b| { a.attention_state .cmp(&b.attention_state) .then_with(|| b.updated_at.cmp(&a.updated_at)) }); Ok(mentions) } /// Compute attention state for a mentioned-in item. /// Same logic as the other sections, but without the not_ready variant /// since it's less relevant for mention-only items. fn compute_mention_attention( my_ts: Option, others_ts: Option, any_ts: Option, ) -> AttentionState { // Stale check if let Some(ts) = any_ts && ts < crate::core::time::now_ms() - STALE_THRESHOLD_MS { return AttentionState::Stale; } // Others commented after me (or I never engaged but others have) if let Some(ots) = others_ts && my_ts.is_none_or(|mts| ots > mts) { return AttentionState::NeedsAttention; } // I replied and my reply is >= others' latest if let Some(mts) = my_ts && mts >= others_ts.unwrap_or(0) { return AttentionState::AwaitingResponse; } AttentionState::NotStarted } // ─── Helpers ──────────────────────────────────────────────────────────────── /// Parse attention state string from SQL CASE result. fn parse_attention_state(s: &str) -> AttentionState { match s { "needs_attention" => AttentionState::NeedsAttention, "not_started" => AttentionState::NotStarted, "awaiting_response" => AttentionState::AwaitingResponse, "stale" => AttentionState::Stale, "not_ready" => AttentionState::NotReady, _ => AttentionState::NotStarted, } } /// Parse activity event type string from SQL. 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, "unassign" => ActivityEventType::Unassign, "review_request" => ActivityEventType::ReviewRequest, "milestone_change" => ActivityEventType::MilestoneChange, _ => ActivityEventType::Note, } } 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). fn build_project_clause_at(column: &str, project_ids: &[i64], start_idx: usize) -> String { match project_ids.len() { 0 => String::new(), 1 => format!("AND {column} = ?{start_idx}"), n => { let placeholders: Vec = (0..n).map(|i| format!("?{}", start_idx + i)).collect(); format!("AND {column} IN ({})", placeholders.join(",")) } } } /// Convenience: project clause starting at param index 2 (after username at ?1). fn build_project_clause(column: &str, project_ids: &[i64]) -> String { build_project_clause_at(column, project_ids, 2) } /// Build the parameter vector: username first, then project IDs. fn build_params(username: &str, project_ids: &[i64]) -> Vec> { let mut params: Vec> = Vec::new(); params.push(Box::new(username.to_string())); for &pid in project_ids { params.push(Box::new(pid)); } params } /// Populate labels for issues via cached per-item queries. fn populate_issue_labels(conn: &Connection, issues: &mut [MeIssue]) -> Result<()> { if issues.is_empty() { return Ok(()); } for issue in issues.iter_mut() { let mut stmt = conn.prepare_cached( "SELECT l.name FROM labels l JOIN issue_labels il ON l.id = il.label_id JOIN issues i ON il.issue_id = i.id JOIN projects p ON i.project_id = p.id WHERE i.iid = ?1 AND p.path_with_namespace = ?2 ORDER BY l.name", )?; let labels: Vec = stmt .query_map(rusqlite::params![issue.iid, issue.project_path], |row| { row.get(0) })? .collect::, _>>()?; issue.labels = labels; } Ok(()) } /// Populate labels for MRs via cached per-item queries. fn populate_mr_labels(conn: &Connection, mrs: &mut [MeMr]) -> Result<()> { if mrs.is_empty() { return Ok(()); } for mr in mrs.iter_mut() { let mut stmt = conn.prepare_cached( "SELECT l.name FROM labels l JOIN mr_labels ml ON l.id = ml.label_id JOIN merge_requests m ON ml.merge_request_id = m.id JOIN projects p ON m.project_id = p.id WHERE m.iid = ?1 AND p.path_with_namespace = ?2 ORDER BY l.name", )?; let labels: Vec = stmt .query_map(rusqlite::params![mr.iid, mr.project_path], |row| row.get(0))? .collect::, _>>()?; mr.labels = labels; } Ok(()) } // ─── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] #[path = "me_tests.rs"] mod tests;