//! Background Forgejo notification poller. Polls //! `GET /notifications?all=false` every 30s, formats each unread //! notification as a broker `Wake { from: "forge" }` message, and //! delivers it to the agent's inbox. Delivered threads are deliberately //! left UNREAD in forge — the hive-forge read-before-comment guard keys //! off forge's own unread-state, and the agent reading the thread via the //! CLI is what marks it read. A delivery-dedupe cursor (thread id → //! last-delivered `updated_at`) stops the still-unread notification from //! re-firing a wake every poll; self-echo notifications (the agent's own //! writes) are still marked read directly. The cursor is persisted as the //! `forge_cursor` field of the harness's consolidated `hyperhive-harness.json` //! (via [`crate::events`]) and reloaded on boot so a container //! rebuild/restart doesn't re-deliver the whole currently-unread backlog — //! it is a private dedup mirror, NOT forge's read-state, so the //! read-before-comment guard is untouched. //! //! Activation gates, self-notification filtering, body excerpt + //! truncation + heading escape, wrapper formats (comment / review / //! new-item / state-change), meta suffix, and review-request override //! all live in [`docs/forge.md::Notification poller`](../../../docs/forge.md). use std::collections::{HashMap, HashSet}; use std::fmt::Write as _; use std::path::{Path, PathBuf}; use std::time::Duration; use forgejo_api::structs::{NotifyGetListQuery, NotifyReadThreadQuery, NotifySubjectType}; use forgejo_api::{Auth, Forgejo, ForgejoError}; use serde::{Deserialize, Deserializer}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use tracing::{debug, info, warn}; const POLL_INTERVAL_SECS: u64 = 30; /// Per-request cap applied to every forge call — natively on the reqwest /// enrichment client, via `tokio::time::timeout` around the typed /// `forgejo-api` client (which exposes no timeout knob of its own). const HTTP_TIMEOUT_SECS: u64 = 10; /// Page size of the unread-notifications fetch. This is also the hard /// bound on the persisted delivery-dedupe cursor: each poll prunes the /// cursor to the ids in this window, so the `forge_cursor` field in /// `hyperhive-harness.json` can never exceed this many entries. Keep /// the two coupled — bumping the fetch limit grows the cursor's ceiling /// with it, deliberately and visibly. const UNREAD_FETCH_LIMIT: usize = 50; /// Maximum characters of a body/comment to include in the wake message. const BODY_TRUNCATE: usize = 500; /// How long to wait between token-read retries when the token file is /// missing or unreadable at startup (e.g. hive-priv hasn't provisioned /// it yet, or a chown race left it temporarily root-owned). const TOKEN_RETRY_SECS: u64 = 30; /// Give up waiting for the token after this many retries (~10 minutes). /// Avoids an infinite wait on agents that genuinely have no forge account. const TOKEN_RETRY_MAX: u32 = 20; /// How close (seconds) the notification's event time must be to a /// subject's `created_at` for us to call it a genuine creation and emit /// a `new ` label. Later activity that lands on the state-change /// path because it carries no `latest_comment_url` (e.g. a bodiless /// review submission) fires well outside this window, so we must not /// claim it's "new" — see docs/forge.md, "new vs activity on". const NEW_ITEM_TOLERANCE_SECS: i64 = 120; /// Spawn point: called once from the `hive-agent` serve loop. Returns immediately if the forge is not /// configured. Otherwise loops forever, polling every /// `POLL_INTERVAL_SECS` seconds. Errors are never fatal. /// pub async fn run(socket: PathBuf) { let forge_url = match std::env::var("HIVE_FORGE_URL") { Ok(u) if !u.is_empty() => u, _ => { debug!("forge_notify: HIVE_FORGE_URL not set — disabled"); return; } }; let state_dir = std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default(); let token_path = format!("{state_dir}/forge-token"); // Retry reading the token to handle races where hive-priv provisions the // token after the harness starts, or where a parent-container chown briefly // makes the file unreadable. We wait up to // TOKEN_RETRY_MAX * TOKEN_RETRY_SECS before giving up. let token = { let mut attempts = 0u32; loop { match tokio::fs::read_to_string(&token_path).await { Ok(t) => { let t = t.trim().to_owned(); if !t.is_empty() { break t; } debug!("forge_notify: empty forge token at {token_path}"); } Err(e) => { debug!("forge_notify: cannot read token at {token_path}: {e}"); } } attempts += 1; if attempts >= TOKEN_RETRY_MAX { debug!( "forge_notify: token not available after {TOKEN_RETRY_MAX} retries — disabled" ); return; } tokio::time::sleep(Duration::from_secs(TOKEN_RETRY_SECS)).await; } }; // Typed Forgejo client for the API calls with stable shapes (identity // probe, notification list, mark-read). The plain reqwest client below // stays for the best-effort enrichment fetches of `subject.url` / // `latest_comment_url`: those follow server-provided URLs whose payload // shape is heterogeneous (issue vs comment vs review), which the typed // client cannot express (its `Endpoint` trait is sealed). let base_url = match url::Url::parse(&forge_url) { Ok(u) => u, Err(e) => { warn!("forge_notify: invalid HIVE_FORGE_URL {forge_url}: {e}"); return; } }; let forge = match Forgejo::new(Auth::Token(&token), base_url) { Ok(f) => f, Err(e) => { warn!("forge_notify: failed to build forge client: {e}"); return; } }; let client = match reqwest::Client::builder() .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS)) .build() { Ok(c) => c, Err(e) => { warn!("forge_notify: failed to build HTTP client: {e}"); return; } }; // Fetch own login for self-notification filtering. Falls back to // empty string on failure — no filtering (safe degradation; see // `docs/forge.md::Self-notification filtering`). A boot-time failure // (e.g. the forge not yet reachable) is re-attempted on each poll tick // below rather than leaving filtering off for the whole process. let mut own_login = resolve_own_login(&forge).await; if own_login.is_empty() { warn!( "forge_notify: could not resolve own login yet — self-notification \ filtering disabled until it resolves on a later poll" ); } else { debug!(%own_login, "forge_notify: own login resolved"); } let mut interval = tokio::time::interval(Duration::from_secs(POLL_INTERVAL_SECS)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); // First tick fires immediately — skip it so we don't race the broker // socket becoming available right at boot. interval.tick().await; info!(forge_url = %forge_url, "forge_notify: polling started"); // Delivery-dedupe cursor: notification thread id -> the `updated_at` // of the version we last woke the agent for. We no longer mark a // thread read on delivery (that would consume the unread signal the // hive-forge read-before-comment guard relies on), so this map is what // stops the same unread notification from re-firing a wake every poll. // A new comment bumps `updated_at`, so the thread re-delivers. This is // purely anti-spam, NOT a correctness oracle. // // It is persisted as the `forge_cursor` field of the harness's // consolidated state file and reloaded here on boot so a container // rebuild/restart doesn't re-deliver the entire currently-unread // backlog. Persisting is safe because we only record a thread AFTER a // successful broker delivery, and the broker inbox is durable sqlite — // so a persisted "delivered" entry can never swallow a wake the agent // never received. The cursor is a private dedup mirror, decoupled from // forge's own read-state, so it doesn't reintroduce the // read-before-comment coupling that the mark-read-on-delivery approach // suffered. let mut delivered: HashMap = crate::harness_state::read_forge_cursor(); if !delivered.is_empty() { info!( entries = delivered.len(), "forge_notify: restored delivery-dedupe cursor" ); } loop { interval.tick().await; // If own-login didn't resolve at boot (forge unreachable then), // retry before this poll so self-echo filtering self-heals instead // of staying off for the whole process lifetime. if own_login.is_empty() { own_login = resolve_own_login(&forge).await; if !own_login.is_empty() { debug!(%own_login, "forge_notify: own login resolved on retry"); } } poll_once(&forge, &client, &token, &socket, &mut delivered, &own_login).await; } } /// Fetch the agent's own forge login (`GET /api/v1/user`) for /// self-notification filtering. Returns the empty string on any failure /// (timeout, HTTP error, missing field); the caller treats empty as /// "filtering disabled" and retries on the next poll tick. async fn resolve_own_login(forge: &Forgejo) -> String { tokio::time::timeout( Duration::from_secs(HTTP_TIMEOUT_SECS), forge.user_get_current().send(), ) .await .ok() .and_then(Result::ok) .and_then(|u| u.login) .unwrap_or_default() } /// Fetch a JSON value from a URL using the agent's forge token. Returns /// `None` on any HTTP or parse error (best-effort enrichment). async fn fetch_json(client: &reqwest::Client, url: &str, token: &str) -> Option { let resp = client .get(url) .header("Authorization", format!("token {token}")) .send() .await .ok()?; if !resp.status().is_success() { return None; } resp.json().await.ok() } /// Map a Forgejo notification `subject.type` to a human-readable label. /// `Commit` / `Repository` keep their API names, matching the old raw /// pass-through of types we don't relabel; a missing type degrades to /// `?` like every other absent field. fn notif_type_label(t: Option) -> &'static str { match t { Some(NotifySubjectType::Pull) => "PR", Some(NotifySubjectType::Issue) => "issue", Some(NotifySubjectType::Commit) => "Commit", Some(NotifySubjectType::Repository) => "Repository", None => "?", } } /// Escape strict-ATX markdown headings in a body before embedding it /// inside the forge-notify wrapper, so a leading `## title` line /// doesn't blow into an h2 in the dashboard render. See /// `docs/forge.md::Body excerpt + truncation + heading escape` for /// the strict-ATX-vs-`#tag` rationale and the `split_inclusive` /// trailing-newline contract. fn escape_md_headings(body: &str) -> String { let mut out = String::with_capacity(body.len()); for line in body.split_inclusive('\n') { let (content, terminator) = match line.strip_suffix('\n') { Some(rest) => (rest, "\n"), None => (line, ""), }; let trimmed = content.trim_start(); if is_atx_heading(trimmed) { let lead = &content[..content.len() - trimmed.len()]; out.push_str(lead); out.push('\\'); out.push_str(trimmed); } else { out.push_str(content); } out.push_str(terminator); } out } /// Strict `CommonMark` ATX-heading detector: 1-6 leading `#`s followed /// by either a space, tab, or end-of-line. Anything tighter (`#tag`, /// `#9`) is a non-heading line that the renderer will not promote. fn is_atx_heading(line: &str) -> bool { let hashes = line.bytes().take_while(|&b| b == b'#').count(); if !(1..=6).contains(&hashes) { return false; } // Bare `#` / `##` / ... on its own line, or proper ATX with a // space/tab after the run of `#`s; anything else (`#tag` / `#9`) // is not a heading. matches!(line.as_bytes().get(hashes), None | Some(b' ' | b'\t')) } fn truncate(s: &str, max: usize) -> String { if s.len() <= max { return s.to_owned(); } let end = s .char_indices() .map(|(i, _)| i) .take_while(|&i| i <= max.saturating_sub(3)) .last() .unwrap_or(0); format!("{}…", &s[..end]) } /// Detect `@username` mentions on a line. A mention is `@` followed by /// at least one username char (alphanumeric / `_` / `-`) where the `@` /// is at line start or follows a non-username char — so email-style /// `foo@bar.com` does NOT count as a mention. fn contains_mention(line: &str) -> bool { let bytes = line.as_bytes(); for (i, &b) in bytes.iter().enumerate() { if b != b'@' { continue; } // Boundary: preceding byte must NOT be a username char. let boundary_ok = match i.checked_sub(1).map(|j| bytes[j]) { None => true, Some(prev) => !is_username_byte(prev), }; if !boundary_ok { continue; } // Following byte must be at least one username char. if bytes.get(i + 1).is_some_and(|&c| is_username_byte(c)) { return true; } } false } fn is_username_byte(b: u8) -> bool { b.is_ascii_alphanumeric() || b == b'_' || b == b'-' } /// Walk `full_body` line-by-line; return lines that contain an /// `@username` mention AND aren't already present (as a substring) in /// `included_excerpt`. Surfaces tags that fell outside the truncation /// window so addressed agents never silently miss a mention on a long /// body. See `docs/forge.md::Body excerpt + truncation + heading /// escape` for the truncate-before-escape ordering rule. fn extract_truncated_mention_lines<'a>(full_body: &'a str, included_excerpt: &str) -> Vec<&'a str> { full_body .lines() .filter(|line| { let trimmed = line.trim(); !trimmed.is_empty() && contains_mention(trimmed) }) .filter(|line| !included_excerpt.contains(line.trim())) .collect() } /// Build the trailing `mentions (truncated from body):\n > …` block. /// Empty string when there's nothing to surface. Caller embeds it /// directly before the meta suffix. fn render_truncated_mentions(lines: &[&str]) -> String { if lines.is_empty() { return String::new(); } let mut out = String::from("\n\nmentions (truncated from body):"); for line in lines { write!(out, "\n > {}", line.trim()).ok(); } out } /// The escaped body excerpt + overflowed-mention suffix for one issue / PR / /// comment body, computed in the one required order: truncate → diff the /// overflowed `@mention` lines against the *unescaped* excerpt → heading-escape /// the excerpt. Both notification formatters build their body block from this /// pair so the ordering contract lives in exactly one place. Returns /// `(escaped_excerpt, mentions_suffix)`; `mentions_suffix` is empty when the /// body fit (nothing was truncated away). fn render_body_excerpt(raw: &str) -> (String, String) { let raw_excerpt = truncate(raw, BODY_TRUNCATE); let mentions = render_truncated_mentions(&extract_truncated_mention_lines(raw, &raw_excerpt)); let excerpt = escape_md_headings(&raw_excerpt); (excerpt, mentions) } /// Map a Forgejo review state to a readable action label. /// Returns `None` for non-review states (regular comments have no `state` field; /// `PENDING` means the review was saved but not submitted yet). /// Forgejo review states: "APPROVED", "`REQUEST_CHANGES`", "COMMENT", "PENDING". fn review_state_label(state: &str) -> Option<&str> { match state { "APPROVED" => Some("approved"), "REQUEST_CHANGES" => Some("changes requested"), "COMMENT" => Some("review comment"), _ => None, } } /// Build a human-readable wake message for one Forgejo notification, /// or `None` for a self-echo the caller should mark-read without /// delivery. Wrapper format table + meta-suffix shape + number/repo /// extraction live in `docs/forge.md::Wrapper format` + /// `::Meta suffix`. async fn format_notification( client: &reqwest::Client, token: &str, notif: &PolledNotification, own_login: &str, ) -> Option { let subj = notif.thread.subject.as_ref(); let title = subj.and_then(|s| s.title.as_deref()).unwrap_or("?"); let subject_type = subj.and_then(|s| s.r#type); // forgejo-api maps a blank `html_url` (Go marshals empty strings) to // `None`, so the API-url fallback also covers present-but-empty — // deliberate: a fetchable API link beats the raw-HTTP predecessor's // empty `url:` line. let html_url = subj .and_then(|s| s.html_url.as_ref().or(s.url.as_ref())) .map_or("", url::Url::as_str); // Extract issue/PR number from the html_url. URL ends with /issues/N or // /pulls/N (possibly followed by #anchor for comments). Best-effort. let num = html_url .split('#') .next() .and_then(|u| u.rsplit('/').next()) .and_then(|s| s.parse::().ok()) .map(|n| format!(" #{n}")) .unwrap_or_default(); // Repo slug for multi-repo disambiguation. Falls back gracefully when absent. let repo = notif .thread .repository .as_ref() .and_then(|r| r.full_name.as_deref()) .map(|r| format!(" {r}")) .unwrap_or_default(); // API URLs for fetching content let subject_api_url = subj .and_then(|s| s.url.as_ref()) .map_or("", url::Url::as_str); let comment_api_url = subj .and_then(|s| s.latest_comment_url.as_ref()) .map_or("", url::Url::as_str); let comment_html_url = subj .and_then(|s| s.latest_comment_html_url.as_ref()) .map_or("", url::Url::as_str); // Always fetch subject detail for assignee/reviewer metadata so // the meta suffix can show current ownership without a follow-up // fetch (see `docs/forge.md::Meta suffix`). let subject = if subject_api_url.is_empty() { None } else { fetch_json(client, subject_api_url, token).await }; // Forgejo's notification `subject.type` is "Pull" / "Issue", never // "Pull Request". let is_pr = subject_type == Some(NotifySubjectType::Pull); let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr); // Determine whether this notification was triggered by a comment/review or // by creation/state-change of the subject itself. let has_comment = !comment_api_url.is_empty() && comment_api_url != subject_api_url; let meta = NotifMeta { title, subject_type, html_url, num, repo, meta_suffix, subject, is_pr, }; // A merged/closed subject keeps its `latest_comment_url` set, so a // just-merged PR that had any discussion would otherwise route to the // comment path and render as `[comment on PR]` instead of `[PR merged]`. // When this notification IS the merge/close transition, prefer // the state-change path even with a comment url present; a genuine later // comment stays on the comment path (see `state_change_is_current`). let is_fresh_state_change = state_change_is_current(¬if.state, notif.thread.updated_at, meta.subject.as_ref()); if has_comment && !is_fresh_state_change { format_comment_notification( client, token, &meta, comment_api_url, comment_html_url, own_login, ) .await } else { // State-change path (merge/close/new/activity). A just-merged or // closed subject keeps its pre-merge last comment on // `latest_comment_url`, so `has_comment` is often true here. When // that comment was genuinely posted AFTER the close — a comment // racing the merge inside the state-change tolerance window — append // it so it isn't lost (best of both worlds; see // `docs/forge.md::Merge racing a comment`). The ordinary pre-merge // last comment (created before `closed_at`) is left off. let comment_tail = if has_comment { fresh_post_close_comment_tail( client, token, comment_api_url, meta.subject.as_ref(), own_login, ) .await } else { None }; format_state_change_notification( notif.thread.updated_at, ¬if.state, &meta, own_login, comment_tail, ) } } /// Shared notification metadata extracted from the polled notification. struct NotifMeta<'a> { title: &'a str, subject_type: Option, html_url: &'a str, num: String, repo: String, meta_suffix: String, /// Fetched subject detail (issue/PR JSON); used for review-request detection. subject: Option, is_pr: bool, } /// Build the `\nassignee: ...` (and optionally `\nreviewer: ...`) /// suffix appended to every wrapper. Shape + presence rules live in /// `docs/forge.md::Meta suffix`. fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool) -> String { let assignees: Vec<&str> = subject .and_then(|s| s["assignees"].as_array()) .map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect()) .unwrap_or_default(); let assignee_line = if assignees.is_empty() { "assignee: unassigned".to_owned() } else { format!("assignee: {}", assignees.join(", ")) }; // For PRs, include requested_reviewers when present. let reviewer_line = if is_pr { let reviewers: Vec<&str> = subject .and_then(|s| s["requested_reviewers"].as_array()) .map(|arr| arr.iter().filter_map(|r| r["login"].as_str()).collect()) .unwrap_or_default(); if reviewers.is_empty() { None } else { Some(format!("reviewer: {}", reviewers.join(", "))) } } else { None }; let mut out = format!("\n{assignee_line}"); if let Some(r) = reviewer_line { write!(out, "\n{r}").ok(); } out } /// Format a notification triggered by a new comment or review submission. async fn format_comment_notification( client: &reqwest::Client, token: &str, meta: &NotifMeta<'_>, comment_api_url: &str, comment_html_url: &str, own_login: &str, ) -> Option { let payload = fetch_json(client, comment_api_url, token).await; let actor_login = payload .as_ref() .and_then(|c| c["user"]["login"].as_str()) .unwrap_or(""); // Self-notification filter: skip if we authored the comment/review. if !own_login.is_empty() && actor_login == own_login { debug!(%own_login, "forge_notify: skipping self-authored comment/review"); return None; } let body_text = payload .as_ref() .and_then(|c| c["body"].as_str()) .unwrap_or("") .trim(); // PR review detection: Forgejo review objects carry a `state` // field with values like "APPROVED" / "REQUEST_CHANGES" / // "COMMENT". Regular issue/PR comments have no such field. Format // reviews distinctly so the agent knows the outcome at a glance. let review_state = payload .as_ref() .and_then(|c| c["state"].as_str()) .and_then(review_state_label); let url = if comment_html_url.is_empty() { meta.html_url } else { comment_html_url }; let author = if actor_login.is_empty() { "?" } else { actor_login }; let NotifMeta { title, subject_type, num, repo, meta_suffix, .. } = meta; // Truncate → mention-overflow → escape (see `render_body_excerpt`). let (body_for_embed, truncated_mentions) = render_body_excerpt(body_text); if let Some(review_label) = review_state { // Review submission on a PR. let kind = format!("PR {review_label}{num}{repo}"); let mut out = format!("[{kind}] {title}\nurl: {url}"); if body_text.is_empty() { // Bodiless review: name who reviewed. `reviewed by:` (not // `reviewer:`) to avoid colliding with the `reviewer:` line the // meta suffix carries for a PR's *requested* reviewers. write!(out, "\n\nreviewed by: {author}").ok(); } else { write!(out, "\n\n{author}: {body_for_embed}{truncated_mentions}").ok(); } out.push_str(meta_suffix); Some(out) } else { // Regular comment. let kind = format!("comment on {}{num}{repo}", notif_type_label(*subject_type)); let mut out = format!( "[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}{truncated_mentions}" ); out.push_str(meta_suffix); Some(out) } } /// 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( event_time: Option, notif_state: &str, meta: &NotifMeta<'_>, own_login: &str, comment_tail: Option, ) -> Option { // Classification uses the raw `subject.state` string extracted in // `parse_notification` — Forgejo returns "open" / "closed" / "merged" // there. We do NOT rely on fetching the PR/issue detail for `merged`: // - `subject.url` points to the *issues* endpoint, which returns // `pull_request.merged`, not top-level `merged`. // - Forgejo API type is "Pull" / "Issue", never "Pull Request". // "New" = the subject is open (or state is absent). Used below for // the review-request override. let is_new = notif_state == "open" || notif_state.is_empty(); let NotifMeta { title, subject_type, html_url, num, repo, meta_suffix, subject, is_pr, } = meta; let label = notif_type_label(*subject_type); // Only claim "new" when the notification actually fired at creation // time. A review submitted with no body carries no // `latest_comment_url`, so it lands here instead of on the comment // path — and its event time is well after `created_at`. Labeling // that "new PR" is misleading (see docs/forge.md, "new vs activity // on"): agents dismiss it as a // 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(event_time, 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}"), "open" | "" if looks_new => format!("new {label}{num}{repo}"), "open" | "" => format!("activity on {label}{num}{repo}"), other => format!("{label}{num}{repo}: {other}"), }; // Review-request override: Forgejo doesn't reliably set // `reason == "review_requested"` (often null), so we check the // subject payload's `requested_reviewers` list directly. See // `docs/forge.md::Review-request override`. let is_review_request = is_new && *is_pr && !own_login.is_empty() && subject .as_ref() .and_then(|s| s["requested_reviewers"].as_array()) .is_some_and(|arr| arr.iter().any(|r| r["login"].as_str() == Some(own_login))); let kind = if is_review_request { format!("review requested{num}{repo}") } else { kind }; // Include the start of the issue/PR description so the agent gets context // without a follow-up fetch. Same body pipeline as comment bodies. let body_block = subject .as_ref() .and_then(|s| s["body"].as_str()) .map(str::trim) .filter(|s| !s.is_empty()) .map(|raw| { let (excerpt, mentions) = render_body_excerpt(raw); format!("\n\n{excerpt}{mentions}") }) .unwrap_or_default(); let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}"); // Append a comment that raced the merge/close (best of both worlds), when // the caller found one genuinely newer than `closed_at`. if let Some(tail) = comment_tail { out.push_str(&tail); } out.push_str(meta_suffix); Some(out) } /// Decide whether a state-change notification represents the subject's /// *creation* (so a `new ` label is truthful) versus later /// activity that merely lacked a `latest_comment_url`. Compares the /// notification's event time (`updated_at`) against the subject's /// `created_at`: within `NEW_ITEM_TOLERANCE_SECS` ⇒ creation. When /// either timestamp is missing or unparseable we default to `true`, /// preserving the prior "new" behavior rather than masking a genuine /// new item behind the neutral fallback. See docs/forge.md, "new vs /// activity on". fn notification_is_creation( event: Option, subject: Option<&serde_json::Value>, ) -> bool { let created = subject .and_then(|s| s["created_at"].as_str()) .and_then(parse_rfc3339); match (created, event) { (Some(c), Some(e)) => (e - c).whole_seconds().abs() <= NEW_ITEM_TOLERANCE_SECS, _ => true, } } /// Whether this notification represents the subject's *own* merge/close /// transition, as opposed to a later comment on an already-merged/closed /// subject. A merged/closed PR keeps its `latest_comment_url` set, so /// without this a just-merged PR would route to the comment path and /// render as `[comment on PR]` instead of `[PR merged]`. We treat /// the transition as current when the notification's event time /// (`updated_at`) is within `NEW_ITEM_TOLERANCE_SECS` of the subject's /// `closed_at` (set for both `merged` and `closed`). A genuine later /// comment bumps `updated_at` well past `closed_at`, so it stays on the /// comment path (and keeps its comment body). Missing/unparseable /// timestamps default to `true` so a merge is never silently hidden /// behind a stale comment — mirrors `notification_is_creation`'s /// preserve-the-signal fallback. fn state_change_is_current( state: &str, event: Option, subject: Option<&serde_json::Value>, ) -> bool { if !matches!(state, "merged" | "closed") { return false; } let closed = subject .and_then(|s| s["closed_at"].as_str()) .and_then(parse_rfc3339); match (closed, event) { (Some(c), Some(e)) => (e - c).whole_seconds().abs() <= NEW_ITEM_TOLERANCE_SECS, _ => true, } } /// True when the (already-fetched) comment payload was created strictly /// after the subject's `closed_at` — i.e. a comment racing the merge/close, /// not the pre-merge last comment a merged/closed subject keeps on /// `latest_comment_url`. Unlike `state_change_is_current`, a missing or /// unparseable timestamp defaults to `false`: we only append a comment we /// can positively place after the close, so an unplaceable one is never /// bolted onto a merge message where it might be stale. fn comment_is_after_close( comment: &serde_json::Value, subject: Option<&serde_json::Value>, ) -> bool { let closed = subject .and_then(|s| s["closed_at"].as_str()) .and_then(parse_rfc3339); let created = comment["created_at"].as_str().and_then(parse_rfc3339); match (created, closed) { (Some(cr), Some(cl)) => cr > cl, _ => false, } } /// Render the trailing comment block to append to a merge/close notification /// when a comment raced the merge (posted after `closed_at`). Fetches the /// comment via `latest_comment_url` and returns `None` when it predates the /// close (the ordinary kept last comment), is self-authored (don't echo the /// agent's own write back at it), is empty/bodiless, or can't be fetched. /// This is the one extra fetch the merge/close path pays for the best of /// both worlds — cheap given how rare merge notifications are. async fn fresh_post_close_comment_tail( client: &reqwest::Client, token: &str, comment_api_url: &str, subject: Option<&serde_json::Value>, own_login: &str, ) -> Option { let payload = fetch_json(client, comment_api_url, token).await?; if !comment_is_after_close(&payload, subject) { return None; } let author = payload["user"]["login"].as_str().unwrap_or(""); // Don't surface the agent's own racing comment back to it — mirrors the // self-authored filter on the comment/review path. if !own_login.is_empty() && author == own_login { return None; } let body = payload["body"].as_str().unwrap_or("").trim(); if body.is_empty() { return None; } let author = if author.is_empty() { "?" } else { author }; let (excerpt, mentions) = render_body_excerpt(body); Some(format!("\n\ncomment by {author}: {excerpt}{mentions}")) } /// Parse an RFC 3339 timestamp as Forgejo emits them /// (`2026-06-13T11:18:42+02:00` or `...Z`, optionally with fractional /// seconds). Returns `None` on any shape `time` doesn't recognise so /// callers can fall back gracefully. fn parse_rfc3339(s: &str) -> Option { OffsetDateTime::parse(s, &Rfc3339).ok() } /// Minimal, drift-tolerant view of a Forgejo notification thread — /// deliberately NOT `forgejo_api::structs::NotificationThread`. That crate /// (0.11.0) lags the running `pkgs.forgejo` release, and one field /// type/shape drift in the upstream struct fails the WHOLE parse, so every /// notification is dropped, never dedup'd / marked-read, and redelivered /// forever. This local struct carries only the fields `forge_notify` reads, /// each optional + lenient, so unknown or reshaped upstream fields can't /// break notification read-state again. #[derive(Debug, Deserialize)] struct NotificationThread { #[serde(default)] id: Option, #[serde(default, deserialize_with = "de_opt_rfc3339")] updated_at: Option, #[serde(default)] subject: Option, #[serde(default)] repository: Option, } #[derive(Debug, Deserialize)] struct NotificationSubject { #[serde(default)] title: Option, #[serde(rename = "type", default, deserialize_with = "de_opt_subject_type")] r#type: Option, #[serde(default, deserialize_with = "de_opt_url")] html_url: Option, #[serde(default, deserialize_with = "de_opt_url")] url: Option, #[serde(default, deserialize_with = "de_opt_url")] latest_comment_url: Option, #[serde(default, deserialize_with = "de_opt_url")] latest_comment_html_url: Option, } #[derive(Debug, Deserialize)] struct NotificationRepo { #[serde(default)] full_name: Option, } /// Deserialize an optional Forgejo subject `type`, mapping any unknown or /// missing value to `None` instead of failing the parse — so a new Forgejo /// subject type can never break notification read-state. fn de_opt_subject_type<'de, D>(d: D) -> Result, D::Error> where D: Deserializer<'de>, { Ok( Option::::deserialize(d)?.and_then(|s| match s.as_str() { "Pull" => Some(NotifySubjectType::Pull), "Issue" => Some(NotifySubjectType::Issue), "Commit" => Some(NotifySubjectType::Commit), "Repository" => Some(NotifySubjectType::Repository), _ => None, }), ) } /// Deserialize an optional URL, mapping a blank or unparseable value to /// `None` (Forgejo marshals empty strings for absent URLs, which a plain /// `Option` would reject). fn de_opt_url<'de, D>(d: D) -> Result, D::Error> where D: Deserializer<'de>, { Ok(Option::::deserialize(d)? .filter(|s| !s.is_empty()) .and_then(|s| url::Url::parse(&s).ok())) } /// Deserialize an optional RFC 3339 timestamp, mapping any unrecognised /// shape to `None` via the module's tolerant [`parse_rfc3339`]. fn de_opt_rfc3339<'de, D>(d: D) -> Result, D::Error> where D: Deserializer<'de>, { Ok(Option::::deserialize(d)? .as_deref() .and_then(parse_rfc3339)) } /// One notification from the poll page: the lenient thread plus two raw /// fields the typed struct can't carry faithfully. struct PolledNotification { thread: NotificationThread, /// Raw `subject.state`, kept as a string. Forgejo reports `"merged"` /// for merged PRs (`services/convert/notification.go`) alongside /// open/closed, and the state is matched as a string throughout, so /// it's extracted verbatim rather than typed into an enum. state: String, /// Raw `updated_at` string, byte-identical to what Forgejo sent, so /// the persisted delivery-dedupe cursor keeps its exact format /// across the typed-client port (no spurious re-deliveries from a /// reformatting round-trip). updated_at: String, } /// Parse one notification JSON object: pull out the raw `subject.state` /// and `updated_at` (see [`PolledNotification`]) as strings, then /// deserialize the rest into the lenient local [`NotificationThread`]. /// Returns `None` (with a warn) only for a fundamentally malformed item /// (e.g. a non-object) — the rest of the page still delivers. fn parse_notification(value: serde_json::Value) -> Option { let state = value["subject"]["state"].as_str().unwrap_or("").to_owned(); let updated_at = value["updated_at"].as_str().unwrap_or("").to_owned(); match serde_json::from_value::(value) { Ok(thread) => Some(PolledNotification { thread, state, updated_at, }), Err(e) => { warn!("forge_notify: notification parse error: {e}"); None } } } #[allow( clippy::too_many_lines, reason = "single-pass notification poll loop — split would obscure the \ sequential 'fetch / classify / dispatch' rhythm and add helper \ functions for state shared across all three phases" )] async fn poll_once( forge: &Forgejo, client: &reqwest::Client, token: &str, socket: &Path, delivered: &mut HashMap, own_login: &str, ) { // Fetch the page as raw JSON (`response_type::`) instead of the // crate's `Vec`: one merged-PR notification // (`subject.state = "merged"`, unrepresentable in `StateType`) would // otherwise poison deserialization of the whole page. HTTP-level errors // still surface as `ForgejoError` exactly like the fully-typed call; // `parse_notification` below does the per-item typed parse. let query = NotifyGetListQuery { all: Some(false), ..NotifyGetListQuery::default() }; let request = forge .notify_get_list(query) .page_size(u32::try_from(UNREAD_FETCH_LIMIT).unwrap_or(u32::MAX)) .response_type::(); let raw = match tokio::time::timeout(Duration::from_secs(HTTP_TIMEOUT_SECS), request.send()).await { Ok(Ok(raw)) => raw, Ok(Err(ForgejoError::UnexpectedStatusCode(status))) => { debug!("forge_notify: poll status {status}"); return; } Ok(Err(e)) => { debug!("forge_notify: poll request failed: {e}"); return; } Err(_) => { debug!("forge_notify: poll request failed: timed out"); return; } }; let values: Vec = match serde_json::from_str(&raw) { Ok(v) => v, Err(e) => { warn!("forge_notify: response parse error: {e}"); return; } }; if values.is_empty() { return; } debug!( count = values.len(), "forge_notify: delivering notifications" ); let notifications: Vec = values.into_iter().filter_map(parse_notification).collect(); // Tracks whether the dedupe cursor changed this poll (a new delivery // recorded, or the prune below dropped now-read threads) so we only // rewrite the on-disk cursor when there's something to persist. let mut cursor_dirty = false; for notif in ¬ifications { let Some(id) = notif.thread.id.and_then(|id| u64::try_from(id).ok()) else { continue; }; // Delivery-dedupe: we no longer mark threads read on delivery, so // an unread notification reappears in every `?all=false` poll. // Skip it silently unless its `updated_at` advanced since the // version we last delivered a wake for (i.e. genuinely new // activity). See the `delivered` cursor note in `run`. let updated_at = notif.updated_at.clone(); if !should_deliver(delivered, id, &updated_at) { debug!(%id, "forge_notify: skipping (already delivered this version)"); continue; } let body_opt = format_notification(client, token, notif, own_login).await; // None means self-echo — mark read silently, no delivery. let Some(body) = body_opt else { mark_read(forge, id).await; continue; }; let req = hive_agent_sock::Request::Wake { from: "forge".to_owned(), body, }; let deliver_result = crate::client::request::<_, hive_agent_sock::Response>(socket, &req) .await .map(|_| ()); match deliver_result { Ok(()) => { debug!(%id, "forge_notify: delivered"); // Record the delivered version in the dedupe cursor INSTEAD // of marking the thread read. Leaving it unread is // deliberate: the hive-forge read-before-comment guard keys // off forge's own unread-state, and the agent reading the // thread via the CLI is what marks it read. Recorded only // here in the Ok arm — a failed delivery leaves the cursor // untouched, so it re-delivers next tick. delivered.insert(id, updated_at); cursor_dirty = true; } Err(e) => { warn!(%id, error = ?e, "forge_notify: deliver failed — leaving unread"); } } } // Prune the dedupe cursor down to the threads still present in this // poll's unread set. Once the agent reads a thread (marking it read // via the CLI) it drops out of `?all=false`, so its cursor entry is // dead weight; dropping it bounds the map to the current unread size. // If such a thread later goes unread again it carries a fresh // `updated_at` and re-delivers correctly. // // This retain IS the cursor's size bound: `current_ids` comes from a // single `limit=UNREAD_FETCH_LIMIT` page, so the persisted cursor can // never exceed that many entries — it tracks the unread *window*, not // the all-time notification count. The assert makes the invariant // loud in tests/dev if a future pagination change silently breaks it. let current_ids: HashSet = notifications .iter() .filter_map(|n| n.thread.id.and_then(|id| u64::try_from(id).ok())) .collect(); let before_prune = delivered.len(); delivered.retain(|id, _| current_ids.contains(id)); debug_assert!( delivered.len() <= UNREAD_FETCH_LIMIT, "dedupe cursor exceeded the fetch window ({} > {UNREAD_FETCH_LIMIT})", delivered.len(), ); if delivered.len() != before_prune { cursor_dirty = true; } // Flush the cursor to the consolidated state file only when it // changed, so a rebuild/restart reloads it instead of re-delivering // the whole unread backlog. if cursor_dirty { crate::harness_state::write_forge_cursor(delivered); } } /// Whether a notification should be delivered as a wake given the /// delivery-dedupe cursor. Delivers when the thread has never been /// delivered, or when its `updated_at` advanced since the last delivered /// version (genuinely new activity). Pure for unit testing. fn should_deliver(delivered: &HashMap, id: u64, updated_at: &str) -> bool { delivered.get(&id).is_none_or(|seen| seen != updated_at) } /// Mark a notification thread as read. Best-effort — logs on failure but /// does not abort the poll loop. Called only on the self-echo path (the /// agent's own comment/review/creation writes) — delivered threads are /// deliberately left unread for the read-before-comment guard, and a failed /// delivery is left unread + out of the dedupe cursor so it resurfaces on the /// next poll tick. async fn mark_read(forge: &Forgejo, id: u64) { let Ok(thread_id) = i64::try_from(id) else { // Thread ids originate from `i64` in the poll parse, so an // unrepresentable value can't actually reach here. return; }; // `to_status: None` → Forgejo's default transition (unread → read), // matching the old bare PATCH. The 205 response body is the thread // JSON, which can carry `subject.state = "merged"` — take it as an // opaque `String` (see `poll_once`) so a merged PR doesn't turn a // successful mark-read into a spurious parse error. let request = forge .notify_read_thread(thread_id, NotifyReadThreadQuery { to_status: None }) .response_type::(); match tokio::time::timeout(Duration::from_secs(HTTP_TIMEOUT_SECS), request.send()).await { Err(_) => { warn!(%id, "forge_notify: mark-read request failed — notification will resurface"); } Ok(Err(e @ ForgejoError::ReqwestError(_))) => { warn!(%id, error = ?e, "forge_notify: mark-read request failed — notification will resurface"); } Ok(Err(e)) => { warn!(%id, error = %e, "forge_notify: mark-read returned non-2xx — notification will resurface"); } Ok(Ok(_)) => { debug!(%id, "forge_notify: marked read"); } } } #[cfg(test)] mod tests { use super::*; #[test] fn should_deliver_when_thread_never_seen() { let delivered = HashMap::new(); assert!(should_deliver(&delivered, 42, "2026-06-22T16:00:00Z")); } #[test] fn should_not_deliver_same_version_again() { // The dedupe case: an unread thread reappears every poll with the // same `updated_at` — must not re-fire a wake. let mut delivered = HashMap::new(); delivered.insert(42, "2026-06-22T16:00:00Z".to_owned()); assert!(!should_deliver(&delivered, 42, "2026-06-22T16:00:00Z")); } #[test] fn should_deliver_when_updated_at_advanced() { // A new comment bumps `updated_at` → genuinely new activity → // deliver again. let mut delivered = HashMap::new(); delivered.insert(42, "2026-06-22T16:00:00Z".to_owned()); assert!(should_deliver(&delivered, 42, "2026-06-22T16:05:00Z")); } #[test] fn should_deliver_tracks_per_thread() { // A cursor for one thread says nothing about another. let mut delivered = HashMap::new(); delivered.insert(42, "2026-06-22T16:00:00Z".to_owned()); assert!(should_deliver(&delivered, 99, "2026-06-22T16:00:00Z")); } #[test] fn escape_md_headings_escapes_top_level_atx() { // Argus reviews start with `## argus review`, which would // otherwise become an h2 inside the wrapper message. assert_eq!( escape_md_headings("## argus review\n\nlgtm."), "\\## argus review\n\nlgtm.", ); } #[test] fn escape_md_headings_escapes_all_heading_depths() { let body = "# h1\n## h2\n### h3\n###### h6\nbody"; assert_eq!( escape_md_headings(body), "\\# h1\n\\## h2\n\\### h3\n\\###### h6\nbody", ); } #[test] fn escape_md_headings_preserves_indent() { // Indented "headings" inside lists / nested quotes keep // their leading whitespace so structure isn't visually // collapsed by the escape. assert_eq!( escape_md_headings(" ## indented\nbody"), " \\## indented\nbody", ); } #[test] fn escape_md_headings_passes_non_heading_lines_through() { let body = "plain text\nwith a #hashtag in middle\n```\n# in fenced code\n```"; let escaped = escape_md_headings(body); // Lines without leading `#` are untouched. The `# in fenced // code` line still gets escaped (we don't track fenced-code // state) — acceptable: inside a fenced block the escape is // visually inert anyway because the renderer treats the // content as literal. assert!(escaped.contains("plain text")); assert!(escaped.contains("with a #hashtag in middle")); assert!(escaped.contains("\\# in fenced code")); } #[test] fn escape_md_headings_handles_empty_and_whitespace_only() { assert_eq!(escape_md_headings(""), ""); assert_eq!(escape_md_headings(" "), " "); assert_eq!(escape_md_headings("\n\n"), "\n\n"); } #[test] fn escape_md_headings_skips_non_atx_hash_lines() { // ATX requires a space after the `#`s. Lines like `#tag`, a // hash-then-digits run, or `#!/bin/bash` are NOT headings — // escaping them would just add cosmetic noise where the // renderer wouldn't promote the line in the first place. let body = "#tag\n#123\n#!/bin/bash\n####### too many hashes\nbody"; // lint:allow: hash-digit heading test input, not a tracker tag let escaped = escape_md_headings(body); // All four leading `#` lines pass through untouched: too few // (still need space), seven `#`s (over the cap), shebang // (no space). assert_eq!(escaped, body); } #[test] fn escape_md_headings_handles_bare_hash_lines() { // `#` alone on a line IS a valid ATX (h1 with empty text) per // CommonMark; escape it to match the renderer's behaviour. assert_eq!(escape_md_headings("#"), "\\#"); assert_eq!(escape_md_headings("##"), "\\##"); assert_eq!(escape_md_headings("###"), "\\###"); } #[test] fn escape_md_headings_preserves_trailing_newline() { // `split_inclusive('\n')` round-trips a body ending in a // newline. Important for embedded forge-notify bodies whose // source already terminates with `\n` — the wrapper's spacing // otherwise gets eaten. assert_eq!(escape_md_headings("## h\n"), "\\## h\n"); assert_eq!(escape_md_headings("body\n"), "body\n"); assert_eq!(escape_md_headings("no trailing"), "no trailing"); } #[test] fn contains_mention_matches_at_line_start_and_mid_line() { assert!(contains_mention("@damocles take a look")); assert!(contains_mention("cc @argus please")); assert!(contains_mention("see (@mara) for context")); // Hyphens / underscores / digits are valid username chars. assert!(contains_mention("ping @h-m1nd-2")); } #[test] fn contains_mention_rejects_email_and_bare_at() { // Email addresses (`foo@bar.com`) and `@` followed by // whitespace or punctuation are not mentions — boundary check // requires the preceding byte to NOT be a username char. assert!(!contains_mention("foo@bar.com")); assert!(!contains_mention("send to user@example.org")); assert!(!contains_mention("just an @")); assert!(!contains_mention("@ space")); assert!(!contains_mention("plain text")); assert!(!contains_mention("")); } #[test] fn extract_truncated_keeps_mention_lines_outside_excerpt() { // Long body where the @mention sits AFTER the excerpt's cutoff // — the truncated extractor must surface it. let full = "first line\nsecond line\n@damocles tagged here\n"; let excerpt = "first line\nsecond line\n…"; // mention not present let lines = extract_truncated_mention_lines(full, excerpt); assert_eq!(lines, vec!["@damocles tagged here"]); } #[test] fn extract_truncated_drops_mentions_already_in_excerpt() { // Mention is inside the embed window already — no need to // re-surface, would be noise. let full = "@damocles read this\nmore body\n"; let excerpt = "@damocles read this\nmore body\n…"; let lines = extract_truncated_mention_lines(full, excerpt); assert!(lines.is_empty()); } #[test] fn extract_truncated_skips_blank_and_no_mention_lines() { // Only lines with an actual mention survive — random body // text past the cutoff stays dropped. let full = "first\n\nsecond paragraph\n@argus reviewer\nfinal\n"; let excerpt = "first"; let lines = extract_truncated_mention_lines(full, excerpt); assert_eq!(lines, vec!["@argus reviewer"]); } #[test] fn extract_truncated_does_not_resurface_heading_mention_inside_window() { // Regression for the truncate-before-escape ordering rule: // if the diff used the escaped excerpt, a body line // `# @argus check this` would survive as-is in the body but // become `\# @argus check this` in the excerpt, so the // `contains` check would fail and the mention would re-surface // as if it fell outside the window. Pass the unescaped excerpt // (which the caller does) and the duplicate disappears. let full = "# @argus check this\nmore body\n"; let raw_excerpt = full; // fits entirely let lines = extract_truncated_mention_lines(full, raw_excerpt); assert!( lines.is_empty(), "heading+mention inside window must not be re-surfaced, got {lines:?}" ); } #[test] fn render_truncated_mentions_empty_is_empty_string() { // Zero overhead on the healthy short-body path: caller // concatenates this directly so an empty input must produce // no spacing. assert_eq!(render_truncated_mentions(&[]), ""); } #[test] fn render_truncated_mentions_formats_block() { let lines = ["cc @damocles", " @argus second mention"]; let rendered = render_truncated_mentions(&lines); assert_eq!( rendered, "\n\nmentions (truncated from body):\n > cc @damocles\n > @argus second mention", ); } #[test] fn parse_rfc3339_handles_offsets_and_z() { // Same instant expressed three ways must parse equal // (`OffsetDateTime` comparison is instant-based). let utc = parse_rfc3339("2026-06-13T09:18:42Z").unwrap(); let plus2 = parse_rfc3339("2026-06-13T11:18:42+02:00").unwrap(); let minus5 = parse_rfc3339("2026-06-13T04:18:42-05:00").unwrap(); assert_eq!(utc, plus2); assert_eq!(utc, minus5); // Fractional seconds parse; second-granularity comparison holds. assert_eq!( parse_rfc3339("2026-06-13T09:18:42.512Z") .unwrap() .unix_timestamp(), utc.unix_timestamp(), ); } #[test] fn parse_rfc3339_rejects_garbage() { assert!(parse_rfc3339("").is_none()); assert!(parse_rfc3339("not-a-date").is_none()); assert!(parse_rfc3339("2026-06-13").is_none()); } #[test] fn notification_is_creation_flags_fresh_and_later_activity() { let subject = serde_json::json!({ "created_at": "2026-06-13T11:18:40+02:00" }); // Fresh PR: notification event time == created_at → "new". let fresh = parse_rfc3339("2026-06-13T11:18:42+02:00"); assert!(notification_is_creation(fresh, Some(&subject))); // Review hours later on the same PR → not a creation. let later = parse_rfc3339("2026-06-13T14:55:00+02:00"); assert!(!notification_is_creation(later, Some(&subject))); // Missing timestamps → default to creation (preserve prior behavior). assert!(notification_is_creation(None, None)); assert!(notification_is_creation(None, Some(&subject))); assert!(notification_is_creation( fresh, Some(&serde_json::json!({})) )); } #[test] fn state_change_is_current_distinguishes_merge_from_later_comment() { // A merged/closed PR whose notification fired ~when it was closed → // the transition itself → prefer the state-change ([PR merged]) path // even though a merged PR keeps its latest_comment_url set. let merged = serde_json::json!({ "closed_at": "2026-06-13T11:18:40+02:00" }); let at_merge = parse_rfc3339("2026-06-13T11:18:42+02:00"); assert!(state_change_is_current("merged", at_merge, Some(&merged))); assert!(state_change_is_current("closed", at_merge, Some(&merged))); // A comment hours after the merge → not the transition → stays on the // comment path so the comment body survives. let later = parse_rfc3339("2026-06-13T14:55:00+02:00"); assert!(!state_change_is_current("merged", later, Some(&merged))); // Non-terminal states never override the comment path. assert!(!state_change_is_current("open", at_merge, Some(&merged))); assert!(!state_change_is_current("", at_merge, Some(&merged))); // Missing/unparseable timestamps → default true so a merge is never // hidden behind a stale comment. assert!(state_change_is_current("merged", None, Some(&merged))); assert!(state_change_is_current( "merged", at_merge, Some(&serde_json::json!({})) )); assert!(state_change_is_current("merged", None, None)); } #[test] fn parse_notification_tolerates_merged_state() { // forgejo-api's `StateType` has no "merged" variant; the raw // extraction must keep the item parseable AND preserve the // string for the `PR merged` wrapper. let polled = parse_notification(serde_json::json!({ "id": 7, "updated_at": "2026-06-13T11:18:42+02:00", "url": "http://forge/api/v1/notifications/threads/7", "subject": { "title": "t", "type": "Pull", "state": "merged", "html_url": "http://forge/o/r/pulls/5", "latest_comment_html_url": "", "latest_comment_url": "", "url": "http://forge/api/v1/repos/o/r/issues/5", }, })) .expect("merged-state notification must parse"); assert_eq!(polled.state, "merged"); // Cursor string is the raw `updated_at`, byte-identical. assert_eq!(polled.updated_at, "2026-06-13T11:18:42+02:00"); assert_eq!(polled.thread.id, Some(7)); assert_eq!( polled.thread.subject.as_ref().and_then(|s| s.r#type), Some(NotifySubjectType::Pull), ); } #[test] fn parse_notification_missing_subject_degrades() { let polled = parse_notification(serde_json::json!({ "id": 1, "updated_at": "2026-06-22T16:00:00Z", "url": "", })) .expect("subject-less notification must parse"); assert_eq!(polled.state, ""); assert!(polled.thread.subject.is_none()); } #[test] fn parse_notification_rejects_unrepresentable_item() { // A non-object item can't become a `NotificationThread`; it is // dropped alone instead of failing the page. assert!(parse_notification(serde_json::json!("nonsense")).is_none()); } #[test] fn parse_notification_survives_schema_drift() { // The whole point of the local lenient struct: a notification // carrying fields the upstream forgejo-api struct didn't expect — // an unknown top-level key, an unknown subject `type`, a blank // url — must still parse so the item can be dedup'd + marked-read. // Otherwise the parse fails, the item is dropped, and it // redelivers forever. let polled = parse_notification(serde_json::json!({ "id": 42, "updated_at": "2026-07-17T12:00:00Z", "url": "", "some_new_forgejo_field": { "nested": true }, "subject": { "title": "t", "type": "SomeBrandNewType", "html_url": "", "url": "http://forge/api/v1/repos/o/r/issues/9", "another_unknown": 123, }, })) .expect("drifted notification must still parse"); assert_eq!(polled.thread.id, Some(42)); // Unknown subject type degrades to None instead of failing. assert!( polled .thread .subject .as_ref() .and_then(|s| s.r#type) .is_none() ); // Blank html_url is None, not a parse error. assert!( polled .thread .subject .as_ref() .is_some_and(|s| s.html_url.is_none()) ); } /// 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", subject_type: Some(NotifySubjectType::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" } })); assert!(format_state_change_notification(None, "open", &meta, "damocles", None).is_none()); } #[test] fn state_change_keeps_other_authored_creation() { let meta = state_change_meta(serde_json::json!({ "user": { "login": "someone-else" } })); assert!(format_state_change_notification(None, "open", &meta, "damocles", None).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 event = parse_rfc3339("2026-06-22T16:00:00Z"); assert!( format_state_change_notification(event, "closed", &meta, "damocles", None).is_some() ); } #[test] fn comment_is_after_close_distinguishes_racing_from_kept_comment() { let subject = serde_json::json!({ "closed_at": "2026-06-13T11:18:40+02:00" }); // Comment posted a minute after the merge → a comment racing the // merge → surface it. let racing = serde_json::json!({ "created_at": "2026-06-13T11:19:40+02:00" }); assert!(comment_is_after_close(&racing, Some(&subject))); // The pre-merge last comment kept on `latest_comment_url` predates // the close → must NOT be appended. let kept = serde_json::json!({ "created_at": "2026-06-13T10:00:00+02:00" }); assert!(!comment_is_after_close(&kept, Some(&subject))); // Missing/unparseable timestamps → false (don't append a comment we // can't place after the close), opposite of the state-change default. assert!(!comment_is_after_close(&racing, None)); assert!(!comment_is_after_close( &serde_json::json!({}), Some(&subject) )); assert!(!comment_is_after_close( &racing, Some(&serde_json::json!({})) )); } #[test] fn format_state_change_appends_comment_tail() { // The racing-comment tail lands between the body block and the meta // suffix, so the assignee line stays last. let meta = state_change_meta(serde_json::json!({ "user": { "login": "someone-else" }, "body": "the PR description", })); let out = format_state_change_notification( None, "merged", &meta, "damocles", Some("\n\ncomment by argus: nice, merging".to_owned()), ) .expect("merge notification must render"); assert!(out.contains("comment by argus: nice, merging")); // Tail precedes the meta suffix. let tail_at = out.find("comment by argus").unwrap(); let assignee_at = out.find("assignee:").unwrap(); assert!(tail_at < assignee_at); } }