fix: use try_from for i64/u64 casts; split format_notification into helpers

This commit is contained in:
damocles 2026-05-22 19:09:03 +02:00
parent 484cea62c7
commit bbe2112dc9
3 changed files with 168 additions and 141 deletions

View file

@ -211,7 +211,6 @@ fn review_state_label(state: &str) -> Option<&str> {
/// ///
/// Number is extracted from `html_url` last path segment before any `#`. /// Number is extracted from `html_url` last path segment before any `#`.
/// Repo slug (`owner/name`) is always included — agents may watch multiple repos. /// Repo slug (`owner/name`) is always included — agents may watch multiple repos.
#[allow(clippy::too_many_lines)] // multiple notification types handled in one place by design
async fn format_notification( async fn format_notification(
client: &reqwest::Client, client: &reqwest::Client,
token: &str, token: &str,
@ -256,157 +255,186 @@ async fn format_notification(
}; };
let is_pr = matches!(notif_type, "Pull Request" | "Pull"); let is_pr = matches!(notif_type, "Pull Request" | "Pull");
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr);
// Build assignee + reviewer suffix appended to all notification kinds.
let meta_suffix = {
let assignees: Vec<&str> = subject
.as_ref()
.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
.as_ref()
.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
};
match reviewer_line {
Some(r) => format!("\n{assignee_line}\n{r}"),
None => format!("\n{assignee_line}"),
}
};
// Determine whether this notification was triggered by a comment/review or // Determine whether this notification was triggered by a comment/review or
// by creation/state-change of the subject itself. // by creation/state-change of the subject itself.
let has_comment = !comment_api_url.is_empty() && comment_api_url != subject_api_url; 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 { if has_comment {
// Notification triggered by a new comment or review submission. format_comment_notification(client, token, &meta, comment_api_url, comment_html_url, own_login).await
let payload = fetch_json(client, comment_api_url, token).await;
let actor_login = payload
.as_ref()
.and_then(|c| c["user"]["login"].as_str())
.unwrap_or("");
// Self-notification filter (#230): skip if we authored the comment/review.
if !own_login.is_empty() && actor_login == own_login {
debug!(%own_login, "forge_notify: skipping self-authored comment/review");
return None;
}
let body_text = payload
.as_ref()
.and_then(|c| c["body"].as_str())
.unwrap_or("")
.trim();
// PR review detection (#231): Forgejo review objects carry a `state` field
// with values like "APPROVED" / "REQUEST_CHANGES" / "COMMENT". Regular
// issue/PR comments have no such field. Format reviews distinctly so the
// agent knows the review outcome immediately without reading the body.
let review_state = payload
.as_ref()
.and_then(|c| c["state"].as_str())
.and_then(review_state_label);
let url = if comment_html_url.is_empty() { html_url } else { comment_html_url };
let author = if actor_login.is_empty() { "?" } else { actor_login };
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}: {}", truncate(body_text, BODY_TRUNCATE)).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}: {}",
truncate(body_text, BODY_TRUNCATE)
);
if out.ends_with('\n') {
out.pop();
}
out.push_str(&meta_suffix);
Some(out)
}
} else { } else {
// Notification triggered by creation or state change of the subject. format_state_change_notification(notif, &meta, own_login)
// }
// 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("");
let reason = notif["reason"].as_str().unwrap_or("");
// Self-notification filter (#230): skip new items we authored ourselves. /// Shared notification metadata extracted from the raw Forgejo JSON.
// `reason == "author"` combined with open state means we just opened the struct NotifMeta<'a> {
// issue/PR. We do NOT filter merged/closed state changes — those are title: &'a str,
// triggered by someone else and we want them. notif_type: &'a str,
let is_new = notif_state == "open" || notif_state.is_empty(); html_url: &'a str,
if is_new && reason == "author" && !own_login.is_empty() { num: String,
debug!(%own_login, "forge_notify: skipping self-authored new item"); repo: String,
return None; meta_suffix: String,
} /// Fetched subject detail (issue/PR JSON); used for review-request detection.
subject: Option<serde_json::Value>,
is_pr: bool,
}
let label = notif_type_label(notif_type); /// Build the `\nassignee: ...` (and optionally `\nreviewer: ...`) suffix
let kind = match notif_state { /// appended to all notification kinds.
"merged" => format!("{label} merged{num}{repo}"), fn build_meta_suffix(subject: Option<&serde_json::Value>, is_pr: bool) -> String {
"closed" => format!("{label} closed{num}{repo}"), let assignees: Vec<&str> = subject
"open" | "" => format!("new {label}{num}{repo}"), .and_then(|s| s["assignees"].as_array())
other => format!("{label}{num}{repo}: {other}"), .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
};
match reviewer_line {
Some(r) => format!("\n{assignee_line}\n{r}"),
None => format!("\n{assignee_line}"),
}
}
// Review-request detection (#253): Forgejo does not always set /// Format a notification triggered by a new comment or review submission.
// reason == "review_requested" (observed reason is null). Check async fn format_comment_notification(
// requested_reviewers instead, which is reliable. If own_login is client: &reqwest::Client,
// in the list, this is a review request -- override the kind. token: &str,
// `subject` and `is_pr` are already fetched unconditionally above (#256). meta: &NotifMeta<'_>,
let is_review_request = is_new comment_api_url: &str,
&& is_pr comment_html_url: &str,
&& !own_login.is_empty() own_login: &str,
&& subject ) -> Option<String> {
.as_ref() let payload = fetch_json(client, comment_api_url, token).await;
.and_then(|s| s["requested_reviewers"].as_array())
.map(|arr| arr.iter().any(|r| r["login"].as_str() == Some(own_login)))
.unwrap_or(false);
let kind = if is_review_request { let actor_login = payload
format!("review requested{num}{repo}") .as_ref()
.and_then(|c| c["user"]["login"].as_str())
.unwrap_or("");
// Self-notification filter (#230): skip if we authored the comment/review.
if !own_login.is_empty() && actor_login == own_login {
debug!(%own_login, "forge_notify: skipping self-authored comment/review");
return None;
}
let body_text = payload
.as_ref()
.and_then(|c| c["body"].as_str())
.unwrap_or("")
.trim();
// PR review detection (#231): Forgejo review objects carry a `state` field
// with values like "APPROVED" / "REQUEST_CHANGES" / "COMMENT". Regular
// issue/PR comments have no such field. Format reviews distinctly so the
// agent knows the review outcome immediately without reading the body.
let review_state = payload
.as_ref()
.and_then(|c| c["state"].as_str())
.and_then(review_state_label);
let url = if comment_html_url.is_empty() { meta.html_url } else { comment_html_url };
let author = if actor_login.is_empty() { "?" } else { actor_login };
let NotifMeta { title, notif_type, num, repo, meta_suffix, .. } = meta;
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 { } else {
kind write!(out, "\n\n{author}: {}", truncate(body_text, BODY_TRUNCATE)).ok();
}; }
out.push_str(meta_suffix);
let mut out = format!("[{kind}] {title}\nurl: {html_url}"); Some(out)
out.push_str(&meta_suffix); } 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}: {}",
truncate(body_text, BODY_TRUNCATE)
);
if out.ends_with('\n') {
out.pop();
}
out.push_str(meta_suffix);
Some(out) Some(out)
} }
} }
/// Format a notification triggered by creation or state change of the subject.
fn format_state_change_notification(
notif: &serde_json::Value,
meta: &NotifMeta<'_>,
own_login: &str,
) -> Option<String> {
// Classification uses notif["subject"]["state"] directly — Forgejo
// returns "open" / "closed" / "merged" here. We do NOT rely on
// fetching the PR/issue detail for `merged`:
// - `subject.url` points to the *issues* endpoint, which returns
// `pull_request.merged`, not top-level `merged`.
// - Forgejo API type is "Pull" / "Issue", never "Pull Request".
let notif_state = notif["subject"]["state"].as_str().unwrap_or("");
let reason = notif["reason"].as_str().unwrap_or("");
// Self-notification filter (#230): skip new items we authored ourselves.
// `reason == "author"` combined with open state means we just opened the
// issue/PR. We do NOT filter merged/closed state changes — those are
// triggered by someone else and we want them.
let is_new = notif_state == "open" || notif_state.is_empty();
if is_new && reason == "author" && !own_login.is_empty() {
debug!(%own_login, "forge_notify: skipping self-authored new item");
return None;
}
let NotifMeta { title, notif_type, html_url, num, repo, meta_suffix, subject, is_pr } = meta;
let label = notif_type_label(notif_type);
let kind = match notif_state {
"merged" => format!("{label} merged{num}{repo}"),
"closed" => format!("{label} closed{num}{repo}"),
"open" | "" => format!("new {label}{num}{repo}"),
other => format!("{label}{num}{repo}: {other}"),
};
// Review-request detection (#253): Forgejo does not always set
// reason == "review_requested" (observed as null). Check
// requested_reviewers instead, which is reliable. If own_login is
// in the list, override the kind.
// subject and is_pr are already fetched unconditionally above (#256).
let is_review_request = is_new
&& *is_pr
&& !own_login.is_empty()
&& subject
.as_ref()
.and_then(|s| s["requested_reviewers"].as_array())
.map(|arr| arr.iter().any(|r| r["login"].as_str() == Some(own_login)))
.unwrap_or(false);
let kind = if is_review_request {
format!("review requested{num}{repo}")
} else {
kind
};
let mut out = format!("[{kind}] {title}\nurl: {html_url}");
out.push_str(meta_suffix);
Some(out)
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn poll_once( async fn poll_once(
client: &reqwest::Client, client: &reqwest::Client,

View file

@ -335,8 +335,7 @@ async fn api_stats(
// Pass the window span to the reminder-stats RPC so the broker // Pass the window span to the reminder-stats RPC so the broker
// filters its counts to the same time range as the chart data. // filters its counts to the same time range as the chart data.
let window_secs = window.span_secs(); let window_secs = window.span_secs();
#[allow(clippy::cast_sign_loss)] // window span is always a positive duration let window_secs_u = u64::try_from(window_secs).unwrap_or(0);
let window_secs_u = window_secs.max(0) as u64;
snapshot.reminder_stats = fetch_reminder_stats(&state.socket, state.flavor(), window_secs_u).await; snapshot.reminder_stats = fetch_reminder_stats(&state.socket, state.flavor(), window_secs_u).await;
axum::Json(snapshot) axum::Json(snapshot)
} }

View file

@ -609,13 +609,13 @@ impl Broker {
/// in the last `since_secs` seconds (0 = all reminders). /// in the last `since_secs` seconds (0 = all reminders).
pub fn reminder_rollup_for(&self, agent: &str, since_secs: u64) -> Result<hive_sh4re::ReminderStats> { pub fn reminder_rollup_for(&self, agent: &str, since_secs: u64) -> Result<hive_sh4re::ReminderStats> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
#[allow(clippy::cast_possible_wrap)] // unix epoch secs fit in i64 until year 292B
let cutoff_time = if since_secs > 0 { let cutoff_time = if since_secs > 0 {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.ok() .ok()
.map_or(0, |d| d.as_secs() as i64); .and_then(|d| i64::try_from(d.as_secs()).ok())
now - since_secs as i64 .unwrap_or(0);
now.saturating_sub(i64::try_from(since_secs).unwrap_or(i64::MAX))
} else { } else {
i64::MIN i64::MIN
}; };