hive-ag3nt + docs: extract forge_notify prose (#716 batch 4)
forge_notify.rs is the biggest remaining #716 hotspot: ~22 attribution cookies (#110 #230 #231 #253 #256 #455 #518 #539 #544) plus a thick module-level docstring + per-function rationale blocks for the notification pipeline. The substantive prose lives in a new `docs/forge.md` covering the wider forge integration story: - Per-agent forge accounts + agent-configs mirror (was implicit across `hive-c0re/src/forge.rs` rustdocs). - Notification poller: activation gates, self-notification filtering, body excerpt + truncation + ATX heading escape pipeline, wrapper formats (comment / review / new-item / state-change) with shape table, meta suffix shape, review-request override, reason drop-list rationale (drop vs allow), auto-unsubscribe on broad watches. In-code rustdocs reduced to 1-line semantic summaries + doc pointers; inline cookie comments scrubbed. Net diff is ~150 lines removed from the .rs file. All 16 forge_notify unit tests pass. CLAUDE.md gets a new `docs/forge.md` file-map entry + reading-path question entry. The existing `src/forge_notify.rs` file-map blurb keeps its `(#539 / #544)` cookie — consistent with the rest of CLAUDE.md's lineage attributions.
This commit is contained in:
parent
4f9697220e
commit
76d3267b55
3 changed files with 277 additions and 149 deletions
|
|
@ -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).
|
||||
|
|
|
|||
191
docs/forge.md
Normal file
191
docs/forge.md
Normal file
|
|
@ -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
|
||||
`<state>/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/<name>`) — 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).
|
||||
- `<state>/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 > <line>` 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<body excerpt>\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: <list>` — always present; `unassigned` when empty so
|
||||
the line shape is stable.
|
||||
- `reviewer: <list>` — PR notifications only, present only when
|
||||
`requested_reviewers` is non-empty.
|
||||
- `reason: <forgejo-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/<owner>/<repo>/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<String>` 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.
|
||||
|
|
@ -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<String> = 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<serde_json::Value>,
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue