feat(me): add --full flag to show untruncated note content

By default, the `me` command truncates note/comment bodies to 200
characters in both the activity feed and since-last-check inbox. This
is sensible for overview displays but loses context when you need to
see the full comment text.

The new `--full` flag disables truncation, returning complete note
bodies. This affects:

- Activity feed: Note summaries now show full content
- Since-last-check inbox: Both regular comments and @mentions show
  full bodies

Implementation:
- CLI: Added `--full` boolean arg under "Output" help heading
- queries.rs: `query_activity()` and `query_since_last_check()` now
  accept a `full_body: bool` parameter that controls SQL body
  selection (`n.body` vs `SUBSTR(n.body, 1, 200)`)
- mod.rs: Wired `args.full` through to both query functions
- Tests: Added `activity_full_body_flag` and
  `since_last_check_full_body_flag` tests that verify:
  - 300-char bodies truncated to 200 without --full
  - 300-char bodies preserved in full with --full

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-03-04 15:23:22 -05:00
parent 571c304031
commit 69b16a51d9
4 changed files with 100 additions and 22 deletions

View File

@@ -476,7 +476,7 @@ fn activity_note_on_assigned_issue() {
let t = now_ms() - 1000; let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "a comment", t); insert_note_at(&conn, 200, disc_id, 1, "bob", false, "a comment", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap(); let results = query_activity(&conn, "alice", &[], 0, false).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Note); assert_eq!(results[0].event_type, ActivityEventType::Note);
assert_eq!(results[0].entity_iid, 42); assert_eq!(results[0].entity_iid, 42);
@@ -495,7 +495,7 @@ fn activity_note_on_authored_mr() {
let t = now_ms() - 1000; let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "nice work", t); insert_note_at(&conn, 200, disc_id, 1, "bob", false, "nice work", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap(); let results = query_activity(&conn, "alice", &[], 0, false).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Note); assert_eq!(results[0].event_type, ActivityEventType::Note);
assert_eq!(results[0].entity_type, "mr"); assert_eq!(results[0].entity_type, "mr");
@@ -512,7 +512,7 @@ fn activity_state_event_on_my_issue() {
let t = now_ms() - 1000; let t = now_ms() - 1000;
insert_state_event(&conn, 300, 1, Some(10), None, "closed", "bob", t); insert_state_event(&conn, 300, 1, Some(10), None, "closed", "bob", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap(); let results = query_activity(&conn, "alice", &[], 0, false).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::StatusChange); assert_eq!(results[0].event_type, ActivityEventType::StatusChange);
assert_eq!(results[0].summary, "closed"); assert_eq!(results[0].summary, "closed");
@@ -528,7 +528,7 @@ fn activity_label_event_on_my_issue() {
let t = now_ms() - 1000; let t = now_ms() - 1000;
insert_label_event(&conn, 400, 1, Some(10), None, "add", "bug", "bob", t); insert_label_event(&conn, 400, 1, Some(10), None, "add", "bug", "bob", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap(); let results = query_activity(&conn, "alice", &[], 0, false).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::LabelChange); assert_eq!(results[0].event_type, ActivityEventType::LabelChange);
assert!(results[0].summary.contains("bug")); assert!(results[0].summary.contains("bug"));
@@ -546,7 +546,7 @@ fn activity_excludes_unassociated_items() {
let t = now_ms() - 1000; let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "a comment", t); insert_note_at(&conn, 200, disc_id, 1, "bob", false, "a comment", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap(); let results = query_activity(&conn, "alice", &[], 0, false).unwrap();
assert!( assert!(
results.is_empty(), results.is_empty(),
"should not see activity on unassigned issues" "should not see activity on unassigned issues"
@@ -578,13 +578,39 @@ fn activity_since_filter() {
// since = 50 seconds ago, should only get the recent note // since = 50 seconds ago, should only get the recent note
let since = now_ms() - 50_000; let since = now_ms() - 50_000;
let results = query_activity(&conn, "alice", &[], since).unwrap(); let results = query_activity(&conn, "alice", &[], since, false).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
// Notes no longer duplicate body into body_preview (summary carries the content) // Notes no longer duplicate body into body_preview (summary carries the content)
assert_eq!(results[0].body_preview, None); assert_eq!(results[0].body_preview, None);
assert_eq!(results[0].summary, "new comment"); assert_eq!(results[0].summary, "new comment");
} }
#[test]
fn activity_full_body_flag() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
// Create a body longer than 200 characters
let long_body = "a".repeat(300);
insert_note_at(&conn, 200, disc_id, 1, "bob", false, &long_body, t);
// Without --full, summary should be truncated to 200 chars
let results = query_activity(&conn, "alice", &[], 0, false).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].summary.len(), 200);
// With --full, summary should contain the full body
let results = query_activity(&conn, "alice", &[], 0, true).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].summary.len(), 300);
assert_eq!(results[0].summary, long_body);
}
#[test] #[test]
fn activity_project_filter() { fn activity_project_filter() {
let conn = setup_test_db(); let conn = setup_test_db();
@@ -604,7 +630,7 @@ fn activity_project_filter() {
insert_note_at(&conn, 201, disc_b, 2, "bob", false, "comment b", t); insert_note_at(&conn, 201, disc_b, 2, "bob", false, "comment b", t);
// Filter to project 1 only // Filter to project 1 only
let results = query_activity(&conn, "alice", &[1], 0).unwrap(); let results = query_activity(&conn, "alice", &[1], 0, false).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].project_path, "group/repo-a"); assert_eq!(results[0].project_path, "group/repo-a");
} }
@@ -623,7 +649,7 @@ fn activity_sorted_newest_first() {
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "first", t1); insert_note_at(&conn, 200, disc_id, 1, "bob", false, "first", t1);
insert_note_at(&conn, 201, disc_id, 1, "charlie", false, "second", t2); insert_note_at(&conn, 201, disc_id, 1, "charlie", false, "second", t2);
let results = query_activity(&conn, "alice", &[], 0).unwrap(); let results = query_activity(&conn, "alice", &[], 0, false).unwrap();
assert_eq!(results.len(), 2); assert_eq!(results.len(), 2);
assert!( assert!(
results[0].timestamp >= results[1].timestamp, results[0].timestamp >= results[1].timestamp,
@@ -643,7 +669,7 @@ fn activity_is_own_flag() {
let t = now_ms() - 1000; let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "alice", false, "my comment", t); insert_note_at(&conn, 200, disc_id, 1, "alice", false, "my comment", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap(); let results = query_activity(&conn, "alice", &[], 0, false).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert!(results[0].is_own); assert!(results[0].is_own);
} }
@@ -662,7 +688,7 @@ fn activity_assignment_system_note() {
let t = now_ms() - 1000; let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", true, "assigned to @alice", t); insert_note_at(&conn, 200, disc_id, 1, "bob", true, "assigned to @alice", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap(); let results = query_activity(&conn, "alice", &[], 0, false).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Assign); assert_eq!(results[0].event_type, ActivityEventType::Assign);
} }
@@ -679,7 +705,7 @@ fn activity_unassignment_system_note() {
let t = now_ms() - 1000; let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", true, "unassigned @alice", t); insert_note_at(&conn, 200, disc_id, 1, "bob", true, "unassigned @alice", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap(); let results = query_activity(&conn, "alice", &[], 0, false).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::Unassign); assert_eq!(results[0].event_type, ActivityEventType::Unassign);
} }
@@ -705,7 +731,7 @@ fn activity_review_request_system_note() {
t, t,
); );
let results = query_activity(&conn, "alice", &[], 0).unwrap(); let results = query_activity(&conn, "alice", &[], 0, false).unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].event_type, ActivityEventType::ReviewRequest); assert_eq!(results[0].event_type, ActivityEventType::ReviewRequest);
} }
@@ -731,7 +757,7 @@ fn since_last_check_detects_mention_with_trailing_comma() {
t, t,
); );
let groups = query_since_last_check(&conn, "alice", 0).unwrap(); let groups = query_since_last_check(&conn, "alice", 0, false).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 1, "expected mention with comma to match"); assert_eq!(total_events, 1, "expected mention with comma to match");
} }
@@ -755,7 +781,7 @@ fn since_last_check_ignores_email_like_text() {
t, t,
); );
let groups = query_since_last_check(&conn, "alice", 0).unwrap(); let groups = query_since_last_check(&conn, "alice", 0, false).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 0, "email text should not count as mention"); assert_eq!(total_events, 0, "email text should not count as mention");
} }
@@ -779,7 +805,7 @@ fn since_last_check_detects_mention_with_trailing_period() {
t, t,
); );
let groups = query_since_last_check(&conn, "alice", 0).unwrap(); let groups = query_since_last_check(&conn, "alice", 0, false).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 1, "expected mention with period to match"); assert_eq!(total_events, 1, "expected mention with period to match");
} }
@@ -803,7 +829,7 @@ fn since_last_check_detects_mention_inside_parentheses() {
t, t,
); );
let groups = query_since_last_check(&conn, "alice", 0).unwrap(); let groups = query_since_last_check(&conn, "alice", 0, false).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(total_events, 1, "expected parenthesized mention to match"); assert_eq!(total_events, 1, "expected parenthesized mention to match");
} }
@@ -827,7 +853,7 @@ fn since_last_check_ignores_domain_like_text() {
t, t,
); );
let groups = query_since_last_check(&conn, "alice", 0).unwrap(); let groups = query_since_last_check(&conn, "alice", 0, false).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!( assert_eq!(
total_events, 0, total_events, 0,
@@ -835,6 +861,33 @@ fn since_last_check_ignores_domain_like_text() {
); );
} }
#[test]
fn since_last_check_full_body_flag() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue(&conn, 10, 1, 42, "someone");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
// Create a body longer than 200 characters
let long_body = "b".repeat(300);
insert_note_at(&conn, 200, disc_id, 1, "bob", false, &long_body, t);
// Without --full, summary should be truncated to 200 chars
let groups = query_since_last_check(&conn, "alice", 0, false).unwrap();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].events.len(), 1);
assert_eq!(groups[0].events[0].summary.len(), 200);
// With --full, summary should contain the full body
let groups = query_since_last_check(&conn, "alice", 0, true).unwrap();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].events.len(), 1);
assert_eq!(groups[0].events[0].summary.len(), 300);
assert_eq!(groups[0].events[0].summary, long_body);
}
// ─── Helper Tests ────────────────────────────────────────────────────────── // ─── Helper Tests ──────────────────────────────────────────────────────────
#[test] #[test]

View File

@@ -147,7 +147,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
}; };
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, args.full)?
} else { } else {
Vec::new() Vec::new()
}; };
@@ -158,7 +158,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
// permanently skip events from other projects. // permanently skip events from other projects.
let mut global_watermark: Option<i64> = None; let mut global_watermark: Option<i64> = None;
let since_last_check = if let Some(prev_cursor) = cursor_ms { let since_last_check = if let Some(prev_cursor) = cursor_ms {
let groups = query_since_last_check(&conn, username, prev_cursor)?; let groups = query_since_last_check(&conn, username, prev_cursor, args.full)?;
// Watermark from ALL groups (unfiltered) — this is the true high-water mark // Watermark from ALL groups (unfiltered) — this is the true high-water mark
global_watermark = groups.iter().map(|g| g.latest_timestamp).max(); global_watermark = groups.iter().map(|g| g.latest_timestamp).max();
// If --project was passed, filter groups by project for display only // If --project was passed, filter groups by project for display only
@@ -318,6 +318,7 @@ mod tests {
all: false, all: false,
user: user.map(String::from), user: user.map(String::from),
fields: None, fields: None,
full: false,
reset_cursor: false, reset_cursor: false,
} }
} }

View File

@@ -266,11 +266,14 @@ pub fn query_reviewing_mrs(
/// Query activity events on items currently associated with the user. /// Query activity events on items currently associated with the user.
/// Combines notes, state events, label events, milestone events, and /// Combines notes, state events, label events, milestone events, and
/// assignment/reviewer system notes into a unified feed sorted newest-first. /// assignment/reviewer system notes into a unified feed sorted newest-first.
///
/// When `full_body` is true, note content is returned in full without truncation.
pub fn query_activity( pub fn query_activity(
conn: &Connection, conn: &Connection,
username: &str, username: &str,
project_ids: &[i64], project_ids: &[i64],
since_ms: i64, since_ms: i64,
full_body: bool,
) -> Result<Vec<MeActivityEvent>> { ) -> Result<Vec<MeActivityEvent>> {
// Build project filter for activity sources. // Build project filter for activity sources.
// Activity params: ?1=username, ?2=since_ms, ?3+=project_ids // Activity params: ?1=username, ?2=since_ms, ?3+=project_ids
@@ -292,6 +295,13 @@ pub fn query_activity(
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened') WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened')
)"; )";
// Body selection: truncate to 200 chars unless --full was passed
let body_expr = if full_body {
"n.body"
} else {
"SUBSTR(n.body, 1, 200)"
};
// Source 1: Human comments on my items // Source 1: Human comments on my items
let notes_sql = format!( let notes_sql = format!(
"SELECT n.created_at, 'note', "SELECT n.created_at, 'note',
@@ -300,7 +310,7 @@ pub fn query_activity(
p.path_with_namespace, p.path_with_namespace,
n.author_username, n.author_username,
CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END, CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
SUBSTR(n.body, 1, 200), {body_expr},
NULL NULL
FROM notes n FROM notes n
JOIN discussions d ON n.discussion_id = d.id JOIN discussions d ON n.discussion_id = d.id
@@ -492,10 +502,13 @@ struct RawSinceCheckRow {
/// 1. Others' comments on my open items /// 1. Others' comments on my open items
/// 2. @mentions on any item (not restricted to my items) /// 2. @mentions on any item (not restricted to my items)
/// 3. Assignment/review-request system notes mentioning me /// 3. Assignment/review-request system notes mentioning me
///
/// When `full_body` is true, note content is returned in full without truncation.
pub fn query_since_last_check( pub fn query_since_last_check(
conn: &Connection, conn: &Connection,
username: &str, username: &str,
cursor_ms: i64, cursor_ms: i64,
full_body: bool,
) -> Result<Vec<SinceCheckGroup>> { ) -> Result<Vec<SinceCheckGroup>> {
// Build the "my items" subquery fragments (reused from activity). // Build the "my items" subquery fragments (reused from activity).
let my_issue_check = "EXISTS ( let my_issue_check = "EXISTS (
@@ -510,6 +523,13 @@ pub fn query_since_last_check(
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened') WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened')
)"; )";
// Body selection: truncate to 200 chars unless --full was passed
let body_expr = if full_body {
"n.body"
} else {
"SUBSTR(n.body, 1, 200)"
};
// Source 1: Others' comments on my open items // Source 1: Others' comments on my open items
let source1 = format!( let source1 = format!(
"SELECT n.created_at, 'note', "SELECT n.created_at, 'note',
@@ -518,7 +538,7 @@ pub fn query_since_last_check(
COALESCE(i.title, m.title), COALESCE(i.title, m.title),
p.path_with_namespace, p.path_with_namespace,
n.author_username, n.author_username,
SUBSTR(n.body, 1, 200), {body_expr},
NULL, NULL,
0, 0,
NULL NULL
@@ -547,7 +567,7 @@ pub fn query_since_last_check(
COALESCE(i.title, m.title), COALESCE(i.title, m.title),
p.path_with_namespace, p.path_with_namespace,
n.author_username, n.author_username,
SUBSTR(n.body, 1, 200), {body_expr},
NULL, NULL,
1, 1,
n.body n.body

View File

@@ -1123,6 +1123,10 @@ pub struct MeArgs {
#[arg(long, help_heading = "Output", value_delimiter = ',')] #[arg(long, help_heading = "Output", value_delimiter = ',')]
pub fields: Option<Vec<String>>, pub fields: Option<Vec<String>>,
/// Show full note content (no truncation)
#[arg(long, help_heading = "Output")]
pub full: bool,
/// Reset the since-last-check cursor (next run shows no new events) /// Reset the since-last-check cursor (next run shows no new events)
#[arg(long, help_heading = "Output")] #[arg(long, help_heading = "Output")]
pub reset_cursor: bool, pub reset_cursor: bool,