// ─── 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 super::types::{ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr}; /// Stale threshold: items with no activity for 30 days are marked "stale". const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000; // ─── 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.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 'needs_attention' WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 'stale' 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 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' {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) THEN 0 WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1 WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3 WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2 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)?; 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: parse_attention_state(&attention_str), 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.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 'needs_attention' WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 'stale' 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 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) THEN 0 WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1 WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3 WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2 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)?; 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: parse_attention_state(&attention_str), 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.others_ts IS NOT NULL AND (nt.my_ts IS NULL OR nt.others_ts > nt.my_ts) THEN 'needs_attention' WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 'stale' 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 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) THEN 0 WHEN nt.any_ts IS NULL AND nt.my_ts IS NULL THEN 1 WHEN nt.any_ts IS NOT NULL AND nt.any_ts < (strftime('%s', 'now') * 1000 - {stale_ms}) THEN 3 WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0) THEN 2 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)?; 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: parse_attention_state(&attention_str), 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 CURRENTLY associated with the user (AC-3.6). 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), SUBSTR(n.body, 1, 200) 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" ); 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) } // ─── 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, "status_change" => ActivityEventType::StatusChange, "label_change" => ActivityEventType::LabelChange, "assign" => ActivityEventType::Assign, "unassign" => ActivityEventType::Unassign, "review_request" => ActivityEventType::ReviewRequest, "milestone_change" => ActivityEventType::MilestoneChange, _ => ActivityEventType::Note, } } /// 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;