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

@@ -835,6 +835,262 @@ fn since_last_check_ignores_domain_like_text() {
); );
} }
// ─── Mentioned In Tests ─────────────────────────────────────────────────────
#[test]
fn mentioned_in_finds_mention_on_unassigned_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
// alice is NOT assigned to issue 42
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"hey @alice can you look?",
t,
);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].entity_type, "issue");
assert_eq!(results[0].iid, 42);
}
#[test]
fn mentioned_in_excludes_assigned_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice"); // alice IS assigned
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap();
assert!(results.is_empty(), "should exclude assigned issues");
}
#[test]
fn mentioned_in_finds_mention_on_non_authored_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
// alice is NOT author or reviewer
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "cc @alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].entity_type, "mr");
assert_eq!(results[0].iid, 99);
}
#[test]
fn mentioned_in_excludes_authored_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "opened", false); // alice IS author
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "@alice thoughts?", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap();
assert!(results.is_empty(), "should exclude authored MRs");
}
#[test]
fn mentioned_in_excludes_reviewer_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "opened", false);
insert_reviewer(&conn, 10, "alice"); // alice IS reviewer
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "charlie", false, "@alice fyi", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap();
assert!(
results.is_empty(),
"should exclude MRs where user is reviewer"
);
}
#[test]
fn mentioned_in_includes_recently_closed_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed");
// Update updated_at to recent (within 7-day window)
conn.execute(
"UPDATE issues SET updated_at = ?1 WHERE id = 10",
rusqlite::params![now_ms() - 2 * 24 * 3600 * 1000], // 2 days ago
)
.unwrap();
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap();
assert_eq!(results.len(), 1, "recently closed issue should be included");
assert_eq!(results[0].state, "closed");
}
#[test]
fn mentioned_in_excludes_old_closed_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed");
// Update updated_at to old (outside 7-day window)
conn.execute(
"UPDATE issues SET updated_at = ?1 WHERE id = 10",
rusqlite::params![now_ms() - 30 * 24 * 3600 * 1000], // 30 days ago
)
.unwrap();
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap();
assert!(results.is_empty(), "old closed issue should be excluded");
}
#[test]
fn mentioned_in_attention_needs_attention_when_unreplied() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"@alice please review",
t,
);
// alice has NOT replied
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].attention_state, AttentionState::NeedsAttention);
}
#[test]
fn mentioned_in_attention_awaiting_when_replied() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t1 = now_ms() - 5000;
let t2 = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"@alice please review",
t1,
);
insert_note_at(&conn, 201, disc_id, 1, "alice", false, "looks good", t2);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].attention_state, AttentionState::AwaitingResponse);
}
#[test]
fn mentioned_in_project_filter() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo-a");
insert_project(&conn, 2, "group/repo-b");
insert_issue(&conn, 10, 1, 42, "someone");
insert_issue(&conn, 11, 2, 43, "someone");
let disc_a = 100;
let disc_b = 101;
insert_discussion(&conn, disc_a, 1, None, Some(10));
insert_discussion(&conn, disc_b, 2, None, Some(11));
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_a, 1, "bob", false, "@alice", t);
insert_note_at(&conn, 201, disc_b, 2, "bob", false, "@alice", t);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[1], recency_cutoff).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].project_path, "group/repo-a");
}
#[test]
fn mentioned_in_deduplicates_multiple_mentions_same_entity() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t1 = now_ms() - 5000;
let t2 = now_ms() - 1000;
// Two different people mention alice on the same issue
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "@alice thoughts?", t1);
insert_note_at(&conn, 201, disc_id, 1, "charlie", false, "@alice +1", t2);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap();
assert_eq!(results.len(), 1, "should deduplicate to one entity");
}
#[test]
fn mentioned_in_rejects_false_positive_email() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"email foo@alice.com",
t,
);
let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000;
let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff).unwrap();
assert!(results.is_empty(), "email-like text should not match");
}
// ─── Helper Tests ────────────────────────────────────────────────────────── // ─── Helper Tests ──────────────────────────────────────────────────────────
#[test] #[test]
@@ -856,6 +1112,67 @@ fn parse_attention_state_all_variants() {
assert_eq!(parse_attention_state("unknown"), AttentionState::NotStarted); assert_eq!(parse_attention_state("unknown"), AttentionState::NotStarted);
} }
#[test]
fn format_attention_reason_not_started() {
let reason = format_attention_reason(&AttentionState::NotStarted, None, None, None);
assert_eq!(reason, "No discussion yet");
}
#[test]
fn format_attention_reason_not_ready() {
let reason = format_attention_reason(&AttentionState::NotReady, None, None, None);
assert_eq!(reason, "Draft with no reviewers assigned");
}
#[test]
fn format_attention_reason_stale_with_timestamp() {
let stale_ts = now_ms() - 35 * 24 * 3600 * 1000; // 35 days ago
let reason = format_attention_reason(&AttentionState::Stale, None, None, Some(stale_ts));
assert!(reason.starts_with("No activity for"), "got: {reason}");
// 35 days = 1 month in our duration bucketing
assert!(reason.contains("1 month"), "got: {reason}");
}
#[test]
fn format_attention_reason_needs_attention_both_timestamps() {
let my_ts = now_ms() - 2 * 86_400_000; // 2 days ago
let others_ts = now_ms() - 3_600_000; // 1 hour ago
let reason = format_attention_reason(
&AttentionState::NeedsAttention,
Some(my_ts),
Some(others_ts),
Some(others_ts),
);
assert!(reason.contains("Others replied"), "got: {reason}");
assert!(reason.contains("you last commented"), "got: {reason}");
}
#[test]
fn format_attention_reason_needs_attention_no_self_comment() {
let others_ts = now_ms() - 3_600_000; // 1 hour ago
let reason = format_attention_reason(
&AttentionState::NeedsAttention,
None,
Some(others_ts),
Some(others_ts),
);
assert!(reason.contains("Others commented"), "got: {reason}");
assert!(reason.contains("you haven't replied"), "got: {reason}");
}
#[test]
fn format_attention_reason_awaiting_response() {
let my_ts = now_ms() - 7_200_000; // 2 hours ago
let reason = format_attention_reason(
&AttentionState::AwaitingResponse,
Some(my_ts),
None,
Some(my_ts),
);
assert!(reason.contains("You replied"), "got: {reason}");
assert!(reason.contains("awaiting others"), "got: {reason}");
}
#[test] #[test]
fn parse_event_type_all_variants() { fn parse_event_type_all_variants() {
assert_eq!(parse_event_type("note"), ActivityEventType::Note); assert_eq!(parse_event_type("note"), ActivityEventType::Note);

View File

@@ -17,7 +17,7 @@ use crate::core::project::resolve_project;
use crate::core::time::parse_since; use crate::core::time::parse_since;
use self::queries::{ use self::queries::{
query_activity, query_authored_mrs, query_open_issues, query_reviewing_mrs, query_activity, query_authored_mrs, query_mentioned_in, query_open_issues, query_reviewing_mrs,
query_since_last_check, query_since_last_check,
}; };
use self::types::{AttentionState, MeDashboard, MeSummary, SinceLastCheck}; use self::types::{AttentionState, MeDashboard, MeSummary, SinceLastCheck};
@@ -25,6 +25,8 @@ use self::types::{AttentionState, MeDashboard, MeSummary, SinceLastCheck};
/// Default activity lookback: 1 day in milliseconds. /// Default activity lookback: 1 day in milliseconds.
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1; const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1;
const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000; const MS_PER_DAY: i64 = 24 * 60 * 60 * 1000;
/// Recency window for closed/merged items in the "Mentioned In" section: 7 days.
const RECENCY_WINDOW_MS: i64 = 7 * MS_PER_DAY;
/// Resolve the effective username from CLI flag or config. /// Resolve the effective username from CLI flag or config.
/// ///
@@ -126,6 +128,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
let want_issues = show_all || args.issues; let want_issues = show_all || args.issues;
let want_mrs = show_all || args.mrs; let want_mrs = show_all || args.mrs;
let want_activity = show_all || args.activity; let want_activity = show_all || args.activity;
let want_mentions = show_all || args.mentions;
// 6. Run queries for requested sections // 6. Run queries for requested sections
let open_issues = if want_issues { let open_issues = if want_issues {
@@ -146,6 +149,13 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
Vec::new() Vec::new()
}; };
let mentioned_in = if want_mentions {
let recency_cutoff = crate::core::time::now_ms() - RECENCY_WINDOW_MS;
query_mentioned_in(&conn, username, &project_ids, recency_cutoff)?
} else {
Vec::new()
};
let activity = if want_activity { let activity = if want_activity {
query_activity(&conn, username, &project_ids, since_ms)? query_activity(&conn, username, &project_ids, since_ms)?
} else { } else {
@@ -187,6 +197,10 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
.filter(|m| m.attention_state == AttentionState::NeedsAttention) .filter(|m| m.attention_state == AttentionState::NeedsAttention)
.count() .count()
+ reviewing_mrs + reviewing_mrs
.iter()
.filter(|m| m.attention_state == AttentionState::NeedsAttention)
.count()
+ mentioned_in
.iter() .iter()
.filter(|m| m.attention_state == AttentionState::NeedsAttention) .filter(|m| m.attention_state == AttentionState::NeedsAttention)
.count(); .count();
@@ -202,12 +216,16 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
for m in &reviewing_mrs { for m in &reviewing_mrs {
project_paths.insert(&m.project_path); project_paths.insert(&m.project_path);
} }
for m in &mentioned_in {
project_paths.insert(&m.project_path);
}
let summary = MeSummary { let summary = MeSummary {
project_count: project_paths.len(), project_count: project_paths.len(),
open_issue_count: open_issues.len(), open_issue_count: open_issues.len(),
authored_mr_count: open_mrs_authored.len(), authored_mr_count: open_mrs_authored.len(),
reviewing_mr_count: reviewing_mrs.len(), reviewing_mr_count: reviewing_mrs.len(),
mentioned_in_count: mentioned_in.len(),
needs_attention_count, needs_attention_count,
}; };
@@ -219,6 +237,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
open_issues, open_issues,
open_mrs_authored, open_mrs_authored,
reviewing_mrs, reviewing_mrs,
mentioned_in,
activity, activity,
since_last_check, since_last_check,
}; };
@@ -237,6 +256,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
single_project, single_project,
want_issues, want_issues,
want_mrs, want_mrs,
want_mentions,
want_activity, want_activity,
); );
} }
@@ -313,6 +333,7 @@ mod tests {
issues: false, issues: false,
mrs: false, mrs: false,
activity: false, activity: false,
mentions: false,
since: None, since: None,
project: None, project: None,
all: false, all: false,

View File

@@ -12,13 +12,77 @@ use regex::Regex;
use std::collections::HashMap; use std::collections::HashMap;
use super::types::{ use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMr, SinceCheckEvent, ActivityEventType, AttentionState, MeActivityEvent, MeIssue, MeMention, MeMr, SinceCheckEvent,
SinceCheckGroup, SinceCheckGroup,
}; };
/// Stale threshold: items with no activity for 30 days are marked "stale". /// Stale threshold: items with no activity for 30 days are marked "stale".
const STALE_THRESHOLD_MS: i64 = 30 * 24 * 3600 * 1000; 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) ───────────────────────────────────────── // ─── Open Issues (AC-5.1, Task #7) ─────────────────────────────────────────
/// Query open issues assigned to the user via issue_assignees. /// 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) WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
THEN 'awaiting_response' THEN 'awaiting_response'
ELSE 'not_started' ELSE 'not_started'
END AS attention_state END AS attention_state,
nt.my_ts, nt.others_ts, nt.any_ts
FROM issues i FROM issues i
JOIN issue_assignees ia ON ia.issue_id = i.id JOIN issue_assignees ia ON ia.issue_id = i.id
JOIN projects p ON i.project_id = p.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 mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| { let rows = stmt.query_map(param_refs.as_slice(), |row| {
let attention_str: String = row.get(6)?; 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 { Ok(MeIssue {
iid: row.get(0)?, iid: row.get(0)?,
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(), title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
@@ -91,7 +161,8 @@ pub fn query_open_issues(
status_name: row.get(3)?, status_name: row.get(3)?,
updated_at: row.get(4)?, updated_at: row.get(4)?,
web_url: row.get(5)?, web_url: row.get(5)?,
attention_state: parse_attention_state(&attention_str), attention_state: state,
attention_reason: reason,
labels: Vec::new(), 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) WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
THEN 'awaiting_response' THEN 'awaiting_response'
ELSE 'not_started' ELSE 'not_started'
END AS attention_state END AS attention_state,
nt.my_ts, nt.others_ts, nt.any_ts
FROM merge_requests m FROM merge_requests m
JOIN projects p ON m.project_id = p.id JOIN projects p ON m.project_id = p.id
LEFT JOIN note_ts nt ON nt.merge_request_id = m.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 mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| { let rows = stmt.query_map(param_refs.as_slice(), |row| {
let attention_str: String = row.get(7)?; 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 { Ok(MeMr {
iid: row.get(0)?, iid: row.get(0)?,
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(), title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
@@ -171,7 +248,8 @@ pub fn query_authored_mrs(
detailed_merge_status: row.get(4)?, detailed_merge_status: row.get(4)?,
updated_at: row.get(5)?, updated_at: row.get(5)?,
web_url: row.get(6)?, web_url: row.get(6)?,
attention_state: parse_attention_state(&attention_str), attention_state: state,
attention_reason: reason,
author_username: None, author_username: None,
labels: Vec::new(), 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) WHEN nt.my_ts IS NOT NULL AND nt.my_ts >= COALESCE(nt.others_ts, 0)
THEN 'awaiting_response' THEN 'awaiting_response'
ELSE 'not_started' ELSE 'not_started'
END AS attention_state END AS attention_state,
nt.my_ts, nt.others_ts, nt.any_ts
FROM merge_requests m FROM merge_requests m
JOIN mr_reviewers r ON r.merge_request_id = m.id JOIN mr_reviewers r ON r.merge_request_id = m.id
JOIN projects p ON m.project_id = p.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 mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| { let rows = stmt.query_map(param_refs.as_slice(), |row| {
let attention_str: String = row.get(8)?; 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 { Ok(MeMr {
iid: row.get(0)?, iid: row.get(0)?,
title: row.get::<_, Option<String>>(1)?.unwrap_or_default(), title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
@@ -251,7 +335,8 @@ pub fn query_reviewing_mrs(
author_username: row.get(5)?, author_username: row.get(5)?,
updated_at: row.get(6)?, updated_at: row.get(6)?,
web_url: row.get(7)?, web_url: row.get(7)?,
attention_state: parse_attention_state(&attention_str), attention_state: state,
attention_reason: reason,
labels: Vec::new(), labels: Vec::new(),
}) })
})?; })?;
@@ -687,6 +772,206 @@ fn group_since_check_events(rows: Vec<RawSinceCheckRow>) -> Vec<SinceCheckGroup>
result 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 ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
/// Parse attention state string from SQL CASE result. /// Parse attention state string from SQL CASE result.

View File

@@ -1,8 +1,8 @@
use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell, Table, Theme}; use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell, Table, Theme};
use super::types::{ use super::types::{
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary, ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMention, MeMr,
SinceLastCheck, MeSummary, SinceLastCheck,
}; };
// ─── Layout Helpers ───────────────────────────────────────────────────────── // ─── Layout Helpers ─────────────────────────────────────────────────────────
@@ -164,12 +164,19 @@ pub fn print_summary_header(summary: &MeSummary, username: &str) {
Theme::dim().render("0 need attention") Theme::dim().render("0 need attention")
}; };
let mentioned = if summary.mentioned_in_count > 0 {
format!(" {} mentioned", summary.mentioned_in_count)
} else {
String::new()
};
println!( println!(
" {} projects {} issues {} authored MRs {} reviewing MRs {}", " {} projects {} issues {} authored MRs {} reviewing MRs{} {}",
summary.project_count, summary.project_count,
summary.open_issue_count, summary.open_issue_count,
summary.authored_mr_count, summary.authored_mr_count,
summary.reviewing_mr_count, summary.reviewing_mr_count,
mentioned,
needs, needs,
); );
@@ -342,6 +349,53 @@ pub fn print_reviewing_mrs_section(mrs: &[MeMr], single_project: bool) {
} }
} }
// ─── Mentioned In Section ────────────────────────────────────────────────
/// Print the "Mentioned In" section for items where user is @mentioned but
/// not assigned, authored, or reviewing.
pub fn print_mentioned_in_section(mentions: &[MeMention], single_project: bool) {
if mentions.is_empty() {
return;
}
println!(
"{}",
render::section_divider(&format!("Mentioned In ({})", mentions.len()))
);
for item in mentions {
let attn = styled_attention(&item.attention_state);
let ref_str = match item.entity_type.as_str() {
"issue" => format!("#{}", item.iid),
"mr" => format!("!{}", item.iid),
_ => format!("{}:{}", item.entity_type, item.iid),
};
let ref_style = match item.entity_type.as_str() {
"issue" => Theme::issue_ref(),
"mr" => Theme::mr_ref(),
_ => Theme::bold(),
};
let state_tag = match item.state.as_str() {
"opened" => String::new(),
other => format!(" [{}]", other),
};
let time = render::format_relative_time(item.updated_at);
println!(
" {} {} {}{} {}",
attn,
ref_style.render(&ref_str),
render::truncate(&item.title, title_width(43)),
Theme::dim().render(&state_tag),
Theme::dim().render(&time),
);
if !single_project {
println!(" {}", Theme::dim().render(&item.project_path));
}
}
}
// ─── Activity Feed ─────────────────────────────────────────────────────────── // ─── Activity Feed ───────────────────────────────────────────────────────────
/// Print the activity feed section (Task #17). /// Print the activity feed section (Task #17).
@@ -587,6 +641,7 @@ pub fn print_me_dashboard(dashboard: &MeDashboard, single_project: bool) {
print_issues_section(&dashboard.open_issues, single_project); print_issues_section(&dashboard.open_issues, single_project);
print_authored_mrs_section(&dashboard.open_mrs_authored, single_project); print_authored_mrs_section(&dashboard.open_mrs_authored, single_project);
print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project); print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project);
print_mentioned_in_section(&dashboard.mentioned_in, single_project);
print_activity_section(&dashboard.activity, single_project); print_activity_section(&dashboard.activity, single_project);
println!(); println!();
} }
@@ -597,6 +652,7 @@ pub fn print_me_dashboard_filtered(
single_project: bool, single_project: bool,
show_issues: bool, show_issues: bool,
show_mrs: bool, show_mrs: bool,
show_mentions: bool,
show_activity: bool, show_activity: bool,
) { ) {
if let Some(ref since) = dashboard.since_last_check { if let Some(ref since) = dashboard.since_last_check {
@@ -611,6 +667,9 @@ pub fn print_me_dashboard_filtered(
print_authored_mrs_section(&dashboard.open_mrs_authored, single_project); print_authored_mrs_section(&dashboard.open_mrs_authored, single_project);
print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project); print_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project);
} }
if show_mentions {
print_mentioned_in_section(&dashboard.mentioned_in, single_project);
}
if show_activity { if show_activity {
print_activity_section(&dashboard.activity, single_project); print_activity_section(&dashboard.activity, single_project);
} }

View File

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

View File

@@ -44,6 +44,7 @@ pub struct MeSummary {
pub open_issue_count: usize, pub open_issue_count: usize,
pub authored_mr_count: usize, pub authored_mr_count: usize,
pub reviewing_mr_count: usize, pub reviewing_mr_count: usize,
pub mentioned_in_count: usize,
pub needs_attention_count: usize, pub needs_attention_count: usize,
} }
@@ -53,6 +54,7 @@ pub struct MeIssue {
pub title: String, pub title: String,
pub project_path: String, pub project_path: String,
pub attention_state: AttentionState, pub attention_state: AttentionState,
pub attention_reason: String,
pub status_name: Option<String>, pub status_name: Option<String>,
pub labels: Vec<String>, pub labels: Vec<String>,
pub updated_at: i64, pub updated_at: i64,
@@ -65,6 +67,7 @@ pub struct MeMr {
pub title: String, pub title: String,
pub project_path: String, pub project_path: String,
pub attention_state: AttentionState, pub attention_state: AttentionState,
pub attention_reason: String,
pub draft: bool, pub draft: bool,
pub detailed_merge_status: Option<String>, pub detailed_merge_status: Option<String>,
pub author_username: Option<String>, pub author_username: Option<String>,
@@ -114,6 +117,21 @@ pub struct SinceLastCheck {
pub total_event_count: usize, pub total_event_count: usize,
} }
/// An issue or MR where the user is @mentioned but not formally associated.
pub struct MeMention {
/// "issue" or "mr"
pub entity_type: String,
pub iid: i64,
pub title: String,
pub project_path: String,
/// "opened", "closed", or "merged"
pub state: String,
pub attention_state: AttentionState,
pub attention_reason: String,
pub updated_at: i64,
pub web_url: Option<String>,
}
/// The complete dashboard result. /// The complete dashboard result.
pub struct MeDashboard { pub struct MeDashboard {
pub username: String, pub username: String,
@@ -122,6 +140,7 @@ pub struct MeDashboard {
pub open_issues: Vec<MeIssue>, pub open_issues: Vec<MeIssue>,
pub open_mrs_authored: Vec<MeMr>, pub open_mrs_authored: Vec<MeMr>,
pub reviewing_mrs: Vec<MeMr>, pub reviewing_mrs: Vec<MeMr>,
pub mentioned_in: Vec<MeMention>,
pub activity: Vec<MeActivityEvent>, pub activity: Vec<MeActivityEvent>,
pub since_last_check: Option<SinceLastCheck>, pub since_last_check: Option<SinceLastCheck>,
} }

View File

@@ -1103,6 +1103,10 @@ pub struct MeArgs {
#[arg(long, help_heading = "Sections")] #[arg(long, help_heading = "Sections")]
pub activity: bool, pub activity: bool,
/// Show items you're @mentioned in (not assigned/authored/reviewing)
#[arg(long, help_heading = "Sections")]
pub mentions: bool,
/// Activity window (e.g. 7d, 2w, 30d). Default: 30d. Only affects activity section. /// Activity window (e.g. 7d, 2w, 30d). Default: 30d. Only affects activity section.
#[arg(long, help_heading = "Filters")] #[arg(long, help_heading = "Filters")]
pub since: Option<String>, pub since: Option<String>,
@@ -1131,7 +1135,7 @@ pub struct MeArgs {
impl MeArgs { impl MeArgs {
/// Returns true if no section flags were passed (show all sections). /// Returns true if no section flags were passed (show all sections).
pub fn show_all_sections(&self) -> bool { pub fn show_all_sections(&self) -> bool {
!self.issues && !self.mrs && !self.activity !self.issues && !self.mrs && !self.activity && !self.mentions
} }
} }

View File

@@ -68,7 +68,25 @@ pub fn expand_fields_preset(fields: &[String], entity: &str) -> Vec<String> {
.iter() .iter()
.map(|s| (*s).to_string()) .map(|s| (*s).to_string())
.collect(), .collect(),
"me_items" => ["iid", "title", "attention_state", "updated_at_iso"] "me_items" => [
"iid",
"title",
"attention_state",
"attention_reason",
"updated_at_iso",
]
.iter()
.map(|s| (*s).to_string())
.collect(),
"me_mentions" => [
"entity_type",
"iid",
"title",
"state",
"attention_state",
"attention_reason",
"updated_at_iso",
]
.iter() .iter()
.map(|s| (*s).to_string()) .map(|s| (*s).to_string())
.collect(), .collect(),