forge_notify: embed issue/pr body excerpt + surface truncated mention lines (closes #539)
This commit is contained in:
parent
8bbb3e4905
commit
4b30f20dcf
1 changed files with 165 additions and 3 deletions
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue