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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user