fix: enrich forge notifications with subject body and comment content
This commit is contained in:
parent
1b7d058d3c
commit
de2179540a
1 changed files with 133 additions and 10 deletions
|
|
@ -5,6 +5,9 @@
|
||||||
//! unread notification as a broker `Wake { from: "forge" }` message so
|
//! unread notification as a broker `Wake { from: "forge" }` message so
|
||||||
//! claude's normal turn loop picks it up.
|
//! 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:
|
//! Graceful no-ops:
|
||||||
//! - `HIVE_FORGE_URL` not set → disabled (no forge configured)
|
//! - `HIVE_FORGE_URL` not set → disabled (no forge configured)
|
||||||
//! - token file absent → disabled (agent has no forge account yet)
|
//! - 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 POLL_INTERVAL_SECS: u64 = 30;
|
||||||
const HTTP_TIMEOUT_SECS: u64 = 10;
|
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
|
/// Spawn point: called once from `hive-ag3nt serve`. Returns immediately if
|
||||||
/// the forge is not configured for this agent. Otherwise loops forever,
|
/// 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) {
|
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 url = format!("{forge_url}/api/v1/notifications?all=false&limit=50");
|
||||||
let resp = match client
|
let resp = match client
|
||||||
|
|
@ -115,17 +247,8 @@ async fn poll_once(client: &reqwest::Client, forge_url: &str, token: &str, socke
|
||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
None => continue,
|
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!(
|
let body = format_notification(client, token, notif).await;
|
||||||
"forge notification: [{notif_type}] {title}\nrepo: {repo}\nurl: {html_url}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let req = hive_sh4re::AgentRequest::Wake {
|
let req = hive_sh4re::AgentRequest::Wake {
|
||||||
from: "forge".to_owned(),
|
from: "forge".to_owned(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue