From 845fafdf1bc6fe3afd193c0e36fa9013fb6c1462 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 22 May 2026 16:44:47 +0200 Subject: [PATCH] forge_notify: show assignees and reviewers unconditionally (closes #256) --- hive-ag3nt/src/forge_notify.rs | 81 +++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 6c179df..eb2382a 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -199,10 +199,13 @@ fn review_state_label(state: &str) -> Option<&str> { /// and should be silently discarded (and marked read by the caller). /// /// Formats: -/// - Comment: `[comment on PR #N repo] title\nurl: ...\n\nauthor: body` -/// - Review: `[PR approved #N repo] title\nurl: ...\n\nreviewer: body` +/// - Comment: `[comment on PR #N repo] title\nurl: ...\n\nauthor: body\nassignee: user` +/// - Review: `[PR approved #N repo] title\nurl: ...\n\nreviewer: body\nassignee: user` /// - New item: `[new issue #N repo] title\nurl: ...\nassignee: user` -/// - State: `[PR merged #N repo] title\nurl: ...` +/// - State: `[PR merged #N repo] title\nurl: ...\nassignee: user` +/// +/// Assignees (and, for PRs, requested_reviewers) are appended unconditionally +/// on all issue/PR notifications (closes #256). /// /// Number is extracted from `html_url` last path segment before any `#`. /// Repo slug (`owner/name`) is always included — agents may watch multiple repos. @@ -241,6 +244,49 @@ async fn format_notification( .as_str() .unwrap_or(""); + // Always fetch subject detail for assignee/reviewer metadata (#256). + // Keeps agents informed of current ownership without a follow-up fetch. + let subject = if !subject_api_url.is_empty() { + fetch_json(client, subject_api_url, token).await + } else { + None + }; + + let is_pr = matches!(notif_type, "Pull Request" | "Pull"); + + // Build assignee + reviewer suffix appended to all notification kinds. + let meta_suffix = { + let assignees: Vec<&str> = subject + .as_ref() + .and_then(|s| s["assignees"].as_array()) + .map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect()) + .unwrap_or_default(); + let assignee_line = if assignees.is_empty() { + "assignee: unassigned".to_owned() + } else { + format!("assignee: {}", assignees.join(", ")) + }; + // For PRs, include requested_reviewers when present. + let reviewer_line = if is_pr { + let reviewers: Vec<&str> = subject + .as_ref() + .and_then(|s| s["requested_reviewers"].as_array()) + .map(|arr| arr.iter().filter_map(|r| r["login"].as_str()).collect()) + .unwrap_or_default(); + if reviewers.is_empty() { + None + } else { + Some(format!("reviewer: {}", reviewers.join(", "))) + } + } else { + None + }; + match reviewer_line { + Some(r) => format!("\n{assignee_line}\n{r}"), + None => format!("\n{assignee_line}"), + } + }; + // Determine whether this notification was triggered by a comment/review or // by creation/state-change of the subject itself. let has_comment = !comment_api_url.is_empty() && comment_api_url != subject_api_url; @@ -287,14 +333,19 @@ async fn format_notification( } else { out.push_str(&format!("\n\nreviewer: {author}")); } + out.push_str(&meta_suffix); Some(out) } else { // Regular comment. let kind = format!("comment on {}{num}{repo}", notif_type_label(notif_type)); - let mut out = format!("[{kind}] {title}\nurl: {url}\n\n{author}: {}", truncate(body_text, BODY_TRUNCATE)); + let mut out = format!( + "[{kind}] {title}\nurl: {url}\n\n{author}: {}", + truncate(body_text, BODY_TRUNCATE) + ); if out.ends_with('\n') { out.pop(); } + out.push_str(&meta_suffix); Some(out) } } else { @@ -328,27 +379,7 @@ async fn format_notification( }; 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. - if is_new { - let subject = fetch_json(client, subject_api_url, token).await; - let assignees: Vec<&str> = subject - .as_ref() - .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.push_str(&meta_suffix); Some(out) } }