Files
gitlore/src/cli/commands/me/queries.rs
teernisse 9c909df6b2 feat(me): add 30-day mention age cutoff to filter stale @-mentions
Previously, query_mentioned_in returned mentions from any time in the
entity's history as long as the entity was still open (or recently closed).
This caused noise: a mention from 6 months ago on a still-open issue would
appear in the dashboard indefinitely.

Now the SQL filters notes by created_at > mention_cutoff_ms, defaulting to
30 days. The recency_cutoff (7 days) still governs closed/merged entity
visibility — this new cutoff governs mention note age on open entities.

Signature change: query_mentioned_in gains a mention_cutoff_ms parameter.
All existing test call sites updated. Two new tests verify the boundary:
- mentioned_in_excludes_old_mention_on_open_issue (45-day mention filtered)
- mentioned_in_includes_recent_mention_on_open_issue (5-day mention kept)
2026-03-12 10:08:22 -04:00

1139 lines
44 KiB
Rust

// ─── Query Functions ────────────────────────────────────────────────────────
//
// SQL queries powering the `lore me` dashboard.
// Each function takes &Connection, username, optional project scope,
// and returns Result<Vec<StructType>>.
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<i64>,
others_ts: Option<i64>,
any_ts: Option<i64>,
) -> 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<Vec<MeIssue>> {
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<i64> = row.get(7)?;
let others_ts: Option<i64> = row.get(8)?;
let any_ts: Option<i64> = 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<String>>(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<MeIssue> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
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<Vec<MeMr>> {
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<i64> = row.get(8)?;
let others_ts: Option<i64> = row.get(9)?;
let any_ts: Option<i64> = 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<String>>(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<MeMr> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
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<Vec<MeMr>> {
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<i64> = row.get(9)?;
let others_ts: Option<i64> = row.get(10)?;
let any_ts: Option<i64> = 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<String>>(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<MeMr> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
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<Vec<MeActivityEvent>> {
// 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<Box<dyn rusqlite::types::ToSql>> = 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<String>>(7)?.unwrap_or_default(),
body_preview: row.get(8)?,
})
})?;
let events: Vec<MeActivityEvent> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
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 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<Vec<SinceCheckGroup>> {
// 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<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
}
// ─── 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<String>,
my_ts: Option<i64>,
others_ts: Option<i64>,
any_ts: Option<i64>,
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 n.created_at > ?3
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 n.created_at > ?3
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`). Only considers mentions in notes
/// created after `mention_cutoff_ms` (typically 30 days ago).
///
/// 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,
mention_cutoff_ms: i64,
) -> Result<Vec<MeMention>> {
let project_clause = build_project_clause_at("p.id", project_ids, 4);
// Materialized CTEs avoid pathological query plans for project-scoped mentions.
let sql = build_mentioned_in_sql(&project_clause);
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
params.push(Box::new(username.to_string()));
params.push(Box::new(recency_cutoff_ms));
params.push(Box::new(mention_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<String>>(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<String>>(10)?.unwrap_or_default(),
})
})?;
let raw: Vec<RawMentionRow> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
// 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<MeMention> = 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<i64>,
others_ts: Option<i64>,
any_ts: Option<i64>,
) -> 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<String> = (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<Box<dyn rusqlite::types::ToSql>> {
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = 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<String> = stmt
.query_map(rusqlite::params![issue.iid, issue.project_path], |row| {
row.get(0)
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
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<String> = stmt
.query_map(rusqlite::params![mr.iid, mr.project_path], |row| row.get(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
mr.labels = labels;
}
Ok(())
}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
#[path = "me_tests.rs"]
mod tests;