diff --git a/docs/forge.md b/docs/forge.md index 4d4cbf62..d49e9f17 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -141,10 +141,10 @@ silently (mark-read without delivery): - **Self-authored comments / reviews** — comment payload's `user.login` matches `own_login`. - -Self-authored *new items* (an agent opening its own PR/issue) are not -filtered and do surface — the notification subject carries no author -field to match against without a per-notification fetch. +- **Self-authored creations** (an agent opening its own PR/issue) — the + already-fetched subject payload's poster `user.login` matches + `own_login`. Only *creations* are dropped; a later state change on the + agent's own subject is driven by someone else and still surfaces. `own_login` is fetched once at startup via `GET /api/v1/user`. On fetch failure the filter degrades open (no filtering) rather than diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 4a8c5b26..35db12f6 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -409,7 +409,7 @@ async fn format_notification( ) .await } else { - Some(format_state_change_notification(notif, &meta, own_login)) + format_state_change_notification(notif, &meta, own_login) } } @@ -553,11 +553,16 @@ async fn format_comment_notification( } /// Format a notification triggered by creation or state change of the subject. +/// +/// Returns `None` for an agent's own *creation* (it opened the issue/PR) — +/// the same "don't loop claude on its own writes" rule the comment/review +/// path applies. Only creations are dropped: a later state change on the +/// agent's own subject is driven by someone else and stays a wake. fn format_state_change_notification( notif: &serde_json::Value, meta: &NotifMeta<'_>, own_login: &str, -) -> String { +) -> Option { // Classification uses notif["subject"]["state"] directly — Forgejo // returns "open" / "closed" / "merged" here. We do NOT rely on // fetching the PR/issue detail for `merged`: @@ -590,6 +595,22 @@ fn format_state_change_notification( // duplicate of the original open notification. When we can't confirm // creation, fall back to a neutral "activity on" label. let looks_new = notification_is_creation(notif, subject.as_ref()); + + // Self-authored creation filter: skip an agent being woken by its own + // freshly-opened issue/PR. The subject payload is already fetched (for + // assignees / reviewers / body), so its poster `user.login` costs no + // extra request. Mirrors the self-authored comment/review drop above. + if looks_new && !own_login.is_empty() { + let author = subject + .as_ref() + .and_then(|s| s["user"]["login"].as_str()) + .unwrap_or(""); + if author == own_login { + debug!(%own_login, "forge_notify: skipping self-authored creation"); + return None; + } + } + let kind = match notif_state { "merged" => format!("{label} merged{num}{repo}"), "closed" => format!("{label} closed{num}{repo}"), @@ -635,7 +656,7 @@ fn format_state_change_notification( let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}"); out.push_str(meta_suffix); - out + Some(out) } /// Decide whether a state-change notification represents the subject's @@ -1162,4 +1183,50 @@ mod tests { assert!(notification_is_creation(&empty, None)); assert!(notification_is_creation(&empty, Some(&subject))); } + + /// Build a `NotifMeta` for the state-change formatter tests. The `&str` + /// fields borrow `'static` literals so the value is self-contained. + fn state_change_meta(subject: serde_json::Value) -> NotifMeta<'static> { + NotifMeta { + title: "subject title", + notif_type: "Issue", + html_url: "http://forge/issues/1", + num: " #1".to_owned(), + repo: " [agents/x]".to_owned(), + meta_suffix: "\nassignee: unassigned".to_owned(), + subject: Some(subject), + is_pr: false, + } + } + + #[test] + fn state_change_drops_self_authored_creation() { + // No timestamps ⇒ treated as a creation; poster login == own_login. + let meta = state_change_meta(serde_json::json!({ "user": { "login": "damocles" } })); + let notif = serde_json::json!({ "subject": { "state": "open" } }); + assert!(format_state_change_notification(¬if, &meta, "damocles").is_none()); + } + + #[test] + fn state_change_keeps_other_authored_creation() { + let meta = state_change_meta(serde_json::json!({ "user": { "login": "someone-else" } })); + let notif = serde_json::json!({ "subject": { "state": "open" } }); + assert!(format_state_change_notification(¬if, &meta, "damocles").is_some()); + } + + #[test] + fn state_change_keeps_self_authored_later_activity() { + // The agent authored the subject, but this notification fired well + // after creation (someone else acted on it) ⇒ not a creation ⇒ still + // surfaces. + let meta = state_change_meta(serde_json::json!({ + "user": { "login": "damocles" }, + "created_at": "2020-01-01T00:00:00Z", + })); + let notif = serde_json::json!({ + "subject": { "state": "closed" }, + "updated_at": "2026-06-22T16:00:00Z", + }); + assert!(format_state_change_notification(¬if, &meta, "damocles").is_some()); + } }