From de2179540a9db3ea17eb2c8c86887ffd26448046 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 20 May 2026 19:26:04 +0200 Subject: [PATCH] fix: enrich forge notifications with subject body and comment content --- hive-ag3nt/src/forge_notify.rs | 143 ++++++++++++++++++++++++++++++--- 1 file changed, 133 insertions(+), 10 deletions(-) diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 24d9ca6..cb56e0a 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -5,6 +5,9 @@ //! unread notification as a broker `Wake { from: "forge" }` message so //! claude's normal turn loop picks it up. //! +//! Each notification is enriched with the subject body and/or latest +//! comment body so the agent sees actual content, not just a title. +//! //! Graceful no-ops: //! - `HIVE_FORGE_URL` not set → disabled (no forge configured) //! - token file absent → disabled (agent has no forge account yet) @@ -21,6 +24,8 @@ 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; /// Spawn point: called once from `hive-ag3nt serve`. Returns immediately if /// the forge is not configured for this agent. Otherwise loops forever, @@ -76,6 +81,133 @@ pub async fn run(socket: PathBuf) { } } +/// 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() +} + +/// Truncate a string to `max` bytes at a char boundary, appending `…` if cut. +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]) +} + +/// Build a human-readable wake message for one Forgejo notification. +/// Fetches the subject and (if present) the latest comment to include +/// author and body. Falls back gracefully when API calls fail. +async fn format_notification( + client: &reqwest::Client, + token: &str, + notif: &serde_json::Value, +) -> String { + 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("")); + let repo = notif["repository"]["full_name"].as_str().unwrap_or("?"); + + // 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(""); + + // Determine whether this notification was triggered by a comment or by + // creation/state-change of the subject itself. + let has_comment = !comment_api_url.is_empty() && comment_api_url != subject_api_url; + + if has_comment { + // Notification triggered by a new comment. + let comment = fetch_json(client, comment_api_url, token).await; + let author = comment + .as_ref() + .and_then(|c| c["user"]["login"].as_str()) + .unwrap_or("?"); + let body = comment + .as_ref() + .and_then(|c| c["body"].as_str()) + .unwrap_or("") + .trim(); + + let kind = match notif_type { + "Pull Request" => "comment on PR", + "Issue" => "comment on issue", + _ => "comment", + }; + let mut out = format!( + "[{kind}] {title}\nrepo: {repo}\nurl: {}\n\n{author}: {}\n", + if comment_html_url.is_empty() { html_url } else { comment_html_url }, + truncate(body, BODY_TRUNCATE), + ); + if out.ends_with('\n') { + out.pop(); + } + out + } else { + // Notification triggered by creation or state change of the subject. + let subject = fetch_json(client, subject_api_url, token).await; + let author = subject + .as_ref() + .and_then(|s| s["user"]["login"].as_str()) + .unwrap_or("?"); + let body = subject + .as_ref() + .and_then(|s| s["body"].as_str()) + .unwrap_or("") + .trim(); + let state = subject + .as_ref() + .and_then(|s| s["state"].as_str()) + .unwrap_or(""); + let merged = subject + .as_ref() + .and_then(|s| s["merged"].as_bool()) + .unwrap_or(false); + + let kind = match (notif_type, state, merged) { + ("Pull Request", "closed", true) => "PR merged".to_owned(), + ("Pull Request", "closed", false) => "PR closed".to_owned(), + ("Pull Request", _, _) => "new PR".to_owned(), + ("Issue", "closed", _) => "issue closed".to_owned(), + ("Issue", _, _) => "new issue".to_owned(), + _ => format!("new {notif_type}"), + }; + + let mut out = format!("[{kind}] {title}\nrepo: {repo}\nurl: {html_url}"); + if !body.is_empty() && !state.contains("closed") && !merged { + out.push_str(&format!( + "\n\n{author}: {}", + truncate(body, BODY_TRUNCATE) + )); + } + out + } +} + async fn poll_once(client: &reqwest::Client, forge_url: &str, token: &str, socket: &Path) { let url = format!("{forge_url}/api/v1/notifications?all=false&limit=50"); let resp = match client @@ -115,17 +247,8 @@ async fn poll_once(client: &reqwest::Client, forge_url: &str, token: &str, socke Some(n) => n, None => continue, }; - 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() - .or_else(|| notif["subject"]["url"].as_str()) - .unwrap_or(""); - let repo = notif["repository"]["full_name"].as_str().unwrap_or("?"); - let body = format!( - "forge notification: [{notif_type}] {title}\nrepo: {repo}\nurl: {html_url}" - ); + let body = format_notification(client, token, notif).await; let req = hive_sh4re::AgentRequest::Wake { from: "forge".to_owned(),