//! Background Forgejo notification poller. Polls //! `GET /notifications?all=false` every 30s, formats each unread //! notification as a broker `Wake { from: "forge" }` message, and //! marks it read after delivery so failures resurface next tick. //! //! Activation gates, self-notification filtering, body excerpt + //! truncation + heading escape, wrapper formats (comment / review / //! new-item / state-change), meta suffix, review-request override, //! reason drop-list, and auto-unsubscribe on broad watches all live //! in [`docs/forge.md::Notification poller`](../../../docs/forge.md). use std::collections::HashSet; use std::fmt::Write as _; use std::path::{Path, PathBuf}; use std::time::Duration; use tracing::{debug, info, warn}; const POLL_INTERVAL_SECS: u64 = 30; const HTTP_TIMEOUT_SECS: u64 = 10; /// Maximum characters of a body/comment to include in the wake message. const BODY_TRUNCATE: usize = 500; /// How long to wait between token-read retries when the token file is /// missing or unreadable at startup (e.g. hive-priv hasn't provisioned /// it yet, or a chown race left it temporarily root-owned). const TOKEN_RETRY_SECS: u64 = 30; /// Give up waiting for the token after this many retries (~10 minutes). /// Avoids an infinite wait on agents that genuinely have no forge account. const TOKEN_RETRY_MAX: u32 = 20; /// Spawn point: called once from `hive-ag3nt serve` (agent) or /// `hive-m1nd serve` (manager). Returns immediately if the forge is not /// configured. Otherwise loops forever, polling every /// `POLL_INTERVAL_SECS` seconds. Errors are never fatal. /// pub async fn run(socket: PathBuf) { let forge_url = match std::env::var("HIVE_FORGE_URL") { Ok(u) if !u.is_empty() => u, _ => { debug!("forge_notify: HIVE_FORGE_URL not set — disabled"); return; } }; let state_dir = std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default(); let token_path = format!("{state_dir}/forge-token"); // Retry reading the token to handle races where hive-priv provisions the // token after the harness starts, or where a parent-container chown briefly // makes the file unreadable (see #1304 / #1309). We wait up to // TOKEN_RETRY_MAX * TOKEN_RETRY_SECS before giving up. let token = { let mut attempts = 0u32; loop { match tokio::fs::read_to_string(&token_path).await { Ok(t) => { let t = t.trim().to_owned(); if !t.is_empty() { break t; } debug!("forge_notify: empty forge token at {token_path}"); } Err(e) => { debug!("forge_notify: cannot read token at {token_path}: {e}"); } } attempts += 1; if attempts >= TOKEN_RETRY_MAX { debug!( "forge_notify: token not available after {TOKEN_RETRY_MAX} retries — disabled" ); return; } tokio::time::sleep(Duration::from_secs(TOKEN_RETRY_SECS)).await; } }; let client = match reqwest::Client::builder() .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS)) .build() { Ok(c) => c, Err(e) => { warn!("forge_notify: failed to build HTTP client: {e}"); return; } }; // Fetch own login once for self-notification filtering. Falls back // to empty string on failure — no filtering (safe degradation; see // `docs/forge.md::Self-notification filtering`). let own_login = { let url = format!("{forge_url}/api/v1/user"); fetch_json(&client, &url, &token) .await .and_then(|v| v["login"].as_str().map(std::borrow::ToOwned::to_owned)) .unwrap_or_default() }; if own_login.is_empty() { warn!("forge_notify: could not resolve own login — self-notification filtering disabled"); } else { debug!(%own_login, "forge_notify: own login resolved"); } let mut interval = tokio::time::interval(Duration::from_secs(POLL_INTERVAL_SECS)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); // First tick fires immediately — skip it so we don't race the broker // socket becoming available right at boot. interval.tick().await; // HIVE_FORGE_KEEP_SUBSCRIPTIONS=1 disables auto-unsubscribe for agents // that intentionally consume the full repo notification firehose (e.g. triage). let keep_subscriptions = std::env::var("HIVE_FORGE_KEEP_SUBSCRIPTIONS") .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")); // Optional reason drop-list — comma-separated Forgejo `reason` // values to silently mark-read instead of deliver. See // `docs/forge.md::Reason drop-list` for the drop-vs-allow rationale. let skip_reasons: Vec = std::env::var("HIVE_FORGE_NOTIFY_SKIP_REASONS") .unwrap_or_default() .split(',') .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_owned) .collect(); if skip_reasons.is_empty() { info!(forge_url = %forge_url, "forge_notify: polling started (all reasons)"); } else { info!(forge_url = %forge_url, skip = ?skip_reasons, "forge_notify: polling started"); } // Repos we have already unsubscribed this process lifetime. Persists // across polls so we don't hammer DELETE on every cycle. let mut unsubbed_repos: HashSet = HashSet::new(); loop { interval.tick().await; poll_once( &client, &forge_url, &token, &socket, keep_subscriptions, &mut unsubbed_repos, &own_login, &skip_reasons, ) .await; } } /// Fetch a JSON value from a URL using the agent's forge token. Returns /// `None` on any HTTP or parse error (best-effort enrichment). async fn fetch_json(client: &reqwest::Client, url: &str, token: &str) -> Option { let resp = client .get(url) .header("Authorization", format!("token {token}")) .send() .await .ok()?; if !resp.status().is_success() { return None; } resp.json().await.ok() } /// Map a Forgejo notification `subject.type` to a human-readable label. /// Known values: "Pull", "Issue", "Commit", "Repository". Any unknown /// type is passed through as-is so new Forgejo types degrade gracefully /// rather than silently collapsing into a generic label. fn notif_type_label(t: &str) -> &str { match t { "Pull" => "PR", "Issue" => "issue", other => other, } } /// Escape strict-ATX markdown headings in a body before embedding it /// inside the forge-notify wrapper, so a leading `## title` line /// doesn't blow into an h2 in the dashboard render. See /// `docs/forge.md::Body excerpt + truncation + heading escape` for /// the strict-ATX-vs-`#tag` rationale and the `split_inclusive` /// trailing-newline contract. fn escape_md_headings(body: &str) -> String { let mut out = String::with_capacity(body.len()); for line in body.split_inclusive('\n') { let (content, terminator) = match line.strip_suffix('\n') { Some(rest) => (rest, "\n"), None => (line, ""), }; let trimmed = content.trim_start(); if is_atx_heading(trimmed) { let lead = &content[..content.len() - trimmed.len()]; out.push_str(lead); out.push('\\'); out.push_str(trimmed); } else { out.push_str(content); } out.push_str(terminator); } out } /// Strict `CommonMark` ATX-heading detector: 1-6 leading `#`s followed /// by either a space, tab, or end-of-line. Anything tighter (`#tag`, /// `#123`) is a non-heading line that the renderer will not promote. fn is_atx_heading(line: &str) -> bool { let hashes = line.bytes().take_while(|&b| b == b'#').count(); if !(1..=6).contains(&hashes) { return false; } // Bare `#` / `##` / ... on its own line, or proper ATX with a // space/tab after the run of `#`s; anything else (`#tag` / `#123`) // is not a heading. matches!(line.as_bytes().get(hashes), None | Some(b' ' | b'\t')) } fn truncate(s: &str, max: usize) -> String { if s.len() <= max { return s.to_owned(); } let end = s .char_indices() .map(|(i, _)| i) .take_while(|&i| i <= max - 3) .last() .unwrap_or(0); 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`. Surfaces tags that fell outside the truncation /// window so addressed agents never silently miss a mention on a long /// body. See `docs/forge.md::Body excerpt + truncation + heading /// escape` for the truncate-before-escape ordering rule. 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). /// Forgejo review states: "APPROVED", "`REQUEST_CHANGES`", "COMMENT", "PENDING". fn review_state_label(state: &str) -> Option<&str> { match state { "APPROVED" => Some("approved"), "REQUEST_CHANGES" => Some("changes requested"), "COMMENT" => Some("review comment"), _ => None, } } /// Build a human-readable wake message for one Forgejo notification, /// or `None` for a self-echo the caller should mark-read without /// delivery. Wrapper format table + meta-suffix shape + number/repo /// extraction live in `docs/forge.md::Wrapper format` + /// `::Meta suffix`. async fn format_notification( client: &reqwest::Client, token: &str, notif: &serde_json::Value, own_login: &str, ) -> Option { let title = notif["subject"]["title"].as_str().unwrap_or("?"); let notif_type = notif["subject"]["type"].as_str().unwrap_or("?"); let html_url = notif["subject"]["html_url"] .as_str() .unwrap_or_else(|| notif["subject"]["url"].as_str().unwrap_or("")); // Extract issue/PR number from the html_url. URL ends with /issues/N or // /pulls/N (possibly followed by #anchor for comments). Best-effort. let num = html_url .split('#') .next() .and_then(|u| u.rsplit('/').next()) .and_then(|s| s.parse::().ok()) .map(|n| format!(" #{n}")) .unwrap_or_default(); // Repo slug for multi-repo disambiguation. Falls back gracefully when absent. let repo = notif["repository"]["full_name"] .as_str() .map(|r| format!(" {r}")) .unwrap_or_default(); // API URLs for fetching content let subject_api_url = notif["subject"]["url"].as_str().unwrap_or(""); let comment_api_url = notif["subject"]["latest_comment_url"] .as_str() .unwrap_or(""); let comment_html_url = notif["subject"]["latest_comment_html_url"] .as_str() .unwrap_or(""); // Always fetch subject detail for assignee/reviewer metadata so // the meta suffix can show current ownership without a follow-up // fetch (see `docs/forge.md::Meta suffix`). let subject = if subject_api_url.is_empty() { None } else { fetch_json(client, subject_api_url, token).await }; let is_pr = matches!(notif_type, "Pull Request" | "Pull"); let reason = notif["reason"].as_str().unwrap_or(""); let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr, reason); // Determine whether this notification was triggered by a comment/review or // by creation/state-change of the subject itself. let has_comment = !comment_api_url.is_empty() && comment_api_url != subject_api_url; let meta = NotifMeta { title, notif_type, html_url, num, repo, meta_suffix, reason, subject, is_pr, }; if has_comment { format_comment_notification( client, token, &meta, comment_api_url, comment_html_url, own_login, ) .await } else { format_state_change_notification(notif, &meta, own_login) } } /// Shared notification metadata extracted from the raw Forgejo JSON. struct NotifMeta<'a> { title: &'a str, notif_type: &'a str, html_url: &'a str, num: String, repo: String, meta_suffix: String, /// Forgejo `reason` value (e.g. "mention", "assigned", "subscribed"). /// Appended to every wrapper as the `reason:` line in the meta /// suffix (see `docs/forge.md::Meta suffix`). reason: &'a str, /// Fetched subject detail (issue/PR JSON); used for review-request detection. subject: Option, is_pr: bool, } /// Build the `\nassignee: ...` (and optionally `\nreviewer: ...` and /// `\nreason: ...`) suffix appended to every wrapper. Shape + /// presence rules live in `docs/forge.md::Meta suffix`. fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: &str) -> String { let assignees: Vec<&str> = subject .and_then(|s| s["assignees"].as_array()) .map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect()) .unwrap_or_default(); let assignee_line = if assignees.is_empty() { "assignee: unassigned".to_owned() } else { format!("assignee: {}", assignees.join(", ")) }; // For PRs, include requested_reviewers when present. let reviewer_line = if is_pr { let reviewers: Vec<&str> = subject .and_then(|s| s["requested_reviewers"].as_array()) .map(|arr| arr.iter().filter_map(|r| r["login"].as_str()).collect()) .unwrap_or_default(); if reviewers.is_empty() { None } else { Some(format!("reviewer: {}", reviewers.join(", "))) } } else { None }; // Always include reason so multiple notifications for the same // event (each with a different Forgejo reason) stay // distinguishable. let reason_line = if reason.is_empty() { None } else { Some(format!("reason: {reason}")) }; let mut out = format!("\n{assignee_line}"); if let Some(r) = reviewer_line { write!(out, "\n{r}").ok(); } if let Some(r) = reason_line { write!(out, "\n{r}").ok(); } out } /// Format a notification triggered by a new comment or review submission. async fn format_comment_notification( client: &reqwest::Client, token: &str, meta: &NotifMeta<'_>, comment_api_url: &str, comment_html_url: &str, own_login: &str, ) -> Option { let payload = fetch_json(client, comment_api_url, token).await; let actor_login = payload .as_ref() .and_then(|c| c["user"]["login"].as_str()) .unwrap_or(""); // Self-notification filter: skip if we authored the comment/review. if !own_login.is_empty() && actor_login == own_login { debug!(%own_login, "forge_notify: skipping self-authored comment/review"); return None; } let body_text = payload .as_ref() .and_then(|c| c["body"].as_str()) .unwrap_or("") .trim(); // PR review detection: Forgejo review objects carry a `state` // field with values like "APPROVED" / "REQUEST_CHANGES" / // "COMMENT". Regular issue/PR comments have no such field. Format // reviews distinctly so the agent knows the outcome at a glance. let review_state = payload .as_ref() .and_then(|c| c["state"].as_str()) .and_then(review_state_label); let url = if comment_html_url.is_empty() { meta.html_url } else { comment_html_url }; let author = if actor_login.is_empty() { "?" } else { actor_login }; let NotifMeta { title, notif_type, num, repo, meta_suffix, .. } = meta; // Truncate → mention-overflow → escape, in that order. See // `docs/forge.md::Body excerpt + truncation + heading escape` for // why truncate comes before escape (mention diff compares against // unescaped raw body). let raw_excerpt = truncate(body_text, BODY_TRUNCATE); let truncated_mentions = if body_text.len() > BODY_TRUNCATE { render_truncated_mentions(&extract_truncated_mention_lines(body_text, &raw_excerpt)) } else { String::new() }; let body_for_embed = escape_md_headings(&raw_excerpt); if let Some(review_label) = review_state { // Review submission on a PR. let kind = format!("PR {review_label}{num}{repo}"); let mut out = format!("[{kind}] {title}\nurl: {url}"); if body_text.is_empty() { write!(out, "\n\nreviewer: {author}").ok(); } else { 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}{truncated_mentions}" ); if out.ends_with('\n') { out.pop(); } out.push_str(meta_suffix); Some(out) } } /// Format a notification triggered by creation or state change of the subject. fn format_state_change_notification( notif: &serde_json::Value, meta: &NotifMeta<'_>, own_login: &str, ) -> Option { // Classification uses notif["subject"]["state"] directly — Forgejo // returns "open" / "closed" / "merged" here. We do NOT rely on // fetching the PR/issue detail for `merged`: // - `subject.url` points to the *issues* endpoint, which returns // `pull_request.merged`, not top-level `merged`. // - Forgejo API type is "Pull" / "Issue", never "Pull Request". let notif_state = notif["subject"]["state"].as_str().unwrap_or(""); // Self-notification filter: drop new items we authored ourselves // (`reason == "author"` + open state). State transitions on our // own PRs (merge / close) come from someone else, so those stay. let is_new = notif_state == "open" || notif_state.is_empty(); if is_new && meta.reason == "author" && !own_login.is_empty() { debug!(%own_login, "forge_notify: skipping self-authored new item"); return None; } let NotifMeta { title, notif_type, html_url, num, repo, meta_suffix, reason: _, subject, is_pr, } = meta; let label = notif_type_label(notif_type); let kind = match notif_state { "merged" => format!("{label} merged{num}{repo}"), "closed" => format!("{label} closed{num}{repo}"), "open" | "" => format!("new {label}{num}{repo}"), other => format!("{label}{num}{repo}: {other}"), }; // Review-request override: Forgejo doesn't reliably set // `reason == "review_requested"` (often null), so we check the // subject payload's `requested_reviewers` list directly. See // `docs/forge.md::Review-request override`. let is_review_request = is_new && *is_pr && !own_login.is_empty() && subject .as_ref() .and_then(|s| s["requested_reviewers"].as_array()) .is_some_and(|arr| arr.iter().any(|r| r["login"].as_str() == Some(own_login))); let kind = if is_review_request { format!("review requested{num}{repo}") } else { kind }; // Include the start of the issue/PR description so the agent // gets context without a follow-up fetch. Same truncate → // mention-overflow → escape pipeline as comment bodies (see // `docs/forge.md::Body excerpt + truncation + heading escape`). let body_block = subject .as_ref() .and_then(|s| s["body"].as_str()) .map(str::trim) .filter(|s| !s.is_empty()) .map(|raw| { let raw_excerpt = truncate(raw, BODY_TRUNCATE); let truncated = extract_truncated_mention_lines(raw, &raw_excerpt); let mentions = render_truncated_mentions(&truncated); let excerpt = escape_md_headings(&raw_excerpt); 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) } #[allow(clippy::too_many_arguments)] #[allow( clippy::too_many_lines, reason = "single-pass notification poll loop — split would obscure the \ sequential 'fetch / classify / dispatch' rhythm and add helper \ functions for state shared across all three phases" )] async fn poll_once( client: &reqwest::Client, forge_url: &str, token: &str, socket: &Path, keep_subscriptions: bool, unsubbed_repos: &mut HashSet, own_login: &str, skip_reasons: &[String], ) { let url = format!("{forge_url}/api/v1/notifications?all=false&limit=50"); let resp = match client .get(&url) .header("Authorization", format!("token {token}")) .send() .await { Ok(r) => r, Err(e) => { debug!("forge_notify: poll request failed: {e}"); return; } }; if !resp.status().is_success() { debug!("forge_notify: poll status {}", resp.status()); return; } let notifications: Vec = match resp.json().await { Ok(v) => v, Err(e) => { warn!("forge_notify: response parse error: {e}"); return; } }; if notifications.is_empty() { return; } debug!( count = notifications.len(), "forge_notify: delivering notifications" ); for notif in ¬ifications { let Some(id) = notif["id"].as_u64() else { continue; }; // Reason drop-list: suppress noisy reasons; null/unknown pass // through so directed signals stay deliverable (see // `docs/forge.md::Reason drop-list`). if !skip_reasons.is_empty() { let reason = notif["reason"].as_str().unwrap_or(""); if !reason.is_empty() && skip_reasons.iter().any(|r| r == reason) { debug!(%id, %reason, "forge_notify: skipping (reason in drop-list)"); mark_read(client, forge_url, token, id).await; continue; } } let body_opt = format_notification(client, token, notif, own_login).await; // None means self-echo — mark read silently, no delivery. let Some(body) = body_opt else { mark_read(client, forge_url, token, id).await; continue; }; let req = hive_sh4re::Request::Wake { from: "forge".to_owned(), body, transient: false, }; let delivered = crate::client::request::<_, hive_sh4re::Response>(socket, &req) .await .map(|_| ()); match delivered { Ok(()) => { debug!(%id, "forge_notify: delivered"); } Err(e) => { warn!(%id, error = ?e, "forge_notify: deliver failed — leaving unread"); continue; } } // Mark as read only after successful delivery so a failed-delivery // notification resurfaces on the next poll tick. mark_read(client, forge_url, token, id).await; // Auto-unsubscribe from broad repo watches after delivering a // `subscribed` notification. Gated by HIVE_FORGE_KEEP_SUBSCRIPTIONS // for triage / firehose agents (see // `docs/forge.md::Auto-unsubscribe on broad watches`). let reason = notif["reason"].as_str().unwrap_or(""); if !keep_subscriptions && reason == "subscribed" && let Some(repo) = notif["repository"]["full_name"].as_str() && !unsubbed_repos.contains(repo) { let unsub_url = format!("{forge_url}/api/v1/repos/{repo}/subscription"); match client .delete(&unsub_url) .header("Authorization", format!("token {token}")) .send() .await { Ok(r) if r.status().is_success() || r.status().as_u16() == 404 => { debug!(%repo, "forge_notify: unsubscribed from repo watch"); unsubbed_repos.insert(repo.to_owned()); } Ok(r) => { debug!(%repo, status = %r.status(), "forge_notify: unsub non-2xx (ignored)"); } Err(e) => { debug!(%repo, error = ?e, "forge_notify: unsub request failed (ignored)"); } } } } } /// Mark a notification thread as read. Best-effort — logs on failure but /// does not abort the poll loop. A notification left unread will resurface /// on the next poll tick (desirable for delivery failures; for self-echo /// silencing we call this without prior delivery). async fn mark_read(client: &reqwest::Client, forge_url: &str, token: &str, id: u64) { let mark_url = format!("{forge_url}/api/v1/notifications/threads/{id}"); match client .patch(&mark_url) .header("Authorization", format!("token {token}")) .send() .await { Err(e) => { warn!(%id, error = ?e, "forge_notify: mark-read request failed — notification will resurface"); } Ok(r) if !r.status().is_success() => { warn!(%id, status = %r.status(), "forge_notify: mark-read returned non-2xx — notification will resurface"); } Ok(_) => { debug!(%id, "forge_notify: marked read"); } } } #[cfg(test)] mod tests { use super::*; #[test] fn escape_md_headings_escapes_top_level_atx() { // Argus reviews start with `## argus review`, which would // otherwise become an h2 inside 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\n"); } #[test] fn escape_md_headings_skips_non_atx_hash_lines() { // ATX requires a space after the `#`s. Lines like `#tag`, // `#123`, `#!/bin/bash` are NOT headings — escaping them // would just add cosmetic noise where the renderer // wouldn't promote the line in the first place. let body = "#tag\n#123\n#!/bin/bash\n####### too many hashes\nbody"; let escaped = escape_md_headings(body); // All four leading `#` lines pass through untouched: too few // (still need space), seven `#`s (over the cap), shebang // (no space). assert_eq!(escaped, body); } #[test] fn escape_md_headings_handles_bare_hash_lines() { // `#` alone on a line IS a valid ATX (h1 with empty text) per // CommonMark; escape it to match the renderer's behaviour. assert_eq!(escape_md_headings("#"), "\\#"); assert_eq!(escape_md_headings("##"), "\\##"); assert_eq!(escape_md_headings("###"), "\\###"); } #[test] fn escape_md_headings_preserves_trailing_newline() { // `split_inclusive('\n')` round-trips a body ending in a // newline. Important for embedded forge-notify bodies whose // source already terminates with `\n` — the wrapper's spacing // otherwise gets eaten. assert_eq!(escape_md_headings("## h\n"), "\\## h\n"); 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 extract_truncated_does_not_resurface_heading_mention_inside_window() { // Regression for the truncate-before-escape ordering rule: // if the diff used the escaped excerpt, a body line // `# @argus check this` would survive as-is in the body but // become `\# @argus check this` in the excerpt, so the // `contains` check would fail and the mention would re-surface // as if it fell outside the window. Pass the unescaped excerpt // (which the caller does) and the duplicate disappears. let full = "# @argus check this\nmore body\n"; let raw_excerpt = full; // fits entirely let lines = extract_truncated_mention_lines(full, raw_excerpt); assert!( lines.is_empty(), "heading+mention inside window must not be re-surfaced, got {lines:?}" ); } #[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", ); } }