fix(claw): dispatch reason-less forge notifications to the thread's agent
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.
This commit is contained in:
1 parent
81da26b955
commit
04549a7df5
2 files changed
+42
-3
No files matched your search
@@ -16,6 +16,7 @@
|
|||||||
//! forges' vocabularies are external data, and the policy must not drift
|
//! forges' vocabularies are external data, and the policy must not drift
|
||||||
//! with them.
|
//! with them.
|
||||||
|
|
||||||
|
use rhema_forge_integration::gitea::COLLAPSED_REASON;
|
||||||
use rhema_forge_integration::{Notification, NotificationReason};
|
use rhema_forge_integration::{Notification, NotificationReason};
|
||||||
|
|
||||||
/// The agent persona a dispatched notification runs as. The key stays
|
/// 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
|
/// Classifies one notification. A forge reason the vocabulary does not
|
||||||
/// know ([`NotificationReason::Other`]) surfaces rather than acts: an
|
/// know ([`NotificationReason::Other`]) surfaces rather than acts: an
|
||||||
/// unknown shape is the user's to judge, never the agent's to guess at.
|
/// 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 {
|
pub fn classify(notification: &Notification) -> Triage {
|
||||||
match ¬ification.reason {
|
match ¬ification.reason {
|
||||||
NotificationReason::ReviewRequested => Triage::Agent(Role::Reviewer),
|
NotificationReason::ReviewRequested => Triage::Agent(Role::Reviewer),
|
||||||
@@ -64,6 +73,9 @@ pub fn classify(notification: &Notification) -> Triage {
|
|||||||
NotificationReason::Author => Triage::Agent(Role::Implementer),
|
NotificationReason::Author => Triage::Agent(Role::Implementer),
|
||||||
NotificationReason::Mention => Triage::Surface,
|
NotificationReason::Mention => Triage::Surface,
|
||||||
NotificationReason::CiActivity => Triage::Ignore,
|
NotificationReason::CiActivity => Triage::Ignore,
|
||||||
|
NotificationReason::Other(kind) if kind == COLLAPSED_REASON => {
|
||||||
|
Triage::Agent(Role::Implementer)
|
||||||
|
}
|
||||||
NotificationReason::Other(_) => Triage::Surface,
|
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]
|
#[test]
|
||||||
fn roles_round_trip_through_serde() {
|
fn roles_round_trip_through_serde() {
|
||||||
for role in [Role::Reviewer, Role::Implementer] {
|
for role in [Role::Reviewer, Role::Implementer] {
|
||||||
|
|||||||
@@ -455,10 +455,18 @@ fn parent_number(url: &str) -> Option<u64> {
|
|||||||
url.trim_end_matches('/').rsplit('/').next()?.parse().ok()
|
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
|
/// The notification a notifications row carries. Gitea's feed names no
|
||||||
/// per-row reason — the thread subject is all a row carries — so every
|
/// per-row reason — the thread subject is all a row carries — so every
|
||||||
/// notification maps to [`NotificationReason::Other`] with a fixed
|
/// notification maps to [`NotificationReason::Other`] under
|
||||||
/// spelling, and the consumer's triage treats it as its default role.
|
/// [`COLLAPSED_REASON`], and the consumer's triage acts on that
|
||||||
|
/// spelling as its default role.
|
||||||
fn notification_event(row: &WireNotification) -> Result<Option<Notification>, ForgeError> {
|
fn notification_event(row: &WireNotification) -> Result<Option<Notification>, ForgeError> {
|
||||||
let id = required(row.id, "notification id")?;
|
let id = required(row.id, "notification id")?;
|
||||||
let updated = required(row.updated_at.clone(), "notification updated_at")?;
|
let updated = required(row.updated_at.clone(), "notification updated_at")?;
|
||||||
@@ -478,7 +486,7 @@ fn notification_event(row: &WireNotification) -> Result<Option<Notification>, Fo
|
|||||||
})?;
|
})?;
|
||||||
Ok(Some(Notification {
|
Ok(Some(Notification {
|
||||||
id: format!("gitea:notification:{id}@{}", updated.raw()),
|
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),
|
repo: RepoRef::new(owner, name),
|
||||||
number,
|
number,
|
||||||
is_pr: subject.kind.as_deref() == Some("Pull"),
|
is_pr: subject.kind.as_deref() == Some("Pull"),
|
||||||
|
|||||||
Reference in new issue
Block a user