feat(forge_notify): assigned-issues rollup todo

After each notification poll, query GET /api/v1/issues/search with
assigned=true for both issues and pulls, read X-Total-Count, and
upsert/clear a keyed 'rollup' todo (subsystem=forge, key=rollup).

When the total is > 0 the summary reads e.g. '3 open assigned: 2 issues,
1 PR'. When it drops to 0 the rollup todo is cleared. The 'rollup' key is
distinct from per-thread numeric keys so clearing it never touches
notification todos.

Closes #2725.
This commit is contained in:
iris 2026-07-26 17:46:44 +02:00 committed by mara
commit 7320e2ba3d

View file

@ -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<u64> {
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::<u64>().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::*;