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

@@ -4,8 +4,8 @@ use crate::cli::robot::RobotMeta;
use crate::core::time::ms_to_iso;
use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
SinceCheckEvent, SinceCheckGroup, SinceLastCheck,
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMention, MeMr,
MeSummary, SinceCheckEvent, SinceCheckGroup, SinceLastCheck,
};
// ─── Robot JSON Output (Task #18) ────────────────────────────────────────────
@@ -28,11 +28,15 @@ pub fn print_me_json(
// Apply --fields filtering (Task #19)
if let Some(f) = fields {
let expanded = crate::cli::robot::expand_fields_preset(f, "me_items");
// Filter all item arrays
// Filter issue/MR arrays with the items preset
for key in &["open_issues", "open_mrs_authored", "reviewing_mrs"] {
crate::cli::robot::filter_fields(&mut value, key, &expanded);
}
// Mentioned-in gets its own preset (needs entity_type + state to disambiguate)
let mentions_expanded = crate::cli::robot::expand_fields_preset(f, "me_mentions");
crate::cli::robot::filter_fields(&mut value, "mentioned_in", &mentions_expanded);
// Activity gets its own minimal preset
let activity_expanded = crate::cli::robot::expand_fields_preset(f, "me_activity");
crate::cli::robot::filter_fields(&mut value, "activity", &activity_expanded);
@@ -84,6 +88,7 @@ struct MeDataJson {
open_issues: Vec<IssueJson>,
open_mrs_authored: Vec<MrJson>,
reviewing_mrs: Vec<MrJson>,
mentioned_in: Vec<MentionJson>,
activity: Vec<ActivityJson>,
}
@@ -97,6 +102,7 @@ impl MeDataJson {
open_issues: d.open_issues.iter().map(IssueJson::from).collect(),
open_mrs_authored: d.open_mrs_authored.iter().map(MrJson::from).collect(),
reviewing_mrs: d.reviewing_mrs.iter().map(MrJson::from).collect(),
mentioned_in: d.mentioned_in.iter().map(MentionJson::from).collect(),
activity: d.activity.iter().map(ActivityJson::from).collect(),
}
}
@@ -110,6 +116,7 @@ struct SummaryJson {
open_issue_count: usize,
authored_mr_count: usize,
reviewing_mr_count: usize,
mentioned_in_count: usize,
needs_attention_count: usize,
}
@@ -120,6 +127,7 @@ impl From<&MeSummary> for SummaryJson {
open_issue_count: s.open_issue_count,
authored_mr_count: s.authored_mr_count,
reviewing_mr_count: s.reviewing_mr_count,
mentioned_in_count: s.mentioned_in_count,
needs_attention_count: s.needs_attention_count,
}
}
@@ -134,6 +142,7 @@ struct IssueJson {
title: String,
state: String,
attention_state: String,
attention_reason: String,
status_name: Option<String>,
labels: Vec<String>,
updated_at_iso: String,
@@ -148,6 +157,7 @@ impl From<&MeIssue> for IssueJson {
title: i.title.clone(),
state: "opened".to_string(),
attention_state: attention_state_str(&i.attention_state),
attention_reason: i.attention_reason.clone(),
status_name: i.status_name.clone(),
labels: i.labels.clone(),
updated_at_iso: ms_to_iso(i.updated_at),
@@ -165,6 +175,7 @@ struct MrJson {
title: String,
state: String,
attention_state: String,
attention_reason: String,
draft: bool,
detailed_merge_status: Option<String>,
author_username: Option<String>,
@@ -181,6 +192,7 @@ impl From<&MeMr> for MrJson {
title: m.title.clone(),
state: "opened".to_string(),
attention_state: attention_state_str(&m.attention_state),
attention_reason: m.attention_reason.clone(),
draft: m.draft,
detailed_merge_status: m.detailed_merge_status.clone(),
author_username: m.author_username.clone(),
@@ -191,6 +203,37 @@ impl From<&MeMr> for MrJson {
}
}
// ─── Mention ─────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct MentionJson {
entity_type: String,
project: String,
iid: i64,
title: String,
state: String,
attention_state: String,
attention_reason: String,
updated_at_iso: String,
web_url: Option<String>,
}
impl From<&MeMention> for MentionJson {
fn from(m: &MeMention) -> Self {
Self {
entity_type: m.entity_type.clone(),
project: m.project_path.clone(),
iid: m.iid,
title: m.title.clone(),
state: m.state.clone(),
attention_state: attention_state_str(&m.attention_state),
attention_reason: m.attention_reason.clone(),
updated_at_iso: ms_to_iso(m.updated_at),
web_url: m.web_url.clone(),
}
}
}
// ─── Activity ────────────────────────────────────────────────────────────────
#[derive(Serialize)]
@@ -365,6 +408,7 @@ mod tests {
title: "Fix auth bug".to_string(),
project_path: "group/repo".to_string(),
attention_state: AttentionState::NeedsAttention,
attention_reason: "Others commented recently; you haven't replied".to_string(),
status_name: Some("In progress".to_string()),
labels: vec!["bug".to_string()],
updated_at: 1_700_000_000_000,
@@ -373,6 +417,10 @@ mod tests {
let json = IssueJson::from(&issue);
assert_eq!(json.iid, 42);
assert_eq!(json.attention_state, "needs_attention");
assert_eq!(
json.attention_reason,
"Others commented recently; you haven't replied"
);
assert_eq!(json.state, "opened");
assert_eq!(json.status_name, Some("In progress".to_string()));
}
@@ -384,6 +432,7 @@ mod tests {
title: "Add feature".to_string(),
project_path: "group/repo".to_string(),
attention_state: AttentionState::AwaitingResponse,
attention_reason: "You replied moments ago; awaiting others".to_string(),
draft: true,
detailed_merge_status: Some("mergeable".to_string()),
author_username: Some("alice".to_string()),
@@ -394,6 +443,10 @@ mod tests {
let json = MrJson::from(&mr);
assert_eq!(json.iid, 99);
assert_eq!(json.attention_state, "awaiting_response");
assert_eq!(
json.attention_reason,
"You replied moments ago; awaiting others"
);
assert!(json.draft);
assert_eq!(json.author_username, Some("alice".to_string()));
}