From 04549a7df5d18b0e3c1a97b7ca2b96104b04bf60 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Mon, 19 Oct 2026 19:40:44 +0200 Subject: [PATCH] fix(claw): dispatch reason-less forge notifications to the thread's agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea's notifications feed names no per-row reason, so the adapter collapses every row under one fixed NotificationReason::Other spelling — and the claw's triage mapped every Other to Surface: the notification reached the device queue and never a dispatch. A comment on a thread the account authored could therefore never wake the agent on a reason-less forge, whatever the watches said. The adapter now publishes that spelling as gitea::COLLAPSED_REASON — it is the seam's contract, not a detail — and the triage treats it as the known shape it is: a notification on a thread of the account's own dispatches the implementer. A spelling no adapter owns still surfaces; an unknown shape remains the user's to judge, never the agent's to guess at. --- crates/rhema-claw/src/triage.rs | 31 +++++++++++++++++++ .../rhema-forge-integration/src/gitea.rs | 14 +++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/rhema-claw/src/triage.rs b/crates/rhema-claw/src/triage.rs index 38ba619..f907625 100644 --- a/crates/rhema-claw/src/triage.rs +++ b/crates/rhema-claw/src/triage.rs @@ -16,6 +16,7 @@ //! forges' vocabularies are external data, and the policy must not drift //! with them. +use rhema_forge_integration::gitea::COLLAPSED_REASON; use rhema_forge_integration::{Notification, NotificationReason}; /// The agent persona a dispatched notification runs as. The key stays @@ -56,6 +57,14 @@ pub enum Triage { /// Classifies one notification. A forge reason the vocabulary does not /// know ([`NotificationReason::Other`]) surfaces rather than acts: an /// unknown shape is the user's to judge, never the agent's to guess at. +/// The one exception is a forge whose feed names no per-row reason at +/// all (Gitea's): its adapter collapses every row under one fixed, +/// published spelling, so that spelling is a known shape — the account +/// was notified on a thread of its own, and the thread's agent acts. +/// The row cannot say *why* the account was notified (a comment, an +/// assignment, and a review request ride the same shape), so the +/// default is the do-the-work role; a reviewer framing can follow in +/// the thread's session once the text of the activity says so. pub fn classify(notification: &Notification) -> Triage { match ¬ification.reason { NotificationReason::ReviewRequested => Triage::Agent(Role::Reviewer), @@ -64,6 +73,9 @@ pub fn classify(notification: &Notification) -> Triage { NotificationReason::Author => Triage::Agent(Role::Implementer), NotificationReason::Mention => Triage::Surface, NotificationReason::CiActivity => Triage::Ignore, + NotificationReason::Other(kind) if kind == COLLAPSED_REASON => { + Triage::Agent(Role::Implementer) + } NotificationReason::Other(_) => Triage::Surface, } } @@ -127,6 +139,25 @@ mod tests { ); } + #[test] + fn the_collapsed_reasonless_forge_dispatches_other_spellings_do_not() { + // Gitea's adapter collapses every notifications row under one + // fixed spelling; that spelling is the known shape the triage + // acts on — this is the path a comment on a thread the account + // authored takes on a reason-less forge. + assert_eq!( + classify(¬ification(NotificationReason::Other("gitea".to_owned()))), + Triage::Agent(Role::Implementer) + ); + // A spelling no adapter owns is still the user's to judge. + assert_eq!( + classify(¬ification(NotificationReason::Other( + "something-new".to_owned() + ))), + Triage::Surface + ); + } + #[test] fn roles_round_trip_through_serde() { for role in [Role::Reviewer, Role::Implementer] { diff --git a/integrations/rhema-forge-integration/src/gitea.rs b/integrations/rhema-forge-integration/src/gitea.rs index 84e8012..635bea1 100644 --- a/integrations/rhema-forge-integration/src/gitea.rs +++ b/integrations/rhema-forge-integration/src/gitea.rs @@ -455,10 +455,18 @@ fn parent_number(url: &str) -> Option { url.trim_end_matches('/').rsplit('/').next()?.parse().ok() } +/// The fixed [`NotificationReason::Other`] spelling every reason-less +/// notifications row maps to (see [`notification_event`]). The +/// consumer's triage matches this spelling to act on the row — +/// an `Other` it does not know only surfaces — so the constant is +/// the seam's contract, not a detail of this module. +pub const COLLAPSED_REASON: &str = "gitea"; + /// The notification a notifications row carries. Gitea's feed names no /// per-row reason — the thread subject is all a row carries — so every -/// notification maps to [`NotificationReason::Other`] with a fixed -/// spelling, and the consumer's triage treats it as its default role. +/// notification maps to [`NotificationReason::Other`] under +/// [`COLLAPSED_REASON`], and the consumer's triage acts on that +/// spelling as its default role. fn notification_event(row: &WireNotification) -> Result, ForgeError> { let id = required(row.id, "notification id")?; let updated = required(row.updated_at.clone(), "notification updated_at")?; @@ -478,7 +486,7 @@ fn notification_event(row: &WireNotification) -> Result, Fo })?; Ok(Some(Notification { id: format!("gitea:notification:{id}@{}", updated.raw()), - reason: NotificationReason::Other("gitea".to_owned()), + reason: NotificationReason::Other(COLLAPSED_REASON.to_owned()), repo: RepoRef::new(owner, name), number, is_pr: subject.kind.as_deref() == Some("Pull"),