diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 5f76608c..2a97547a 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -105,6 +105,18 @@ async fn fetch_json( resp.json().await.ok() } +/// Map a Forgejo notification `subject.type` to a human-readable label. +/// Known values: "Pull", "Issue", "Commit", "Repository". Any unknown +/// type is passed through as-is so new Forgejo types degrade gracefully +/// rather than silently collapsing into a generic label. +fn notif_type_label(t: &str) -> &str { + match t { + "Pull" => "PR", + "Issue" => "issue", + other => other, + } +} + /// Truncate a string to `max` bytes at a char boundary, appending `…` if cut. fn truncate(s: &str, max: usize) -> String { if s.len() <= max { @@ -158,11 +170,7 @@ async fn format_notification( .unwrap_or("") .trim(); - let kind = match notif_type { - "Pull Request" => "comment on PR", - "Issue" => "comment on issue", - _ => "comment", - }; + 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 }, @@ -174,40 +182,41 @@ async fn format_notification( 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); + // + // Classification uses notif["subject"]["state"] directly — Forgejo + // returns "open" / "closed" / "merged" here. We do NOT rely on + // fetching the PR/issue detail for `merged`: + // - `subject.url` points to the *issues* endpoint, which returns + // `pull_request.merged`, not top-level `merged`. + // - Forgejo API type is "Pull" / "Issue", never "Pull Request". + let notif_state = notif["subject"]["state"].as_str().unwrap_or(""); - 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 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}"), }; + // 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 !body.is_empty() && !state.contains("closed") && !merged { - out.push_str(&format!( - "\n\n{author}: {}", - truncate(body, BODY_TRUNCATE) - )); + if is_open { + 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(); + if !body.is_empty() { + out.push_str(&format!("\n\n{author}: {}", truncate(body, BODY_TRUNCATE))); + } } out }