hyperhive/hive-ag3nt/src/forge_notify.rs
iris 9ed58ab96d clippy: fix lints that crane's cargoClippy properly enforces (#538)
The naersk → crane swap in the parent commit flips clippy from
silently passing to actually failing on `-D warnings` (naersk's
`mode = "clippy"` mangled the `--` separator so the deny never took
effect). This commit clears the surfaced lints so the workspace
builds clean under the new enforcement — every fix is mechanical and
preserves behaviour. Tests still pass (160 across the workspace).

Auto-fixes via `cargo clippy --fix`:
- `doc_markdown` (19 sites): bare identifiers in doc comments
  wrapped in backticks
- `format_in_format_args`, `explicit_into_iter_loop`,
  `redundant_closure_for_method_calls`, `useless_conversion`, and
  a few more — mechanical rewrites of the kind cargo can apply
  safely.

Hand-fixed:
- `match_same_arms` (forge_notify::is_atx_heading): two arms returning
  `true` collapsed into a single `matches!` pattern.
- `cast_sign_loss` + `format_push_string` (mcp.rs status formatter):
  guarded `i64 → u64` through `u64::try_from(…).unwrap_or(0)` (status
  timestamps are always positive in practice; clamp the skew edge to
  0) and swapped `out.push_str(&format!(…))` for `write!` into the
  buffer with an infallible-writer `let _ =`.
- `doc_lazy_continuation` in turn.rs + manager_server.rs + sh4re/lib.rs:
  doc paragraphs that the markdown parser was treating as list-item
  continuations got either a separating blank line or a `/`-for-`+`
  word swap so the parser stops seeing a list.
- `unused_async` (manager_server::handle_request_schedule_prompt):
  function has no `.await`; dropped the `async` and its `.await` call
  site.
- `needless_pass_by_value` (scheduled_prompts::submit): take
  `&NewSchedule` instead of moving the struct in; updated two prod
  callers and eight test sites to pass references.
- `type_complexity` (approvals::mark_cancelled): hoisted the
  7-tuple SELECT row shape into a `type CancelLookupRow = (…);` alias.

Allow-with-reason for intentional patterns:
- `option_option` (6 sites across dashboard / scheduled_prompts /
  manager_server): `Option<Option<T>>` carries three-state PATCH
  semantics (missing key = leave alone, `Some(None)` = clear,
  `Some(Some(v))` = set). Collapsing to `Option<T>` loses the
  "clear" state.
- `dead_code` (rebuild_queue::QueueKind::Destroy /
  QueueSource::CrashRecover; topology::parent_of / default_seed):
  wire-shape variants + API surfaces kept for the upcoming features
  (#361 follow-ups, future `Destroy` queue routing, crash-recovery
  path). Allowed at the variant / function level with the rationale
  in `reason = "…"`.
- `too_many_lines` on three specific call-sites: a 117-line
  exhaustive-variant test (dashboard_events::kind_tag_matches_…),
  the meta-flake string template renderer
  (meta::render_flake_with_lookup), and the notification poll loop
  (forge_notify::poll_once) — splitting any of them would just hide
  the contiguous shape they exist to keep visible.

`nix flake check` formatting target is still broken on main itself
(pre-existing nixfmt drift across ~28 files unrelated to this PR);
left alone here so the scope stays "crane port + lints the port
exposed" and the operator's review doesn't have to triage drive-by
nixfmt churn.
2026-05-29 01:45:48 +02:00

1023 lines
38 KiB
Rust

//! Background Forgejo notification poller.
//!
//! Reads `HIVE_FORGE_URL` + `{HYPERHIVE_STATE_DIR}/forge-token`, polls
//! `GET /notifications?all=false` every 30 seconds, and delivers each
//! unread notification as a broker `Wake { from: "forge" }` message so
//! claude's normal turn loop picks it up.
//!
//! Each notification is enriched with the subject body and/or latest
//! comment body so the agent sees actual content, not just a title.
//!
//! Graceful no-ops:
//! - `HIVE_FORGE_URL` not set → disabled (no forge configured)
//! - token file absent → disabled (agent has no forge account yet)
//! - HTTP errors → logged at debug, retry next tick
//!
//! After successfully delivering a notification it is marked read via
//! `PATCH /notifications/threads/{id}` so it does not re-fire. If delivery
//! fails the thread is left unread so it resurfaces next tick.
//!
//! Self-notification filtering (closes #230):
//! - New issues/PRs created by this agent (`reason == "author"` + `state == open`)
//! are silently marked read — the agent already knows it opened them.
//! - Comment notifications where the comment author matches this agent's own
//! forge login are silently marked read.
//!
//! Own login is fetched once at startup via `GET /user` and cached for the
//! lifetime of the polling loop.
//!
//! PR review formatting (closes #231):
//! - When `latest_comment_url` points to a review (the fetched JSON has a
//! `state` field like `APPROVED` / `REQUEST_CHANGES` / `COMMENT`), the
//! notification is formatted as `[PR approved #N repo]` instead of the
//! generic `[comment on PR #N repo]` so agents can action it immediately.
use std::collections::HashSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::{debug, info, warn};
const POLL_INTERVAL_SECS: u64 = 30;
const HTTP_TIMEOUT_SECS: u64 = 10;
/// Maximum characters of a body/comment to include in the wake message.
const BODY_TRUNCATE: usize = 500;
/// Spawn point: called once from `hive-ag3nt serve` (agent) or
/// `hive-m1nd serve` (manager). Returns immediately if the forge is not
/// configured. Otherwise loops forever, polling every
/// `POLL_INTERVAL_SECS` seconds. Errors are never fatal.
///
/// `is_manager`: when true, wakes the inbox via `ManagerRequest::Wake`
/// instead of `AgentRequest::Wake` (the manager socket rejects the agent
/// request type).
pub async fn run(socket: PathBuf, is_manager: bool) {
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");
let token = match tokio::fs::read_to_string(&token_path).await {
Ok(t) => {
let t = t.trim().to_owned();
if t.is_empty() {
debug!("forge_notify: empty forge token at {token_path} — disabled");
return;
}
t
}
Err(e) => {
debug!("forge_notify: no forge token at {token_path} ({e}) — disabled");
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 once for self-notification filtering (closes #230).
// Falls back to empty string on failure — no filtering (safe degradation).
let own_login = {
let url = format!("{forge_url}/api/v1/user");
fetch_json(&client, &url, &token)
.await
.and_then(|v| v["login"].as_str().map(std::borrow::ToOwned::to_owned))
.unwrap_or_default()
};
if own_login.is_empty() {
warn!("forge_notify: could not resolve own login — self-notification filtering disabled");
} 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;
// HIVE_FORGE_KEEP_SUBSCRIPTIONS=1 disables auto-unsubscribe for agents
// that intentionally consume the full repo notification firehose (e.g. triage).
let keep_subscriptions = std::env::var("HIVE_FORGE_KEEP_SUBSCRIPTIONS")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
// Optional reason drop-list. `HIVE_FORGE_NOTIFY_SKIP_REASONS` is a
// comma-separated list of Forgejo notification `reason` values to
// suppress (e.g. `subscribed,participating`). Notifications with
// those reasons are marked read and silently dropped; everything
// else -- including notifications with a null/unrecognised reason --
// is delivered. Drop-list is safer than an allow-list: it kills the
// firehose without risking silent misses of directed signals
// (review_requested, assigned) or future unknown reason strings.
// Configurable per-agent via `hyperhive.forge.skipNotifyReasons` in agent.nix.
let skip_reasons: Vec<String> = std::env::var("HIVE_FORGE_NOTIFY_SKIP_REASONS")
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
if skip_reasons.is_empty() {
info!(forge_url = %forge_url, "forge_notify: polling started (all reasons)");
} else {
info!(forge_url = %forge_url, skip = ?skip_reasons, "forge_notify: polling started");
}
// Repos we have already unsubscribed this process lifetime. Persists
// across polls so we don't hammer DELETE on every cycle.
let mut unsubbed_repos: HashSet<String> = HashSet::new();
loop {
interval.tick().await;
poll_once(
&client,
&forge_url,
&token,
&socket,
is_manager,
keep_subscriptions,
&mut unsubbed_repos,
&own_login,
&skip_reasons,
)
.await;
}
}
/// 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<serde_json::Value> {
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.
/// Known values: "Pull", "Issue", "Commit", "Repository". Any unknown
/// type is passed through as-is so new Forgejo types degrade gracefully
/// rather than silently collapsing into a generic label.
fn notif_type_label(t: &str) -> &str {
match t {
"Pull" => "PR",
"Issue" => "issue",
other => other,
}
}
/// Truncate a string to `max` bytes at a char boundary, appending `…` if cut.
/// Escape ATX-style markdown headings (`# h`, `## h`, …) in a
/// comment/review body before we embed it inline in the forge-notify
/// wrapper. The wrapper is the markdown context the dashboard's
/// `marked.parse` sees; without this, a body line like `## argus
/// review` blows into a top-level h2 in the agent's chat row,
/// dwarfing the rest of the wrapper text (closes #455).
///
/// Backslash before `#` is the standard markdown escape — `\#` renders
/// as the literal character `#`, so the line is preserved verbatim
/// without claiming heading-level styling. Indented lines keep their
/// indentation. Lines that don't start with `#` (ignoring leading
/// whitespace) are passed through unchanged. Setext-style headings
/// (`heading\n===`) are not handled here — rarer in practice and
/// would need multi-line lookahead; revisit if it actually shows up.
///
/// **ATX shape strictly:** `CommonMark` requires a space (or end-of-line)
/// after the 1-6 leading `#`s to count as an ATX heading. Lines like
/// `#tag`, `#123`, `#!/bin/bash` are NOT headings — passing them through
/// untouched avoids the cosmetic noise argus flagged on PR #518 (`\#tag`
/// renders the same as `#tag`, but the escape is unnecessary).
///
/// **Trailing newline preserved:** `split_inclusive('\n')` keeps each
/// line's terminator so the join round-trips a body that ended in `\n`.
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`,
/// `#123`) 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` / `#123`)
// 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 - 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`. Used to surface mentions that fell outside the
/// truncation window so addressed agents see they were tagged even
/// when the body is long (closes #539).
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
}
/// 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.
/// Returns `None` when the notification is a self-echo (actor is `own_login`)
/// and should be silently discarded (and marked read by the caller).
///
/// Formats:
/// - Comment: `[comment on PR #N repo] title\nurl: ...\n\nauthor: body\nassignee: user\nreason: mention`
/// - Review: `[PR approved #N repo] title\nurl: ...\n\nreviewer: body\nassignee: user\nreason: review_requested`
/// - New item: `[new issue #N repo] title\nurl: ...\nassignee: user\nreason: author`
/// - State: `[PR merged #N repo] title\nurl: ...\nassignee: user\nreason: subscribed`
///
/// Assignees (and, for PRs, `requested_reviewers`) are appended unconditionally
/// on all issue/PR notifications (closes #256).
///
/// The `reason` field from the Forgejo notification is always appended (closes #110).
/// Forgejo emits one notification entry per reason for the same event, so including
/// it makes otherwise-identical messages distinguishable (e.g. `mention` vs
/// `subscribed` both arriving for the same PR comment).
///
/// Number is extracted from `html_url` last path segment before any `#`.
/// Repo slug (`owner/name`) is always included — agents may watch multiple repos.
async fn format_notification(
client: &reqwest::Client,
token: &str,
notif: &serde_json::Value,
own_login: &str,
) -> Option<String> {
let title = notif["subject"]["title"].as_str().unwrap_or("?");
let notif_type = notif["subject"]["type"].as_str().unwrap_or("?");
let html_url = notif["subject"]["html_url"]
.as_str()
.unwrap_or_else(|| notif["subject"]["url"].as_str().unwrap_or(""));
// 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::<u64>().ok())
.map(|n| format!(" #{n}"))
.unwrap_or_default();
// Repo slug for multi-repo disambiguation. Falls back gracefully when absent.
let repo = notif["repository"]["full_name"]
.as_str()
.map(|r| format!(" {r}"))
.unwrap_or_default();
// API URLs for fetching content
let subject_api_url = notif["subject"]["url"].as_str().unwrap_or("");
let comment_api_url = notif["subject"]["latest_comment_url"]
.as_str()
.unwrap_or("");
let comment_html_url = notif["subject"]["latest_comment_html_url"]
.as_str()
.unwrap_or("");
// Always fetch subject detail for assignee/reviewer metadata (#256).
// Keeps agents informed of current ownership without a follow-up fetch.
let subject = if subject_api_url.is_empty() {
None
} else {
fetch_json(client, subject_api_url, token).await
};
let is_pr = matches!(notif_type, "Pull Request" | "Pull");
let reason = notif["reason"].as_str().unwrap_or("");
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr, reason);
// 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,
notif_type,
html_url,
num,
repo,
meta_suffix,
reason,
subject,
is_pr,
};
if has_comment {
format_comment_notification(
client,
token,
&meta,
comment_api_url,
comment_html_url,
own_login,
)
.await
} else {
format_state_change_notification(notif, &meta, own_login)
}
}
/// Shared notification metadata extracted from the raw Forgejo JSON.
struct NotifMeta<'a> {
title: &'a str,
notif_type: &'a str,
html_url: &'a str,
num: String,
repo: String,
meta_suffix: String,
/// Forgejo `reason` value (e.g. "mention", "assigned", "subscribed").
/// Appended to every formatted message so that multiple notifications for
/// the same event (each with a different reason) are distinguishable (closes #110).
reason: &'a str,
/// Fetched subject detail (issue/PR JSON); used for review-request detection.
subject: Option<serde_json::Value>,
is_pr: bool,
}
/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...` and `\nreason: ...`) suffix
/// appended to all notification kinds.
fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: &str) -> 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
};
// Always include reason so multiple notifications for the same event
// (each with a different Forgejo reason) are distinguishable (closes #110).
let reason_line = if reason.is_empty() {
None
} else {
Some(format!("reason: {reason}"))
};
let mut out = format!("\n{assignee_line}");
if let Some(r) = reviewer_line {
write!(out, "\n{r}").ok();
}
if let Some(r) = reason_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<String> {
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 (#230): 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 (#231): 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 review outcome immediately without reading the body.
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,
notif_type,
num,
repo,
meta_suffix,
..
} = meta;
// Truncate the raw body first so the mention-overflow diff compares
// like-for-like (escape_md_headings rewrites `# foo` to `\# foo`, so
// doing it before the diff would re-surface heading-prefixed mention
// lines as fake overflow). Escape happens after for display only.
let raw_excerpt = truncate(body_text, BODY_TRUNCATE);
// Surface @mentions that fell outside the truncation window so an
// addressed agent never silently misses a tag on a long comment
// (closes #539). Skipped when the embed wasn't actually truncated.
let truncated_mentions = if body_text.len() > BODY_TRUNCATE {
render_truncated_mentions(&extract_truncated_mention_lines(body_text, &raw_excerpt))
} else {
String::new()
};
// Escape ATX headings in the user-authored body so the embedded
// text doesn't blow into top-level h1/h2 in the wrapper message
// when the dashboard renders it (closes #455).
let body_for_embed = escape_md_headings(&raw_excerpt);
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() {
write!(out, "\n\nreviewer: {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(notif_type));
let mut out = format!(
"[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}{truncated_mentions}"
);
if out.ends_with('\n') {
out.pop();
}
out.push_str(meta_suffix);
Some(out)
}
}
/// Format a notification triggered by creation or state change of the subject.
fn format_state_change_notification(
notif: &serde_json::Value,
meta: &NotifMeta<'_>,
own_login: &str,
) -> Option<String> {
// Classification uses notif["subject"]["state"] directly — Forgejo
// returns "open" / "closed" / "merged" here. 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".
let notif_state = notif["subject"]["state"].as_str().unwrap_or("");
// Self-notification filter (#230): skip new items we authored ourselves.
// `reason == "author"` combined with open state means we just opened the
// issue/PR. We do NOT filter merged/closed state changes — those are
// triggered by someone else and we want them.
let is_new = notif_state == "open" || notif_state.is_empty();
if is_new && meta.reason == "author" && !own_login.is_empty() {
debug!(%own_login, "forge_notify: skipping self-authored new item");
return None;
}
let NotifMeta {
title,
notif_type,
html_url,
num,
repo,
meta_suffix,
reason: _,
subject,
is_pr,
} = meta;
let label = notif_type_label(notif_type);
let kind = match notif_state {
"merged" => format!("{label} merged{num}{repo}"),
"closed" => format!("{label} closed{num}{repo}"),
"open" | "" => format!("new {label}{num}{repo}"),
other => format!("{label}{num}{repo}: {other}"),
};
// Review-request detection (#253): Forgejo does not always set
// reason == "review_requested" (observed as null). Check
// requested_reviewers instead, which is reliable. If own_login is
// in the list, override the kind.
// subject and is_pr are already fetched unconditionally above (#256).
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 (closes #539). Same escape +
// truncate pipeline as comment bodies. Mentions that fell outside
// the truncation window are surfaced separately so an addressed
// agent never silently misses a long-body @tag.
let body_block = subject
.as_ref()
.and_then(|s| s["body"].as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|raw| {
// Truncate raw first so mention diff sees the same heading
// markers as full_body (see comment in
// format_comment_notification). Escape happens after for
// display only.
let raw_excerpt = truncate(raw, BODY_TRUNCATE);
let truncated = extract_truncated_mention_lines(raw, &raw_excerpt);
let mentions = render_truncated_mentions(&truncated);
let excerpt = escape_md_headings(&raw_excerpt);
format!("\n\n{excerpt}{mentions}")
})
.unwrap_or_default();
let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}");
out.push_str(meta_suffix);
Some(out)
}
#[allow(clippy::too_many_arguments)]
#[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(
client: &reqwest::Client,
forge_url: &str,
token: &str,
socket: &Path,
is_manager: bool,
keep_subscriptions: bool,
unsubbed_repos: &mut HashSet<String>,
own_login: &str,
skip_reasons: &[String],
) {
let url = format!("{forge_url}/api/v1/notifications?all=false&limit=50");
let resp = match client
.get(&url)
.header("Authorization", format!("token {token}"))
.send()
.await
{
Ok(r) => r,
Err(e) => {
debug!("forge_notify: poll request failed: {e}");
return;
}
};
if !resp.status().is_success() {
debug!("forge_notify: poll status {}", resp.status());
return;
}
let notifications: Vec<serde_json::Value> = match resp.json().await {
Ok(v) => v,
Err(e) => {
warn!("forge_notify: response parse error: {e}");
return;
}
};
if notifications.is_empty() {
return;
}
debug!(
count = notifications.len(),
"forge_notify: delivering notifications"
);
for notif in &notifications {
let Some(id) = notif["id"].as_u64() else {
continue;
};
// Reason drop-list: suppress noisy reasons (subscribed/participating).
// null/unknown reasons pass through — directed signals are never
// silently dropped even if Forgejo returns an unexpected value.
if !skip_reasons.is_empty() {
let reason = notif["reason"].as_str().unwrap_or("");
if !reason.is_empty() && skip_reasons.iter().any(|r| r == reason) {
debug!(%id, %reason, "forge_notify: skipping (reason in drop-list)");
mark_read(client, forge_url, token, id).await;
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(client, forge_url, token, id).await;
continue;
};
let delivered = if is_manager {
let req = hive_sh4re::ManagerRequest::Wake {
from: "forge".to_owned(),
body,
};
crate::client::request::<_, hive_sh4re::ManagerResponse>(socket, &req)
.await
.map(|_| ())
} else {
let req = hive_sh4re::AgentRequest::Wake {
from: "forge".to_owned(),
body,
};
crate::client::request::<_, hive_sh4re::AgentResponse>(socket, &req)
.await
.map(|_| ())
};
match delivered {
Ok(()) => {
debug!(%id, "forge_notify: delivered");
}
Err(e) => {
warn!(%id, error = ?e, "forge_notify: deliver failed — leaving unread");
continue;
}
}
// Mark as read only after successful delivery so a failed-delivery
// notification resurfaces on the next poll tick.
mark_read(client, forge_url, token, id).await;
// Auto-unsubscribe from broad repo watches when the notification
// reason is "subscribed" (agent watching the whole repo). Skipped
// when HIVE_FORGE_KEEP_SUBSCRIPTIONS=1 — triage and other firehose
// consumers set this to retain broad repo visibility.
let reason = notif["reason"].as_str().unwrap_or("");
if !keep_subscriptions
&& reason == "subscribed"
&& let Some(repo) = notif["repository"]["full_name"].as_str()
&& !unsubbed_repos.contains(repo)
{
let unsub_url = format!("{forge_url}/api/v1/repos/{repo}/subscription");
match client
.delete(&unsub_url)
.header("Authorization", format!("token {token}"))
.send()
.await
{
Ok(r) if r.status().is_success() || r.status().as_u16() == 404 => {
debug!(%repo, "forge_notify: unsubscribed from repo watch");
unsubbed_repos.insert(repo.to_owned());
}
Ok(r) => {
debug!(%repo, status = %r.status(), "forge_notify: unsub non-2xx (ignored)");
}
Err(e) => {
debug!(%repo, error = ?e, "forge_notify: unsub request failed (ignored)");
}
}
}
}
}
/// Mark a notification thread as read. Best-effort — logs on failure but
/// does not abort the poll loop. A notification left unread will resurface
/// on the next poll tick (desirable for delivery failures; for self-echo
/// silencing we call this without prior delivery).
async fn mark_read(client: &reqwest::Client, forge_url: &str, token: &str, id: u64) {
let mark_url = format!("{forge_url}/api/v1/notifications/threads/{id}");
match client
.patch(&mark_url)
.header("Authorization", format!("token {token}"))
.send()
.await
{
Err(e) => {
warn!(%id, error = ?e, "forge_notify: mark-read request failed — notification will resurface");
}
Ok(r) if !r.status().is_success() => {
warn!(%id, status = %r.status(), "forge_notify: mark-read returned non-2xx — notification will resurface");
}
Ok(_) => {
debug!(%id, "forge_notify: marked read");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_md_headings_escapes_top_level_atx() {
// The #455 repro: argus reviews start with `## argus review`,
// which would otherwise become an h2 in 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`,
// `#123`, `#!/bin/bash` are NOT headings — argus's PR #518
// yellow nit: don't 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";
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 argus's nit on PR #544: the diff used to
// compare full_body against the *escaped* excerpt. Lines like
// `# @argus check this` survived as-is in the body but became
// `\# @argus check this` in the excerpt, so the `contains`
// check failed and the mention was re-surfaced as if it had
// fallen outside the window. Pass the unescaped excerpt 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",
);
}
}