diff --git a/CLAUDE.md b/CLAUDE.md index 95479222..6ada7a4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -308,6 +308,10 @@ docs/ damocles-migration.md future migration plan for damocles → hyperhive gateway.md nginx vhost map, matrix discovery flow, firewall posture, HIVE_FORGE_URL loopback rationale (#764, #772, #793) + forge.md per-agent forge accounts + agent-configs mirror; + forge_notify poller (gates, self-filter, body excerpt + pipeline, wrapper formats, reason drop-list, + auto-unsubscribe) matrix.md matrix container shape, serverName/gatewayHost split, firewall + federation, provisioning flow, fluffychat-web build network.md host-side bridge + dnsmasq resolver, v1/v2 roadmap, @@ -335,6 +339,9 @@ read them à la carte. [`docs/gotchas.md`](docs/gotchas.md). - **"What nginx vhosts does the gateway serve? How does matrix discovery work?"** → [`docs/gateway.md`](docs/gateway.md). +- **"How do per-agent forge accounts work? What does forge_notify + poll + how does it format wake messages?"** → + [`docs/forge.md`](docs/forge.md). - **"How does the matrix-tuwunel container work? What about fluffychat-web and per-agent matrix accounts?"** → [`docs/matrix.md`](docs/matrix.md). diff --git a/docs/forge.md b/docs/forge.md new file mode 100644 index 00000000..ee15c5b1 --- /dev/null +++ b/docs/forge.md @@ -0,0 +1,191 @@ +# hive-forge + +Private Forgejo instance running in a nixos-container, used as the +swarm's persistent code-collaboration surface (issues, PRs, reviews, +attachments). Configured via `services.hyperhive.forge.*`. Container +shape, ROOT_URL / sub-domain routing, and operator-vs-in-cluster URL +handling live in [`docs/gateway.md`](gateway.md); this file owns the +per-agent integration story and the notification pump that wakes +each agent on relevant activity. + +## Per-agent forge accounts + +Each agent gets its own Forgejo user + access token, provisioned at +boot by `hive-c0re::forge`. The provisioning flow is idempotent: +existing accounts + tokens are reused, so container destroy/recreate +doesn't lose forge identity. The token is written to +`/forge-token` (one line, no trailing newline) inside the +agent container so `hive-forge` CLI + `forge_notify` poller can +read it without touching c0re's host-side credential store. + +Two things live in the `agent-configs` Forgejo organization: + +- A mirror repo per agent (`agent-configs/`) — c0re pushes the + agent's applied config repo on each `↻ R3BU1LD`. Agents are + read-only collaborators on `core/meta` (the hive-c0re-owned meta + flake) so they can fetch but never push. +- The dashboard links each container's "config" anchor to this + mirror, so operators can click straight from the SW4RM tab into + the rendered repo without an extra `git` step. + +The `hive-forge` CLI (separate workspace crate, see +[`README.md`](../README.md) file map) wraps the Forgejo REST API +with the per-agent token; agents call it for issue / PR / comment +ops as if it were a peer. + +## Notification poller (`hive-ag3nt/src/forge_notify.rs`) + +Background task spawned once per harness boot. Polls +`GET /api/v1/notifications?all=false` every 30 seconds (Forgejo's +unread-only filter), formats each notification as a broker +`Wake { from: "forge" }` message, and delivers it to the agent's own +inbox so claude's normal turn loop picks it up. Mark-read happens +after successful delivery so a failed-delivery notification +resurfaces on the next tick. + +### Activation gates (graceful no-ops) + +The poller starts disabled and stays that way for any of: + +- `HIVE_FORGE_URL` not set (no forge configured for this hive). +- `/forge-token` missing or empty (agent has no forge + account — pre-provisioning or destroy-without-purge race). +- Initial `reqwest::Client::builder` fails (extremely unlikely; + treated as fatal-to-the-task only). + +Disabled = the spawned task returns immediately. All other failure +modes (HTTP errors, parse errors, mark-read failures) are +best-effort: logged at debug/warn and retried next tick. + +### Self-notification filtering + +Forgejo fires notifications for the agent's own actions (it opened a +PR, posted a comment, submitted a review). Surfacing those would +loop claude on its own writes. Two filter rules drop them silently +(mark-read without delivery): + +- **Self-authored new items** — notifications with + `reason == "author"` AND subject state `open` (or missing). State + transitions (merge / close) on the agent's own PRs DO surface, + since those are triggered by someone else. +- **Self-authored comments / reviews** — comment payload's + `user.login` matches `own_login`. + +`own_login` is fetched once at startup via `GET /api/v1/user`. On +fetch failure the filter degrades open (no filtering) rather than +crashing the task — a noisy inbox beats a silently-stuck poller. + +### Body excerpt + truncation + heading escape + +The wake message embeds the comment / review / new-item body so the +agent sees actual content without a follow-up fetch. Three pipeline +steps in order: + +1. **Truncate** to `BODY_TRUNCATE = 500` chars at a char-boundary; + appends `…` when cut. Truncation happens BEFORE escape so the + mention-overflow diff (next step) compares like-for-like against + the raw body. +2. **Mention overflow extraction** — when truncation actually + trimmed content, walk the full body line-by-line and surface any + `@username` lines that fell outside the embed window. Rendered as + a trailing `mentions (truncated from body):\n > ` block. + Mention detection requires the `@` to be at line start or + following a non-username byte, so email-style `foo@bar.com` does + NOT count. +3. **ATX heading escape** — for each line that's a strict + `CommonMark` ATX heading (1-6 leading `#`s followed by a space, + tab, or end-of-line), prepend `\` so the embedded body doesn't + blow into a top-level h1/h2 inside the wrapper message when the + dashboard renders it. Lines like `#tag`, `#123`, `#!/bin/bash` + are NOT headings — no escape, no cosmetic noise. Indented + "headings" inside lists / nested quotes keep their leading + whitespace. + +The strict ATX rule is deliberate: `\#tag` and `#tag` render +identically, so an over-eager escape just adds visual clutter +without changing behavior. Setext-style headings (`title\n====`) +are not handled — rarer in practice, would need multi-line +lookahead. + +### Wrapper format + +Four shapes, distinguished by the notification's classification: + +| Trigger | Wrapper | +| --- | --- | +| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...\nreason: mention` | +| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...\nreason: review_requested` | +| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...\nreason: subscribed` | +| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...\nreason: subscribed` | + +Review labels come from the Forgejo `state` field: `APPROVED` → +`approved`, `REQUEST_CHANGES` → `changes requested`, `COMMENT` → +`review comment`. `PENDING` is dropped (review saved but not +submitted yet — no peer-visible event). Unknown states fall back to +the generic comment wrapper. + +Number is extracted from `subject.html_url`'s last path segment +(strips `#anchor` first); repo slug from `repository.full_name`. +Both degrade gracefully when absent (number → blank, repo → blank) +so unexpected Forgejo shapes don't crash the formatter. + +### Meta suffix + +Every wrapper ends with one or more of: + +- `assignee: ` — always present; `unassigned` when empty so + the line shape is stable. +- `reviewer: ` — PR notifications only, present only when + `requested_reviewers` is non-empty. +- `reason: ` — always present when the notification + carries a reason; absent when the field is null/missing. + +The `reason` line distinguishes otherwise-identical messages: Forgejo +emits one notification per applicable reason for the same event +(e.g. both `mention` and `subscribed` arrive for a PR comment that +tags the agent). Without the suffix, the agent would see duplicated +wrapper text with no signal which Forgejo path triggered each copy. + +### Review-request override + +For new PRs, the kind label flips to `[review requested #N +owner/repo]` when `own_login` appears in `requested_reviewers`, +regardless of the Forgejo `reason` field. Forgejo doesn't reliably +set `reason == "review_requested"` (often null instead), so the +fallback checks the subject payload directly. Detection is gated on +`is_new` so the label only fires once on PR creation, not on every +subsequent comment. + +### Reason drop-list + +`HIVE_FORGE_NOTIFY_SKIP_REASONS` (comma-separated) suppresses +notifications whose Forgejo `reason` matches an entry. Marked-read +silently, no delivery. Drop-list is intentionally chosen over an +allow-list: + +- Allow-list would silently miss any directed signal Forgejo adds + later (`review_requested`, `mention`, future kinds). +- Drop-list explicitly identifies the noisy paths + (`subscribed`, `participating`) and lets unknown / null reasons + pass through. + +Configured per-agent via `hyperhive.forge.skipNotifyReasons` in +`agent.nix`. Default is empty (deliver everything). + +### Auto-unsubscribe on broad watches + +Default behavior: after delivering a `reason == "subscribed"` +notification, `DELETE /api/v1/repos///subscription` is +called to drop the agent's broad-watch on that repo. The agent +remains subscribed to specific issues/PRs it interacts with, but +stops receiving the firehose of every commit / new issue. + +`HIVE_FORGE_KEEP_SUBSCRIPTIONS=1` disables this — triage agents and +other firehose consumers need to keep watching every repo activity. +Set via `hyperhive.forge.keepSubscriptions = true` in `agent.nix`. + +The auto-unsub set is process-local (a `HashSet` keyed by +`owner/repo`), so a single repo only gets one DELETE per harness +boot. After harness restart the agent might re-watch the repo via +some other path; the next `subscribed` notification re-triggers the +unsubscribe. diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index cba2cd5e..64f74168 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -1,36 +1,13 @@ -//! Background Forgejo notification poller. +//! Background Forgejo notification poller. Polls +//! `GET /notifications?all=false` every 30s, formats each unread +//! notification as a broker `Wake { from: "forge" }` message, and +//! marks it read after delivery so failures resurface next tick. //! -//! 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. +//! Activation gates, self-notification filtering, body excerpt + +//! truncation + heading escape, wrapper formats (comment / review / +//! new-item / state-change), meta suffix, review-request override, +//! reason drop-list, and auto-unsubscribe on broad watches all live +//! in [`docs/forge.md::Notification poller`](../../../docs/forge.md). use std::collections::HashSet; use std::fmt::Write as _; @@ -89,8 +66,9 @@ pub async fn run(socket: PathBuf, is_manager: bool) { } }; - // Fetch own login once for self-notification filtering (closes #230). - // Falls back to empty string on failure — no filtering (safe degradation). + // Fetch own login once for self-notification filtering. Falls back + // to empty string on failure — no filtering (safe degradation; see + // `docs/forge.md::Self-notification filtering`). let own_login = { let url = format!("{forge_url}/api/v1/user"); fetch_json(&client, &url, &token) @@ -116,15 +94,9 @@ pub async fn run(socket: PathBuf, is_manager: bool) { .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. + // Optional reason drop-list — comma-separated Forgejo `reason` + // values to silently mark-read instead of deliver. See + // `docs/forge.md::Reason drop-list` for the drop-vs-allow rationale. let skip_reasons: Vec = std::env::var("HIVE_FORGE_NOTIFY_SKIP_REASONS") .unwrap_or_default() .split(',') @@ -187,30 +159,12 @@ fn notif_type_label(t: &str) -> &str { } } -/// 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`. +/// 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') { @@ -291,9 +245,10 @@ fn is_username_byte(b: u8) -> bool { /// 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). +/// `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, @@ -335,26 +290,11 @@ fn review_state_label(state: &str) -> Option<&str> { } } -/// 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. +/// 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, @@ -392,8 +332,9 @@ async fn format_notification( .as_str() .unwrap_or(""); - // Always fetch subject detail for assignee/reviewer metadata (#256). - // Keeps agents informed of current ownership without a follow-up fetch. + // 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 { @@ -443,16 +384,17 @@ struct NotifMeta<'a> { 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). + /// Appended to every wrapper as the `reason:` line in the meta + /// suffix (see `docs/forge.md::Meta suffix`). reason: &'a str, /// Fetched subject detail (issue/PR JSON); used for review-request detection. subject: Option, is_pr: bool, } -/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...` and `\nreason: ...`) suffix -/// appended to all notification kinds. +/// Build the `\nassignee: ...` (and optionally `\nreviewer: ...` and +/// `\nreason: ...`) 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, reason: &str) -> String { let assignees: Vec<&str> = subject .and_then(|s| s["assignees"].as_array()) @@ -477,8 +419,9 @@ fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool, reason: & } else { None }; - // Always include reason so multiple notifications for the same event - // (each with a different Forgejo reason) are distinguishable (closes #110). + // Always include reason so multiple notifications for the same + // event (each with a different Forgejo reason) stay + // distinguishable. let reason_line = if reason.is_empty() { None } else { @@ -510,7 +453,7 @@ async fn format_comment_notification( .and_then(|c| c["user"]["login"].as_str()) .unwrap_or(""); - // Self-notification filter (#230): skip if we authored the comment/review. + // 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; @@ -522,10 +465,10 @@ async fn format_comment_notification( .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. + // 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()) @@ -550,22 +493,16 @@ async fn format_comment_notification( .. } = 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. + // Truncate → mention-overflow → escape, in that order. See + // `docs/forge.md::Body excerpt + truncation + heading escape` for + // why truncate comes before escape (mention diff compares against + // unescaped raw body). 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. @@ -606,10 +543,9 @@ fn format_state_change_notification( // - 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. + // Self-notification filter: drop new items we authored ourselves + // (`reason == "author"` + open state). State transitions on our + // own PRs (merge / close) come from someone else, so those stay. 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"); @@ -635,11 +571,10 @@ fn format_state_change_notification( 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). + // 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() @@ -653,21 +588,16 @@ fn format_state_change_notification( 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. + // Include the start of the issue/PR description so the agent + // gets context without a follow-up fetch. Same truncate → + // mention-overflow → escape pipeline as comment bodies (see + // `docs/forge.md::Body excerpt + truncation + heading escape`). 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); @@ -740,9 +670,9 @@ async fn poll_once( 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. + // Reason drop-list: suppress noisy reasons; null/unknown pass + // through so directed signals stay deliverable (see + // `docs/forge.md::Reason drop-list`). if !skip_reasons.is_empty() { let reason = notif["reason"].as_str().unwrap_or(""); if !reason.is_empty() && skip_reasons.iter().any(|r| r == reason) { @@ -791,10 +721,10 @@ async fn poll_once( // 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. + // Auto-unsubscribe from broad repo watches after delivering a + // `subscribed` notification. Gated by HIVE_FORGE_KEEP_SUBSCRIPTIONS + // for triage / firehose agents (see + // `docs/forge.md::Auto-unsubscribe on broad watches`). let reason = notif["reason"].as_str().unwrap_or(""); if !keep_subscriptions && reason == "subscribed" @@ -853,8 +783,8 @@ mod tests { #[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. + // 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.", @@ -905,8 +835,8 @@ mod tests { #[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 + // `#123`, `#!/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"; let escaped = escape_md_headings(body); @@ -990,13 +920,13 @@ mod tests { #[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. + // 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);