From 20ff89196210f9ed819006d9312559ae74bde6de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 5 Jul 2026 22:32:00 +0200 Subject: [PATCH] refactor(forge_notify): share render_body_excerpt across formatters; fix nits + docs --- docs/forge.md | 6 ++- hive-ag3nt/src/forge_notify.rs | 77 +++++++++++++++++++--------------- 2 files changed, 48 insertions(+), 35 deletions(-) diff --git a/docs/forge.md b/docs/forge.md index 0ea3f431..093b5860 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -213,7 +213,7 @@ Five shapes, distinguished by the notification's classification: | Trigger | Wrapper | | ----------------------------------- | --------------------------------------------------------------------------------- | | Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` | -| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...` | +| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` | | New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...` | | Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...` | | State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` | @@ -224,6 +224,10 @@ Review labels come from the Forgejo `state` field: `APPROVED` → submitted yet — no peer-visible event). Unknown states fall back to the generic comment wrapper. +A review submitted with **no body** renders `reviewed by: ` in +place of the `: ` line — deliberately worded to not collide +with the meta-suffix `reviewer:` line (requested reviewers, below). + ### "new" vs "activity on" A review submitted with **no body** carries no `latest_comment_url`, diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 855c3f87..99a1f225 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -6,8 +6,8 @@ //! off forge's own unread-state, and the agent reading the thread via the //! CLI is what marks it read. A delivery-dedupe cursor (thread id → //! last-delivered `updated_at`) stops the still-unread notification from -//! re-firing a wake every poll; self-echo and drop-listed notifications -//! are still marked read directly. The cursor is persisted as the +//! re-firing a wake every poll; self-echo notifications (the agent's own +//! writes) are still marked read directly. The cursor is persisted as the //! `forge_cursor` field of the harness's consolidated `hyperhive-harness.json` //! (via [`crate::events`]) and reloaded on boot so a container //! rebuild/restart doesn't re-deliver the whole currently-unread backlog — @@ -247,7 +247,7 @@ fn truncate(s: &str, max: usize) -> String { let end = s .char_indices() .map(|(i, _)| i) - .take_while(|&i| i <= max - 3) + .take_while(|&i| i <= max.saturating_sub(3)) .last() .unwrap_or(0); format!("{}…", &s[..end]) @@ -314,6 +314,20 @@ fn render_truncated_mentions(lines: &[&str]) -> String { out } +/// The escaped body excerpt + overflowed-mention suffix for one issue / PR / +/// comment body, computed in the one required order: truncate → diff the +/// overflowed `@mention` lines against the *unescaped* excerpt → heading-escape +/// the excerpt. Both notification formatters build their body block from this +/// pair so the ordering contract lives in exactly one place. Returns +/// `(escaped_excerpt, mentions_suffix)`; `mentions_suffix` is empty when the +/// body fit (nothing was truncated away). +fn render_body_excerpt(raw: &str) -> (String, String) { + let raw_excerpt = truncate(raw, BODY_TRUNCATE); + let mentions = render_truncated_mentions(&extract_truncated_mention_lines(raw, &raw_excerpt)); + let excerpt = escape_md_headings(&raw_excerpt); + (excerpt, mentions) +} + /// 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). @@ -378,7 +392,9 @@ async fn format_notification( fetch_json(client, subject_api_url, token).await }; - let is_pr = matches!(notif_type, "Pull Request" | "Pull"); + // Forgejo's notification `subject.type` is "Pull" / "Issue" (never + // "Pull Request") — see the comment in `build_meta_suffix` below. + let is_pr = notif_type == "Pull"; let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr); // Determine whether this notification was triggered by a comment/review or @@ -513,23 +529,17 @@ async fn format_comment_notification( .. } = 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); + // Truncate → mention-overflow → escape (see `render_body_excerpt`). + let (body_for_embed, truncated_mentions) = render_body_excerpt(body_text); 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(); + // Bodiless review: name who reviewed. `reviewed by:` (not + // `reviewer:`) to avoid colliding with the `reviewer:` line the + // meta suffix carries for a PR's *requested* reviewers. + write!(out, "\n\nreviewed by: {author}").ok(); } else { write!(out, "\n\n{author}: {body_for_embed}{truncated_mentions}").ok(); } @@ -541,9 +551,6 @@ async fn format_comment_notification( 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) } @@ -633,20 +640,15 @@ fn format_state_change_notification( 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`). + // Include the start of the issue/PR description so the agent gets context + // without a follow-up fetch. Same body pipeline as comment bodies. 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); + let (excerpt, mentions) = render_body_excerpt(raw); format!("\n\n{excerpt}{mentions}") }) .unwrap_or_default(); @@ -712,8 +714,15 @@ fn parse_rfc3339_secs(s: &str) -> Option { if !(rest.is_empty() || rest.starts_with('Z')) { let sign = rest.as_bytes()[0]; let off = &rest[1..]; - let oh: i64 = off.get(0..2)?.parse().ok()?; - let om: i64 = off.get(3..5).unwrap_or("00").parse().ok()?; + // Accept `HH:MM` (Forgejo's form) and bare `HHMM`; minutes optional. + // Both fields fail the same way — a present-but-unparseable component + // returns `None` rather than one silently defaulting. + let (hh, mm) = match off.split_once(':') { + Some((h, m)) => (h, m), + None => (off.get(0..2)?, off.get(2..4).unwrap_or("00")), + }; + let oh: i64 = hh.parse().ok()?; + let om: i64 = if mm.is_empty() { 0 } else { mm.parse().ok()? }; let offset = oh * 3_600 + om * 60; match sign { b'+' => epoch -= offset, @@ -885,11 +894,11 @@ fn should_deliver(delivered: &HashMap, id: u64, updated_at: &str) - } /// Mark a notification thread as read. Best-effort — logs on failure but -/// does not abort the poll loop. Called only on the self-echo and -/// drop-listed paths (the agent's own writes / explicitly-suppressed -/// reasons) — delivered threads are deliberately left unread for the -/// read-before-comment guard, and a failed delivery is left unread + out -/// of the dedupe cursor so it resurfaces on the next poll tick. +/// does not abort the poll loop. Called only on the self-echo path (the +/// agent's own comment/review/creation writes) — delivered threads are +/// deliberately left unread for the read-before-comment guard, and a failed +/// delivery is left unread + out of the dedupe cursor so it resurfaces on the +/// next poll tick. 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