diff --git a/hive-agent/src/forge_notify.rs b/hive-agent/src/forge_notify.rs index f45c6cd0..fa0246d7 100644 --- a/hive-agent/src/forge_notify.rs +++ b/hive-agent/src/forge_notify.rs @@ -186,6 +186,7 @@ pub async fn run(socket: PathBuf) { } } poll_once(&forge, &client, &token, &socket, &mut delivered, &own_login).await; + update_assigned_rollup(&client, &forge_url, &token, &socket).await; } } @@ -1170,6 +1171,85 @@ async fn mark_read(forge: &Forgejo, id: u64) { } } +/// Fetch the total count of open issues or PRs assigned to this agent via +/// Forgejo's global search API (`GET /api/v1/issues/search`). `issue_type` +/// is `"issues"` or `"pulls"`. Reads the `X-Total-Count` response header +/// rather than deserialising the full body — only page 1 with limit 1 is +/// fetched, keeping the request cheap. Returns `None` on any error (timeout, +/// HTTP error, missing or unparseable header). +async fn count_assigned( + client: &reqwest::Client, + forge_url: &str, + token: &str, + issue_type: &str, +) -> Option { + let url = format!( + "{forge_url}/api/v1/issues/search\ + ?type={issue_type}&state=open&assigned=true&limit=1&page=1" + ); + let resp = match client + .get(&url) + .header("Authorization", format!("token {token}")) + .send() + .await + { + Ok(r) if r.status().is_success() => r, + _ => return None, + }; + let count_str = resp.headers().get("x-total-count")?.to_str().ok()?; + count_str.parse::().ok() +} + +/// After each notification poll, query the forge for the count of open +/// issues and PRs assigned to this agent and keep a keyed `"rollup"` todo +/// in sync. When the count is positive the todo summarises the breakdown +/// (`N issues, M PRs`); when it reaches zero the todo is cleared. The rollup +/// key is distinct from per-thread numeric keys so clearing it never touches +/// notification todos. +async fn update_assigned_rollup( + client: &reqwest::Client, + forge_url: &str, + token: &str, + socket: &Path, +) { + let issues = count_assigned(client, forge_url, token, "issues") + .await + .unwrap_or(0); + let pulls = count_assigned(client, forge_url, token, "pulls") + .await + .unwrap_or(0); + let total = issues + pulls; + + let req = if total == 0 { + hive_agent_sock::Request::ClearTodo { + subsystem: "forge".to_owned(), + key: Some("rollup".to_owned()), + all: false, + } + } else { + let breakdown = match (issues, pulls) { + (i, 0) => format!("{i} issue{}", if i == 1 { "" } else { "s" }), + (0, p) => format!("{p} PR{}", if p == 1 { "" } else { "s" }), + (i, p) => format!( + "{i} issue{}, {p} PR{}", + if i == 1 { "" } else { "s" }, + if p == 1 { "" } else { "s" } + ), + }; + hive_agent_sock::Request::UpsertTodo { + subsystem: "forge".to_owned(), + key: Some("rollup".to_owned()), + summary: format!("{total} open assigned: {breakdown}"), + source: None, + } + }; + + match crate::client::request::<_, hive_agent_sock::Response>(socket, &req).await { + Ok(_) => debug!(total, "forge_notify: assigned rollup todo updated"), + Err(e) => debug!("forge_notify: assigned rollup todo update failed: {e}"), + } +} + #[cfg(test)] mod tests { use super::*;