From 20bb343f27337a46a39d4e137311e663faaddd3c Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 22 May 2026 00:40:51 +0200 Subject: [PATCH] forge_notify: add issue/PR number to header, drop repo: line, show assignee on new items (closes #225) --- hive-ag3nt/src/forge_notify.rs | 68 +++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index d36e1bc..4c04cc0 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -154,6 +154,12 @@ fn truncate(s: &str, max: usize) -> String { /// 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. +/// +/// Format: `[kind #N] title\nurl: \n\nauthor: body` (comments) +/// `[kind #N] title\nurl: \nassignee: ` (new items) +/// +/// Number is extracted from `html_url` (last path segment before any `#`). +/// `repo:` line is omitted — single-repo deployments don't benefit from the noise. async fn format_notification( client: &reqwest::Client, token: &str, @@ -164,7 +170,16 @@ async fn format_notification( 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("?"); + + // 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(); // API URLs for fetching content let subject_api_url = notif["subject"]["url"].as_str().unwrap_or(""); @@ -190,12 +205,9 @@ async fn format_notification( .unwrap_or("") .trim(); - let kind = format!("comment on {}", notif_type_label(notif_type)); - 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), - ); + let kind = format!("comment on {}{num}", notif_type_label(notif_type)); + let url = if comment_html_url.is_empty() { html_url } else { comment_html_url }; + let mut out = format!("[{kind}] {title}\nurl: {url}\n\n{author}: {}", truncate(body, BODY_TRUNCATE)); if out.ends_with('\n') { out.pop(); } @@ -213,29 +225,33 @@ async fn format_notification( let label = notif_type_label(notif_type); let kind = match notif_state { - "merged" => format!("{label} merged"), - "closed" => format!("{label} closed"), - "open" | "" => format!("new {label}"), - other => format!("{label}: {other}"), + "merged" => format!("{label} merged{num}"), + "closed" => format!("{label} closed{num}"), + "open" | "" => format!("new {label}{num}"), + other => format!("{label}{num}: {other}"), }; - // Fetch subject only for body/author on new (open) items — not - // worth an extra HTTP round-trip for already-closed/merged ones. - let is_open = notif_state == "open" || notif_state.is_empty(); - let mut out = format!("[{kind}] {title}\nrepo: {repo}\nurl: {html_url}"); - if is_open { + let mut out = format!("[{kind}] {title}\nurl: {html_url}"); + + // For new (open) items: fetch assignee(s). Skip body — agents can + // run `hive-forge view ` for full content. One extra API call + // only on creation events, not state changes. + let is_new = notif_state == "open" || notif_state.is_empty(); + if is_new { let subject = fetch_json(client, subject_api_url, token).await; - let author = subject + let assignees: Vec<&str> = 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(); - if !body.is_empty() { - out.push_str(&format!("\n\n{author}: {}", truncate(body, BODY_TRUNCATE))); + .and_then(|s| s["assignees"].as_array()) + .map(|arr| { + arr.iter() + .filter_map(|a| a["login"].as_str()) + .collect() + }) + .unwrap_or_default(); + if assignees.is_empty() { + out.push_str("\nassignee: unassigned"); + } else { + out.push_str(&format!("\nassignee: {}", assignees.join(", "))); } } out