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:
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -856,6 +1112,67 @@ fn parse_attention_state_all_variants() {
|
||||
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]
|
||||
fn parse_event_type_all_variants() {
|
||||
assert_eq!(parse_event_type("note"), ActivityEventType::Note);
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::core::project::resolve_project;
|
||||
use crate::core::time::parse_since;
|
||||
|
||||
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,
|
||||
};
|
||||
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.
|
||||
const DEFAULT_ACTIVITY_SINCE_DAYS: i64 = 1;
|
||||
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.
|
||||
///
|
||||
@@ -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_mrs = show_all || args.mrs;
|
||||
let want_activity = show_all || args.activity;
|
||||
let want_mentions = show_all || args.mentions;
|
||||
|
||||
// 6. Run queries for requested sections
|
||||
let open_issues = if want_issues {
|
||||
@@ -146,6 +149,13 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
||||
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 {
|
||||
query_activity(&conn, username, &project_ids, since_ms)?
|
||||
} else {
|
||||
@@ -187,6 +197,10 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
||||
.filter(|m| m.attention_state == AttentionState::NeedsAttention)
|
||||
.count()
|
||||
+ reviewing_mrs
|
||||
.iter()
|
||||
.filter(|m| m.attention_state == AttentionState::NeedsAttention)
|
||||
.count()
|
||||
+ mentioned_in
|
||||
.iter()
|
||||
.filter(|m| m.attention_state == AttentionState::NeedsAttention)
|
||||
.count();
|
||||
@@ -202,12 +216,16 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
||||
for m in &reviewing_mrs {
|
||||
project_paths.insert(&m.project_path);
|
||||
}
|
||||
for m in &mentioned_in {
|
||||
project_paths.insert(&m.project_path);
|
||||
}
|
||||
|
||||
let summary = MeSummary {
|
||||
project_count: project_paths.len(),
|
||||
open_issue_count: open_issues.len(),
|
||||
authored_mr_count: open_mrs_authored.len(),
|
||||
reviewing_mr_count: reviewing_mrs.len(),
|
||||
mentioned_in_count: mentioned_in.len(),
|
||||
needs_attention_count,
|
||||
};
|
||||
|
||||
@@ -219,6 +237,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
||||
open_issues,
|
||||
open_mrs_authored,
|
||||
reviewing_mrs,
|
||||
mentioned_in,
|
||||
activity,
|
||||
since_last_check,
|
||||
};
|
||||
@@ -237,6 +256,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
|
||||
single_project,
|
||||
want_issues,
|
||||
want_mrs,
|
||||
want_mentions,
|
||||
want_activity,
|
||||
);
|
||||
}
|
||||
@@ -313,6 +333,7 @@ mod tests {
|
||||
issues: false,
|
||||
mrs: false,
|
||||
activity: false,
|
||||
mentions: false,
|
||||
since: None,
|
||||
project: None,
|
||||
all: false,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::cli::render::{self, Align, GlyphMode, Icons, LoreRenderer, StyledCell, Table, Theme};
|
||||
|
||||
use super::types::{
|
||||
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMr, MeSummary,
|
||||
SinceLastCheck,
|
||||
ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMention, MeMr,
|
||||
MeSummary, SinceLastCheck,
|
||||
};
|
||||
|
||||
// ─── Layout Helpers ─────────────────────────────────────────────────────────
|
||||
@@ -164,12 +164,19 @@ pub fn print_summary_header(summary: &MeSummary, username: &str) {
|
||||
Theme::dim().render("0 need attention")
|
||||
};
|
||||
|
||||
let mentioned = if summary.mentioned_in_count > 0 {
|
||||
format!(" {} mentioned", summary.mentioned_in_count)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!(
|
||||
" {} projects {} issues {} authored MRs {} reviewing MRs {}",
|
||||
" {} projects {} issues {} authored MRs {} reviewing MRs{} {}",
|
||||
summary.project_count,
|
||||
summary.open_issue_count,
|
||||
summary.authored_mr_count,
|
||||
summary.reviewing_mr_count,
|
||||
mentioned,
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/// 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_authored_mrs_section(&dashboard.open_mrs_authored, 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);
|
||||
println!();
|
||||
}
|
||||
@@ -597,6 +652,7 @@ pub fn print_me_dashboard_filtered(
|
||||
single_project: bool,
|
||||
show_issues: bool,
|
||||
show_mrs: bool,
|
||||
show_mentions: bool,
|
||||
show_activity: bool,
|
||||
) {
|
||||
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_reviewing_mrs_section(&dashboard.reviewing_mrs, single_project);
|
||||
}
|
||||
if show_mentions {
|
||||
print_mentioned_in_section(&dashboard.mentioned_in, single_project);
|
||||
}
|
||||
if show_activity {
|
||||
print_activity_section(&dashboard.activity, single_project);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ pub struct MeSummary {
|
||||
pub open_issue_count: usize,
|
||||
pub authored_mr_count: usize,
|
||||
pub reviewing_mr_count: usize,
|
||||
pub mentioned_in_count: usize,
|
||||
pub needs_attention_count: usize,
|
||||
}
|
||||
|
||||
@@ -53,6 +54,7 @@ pub struct MeIssue {
|
||||
pub title: String,
|
||||
pub project_path: String,
|
||||
pub attention_state: AttentionState,
|
||||
pub attention_reason: String,
|
||||
pub status_name: Option<String>,
|
||||
pub labels: Vec<String>,
|
||||
pub updated_at: i64,
|
||||
@@ -65,6 +67,7 @@ pub struct MeMr {
|
||||
pub title: String,
|
||||
pub project_path: String,
|
||||
pub attention_state: AttentionState,
|
||||
pub attention_reason: String,
|
||||
pub draft: bool,
|
||||
pub detailed_merge_status: Option<String>,
|
||||
pub author_username: Option<String>,
|
||||
@@ -114,6 +117,21 @@ pub struct SinceLastCheck {
|
||||
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.
|
||||
pub struct MeDashboard {
|
||||
pub username: String,
|
||||
@@ -122,6 +140,7 @@ pub struct MeDashboard {
|
||||
pub open_issues: Vec<MeIssue>,
|
||||
pub open_mrs_authored: Vec<MeMr>,
|
||||
pub reviewing_mrs: Vec<MeMr>,
|
||||
pub mentioned_in: Vec<MeMention>,
|
||||
pub activity: Vec<MeActivityEvent>,
|
||||
pub since_last_check: Option<SinceLastCheck>,
|
||||
}
|
||||
|
||||
@@ -1103,6 +1103,10 @@ pub struct MeArgs {
|
||||
#[arg(long, help_heading = "Sections")]
|
||||
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.
|
||||
#[arg(long, help_heading = "Filters")]
|
||||
pub since: Option<String>,
|
||||
@@ -1131,7 +1135,7 @@ pub struct MeArgs {
|
||||
impl MeArgs {
|
||||
/// Returns true if no section flags were passed (show all sections).
|
||||
pub fn show_all_sections(&self) -> bool {
|
||||
!self.issues && !self.mrs && !self.activity
|
||||
!self.issues && !self.mrs && !self.activity && !self.mentions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,25 @@ pub fn expand_fields_preset(fields: &[String], entity: &str) -> Vec<String> {
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.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()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect(),
|
||||
|
||||
Reference in New Issue
Block a user