fix: enrich forge notifications with subject body and comment content

This commit is contained in:
damocles 2026-05-20 19:26:04 +02:00 committed by Mara
parent 1b7d058d3c
commit de2179540a

View file

@ -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<serde_json::Value> {
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(),