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;
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[0].event_type, ActivityEventType::Note);
assert_eq!(results[0].entity_iid, 42);
@@ -495,7 +495,7 @@ fn activity_note_on_authored_mr() {
let t = now_ms() - 1000;
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[0].event_type, ActivityEventType::Note);
assert_eq!(results[0].entity_type, "mr");
@@ -512,7 +512,7 @@ fn activity_state_event_on_my_issue() {
let t = now_ms() - 1000;
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[0].event_type, ActivityEventType::StatusChange);
assert_eq!(results[0].summary, "closed");
@@ -528,7 +528,7 @@ fn activity_label_event_on_my_issue() {
let t = now_ms() - 1000;
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[0].event_type, ActivityEventType::LabelChange);
assert!(results[0].summary.contains("bug"));
@@ -546,7 +546,7 @@ fn activity_excludes_unassociated_items() {
let t = now_ms() - 1000;
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!(
results.is_empty(),
"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
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);
// Notes no longer duplicate body into body_preview (summary carries the content)
assert_eq!(results[0].body_preview, None);
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]
fn activity_project_filter() {
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);
// 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[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, 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!(
results[0].timestamp >= results[1].timestamp,
@@ -643,7 +669,7 @@ fn activity_is_own_flag() {
let t = now_ms() - 1000;
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!(results[0].is_own);
}
@@ -662,7 +688,7 @@ fn activity_assignment_system_note() {
let t = now_ms() - 1000;
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[0].event_type, ActivityEventType::Assign);
}
@@ -679,7 +705,7 @@ fn activity_unassignment_system_note() {
let t = now_ms() - 1000;
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[0].event_type, ActivityEventType::Unassign);
}
@@ -705,7 +731,7 @@ fn activity_review_request_system_note() {
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[0].event_type, ActivityEventType::ReviewRequest);
}
@@ -731,7 +757,7 @@ fn since_last_check_detects_mention_with_trailing_comma() {
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();
assert_eq!(total_events, 1, "expected mention with comma to match");
}
@@ -755,7 +781,7 @@ fn since_last_check_ignores_email_like_text() {
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();
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,
);
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();
assert_eq!(total_events, 1, "expected mention with period to match");
}
@@ -803,7 +829,7 @@ fn since_last_check_detects_mention_inside_parentheses() {
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();
assert_eq!(total_events, 1, "expected parenthesized mention to match");
}
@@ -827,7 +853,7 @@ fn since_last_check_ignores_domain_like_text() {
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();
assert_eq!(
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 ──────────────────────────────────────────────────────────
#[test]

View File

@@ -147,7 +147,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
};
let activity = if want_activity {
query_activity(&conn, username, &project_ids, since_ms)?
query_activity(&conn, username, &project_ids, since_ms, args.full)?
} else {
Vec::new()
};
@@ -158,7 +158,7 @@ pub fn run_me(config: &Config, args: &MeArgs, robot_mode: bool) -> Result<()> {
// permanently skip events from other projects.
let mut global_watermark: Option<i64> = None;
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
global_watermark = groups.iter().map(|g| g.latest_timestamp).max();
// If --project was passed, filter groups by project for display only
@@ -318,6 +318,7 @@ mod tests {
all: false,
user: user.map(String::from),
fields: None,
full: 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.
/// Combines notes, state events, label events, milestone events, and
/// 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(
conn: &Connection,
username: &str,
project_ids: &[i64],
since_ms: i64,
full_body: bool,
) -> Result<Vec<MeActivityEvent>> {
// Build project filter for activity sources.
// 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')
)";
// 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
let notes_sql = format!(
"SELECT n.created_at, 'note',
@@ -300,7 +310,7 @@ pub fn query_activity(
p.path_with_namespace,
n.author_username,
CASE WHEN n.author_username = ?1 THEN 1 ELSE 0 END,
SUBSTR(n.body, 1, 200),
{body_expr},
NULL
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
@@ -492,10 +502,13 @@ struct RawSinceCheckRow {
/// 1. Others' comments on my open items
/// 2. @mentions on any item (not restricted to my items)
/// 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(
conn: &Connection,
username: &str,
cursor_ms: i64,
full_body: bool,
) -> Result<Vec<SinceCheckGroup>> {
// Build the "my items" subquery fragments (reused from activity).
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')
)";
// 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
let source1 = format!(
"SELECT n.created_at, 'note',
@@ -518,7 +538,7 @@ pub fn query_since_last_check(
COALESCE(i.title, m.title),
p.path_with_namespace,
n.author_username,
SUBSTR(n.body, 1, 200),
{body_expr},
NULL,
0,
NULL
@@ -547,7 +567,7 @@ pub fn query_since_last_check(
COALESCE(i.title, m.title),
p.path_with_namespace,
n.author_username,
SUBSTR(n.body, 1, 200),
{body_expr},
NULL,
1,
n.body

View File

@@ -1123,6 +1123,10 @@ pub struct MeArgs {
#[arg(long, help_heading = "Output", value_delimiter = ',')]
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)
#[arg(long, help_heading = "Output")]
pub reset_cursor: bool,