//! 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::{NotifyGetRepoListQuery, 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 /// `#`, 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`. pub fn unread_thread_id(client: &Client, repo: &str, number: u64) -> Result> { let (owner, name) = split_repo(repo)?; for page in 1..=MAX_PAGES { let query = NotifyGetRepoListQuery { all: Some(false), ..Default::default() }; let (_, threads) = client .api() .notify_get_repo_list(owner, name, query) .page(page) .page_size(PAGE_SIZE) .send()?; for n in &threads { let subject_url = n .subject .as_ref() .and_then(|s| s.url.as_ref()) .map_or("", url::Url::as_str); 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::().ok()) == Some(number) } #[cfg(test)] mod tests { use super::subject_matches; #[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 )); } }