feat(me): add mentions section for @-mentions in dashboard

Add a new --mentions flag to the `lore me` command that surfaces items
where the user is @-mentioned but NOT already assigned, authoring, or
reviewing. This fills an important gap in the personal work dashboard:
cross-team requests and callouts that don't show up in the standard
issue/MR sections.

Implementation details:
- query_mentioned_in() scans notes for @username patterns, then filters
  out entities where the user is already an assignee, author, or reviewer
- MentionedInItem type captures entity_type (issue/mr), iid, title, state,
  project path, attention state, and updated timestamp
- Attention state computation marks items as needs_attention when there's
  recent activity from others
- Recency cutoff (7 days) prevents surfacing stale mentions
- Both human and robot renderers include the new section

The robot mode schema adds mentioned_in array with me_mentions field
preset for token-efficient output.

Test coverage:
- mentioned_in_finds_mention_on_unassigned_issue: basic case
- mentioned_in_excludes_assigned_issue: no duplicate surfacing
- mentioned_in_excludes_author_on_mr: author already sees in authored MRs
- mentioned_in_excludes_reviewer_on_mr: reviewer already sees in reviewing
- mentioned_in_uses_recency_cutoff: old mentions filtered
- mentioned_in_respects_project_filter: scoping works

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-03-05 13:01:55 -05:00
parent 571c304031
commit ffbd1e2dce
8 changed files with 795 additions and 19 deletions

View File

@@ -12,13 +12,77 @@ use regex::Regex;
use std::collections::HashMap;
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr, SinceCheckEvent,
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.
@@ -51,7 +115,8 @@ pub fn query_open_issues(
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
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
@@ -84,6 +149,11 @@ pub fn query_open_issues(
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(),
@@ -91,7 +161,8 @@ pub fn query_open_issues(
status_name: row.get(3)?,
updated_at: row.get(4)?,
web_url: row.get(5)?,
attention_state: parse_attention_state(&attention_str),
attention_state: state,
attention_reason: reason,
labels: Vec::new(),
})
})?;
@@ -135,7 +206,8 @@ pub fn query_authored_mrs(
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
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
@@ -163,6 +235,11 @@ pub fn query_authored_mrs(
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(),
@@ -171,7 +248,8 @@ pub fn query_authored_mrs(
detailed_merge_status: row.get(4)?,
updated_at: row.get(5)?,
web_url: row.get(6)?,
attention_state: parse_attention_state(&attention_str),
attention_state: state,
attention_reason: reason,
author_username: None,
labels: Vec::new(),
})
@@ -214,7 +292,8 @@ pub fn query_reviewing_mrs(
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
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
@@ -242,6 +321,11 @@ pub fn query_reviewing_mrs(
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(),
@@ -251,7 +335,8 @@ pub fn query_reviewing_mrs(
author_username: row.get(5)?,
updated_at: row.get(6)?,
web_url: row.get(7)?,
attention_state: parse_attention_state(&attention_str),
attention_state: state,
attention_reason: reason,
labels: Vec::new(),
})
})?;
@@ -687,6 +772,206 @@ fn group_since_check_events(rows: Vec<RawSinceCheckRow>) -> Vec<SinceCheckGroup>
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,
}
/// 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<Vec<MeMention>> {
let project_clause = build_project_clause_at("p.id", project_ids, 3);
// CTE: note timestamps per issue (for attention state computation)
// CTE: note timestamps per MR
// Then UNION ALL of issue mentions + MR mentions
let sql = format!(
"WITH note_ts_issue 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
),
note_ts_mr 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
)
-- Issue mentions
SELECT 'issue', i.iid, i.title, p.path_with_namespace, i.state,
i.updated_at, i.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 issues i ON d.issue_id = i.id
JOIN projects p ON i.project_id = p.id
LEFT JOIN note_ts_issue nt ON nt.issue_id = i.id
WHERE n.is_system = 0
AND n.author_username != ?1
AND d.issue_id IS NOT NULL
AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%'
AND NOT EXISTS (
SELECT 1 FROM issue_assignees ia
WHERE ia.issue_id = d.issue_id AND ia.username = ?1
)
AND (i.state = 'opened' OR (i.state = 'closed' AND i.updated_at > ?2))
{project_clause}
UNION ALL
-- MR mentions
SELECT 'mr', m.iid, m.title, p.path_with_namespace, m.state,
m.updated_at, m.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 merge_requests m ON d.merge_request_id = m.id
JOIN projects p ON m.project_id = p.id
LEFT JOIN note_ts_mr nt ON nt.merge_request_id = m.id
WHERE n.is_system = 0
AND n.author_username != ?1
AND d.merge_request_id IS NOT NULL
AND LOWER(n.body) LIKE '%@' || LOWER(?1) || '%'
AND m.author_username != ?1
AND NOT EXISTS (
SELECT 1 FROM mr_reviewers rv
WHERE rv.merge_request_id = d.merge_request_id AND rv.username = ?1
)
AND (m.state = 'opened'
OR (m.state IN ('merged', 'closed') AND m.updated_at > ?2))
{project_clause}
ORDER BY 6 DESC
LIMIT 500",
);
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));
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.