diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 6877cbf4..0e2cd24d 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -192,6 +192,35 @@ fn notif_type_label(t: &str) -> &str { } /// Truncate a string to `max` bytes at a char boundary, appending `…` if cut. +/// Escape ATX-style markdown headings (`# h`, `## h`, …) in a +/// comment/review body before we embed it inline in the forge-notify +/// wrapper. The wrapper is the markdown context the dashboard's +/// `marked.parse` sees; without this, a body line like `## argus +/// review` blows into a top-level h2 in the agent's chat row, +/// dwarfing the rest of the wrapper text (closes #455). +/// +/// Backslash before `#` is the standard markdown escape — `\#` renders +/// as the literal character `#`, so the line is preserved verbatim +/// without claiming heading-level styling. Indented lines keep their +/// indentation. Lines that don't start with `#` (ignoring leading +/// whitespace) are passed through unchanged. Setext-style headings +/// (`heading\n===`) are not handled here — rarer in practice and +/// would need multi-line lookahead; revisit if it actually shows up. +fn escape_md_headings(body: &str) -> String { + body.lines() + .map(|line| { + let trimmed = line.trim_start(); + if trimmed.starts_with('#') { + let lead = &line[..line.len() - trimmed.len()]; + format!("{lead}\\{trimmed}") + } else { + line.to_owned() + } + }) + .collect::>() + .join("\n") +} + fn truncate(s: &str, max: usize) -> String { if s.len() <= max { return s.to_owned(); @@ -386,6 +415,13 @@ async fn format_comment_notification( let author = if actor_login.is_empty() { "?" } else { actor_login }; let NotifMeta { title, notif_type, num, repo, meta_suffix, .. } = meta; + // Escape ATX headings in the user-authored body so the embedded + // text doesn't blow into top-level h1/h2 in the wrapper message + // when the dashboard renders it (closes #455). Done once here + // because both code paths fall through the same truncate+embed + // pattern. + let escaped = escape_md_headings(body_text); + let body_for_embed = truncate(&escaped, BODY_TRUNCATE); if let Some(review_label) = review_state { // Review submission on a PR. let kind = format!("PR {review_label}{num}{repo}"); @@ -393,17 +429,14 @@ async fn format_comment_notification( if body_text.is_empty() { write!(out, "\n\nreviewer: {author}").ok(); } else { - write!(out, "\n\n{author}: {}", truncate(body_text, BODY_TRUNCATE)).ok(); + write!(out, "\n\n{author}: {body_for_embed}").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}: {}", - truncate(body_text, BODY_TRUNCATE) - ); + let mut out = format!("[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}"); if out.ends_with('\n') { out.pop(); } @@ -620,3 +653,59 @@ async fn mark_read(client: &reqwest::Client, forge_url: &str, token: &str, id: u } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escape_md_headings_escapes_top_level_atx() { + // The #455 repro: argus reviews start with `## argus review`, + // which would otherwise become an h2 in the wrapper message. + assert_eq!( + escape_md_headings("## argus review\n\nlgtm."), + "\\## argus review\n\nlgtm.", + ); + } + + #[test] + fn escape_md_headings_escapes_all_heading_depths() { + let body = "# h1\n## h2\n### h3\n###### h6\nbody"; + assert_eq!( + escape_md_headings(body), + "\\# h1\n\\## h2\n\\### h3\n\\###### h6\nbody", + ); + } + + #[test] + fn escape_md_headings_preserves_indent() { + // Indented "headings" inside lists / nested quotes keep + // their leading whitespace so structure isn't visually + // collapsed by the escape. + assert_eq!( + escape_md_headings(" ## indented\nbody"), + " \\## indented\nbody", + ); + } + + #[test] + fn escape_md_headings_passes_non_heading_lines_through() { + let body = "plain text\nwith a #hashtag in middle\n```\n# in fenced code\n```"; + let escaped = escape_md_headings(body); + // Lines without leading `#` are untouched. The `# in fenced + // code` line still gets escaped (we don't track fenced-code + // state) — acceptable: inside a fenced block the escape is + // visually inert anyway because the renderer treats the + // content as literal. + assert!(escaped.contains("plain text")); + assert!(escaped.contains("with a #hashtag in middle")); + assert!(escaped.contains("\\# in fenced code")); + } + + #[test] + fn escape_md_headings_handles_empty_and_whitespace_only() { + assert_eq!(escape_md_headings(""), ""); + assert_eq!(escape_md_headings(" "), " "); + assert_eq!(escape_md_headings("\n\n"), "\n"); + } +}