From 4b30f20dcf9f1370423520c17330f5e5f913048a Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 28 May 2026 19:43:31 +0200 Subject: [PATCH] forge_notify: embed issue/pr body excerpt + surface truncated mention lines (closes #539) --- hive-ag3nt/src/forge_notify.rs | 168 ++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 3 deletions(-) diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index fad34d8f..efc0ba21 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -264,6 +264,69 @@ fn truncate(s: &str, max: usize) -> String { format!("{}…", &s[..end]) } +/// Detect `@username` mentions on a line. A mention is `@` followed by +/// at least one username char (alphanumeric / `_` / `-`) where the `@` +/// is at line start or follows a non-username char — so email-style +/// `foo@bar.com` does NOT count as a mention. +fn contains_mention(line: &str) -> bool { + let bytes = line.as_bytes(); + for (i, &b) in bytes.iter().enumerate() { + if b != b'@' { + continue; + } + // Boundary: preceding byte must NOT be a username char. + let boundary_ok = match i.checked_sub(1).map(|j| bytes[j]) { + None => true, + Some(prev) => !is_username_byte(prev), + }; + if !boundary_ok { + continue; + } + // Following byte must be at least one username char. + if bytes.get(i + 1).is_some_and(|&c| is_username_byte(c)) { + return true; + } + } + false +} + +fn is_username_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'-' +} + +/// Walk `full_body` line-by-line; return lines that contain an +/// `@username` mention AND aren't already present (as a substring) in +/// `included_excerpt`. Used to surface mentions that fell outside the +/// truncation window so addressed agents see they were tagged even +/// when the body is long (closes #539). +fn extract_truncated_mention_lines<'a>( + full_body: &'a str, + included_excerpt: &str, +) -> Vec<&'a str> { + full_body + .lines() + .filter(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() && contains_mention(trimmed) + }) + .filter(|line| !included_excerpt.contains(line.trim())) + .collect() +} + +/// Build the trailing `mentions (truncated from body):\n > …` block. +/// Empty string when there's nothing to surface. Caller embeds it +/// directly before the meta suffix. +fn render_truncated_mentions(lines: &[&str]) -> String { + if lines.is_empty() { + return String::new(); + } + let mut out = String::from("\n\nmentions (truncated from body):"); + for line in lines { + write!(out, "\n > {}", line.trim()).ok(); + } + out +} + /// Map a Forgejo review state to a readable action label. /// Returns `None` for non-review states (regular comments have no `state` field; /// `PENDING` means the review was saved but not submitted yet). @@ -452,6 +515,14 @@ async fn format_comment_notification( // pattern. let escaped = escape_md_headings(body_text); let body_for_embed = truncate(&escaped, BODY_TRUNCATE); + // Surface @mentions that fell outside the truncation window so an + // addressed agent never silently misses a tag on a long comment + // (closes #539). Skipped when the embed wasn't actually truncated. + let truncated_mentions = if body_text.len() > BODY_TRUNCATE { + render_truncated_mentions(&extract_truncated_mention_lines(body_text, &body_for_embed)) + } else { + String::new() + }; if let Some(review_label) = review_state { // Review submission on a PR. let kind = format!("PR {review_label}{num}{repo}"); @@ -459,14 +530,16 @@ async fn format_comment_notification( if body_text.is_empty() { write!(out, "\n\nreviewer: {author}").ok(); } else { - write!(out, "\n\n{author}: {body_for_embed}").ok(); + write!(out, "\n\n{author}: {body_for_embed}{truncated_mentions}").ok(); } out.push_str(meta_suffix); Some(out) } else { // Regular comment. let kind = format!("comment on {}{num}{repo}", notif_type_label(notif_type)); - let mut out = format!("[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}"); + let mut out = format!( + "[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}{truncated_mentions}" + ); if out.ends_with('\n') { out.pop(); } @@ -526,7 +599,26 @@ fn format_state_change_notification( kind }; - let mut out = format!("[{kind}] {title}\nurl: {html_url}"); + // Include the start of the issue/PR description so the agent gets + // context without a follow-up fetch (closes #539). Same escape + + // truncate pipeline as comment bodies. Mentions that fell outside + // the truncation window are surfaced separately so an addressed + // agent never silently misses a long-body @tag. + let body_block = subject + .as_ref() + .and_then(|s| s["body"].as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|raw| { + let escaped = escape_md_headings(raw); + let excerpt = truncate(&escaped, BODY_TRUNCATE); + let truncated = extract_truncated_mention_lines(raw, &excerpt); + let mentions = render_truncated_mentions(&truncated); + format!("\n\n{excerpt}{mentions}") + }) + .unwrap_or_default(); + + let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}"); out.push_str(meta_suffix); Some(out) } @@ -772,4 +864,74 @@ mod tests { assert_eq!(escape_md_headings("body\n"), "body\n"); assert_eq!(escape_md_headings("no trailing"), "no trailing"); } + + #[test] + fn contains_mention_matches_at_line_start_and_mid_line() { + assert!(contains_mention("@damocles take a look")); + assert!(contains_mention("cc @argus please")); + assert!(contains_mention("see (@mara) for context")); + // Hyphens / underscores / digits are valid username chars. + assert!(contains_mention("ping @h-m1nd-2")); + } + + #[test] + fn contains_mention_rejects_email_and_bare_at() { + // Email addresses (`foo@bar.com`) and `@` followed by + // whitespace or punctuation are not mentions — boundary check + // requires the preceding byte to NOT be a username char. + assert!(!contains_mention("foo@bar.com")); + assert!(!contains_mention("send to user@example.org")); + assert!(!contains_mention("just an @")); + assert!(!contains_mention("@ space")); + assert!(!contains_mention("plain text")); + assert!(!contains_mention("")); + } + + #[test] + fn extract_truncated_keeps_mention_lines_outside_excerpt() { + // Long body where the @mention sits AFTER the excerpt's cutoff + // — the truncated extractor must surface it. + let full = "first line\nsecond line\n@damocles tagged here\n"; + let excerpt = "first line\nsecond line\n…"; // mention not present + let lines = extract_truncated_mention_lines(full, excerpt); + assert_eq!(lines, vec!["@damocles tagged here"]); + } + + #[test] + fn extract_truncated_drops_mentions_already_in_excerpt() { + // Mention is inside the embed window already — no need to + // re-surface, would be noise. + let full = "@damocles read this\nmore body\n"; + let excerpt = "@damocles read this\nmore body\n…"; + let lines = extract_truncated_mention_lines(full, excerpt); + assert!(lines.is_empty()); + } + + #[test] + fn extract_truncated_skips_blank_and_no_mention_lines() { + // Only lines with an actual mention survive — random body + // text past the cutoff stays dropped. + let full = "first\n\nsecond paragraph\n@argus reviewer\nfinal\n"; + let excerpt = "first"; + let lines = extract_truncated_mention_lines(full, excerpt); + assert_eq!(lines, vec!["@argus reviewer"]); + } + + #[test] + fn render_truncated_mentions_empty_is_empty_string() { + // Zero overhead on the healthy short-body path: caller + // concatenates this directly so an empty input must produce + // no spacing. + assert_eq!(render_truncated_mentions(&[]), ""); + } + + #[test] + fn render_truncated_mentions_formats_block() { + let lines = ["cc @damocles", " @argus second mention"]; + let rendered = render_truncated_mentions(&lines); + assert_eq!( + rendered, + "\n\nmentions (truncated from body):\n > cc @damocles\n > @argus second mention", + ); + } }