1186 lines
45 KiB
Rust
1186 lines
45 KiB
Rust
//! Background Forgejo notification poller. Polls
|
|
//! `GET /notifications?all=false` every 30s, formats each unread
|
|
//! notification as a broker `Wake { from: "forge" }` message, and
|
|
//! delivers it to the agent's inbox. Delivered threads are deliberately
|
|
//! left UNREAD in forge — the hive-forge read-before-comment guard keys
|
|
//! off forge's own unread-state, and the agent reading the thread via the
|
|
//! CLI is what marks it read. A delivery-dedupe cursor (thread id →
|
|
//! last-delivered `updated_at`) stops the still-unread notification from
|
|
//! re-firing a wake every poll; self-echo and drop-listed notifications
|
|
//! are still marked read directly. The cursor is persisted as the
|
|
//! `forge_cursor` field of the harness's consolidated `hyperhive-harness.json`
|
|
//! (via [`crate::events`]) and reloaded on boot so a container
|
|
//! rebuild/restart doesn't re-deliver the whole currently-unread backlog —
|
|
//! it is a private dedup mirror, NOT forge's read-state, so the
|
|
//! read-before-comment guard is untouched.
|
|
//!
|
|
//! Activation gates, self-notification filtering, body excerpt +
|
|
//! truncation + heading escape, wrapper formats (comment / review /
|
|
//! new-item / state-change), meta suffix, and review-request override
|
|
//! all live in [`docs/forge.md::Notification poller`](../../../docs/forge.md).
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::fmt::Write as _;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
use 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;
|
|
/// How long to wait between token-read retries when the token file is
|
|
/// missing or unreadable at startup (e.g. hive-priv hasn't provisioned
|
|
/// it yet, or a chown race left it temporarily root-owned).
|
|
const TOKEN_RETRY_SECS: u64 = 30;
|
|
/// Give up waiting for the token after this many retries (~10 minutes).
|
|
/// Avoids an infinite wait on agents that genuinely have no forge account.
|
|
const TOKEN_RETRY_MAX: u32 = 20;
|
|
/// How close (seconds) the notification's event time must be to a
|
|
/// subject's `created_at` for us to call it a genuine creation and emit
|
|
/// a `new <kind>` label. Later activity that lands on the state-change
|
|
/// path because it carries no `latest_comment_url` (e.g. a bodiless
|
|
/// review submission) fires well outside this window, so we must not
|
|
/// claim it's "new" — see docs/forge.md, "new vs activity on".
|
|
const NEW_ITEM_TOLERANCE_SECS: i64 = 120;
|
|
|
|
/// Spawn point: called once from `hive serve`. Returns immediately if the forge is not
|
|
/// configured. Otherwise loops forever, polling every
|
|
/// `POLL_INTERVAL_SECS` seconds. Errors are never fatal.
|
|
///
|
|
pub async fn run(socket: PathBuf) {
|
|
let forge_url = match std::env::var("HIVE_FORGE_URL") {
|
|
Ok(u) if !u.is_empty() => u,
|
|
_ => {
|
|
debug!("forge_notify: HIVE_FORGE_URL not set — disabled");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let state_dir = std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default();
|
|
let token_path = format!("{state_dir}/forge-token");
|
|
// Retry reading the token to handle races where hive-priv provisions the
|
|
// token after the harness starts, or where a parent-container chown briefly
|
|
// makes the file unreadable. We wait up to
|
|
// TOKEN_RETRY_MAX * TOKEN_RETRY_SECS before giving up.
|
|
let token = {
|
|
let mut attempts = 0u32;
|
|
loop {
|
|
match tokio::fs::read_to_string(&token_path).await {
|
|
Ok(t) => {
|
|
let t = t.trim().to_owned();
|
|
if !t.is_empty() {
|
|
break t;
|
|
}
|
|
debug!("forge_notify: empty forge token at {token_path}");
|
|
}
|
|
Err(e) => {
|
|
debug!("forge_notify: cannot read token at {token_path}: {e}");
|
|
}
|
|
}
|
|
attempts += 1;
|
|
if attempts >= TOKEN_RETRY_MAX {
|
|
debug!(
|
|
"forge_notify: token not available after {TOKEN_RETRY_MAX} retries — disabled"
|
|
);
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(TOKEN_RETRY_SECS)).await;
|
|
}
|
|
};
|
|
|
|
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. 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)
|
|
.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;
|
|
|
|
info!(forge_url = %forge_url, "forge_notify: polling started");
|
|
|
|
// Delivery-dedupe cursor: notification thread id -> the `updated_at`
|
|
// of the version we last woke the agent for. We no longer mark a
|
|
// thread read on delivery (that would consume the unread signal the
|
|
// hive-forge read-before-comment guard relies on), so this map is what
|
|
// stops the same unread notification from re-firing a wake every poll.
|
|
// A new comment bumps `updated_at`, so the thread re-delivers. This is
|
|
// purely anti-spam, NOT a correctness oracle.
|
|
//
|
|
// It is persisted as the `forge_cursor` field of the harness's
|
|
// consolidated state file and reloaded here on boot so a container
|
|
// rebuild/restart doesn't re-deliver the entire currently-unread
|
|
// backlog. Persisting is safe because we only record a thread AFTER a
|
|
// successful broker delivery, and the broker inbox is durable sqlite —
|
|
// so a persisted "delivered" entry can never swallow a wake the agent
|
|
// never received. The cursor is a private dedup mirror, decoupled from
|
|
// forge's own read-state, so it doesn't reintroduce the
|
|
// read-before-comment coupling that the mark-read-on-delivery approach
|
|
// suffered.
|
|
let mut delivered: HashMap<u64, String> = crate::events::read_forge_cursor();
|
|
if !delivered.is_empty() {
|
|
info!(
|
|
entries = delivered.len(),
|
|
"forge_notify: restored delivery-dedupe cursor"
|
|
);
|
|
}
|
|
|
|
loop {
|
|
interval.tick().await;
|
|
poll_once(
|
|
&client,
|
|
&forge_url,
|
|
&token,
|
|
&socket,
|
|
&mut delivered,
|
|
&own_login,
|
|
)
|
|
.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,
|
|
}
|
|
}
|
|
|
|
/// Escape strict-ATX markdown headings in a body before embedding it
|
|
/// inside the forge-notify wrapper, so a leading `## title` line
|
|
/// doesn't blow into an h2 in the dashboard render. See
|
|
/// `docs/forge.md::Body excerpt + truncation + heading escape` for
|
|
/// the strict-ATX-vs-`#tag` rationale and the `split_inclusive`
|
|
/// trailing-newline contract.
|
|
fn escape_md_headings(body: &str) -> String {
|
|
let mut out = String::with_capacity(body.len());
|
|
for line in body.split_inclusive('\n') {
|
|
let (content, terminator) = match line.strip_suffix('\n') {
|
|
Some(rest) => (rest, "\n"),
|
|
None => (line, ""),
|
|
};
|
|
let trimmed = content.trim_start();
|
|
if is_atx_heading(trimmed) {
|
|
let lead = &content[..content.len() - trimmed.len()];
|
|
out.push_str(lead);
|
|
out.push('\\');
|
|
out.push_str(trimmed);
|
|
} else {
|
|
out.push_str(content);
|
|
}
|
|
out.push_str(terminator);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Strict `CommonMark` ATX-heading detector: 1-6 leading `#`s followed
|
|
/// by either a space, tab, or end-of-line. Anything tighter (`#tag`,
|
|
/// `#9`) is a non-heading line that the renderer will not promote.
|
|
fn is_atx_heading(line: &str) -> bool {
|
|
let hashes = line.bytes().take_while(|&b| b == b'#').count();
|
|
if !(1..=6).contains(&hashes) {
|
|
return false;
|
|
}
|
|
// Bare `#` / `##` / ... on its own line, or proper ATX with a
|
|
// space/tab after the run of `#`s; anything else (`#tag` / `#9`)
|
|
// is not a heading.
|
|
matches!(line.as_bytes().get(hashes), None | Some(b' ' | b'\t'))
|
|
}
|
|
|
|
fn truncate(s: &str, max: usize) -> String {
|
|
if s.len() <= max {
|
|
return s.to_owned();
|
|
}
|
|
let end = s
|
|
.char_indices()
|
|
.map(|(i, _)| i)
|
|
.take_while(|&i| i <= max - 3)
|
|
.last()
|
|
.unwrap_or(0);
|
|
format!("{}…", &s[..end])
|
|
}
|
|
|
|
/// Detect `@username` mentions on a line. A mention is `@` followed by
|
|
/// at least one username char (alphanumeric / `_` / `-`) where the `@`
|
|
/// is at line start or follows a non-username char — so email-style
|
|
/// `foo@bar.com` does NOT count as a mention.
|
|
fn contains_mention(line: &str) -> bool {
|
|
let bytes = line.as_bytes();
|
|
for (i, &b) in bytes.iter().enumerate() {
|
|
if b != b'@' {
|
|
continue;
|
|
}
|
|
// Boundary: preceding byte must NOT be a username char.
|
|
let boundary_ok = match i.checked_sub(1).map(|j| bytes[j]) {
|
|
None => true,
|
|
Some(prev) => !is_username_byte(prev),
|
|
};
|
|
if !boundary_ok {
|
|
continue;
|
|
}
|
|
// Following byte must be at least one username char.
|
|
if bytes.get(i + 1).is_some_and(|&c| is_username_byte(c)) {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn is_username_byte(b: u8) -> bool {
|
|
b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
|
|
}
|
|
|
|
/// Walk `full_body` line-by-line; return lines that contain an
|
|
/// `@username` mention AND aren't already present (as a substring) in
|
|
/// `included_excerpt`. Surfaces tags that fell outside the truncation
|
|
/// window so addressed agents never silently miss a mention on a long
|
|
/// body. See `docs/forge.md::Body excerpt + truncation + heading
|
|
/// escape` for the truncate-before-escape ordering rule.
|
|
fn extract_truncated_mention_lines<'a>(full_body: &'a str, included_excerpt: &str) -> Vec<&'a str> {
|
|
full_body
|
|
.lines()
|
|
.filter(|line| {
|
|
let trimmed = line.trim();
|
|
!trimmed.is_empty() && contains_mention(trimmed)
|
|
})
|
|
.filter(|line| !included_excerpt.contains(line.trim()))
|
|
.collect()
|
|
}
|
|
|
|
/// Build the trailing `mentions (truncated from body):\n > …` block.
|
|
/// Empty string when there's nothing to surface. Caller embeds it
|
|
/// directly before the meta suffix.
|
|
fn render_truncated_mentions(lines: &[&str]) -> String {
|
|
if lines.is_empty() {
|
|
return String::new();
|
|
}
|
|
let mut out = String::from("\n\nmentions (truncated from body):");
|
|
for line in lines {
|
|
write!(out, "\n > {}", line.trim()).ok();
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Map a Forgejo review state to a readable action label.
|
|
/// Returns `None` for non-review states (regular comments have no `state` field;
|
|
/// `PENDING` means the review was saved but not submitted yet).
|
|
/// Forgejo review states: "APPROVED", "`REQUEST_CHANGES`", "COMMENT", "PENDING".
|
|
fn review_state_label(state: &str) -> Option<&str> {
|
|
match state {
|
|
"APPROVED" => Some("approved"),
|
|
"REQUEST_CHANGES" => Some("changes requested"),
|
|
"COMMENT" => Some("review comment"),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Build a human-readable wake message for one Forgejo notification,
|
|
/// or `None` for a self-echo the caller should mark-read without
|
|
/// delivery. Wrapper format table + meta-suffix shape + number/repo
|
|
/// extraction live in `docs/forge.md::Wrapper format` +
|
|
/// `::Meta suffix`.
|
|
async fn format_notification(
|
|
client: &reqwest::Client,
|
|
token: &str,
|
|
notif: &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 so
|
|
// the meta suffix can show current ownership without a follow-up
|
|
// fetch (see `docs/forge.md::Meta suffix`).
|
|
let subject = if subject_api_url.is_empty() {
|
|
None
|
|
} else {
|
|
fetch_json(client, subject_api_url, token).await
|
|
};
|
|
|
|
let is_pr = matches!(notif_type, "Pull Request" | "Pull");
|
|
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr);
|
|
|
|
// Determine whether this notification was triggered by a comment/review or
|
|
// by creation/state-change of the subject itself.
|
|
let has_comment = !comment_api_url.is_empty() && comment_api_url != subject_api_url;
|
|
|
|
let meta = NotifMeta {
|
|
title,
|
|
notif_type,
|
|
html_url,
|
|
num,
|
|
repo,
|
|
meta_suffix,
|
|
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,
|
|
/// 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: ...`)
|
|
/// suffix appended to every wrapper. Shape + presence rules live in
|
|
/// `docs/forge.md::Meta suffix`.
|
|
fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool) -> String {
|
|
let assignees: Vec<&str> = subject
|
|
.and_then(|s| s["assignees"].as_array())
|
|
.map(|arr| arr.iter().filter_map(|a| a["login"].as_str()).collect())
|
|
.unwrap_or_default();
|
|
let assignee_line = if assignees.is_empty() {
|
|
"assignee: unassigned".to_owned()
|
|
} else {
|
|
format!("assignee: {}", assignees.join(", "))
|
|
};
|
|
// For PRs, include requested_reviewers when present.
|
|
let reviewer_line = if is_pr {
|
|
let reviewers: Vec<&str> = subject
|
|
.and_then(|s| s["requested_reviewers"].as_array())
|
|
.map(|arr| arr.iter().filter_map(|r| r["login"].as_str()).collect())
|
|
.unwrap_or_default();
|
|
if reviewers.is_empty() {
|
|
None
|
|
} else {
|
|
Some(format!("reviewer: {}", reviewers.join(", ")))
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let mut out = format!("\n{assignee_line}");
|
|
if let Some(r) = reviewer_line {
|
|
write!(out, "\n{r}").ok();
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Format a notification triggered by a new comment or review submission.
|
|
async fn format_comment_notification(
|
|
client: &reqwest::Client,
|
|
token: &str,
|
|
meta: &NotifMeta<'_>,
|
|
comment_api_url: &str,
|
|
comment_html_url: &str,
|
|
own_login: &str,
|
|
) -> Option<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: skip if we authored the comment/review.
|
|
if !own_login.is_empty() && actor_login == own_login {
|
|
debug!(%own_login, "forge_notify: skipping self-authored comment/review");
|
|
return None;
|
|
}
|
|
|
|
let body_text = payload
|
|
.as_ref()
|
|
.and_then(|c| c["body"].as_str())
|
|
.unwrap_or("")
|
|
.trim();
|
|
|
|
// PR review detection: Forgejo review objects carry a `state`
|
|
// field with values like "APPROVED" / "REQUEST_CHANGES" /
|
|
// "COMMENT". Regular issue/PR comments have no such field. Format
|
|
// reviews distinctly so the agent knows the outcome at a glance.
|
|
let review_state = payload
|
|
.as_ref()
|
|
.and_then(|c| c["state"].as_str())
|
|
.and_then(review_state_label);
|
|
|
|
let url = if comment_html_url.is_empty() {
|
|
meta.html_url
|
|
} else {
|
|
comment_html_url
|
|
};
|
|
let author = if actor_login.is_empty() {
|
|
"?"
|
|
} else {
|
|
actor_login
|
|
};
|
|
let NotifMeta {
|
|
title,
|
|
notif_type,
|
|
num,
|
|
repo,
|
|
meta_suffix,
|
|
..
|
|
} = meta;
|
|
|
|
// 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);
|
|
let truncated_mentions = if body_text.len() > BODY_TRUNCATE {
|
|
render_truncated_mentions(&extract_truncated_mention_lines(body_text, &raw_excerpt))
|
|
} else {
|
|
String::new()
|
|
};
|
|
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.
|
|
///
|
|
/// Returns `None` for an agent's own *creation* (it opened the issue/PR) —
|
|
/// the same "don't loop claude on its own writes" rule the comment/review
|
|
/// path applies. Only creations are dropped: a later state change on the
|
|
/// agent's own subject is driven by someone else and stays a wake.
|
|
fn format_state_change_notification(
|
|
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("");
|
|
|
|
// "New" = the subject is open (or state is absent). Used below for
|
|
// the review-request override.
|
|
let is_new = notif_state == "open" || notif_state.is_empty();
|
|
|
|
let NotifMeta {
|
|
title,
|
|
notif_type,
|
|
html_url,
|
|
num,
|
|
repo,
|
|
meta_suffix,
|
|
subject,
|
|
is_pr,
|
|
} = meta;
|
|
let label = notif_type_label(notif_type);
|
|
// Only claim "new" when the notification actually fired at creation
|
|
// time. A review submitted with no body carries no
|
|
// `latest_comment_url`, so it lands here instead of on the comment
|
|
// path — and its event time is well after `created_at`. Labeling
|
|
// that "new PR" is misleading (see docs/forge.md, "new vs activity
|
|
// on"): agents dismiss it as a
|
|
// duplicate of the original open notification. When we can't confirm
|
|
// creation, fall back to a neutral "activity on" label.
|
|
let looks_new = notification_is_creation(notif, subject.as_ref());
|
|
|
|
// Self-authored creation filter: skip an agent being woken by its own
|
|
// freshly-opened issue/PR. The subject payload is already fetched (for
|
|
// assignees / reviewers / body), so its poster `user.login` costs no
|
|
// extra request. Mirrors the self-authored comment/review drop above.
|
|
if looks_new && !own_login.is_empty() {
|
|
let author = subject
|
|
.as_ref()
|
|
.and_then(|s| s["user"]["login"].as_str())
|
|
.unwrap_or("");
|
|
if author == own_login {
|
|
debug!(%own_login, "forge_notify: skipping self-authored creation");
|
|
return None;
|
|
}
|
|
}
|
|
|
|
let kind = match notif_state {
|
|
"merged" => format!("{label} merged{num}{repo}"),
|
|
"closed" => format!("{label} closed{num}{repo}"),
|
|
"open" | "" if looks_new => format!("new {label}{num}{repo}"),
|
|
"open" | "" => format!("activity on {label}{num}{repo}"),
|
|
other => format!("{label}{num}{repo}: {other}"),
|
|
};
|
|
|
|
// Review-request override: Forgejo doesn't reliably set
|
|
// `reason == "review_requested"` (often null), so we check the
|
|
// subject payload's `requested_reviewers` list directly. See
|
|
// `docs/forge.md::Review-request override`.
|
|
let is_review_request = is_new
|
|
&& *is_pr
|
|
&& !own_login.is_empty()
|
|
&& subject
|
|
.as_ref()
|
|
.and_then(|s| s["requested_reviewers"].as_array())
|
|
.is_some_and(|arr| arr.iter().any(|r| r["login"].as_str() == Some(own_login)));
|
|
let kind = if is_review_request {
|
|
format!("review requested{num}{repo}")
|
|
} else {
|
|
kind
|
|
};
|
|
|
|
// Include the start of the issue/PR description so the agent
|
|
// gets context without a follow-up fetch. Same 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| {
|
|
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)
|
|
}
|
|
|
|
/// Decide whether a state-change notification represents the subject's
|
|
/// *creation* (so a `new <kind>` label is truthful) versus later
|
|
/// activity that merely lacked a `latest_comment_url`. Compares the
|
|
/// notification's event time (`updated_at`) against the subject's
|
|
/// `created_at`: within `NEW_ITEM_TOLERANCE_SECS` ⇒ creation. When
|
|
/// either timestamp is missing or unparseable we default to `true`,
|
|
/// preserving the prior "new" behavior rather than masking a genuine
|
|
/// new item behind the neutral fallback. See docs/forge.md, "new vs
|
|
/// activity on".
|
|
fn notification_is_creation(
|
|
notif: &serde_json::Value,
|
|
subject: Option<&serde_json::Value>,
|
|
) -> bool {
|
|
let created = subject
|
|
.and_then(|s| s["created_at"].as_str())
|
|
.and_then(parse_rfc3339_secs);
|
|
let event = notif["updated_at"].as_str().and_then(parse_rfc3339_secs);
|
|
match (created, event) {
|
|
(Some(c), Some(e)) => (e - c).abs() <= NEW_ITEM_TOLERANCE_SECS,
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
/// Minimal dependency-free RFC 3339 / ISO 8601 parser → Unix epoch
|
|
/// seconds. Forgejo emits timestamps like `2026-06-13T11:18:42+02:00`
|
|
/// or `...Z`, optionally with fractional seconds. We only need
|
|
/// second-granularity comparison, so the fractional part is skipped.
|
|
/// Returns `None` on any shape we don't recognise so callers can fall
|
|
/// back gracefully.
|
|
fn parse_rfc3339_secs(s: &str) -> Option<i64> {
|
|
if s.len() < 19 {
|
|
return None;
|
|
}
|
|
let year: i64 = s.get(0..4)?.parse().ok()?;
|
|
let month: i64 = s.get(5..7)?.parse().ok()?;
|
|
let day: i64 = s.get(8..10)?.parse().ok()?;
|
|
let hour: i64 = s.get(11..13)?.parse().ok()?;
|
|
let minute: i64 = s.get(14..16)?.parse().ok()?;
|
|
let second: i64 = s.get(17..19)?.parse().ok()?;
|
|
|
|
let mut epoch =
|
|
days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second;
|
|
|
|
// Remainder after seconds: optional `.fff` fraction, then a zone.
|
|
let mut rest = &s[19..];
|
|
if let Some(frac) = rest.strip_prefix('.') {
|
|
let end = frac
|
|
.find(|c: char| !c.is_ascii_digit())
|
|
.unwrap_or(frac.len());
|
|
rest = &frac[end..];
|
|
}
|
|
// Zone: `Z`/empty = UTC; otherwise `±HH:MM`. Subtract the offset to
|
|
// normalise to UTC epoch seconds.
|
|
if !(rest.is_empty() || rest.starts_with('Z')) {
|
|
let sign = rest.as_bytes()[0];
|
|
let off = &rest[1..];
|
|
let oh: i64 = off.get(0..2)?.parse().ok()?;
|
|
let om: i64 = off.get(3..5).unwrap_or("00").parse().ok()?;
|
|
let offset = oh * 3_600 + om * 60;
|
|
match sign {
|
|
b'+' => epoch -= offset,
|
|
b'-' => epoch += offset,
|
|
_ => return None,
|
|
}
|
|
}
|
|
Some(epoch)
|
|
}
|
|
|
|
/// Days since the Unix epoch for a proleptic-Gregorian `y-m-d`
|
|
/// (Howard Hinnant's `days_from_civil`). Valid for all dates Forgejo
|
|
/// can emit.
|
|
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
|
let y = if m <= 2 { y - 1 } else { y };
|
|
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
|
let yoe = y - era * 400;
|
|
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
era * 146_097 + doe - 719_468
|
|
}
|
|
|
|
#[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,
|
|
delivered: &mut HashMap<u64, String>,
|
|
own_login: &str,
|
|
) {
|
|
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"
|
|
);
|
|
|
|
// Tracks whether the dedupe cursor changed this poll (a new delivery
|
|
// recorded, or the prune below dropped now-read threads) so we only
|
|
// rewrite the on-disk cursor when there's something to persist.
|
|
let mut cursor_dirty = false;
|
|
|
|
for notif in ¬ifications {
|
|
let Some(id) = notif["id"].as_u64() else {
|
|
continue;
|
|
};
|
|
|
|
// Delivery-dedupe: we no longer mark threads read on delivery, so
|
|
// an unread notification reappears in every `?all=false` poll.
|
|
// Skip it silently unless its `updated_at` advanced since the
|
|
// version we last delivered a wake for (i.e. genuinely new
|
|
// activity). See the `delivered` cursor note in `run`.
|
|
let updated_at = notif["updated_at"].as_str().unwrap_or("").to_owned();
|
|
if !should_deliver(delivered, id, &updated_at) {
|
|
debug!(%id, "forge_notify: skipping (already delivered this version)");
|
|
continue;
|
|
}
|
|
|
|
let body_opt = format_notification(client, token, notif, own_login).await;
|
|
|
|
// None means self-echo — mark read silently, no delivery.
|
|
let Some(body) = body_opt else {
|
|
mark_read(client, forge_url, token, id).await;
|
|
continue;
|
|
};
|
|
|
|
let req = hive_sh4re::Request::Wake {
|
|
from: "forge".to_owned(),
|
|
body,
|
|
transient: false,
|
|
};
|
|
let deliver_result = crate::client::request::<_, hive_sh4re::Response>(socket, &req)
|
|
.await
|
|
.map(|_| ());
|
|
match deliver_result {
|
|
Ok(()) => {
|
|
debug!(%id, "forge_notify: delivered");
|
|
// Record the delivered version in the dedupe cursor INSTEAD
|
|
// of marking the thread read. Leaving it unread is
|
|
// deliberate: the hive-forge read-before-comment guard keys
|
|
// off forge's own unread-state, and the agent reading the
|
|
// thread via the CLI is what marks it read. Recorded only
|
|
// here in the Ok arm — a failed delivery leaves the cursor
|
|
// untouched, so it re-delivers next tick.
|
|
delivered.insert(id, updated_at);
|
|
cursor_dirty = true;
|
|
}
|
|
Err(e) => {
|
|
warn!(%id, error = ?e, "forge_notify: deliver failed — leaving unread");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prune the dedupe cursor down to the threads still present in this
|
|
// poll's unread set. Once the agent reads a thread (marking it read
|
|
// via the CLI) it drops out of `?all=false`, so its cursor entry is
|
|
// dead weight; dropping it bounds the map to the current unread size.
|
|
// If such a thread later goes unread again it carries a fresh
|
|
// `updated_at` and re-delivers correctly.
|
|
let current_ids: HashSet<u64> = notifications
|
|
.iter()
|
|
.filter_map(|n| n["id"].as_u64())
|
|
.collect();
|
|
let before_prune = delivered.len();
|
|
delivered.retain(|id, _| current_ids.contains(id));
|
|
if delivered.len() != before_prune {
|
|
cursor_dirty = true;
|
|
}
|
|
|
|
// Flush the cursor to the consolidated state file only when it
|
|
// changed, so a rebuild/restart reloads it instead of re-delivering
|
|
// the whole unread backlog.
|
|
if cursor_dirty {
|
|
crate::events::write_forge_cursor(delivered);
|
|
}
|
|
}
|
|
|
|
/// Whether a notification should be delivered as a wake given the
|
|
/// delivery-dedupe cursor. Delivers when the thread has never been
|
|
/// delivered, or when its `updated_at` advanced since the last delivered
|
|
/// version (genuinely new activity). Pure for unit testing.
|
|
fn should_deliver(delivered: &HashMap<u64, String>, id: u64, updated_at: &str) -> bool {
|
|
delivered.get(&id).is_none_or(|seen| seen != updated_at)
|
|
}
|
|
|
|
/// Mark a notification thread as read. Best-effort — logs on failure but
|
|
/// does not abort the poll loop. Called only on the self-echo and
|
|
/// drop-listed paths (the agent's own writes / explicitly-suppressed
|
|
/// reasons) — delivered threads are deliberately left unread for the
|
|
/// read-before-comment guard, and a failed delivery is left unread + out
|
|
/// of the dedupe cursor so it resurfaces on the next poll tick.
|
|
async fn mark_read(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 should_deliver_when_thread_never_seen() {
|
|
let delivered = HashMap::new();
|
|
assert!(should_deliver(&delivered, 42, "2026-06-22T16:00:00Z"));
|
|
}
|
|
|
|
#[test]
|
|
fn should_not_deliver_same_version_again() {
|
|
// The dedupe case: an unread thread reappears every poll with the
|
|
// same `updated_at` — must not re-fire a wake.
|
|
let mut delivered = HashMap::new();
|
|
delivered.insert(42, "2026-06-22T16:00:00Z".to_owned());
|
|
assert!(!should_deliver(&delivered, 42, "2026-06-22T16:00:00Z"));
|
|
}
|
|
|
|
#[test]
|
|
fn should_deliver_when_updated_at_advanced() {
|
|
// A new comment bumps `updated_at` → genuinely new activity →
|
|
// deliver again.
|
|
let mut delivered = HashMap::new();
|
|
delivered.insert(42, "2026-06-22T16:00:00Z".to_owned());
|
|
assert!(should_deliver(&delivered, 42, "2026-06-22T16:05:00Z"));
|
|
}
|
|
|
|
#[test]
|
|
fn should_deliver_tracks_per_thread() {
|
|
// A cursor for one thread says nothing about another.
|
|
let mut delivered = HashMap::new();
|
|
delivered.insert(42, "2026-06-22T16:00:00Z".to_owned());
|
|
assert!(should_deliver(&delivered, 99, "2026-06-22T16:00:00Z"));
|
|
}
|
|
|
|
#[test]
|
|
fn escape_md_headings_escapes_top_level_atx() {
|
|
// Argus reviews start with `## argus review`, which would
|
|
// otherwise become an h2 inside the wrapper message.
|
|
assert_eq!(
|
|
escape_md_headings("## argus review\n\nlgtm."),
|
|
"\\## argus review\n\nlgtm.",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn escape_md_headings_escapes_all_heading_depths() {
|
|
let body = "# h1\n## h2\n### h3\n###### h6\nbody";
|
|
assert_eq!(
|
|
escape_md_headings(body),
|
|
"\\# h1\n\\## h2\n\\### h3\n\\###### h6\nbody",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn escape_md_headings_preserves_indent() {
|
|
// Indented "headings" inside lists / nested quotes keep
|
|
// their leading whitespace so structure isn't visually
|
|
// collapsed by the escape.
|
|
assert_eq!(
|
|
escape_md_headings(" ## indented\nbody"),
|
|
" \\## indented\nbody",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn escape_md_headings_passes_non_heading_lines_through() {
|
|
let body = "plain text\nwith a #hashtag in middle\n```\n# in fenced code\n```";
|
|
let escaped = escape_md_headings(body);
|
|
// Lines without leading `#` are untouched. The `# in fenced
|
|
// code` line still gets escaped (we don't track fenced-code
|
|
// state) — acceptable: inside a fenced block the escape is
|
|
// visually inert anyway because the renderer treats the
|
|
// content as literal.
|
|
assert!(escaped.contains("plain text"));
|
|
assert!(escaped.contains("with a #hashtag in middle"));
|
|
assert!(escaped.contains("\\# in fenced code"));
|
|
}
|
|
|
|
#[test]
|
|
fn escape_md_headings_handles_empty_and_whitespace_only() {
|
|
assert_eq!(escape_md_headings(""), "");
|
|
assert_eq!(escape_md_headings(" "), " ");
|
|
assert_eq!(escape_md_headings("\n\n"), "\n\n");
|
|
}
|
|
|
|
#[test]
|
|
fn escape_md_headings_skips_non_atx_hash_lines() {
|
|
// ATX requires a space after the `#`s. Lines like `#tag`, a
|
|
// hash-then-digits run, or `#!/bin/bash` are NOT headings —
|
|
// escaping them would just add cosmetic noise where the
|
|
// renderer wouldn't promote the line in the first place.
|
|
let body = "#tag\n#123\n#!/bin/bash\n####### too many hashes\nbody"; // lint:allow: hash-digit heading test input, not a tracker tag
|
|
let escaped = escape_md_headings(body);
|
|
// All four leading `#` lines pass through untouched: too few
|
|
// (still need space), seven `#`s (over the cap), shebang
|
|
// (no space).
|
|
assert_eq!(escaped, body);
|
|
}
|
|
|
|
#[test]
|
|
fn escape_md_headings_handles_bare_hash_lines() {
|
|
// `#` alone on a line IS a valid ATX (h1 with empty text) per
|
|
// CommonMark; escape it to match the renderer's behaviour.
|
|
assert_eq!(escape_md_headings("#"), "\\#");
|
|
assert_eq!(escape_md_headings("##"), "\\##");
|
|
assert_eq!(escape_md_headings("###"), "\\###");
|
|
}
|
|
|
|
#[test]
|
|
fn escape_md_headings_preserves_trailing_newline() {
|
|
// `split_inclusive('\n')` round-trips a body ending in a
|
|
// newline. Important for embedded forge-notify bodies whose
|
|
// source already terminates with `\n` — the wrapper's spacing
|
|
// otherwise gets eaten.
|
|
assert_eq!(escape_md_headings("## h\n"), "\\## h\n");
|
|
assert_eq!(escape_md_headings("body\n"), "body\n");
|
|
assert_eq!(escape_md_headings("no trailing"), "no trailing");
|
|
}
|
|
|
|
#[test]
|
|
fn contains_mention_matches_at_line_start_and_mid_line() {
|
|
assert!(contains_mention("@damocles take a look"));
|
|
assert!(contains_mention("cc @argus please"));
|
|
assert!(contains_mention("see (@mara) for context"));
|
|
// Hyphens / underscores / digits are valid username chars.
|
|
assert!(contains_mention("ping @h-m1nd-2"));
|
|
}
|
|
|
|
#[test]
|
|
fn contains_mention_rejects_email_and_bare_at() {
|
|
// Email addresses (`foo@bar.com`) and `@` followed by
|
|
// whitespace or punctuation are not mentions — boundary check
|
|
// requires the preceding byte to NOT be a username char.
|
|
assert!(!contains_mention("foo@bar.com"));
|
|
assert!(!contains_mention("send to user@example.org"));
|
|
assert!(!contains_mention("just an @"));
|
|
assert!(!contains_mention("@ space"));
|
|
assert!(!contains_mention("plain text"));
|
|
assert!(!contains_mention(""));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_truncated_keeps_mention_lines_outside_excerpt() {
|
|
// Long body where the @mention sits AFTER the excerpt's cutoff
|
|
// — the truncated extractor must surface it.
|
|
let full = "first line\nsecond line\n@damocles tagged here\n";
|
|
let excerpt = "first line\nsecond line\n…"; // mention not present
|
|
let lines = extract_truncated_mention_lines(full, excerpt);
|
|
assert_eq!(lines, vec!["@damocles tagged here"]);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_truncated_drops_mentions_already_in_excerpt() {
|
|
// Mention is inside the embed window already — no need to
|
|
// re-surface, would be noise.
|
|
let full = "@damocles read this\nmore body\n";
|
|
let excerpt = "@damocles read this\nmore body\n…";
|
|
let lines = extract_truncated_mention_lines(full, excerpt);
|
|
assert!(lines.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_truncated_skips_blank_and_no_mention_lines() {
|
|
// Only lines with an actual mention survive — random body
|
|
// text past the cutoff stays dropped.
|
|
let full = "first\n\nsecond paragraph\n@argus reviewer\nfinal\n";
|
|
let excerpt = "first";
|
|
let lines = extract_truncated_mention_lines(full, excerpt);
|
|
assert_eq!(lines, vec!["@argus reviewer"]);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_truncated_does_not_resurface_heading_mention_inside_window() {
|
|
// Regression for the truncate-before-escape ordering rule:
|
|
// if the diff used the escaped excerpt, a body line
|
|
// `# @argus check this` would survive as-is in the body but
|
|
// become `\# @argus check this` in the excerpt, so the
|
|
// `contains` check would fail and the mention would re-surface
|
|
// as if it fell outside the window. Pass the unescaped excerpt
|
|
// (which the caller does) and the duplicate disappears.
|
|
let full = "# @argus check this\nmore body\n";
|
|
let raw_excerpt = full; // fits entirely
|
|
let lines = extract_truncated_mention_lines(full, raw_excerpt);
|
|
assert!(
|
|
lines.is_empty(),
|
|
"heading+mention inside window must not be re-surfaced, got {lines:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_truncated_mentions_empty_is_empty_string() {
|
|
// Zero overhead on the healthy short-body path: caller
|
|
// concatenates this directly so an empty input must produce
|
|
// no spacing.
|
|
assert_eq!(render_truncated_mentions(&[]), "");
|
|
}
|
|
|
|
#[test]
|
|
fn render_truncated_mentions_formats_block() {
|
|
let lines = ["cc @damocles", " @argus second mention"];
|
|
let rendered = render_truncated_mentions(&lines);
|
|
assert_eq!(
|
|
rendered,
|
|
"\n\nmentions (truncated from body):\n > cc @damocles\n > @argus second mention",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rfc3339_secs_handles_offsets_and_z() {
|
|
// Same instant expressed three ways must parse equal.
|
|
let utc = parse_rfc3339_secs("2026-06-13T09:18:42Z").unwrap();
|
|
let plus2 = parse_rfc3339_secs("2026-06-13T11:18:42+02:00").unwrap();
|
|
let minus5 = parse_rfc3339_secs("2026-06-13T04:18:42-05:00").unwrap();
|
|
assert_eq!(utc, plus2);
|
|
assert_eq!(utc, minus5);
|
|
// Fractional seconds are skipped (second granularity).
|
|
assert_eq!(parse_rfc3339_secs("2026-06-13T09:18:42.512Z").unwrap(), utc);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rfc3339_secs_rejects_garbage() {
|
|
assert!(parse_rfc3339_secs("").is_none());
|
|
assert!(parse_rfc3339_secs("not-a-date").is_none());
|
|
assert!(parse_rfc3339_secs("2026-06-13").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn notification_is_creation_flags_fresh_and_later_activity() {
|
|
// Fresh PR: notification event time == created_at → "new".
|
|
let fresh = serde_json::json!({ "updated_at": "2026-06-13T11:18:42+02:00" });
|
|
let subject = serde_json::json!({ "created_at": "2026-06-13T11:18:40+02:00" });
|
|
assert!(notification_is_creation(&fresh, Some(&subject)));
|
|
|
|
// Review hours later on the same PR → not a creation.
|
|
let later = serde_json::json!({ "updated_at": "2026-06-13T14:55:00+02:00" });
|
|
assert!(!notification_is_creation(&later, Some(&subject)));
|
|
|
|
// Missing timestamps → default to creation (preserve prior behavior).
|
|
let empty = serde_json::json!({});
|
|
assert!(notification_is_creation(&empty, None));
|
|
assert!(notification_is_creation(&empty, Some(&subject)));
|
|
}
|
|
|
|
/// Build a `NotifMeta` for the state-change formatter tests. The `&str`
|
|
/// fields borrow `'static` literals so the value is self-contained.
|
|
fn state_change_meta(subject: serde_json::Value) -> NotifMeta<'static> {
|
|
NotifMeta {
|
|
title: "subject title",
|
|
notif_type: "Issue",
|
|
html_url: "http://forge/issues/1",
|
|
num: " #1".to_owned(),
|
|
repo: " [agents/x]".to_owned(),
|
|
meta_suffix: "\nassignee: unassigned".to_owned(),
|
|
subject: Some(subject),
|
|
is_pr: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn state_change_drops_self_authored_creation() {
|
|
// No timestamps ⇒ treated as a creation; poster login == own_login.
|
|
let meta = state_change_meta(serde_json::json!({ "user": { "login": "damocles" } }));
|
|
let notif = serde_json::json!({ "subject": { "state": "open" } });
|
|
assert!(format_state_change_notification(¬if, &meta, "damocles").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn state_change_keeps_other_authored_creation() {
|
|
let meta = state_change_meta(serde_json::json!({ "user": { "login": "someone-else" } }));
|
|
let notif = serde_json::json!({ "subject": { "state": "open" } });
|
|
assert!(format_state_change_notification(¬if, &meta, "damocles").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn state_change_keeps_self_authored_later_activity() {
|
|
// The agent authored the subject, but this notification fired well
|
|
// after creation (someone else acted on it) ⇒ not a creation ⇒ still
|
|
// surfaces.
|
|
let meta = state_change_meta(serde_json::json!({
|
|
"user": { "login": "damocles" },
|
|
"created_at": "2020-01-01T00:00:00Z",
|
|
}));
|
|
let notif = serde_json::json!({
|
|
"subject": { "state": "closed" },
|
|
"updated_at": "2026-06-22T16:00:00Z",
|
|
});
|
|
assert!(format_state_change_notification(¬if, &meta, "damocles").is_some());
|
|
}
|
|
}
|