hyperhive/hive-forge/src/notify.rs

199 lines
7.2 KiB
Rust

//! Read-before-comment guard backed by forge's own notification
//! read-state — no local mirror (which drifts across restarts).
//!
//! When someone comments on an issue/PR the caller participates in,
//! forge raises an unread notification for that thread; once the agent
//! actually reads the thread (`comments` / `view`) we mark it read via
//! the notifications API. So "is there unread activity here?" is just
//! "does forge still have an unread notification for this thread?" —
//! forge is the single source of truth, nothing to lose on restart.
//!
//! (This pairs with the `forge_notify` harness change that stops
//! marking notifications read on wake *delivery* — issue tracker
//! `forge cli: agents keep commenting without reading prev comments`.)
use anyhow::Result;
use forgejo_api::structs::NotifyReadThreadQuery;
use crate::client::{Client, index, split_repo};
/// Forgejo's per-page notification cap.
const PAGE_SIZE: u32 = 50;
/// How many pages of unread notifications to scan for the thread.
/// Notifications come newest-first and a thread the caller is about to
/// comment on was just active, so it sits near the top; this cap keeps
/// the guard cheap even when the unread list is large (an uncontrolled
/// firehose), at the cost of not detecting a match buried past
/// `MAX_PAGES * PAGE_SIZE` unread items (degrade-open — acceptable for
/// a courtesy guard).
const MAX_PAGES: u32 = 5;
/// The notification thread id of an UNREAD notification on
/// `<repo>#<number>`, or `None` when the thread has no unread
/// notification (nothing to read / already read). Repo-scoped so a bare
/// number can't false-match another repo. Errors only on transport /
/// non-2xx — callers degrade open on `Err`.
/// Minimal, drift-tolerant view of a Forgejo notification thread — only
/// the two fields the read-before-comment guard needs: `id` (to mark the
/// thread read) and `subject.url` (to match the thread to an issue/PR
/// number). Both `#[serde(default)]` so a schema change anywhere else in
/// the notification object can't break this deserialize. This is the
/// deliberate replacement for `forgejo-api`'s strict `NotificationThread`,
/// whose schema drift vs the running (latest-line) Forgejo was breaking
/// the whole notifications call — silently disabling the guard and
/// wedging notification read-state (so wakes kept redelivering).
#[derive(serde::Deserialize)]
struct Notification {
#[serde(default)]
id: Option<i64>,
#[serde(default)]
subject: Option<Subject>,
}
/// The one notification-subject field the guard reads: the API `url`,
/// which ends in the issue/PR number (see [`subject_matches`]).
#[derive(serde::Deserialize)]
struct Subject {
#[serde(default)]
url: Option<String>,
}
pub fn unread_thread_id(client: &Client, repo: &str, number: u64) -> Result<Option<u64>> {
let (owner, name) = split_repo(repo)?;
let limit = PAGE_SIZE.to_string();
let path = format!("/repos/{owner}/{name}/notifications");
for page in 1..=MAX_PAGES {
let page_str = page.to_string();
let threads: Vec<Notification> = client.get_api_json(
&path,
&[("all", "false"), ("page", &page_str), ("limit", &limit)],
)?;
for n in &threads {
let subject_url = n
.subject
.as_ref()
.and_then(|s| s.url.as_deref())
.unwrap_or("");
if subject_matches(subject_url, number) {
return Ok(n.id.and_then(|id| u64::try_from(id).ok()));
}
}
// Last (short) page reached — stop.
if threads.len() < PAGE_SIZE as usize {
break;
}
}
Ok(None)
}
/// Mark a notification thread read (best-effort; the caller ignores the
/// result). Clears the unread signal so a subsequent comment isn't
/// blocked after the agent has read the thread.
pub fn mark_thread_read(client: &Client, thread_id: u64) -> Result<()> {
client
.api()
.notify_read_thread(index(thread_id)?, NotifyReadThreadQuery::default())
.send()?;
Ok(())
}
/// Reading a thread (`comments` / `view`) is the "I've seen it" signal:
/// clear its unread notification so the read-before-comment guard lets a
/// subsequent comment through. Fully best-effort — never fails or noises
/// up the read verb (a thread with no unread notification is a no-op).
pub fn mark_read_best_effort(client: &Client, repo: &str, number: u64) {
if let Ok(Some(id)) = unread_thread_id(client, repo, number) {
let _ = mark_thread_read(client, id);
}
}
/// True when a notification `subject.url` refers to issue/PR `number` in
/// the (already repo-scoped) query. Forgejo subject URLs end in the
/// issue/PR number (`…/repos/o/r/issues/42`, or `…/pulls/42`); match the
/// trailing path segment. Tolerates a trailing slash.
#[must_use]
pub fn subject_matches(subject_url: &str, number: u64) -> bool {
subject_url
.trim_end_matches('/')
.rsplit('/')
.next()
.and_then(|seg| seg.parse::<u64>().ok())
== Some(number)
}
#[cfg(test)]
mod tests {
use super::{Notification, subject_matches};
#[test]
fn notification_deserializes_leniently_from_drifted_json() {
// A payload carrying fields the guard doesn't model (which would
// break `forgejo-api`'s strict struct) plus a missing subject —
// must still yield `id` + `subject.url` and never fail to parse.
let json = serde_json::json!([
{
"id": 42,
"unread": true,
"pinned": false,
"subject": {
"title": "whatever",
"url": "https://forge/api/v1/repos/o/r/issues/7",
"type": "SomeFutureSubjectTypeWeDoNotKnow"
},
"some_new_field": { "nested": 1 }
},
{ "id": 43 }
]);
let ns: Vec<Notification> = serde_json::from_value(json).expect("lenient deser");
assert_eq!(ns[0].id, Some(42));
assert_eq!(
ns[0].subject.as_ref().and_then(|s| s.url.as_deref()),
Some("https://forge/api/v1/repos/o/r/issues/7")
);
assert_eq!(ns[1].id, Some(43));
assert!(ns[1].subject.is_none());
}
#[test]
fn matches_issue_subject_url() {
assert!(subject_matches(
"https://forge/api/v1/repos/o/r/issues/42",
42
));
}
#[test]
fn matches_pull_subject_url() {
assert!(subject_matches("https://forge/api/v1/repos/o/r/pulls/7", 7));
}
#[test]
fn tolerates_trailing_slash() {
assert!(subject_matches(
"https://forge/api/v1/repos/o/r/issues/9/",
9
));
}
#[test]
fn rejects_different_number() {
assert!(!subject_matches(
"https://forge/api/v1/repos/o/r/issues/42",
7
));
}
#[test]
fn rejects_non_numeric_tail() {
assert!(!subject_matches("https://forge/api/v1/repos/o/r", 42));
}
#[test]
fn rejects_substring_number() {
// 142 must not match 42 — full-segment parse, not substring.
assert!(!subject_matches(
"https://forge/api/v1/repos/o/r/issues/142",
42
));
}
}