//! `hive-github-notify` binary — long-running per-agent **github.com** //! notification poller. Same delivery path as its Forgejo sibling: unread //! list, per-thread summary, todo upsert on the harness's in-agent socket, //! mark read on the source. //! //! Takes no arguments. `HYPERHIVE_STATE_DIR` holds the PAT //! (`github-token`, provisioned from the dashboard credentials tab — see //! `docs/integrations/github.md`) and `HIVE_AGENT_SOCKET` is the harness's todo socket. //! Having a PAT *is* the opt-in: with no token the poller logs why and //! exits 0, so deploying this unit to an agent that never gets one costs a //! settled process rather than a restart loop. //! //! ⚠️ Reading the notification stream needs the **`notifications` scope** //! on the PAT — a token minted for `gh` + `git push` usually carries `repo` //! only, which is enough to push and open PRs but not to read (or mark //! read) notifications. A PAT without it is not fatal: the poller logs the //! refusal and stays quiet, so the symptom is silence rather than an error. mod source; use std::collections::HashMap; use std::time::Duration; use hive_forge_notify::notify::{ POLL_INTERVAL_SECS, TOKEN_RETRY_MAX, TOKEN_RETRY_SECS, poll_once, resolve_own_login, }; use hive_forge_notify::{HTTP_TIMEOUT_SECS, agent_socket, init_tracing}; use source::GithubSource; use tracing::{debug, info, warn}; #[tokio::main] async fn main() { init_tracing(); let socket = agent_socket(); info!(socket = %socket.display(), "hive-github-notify starting"); // Returns only when no PAT ever arrives; otherwise loops forever. github_loop(hive_forge_notify::state_dir(), socket).await; } /// Waits for the PAT to appear: the token is written out of band from the /// dashboard and takes effect without a rebuild, so an agent that gains a /// PAT mid-session starts getting notifications on the next tick rather /// than after a restart. Gives up — returning, so the process exits 0 /// rather than restart-looping — when no PAT ever arrives, which is the /// common case for an agent that has the unit but no account. async fn github_loop(state_dir: String, socket: std::path::PathBuf) { let token_path = format!("{state_dir}/github-token"); let mut attempts = 0u32; let token = loop { match tokio::fs::read_to_string(&token_path).await { Ok(t) if !t.trim().is_empty() => break t.trim().to_owned(), _ => debug!("forge_notify: no github token at {token_path} yet"), } attempts += 1; if attempts >= TOKEN_RETRY_MAX { debug!( "forge_notify: no github token after {TOKEN_RETRY_MAX} retries — github 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 github HTTP client: {e}"); return; } }; let source = GithubSource::new(&token); let mut own_login = resolve_own_login(&client, &source).await; let mut delivered: HashMap = HashMap::new(); // GitHub tells callers how often it is willing to be polled // (`X-Poll-Interval`, 60s in practice) and rate-limits those who // ignore it. Start at our own cadence and re-arm to whatever the // server asks for — never faster than it wants, never slower than we // need. let mut cadence = POLL_INTERVAL_SECS; let mut interval = tokio::time::interval(Duration::from_secs(cadence)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); interval.tick().await; info!("forge_notify: github polling started"); loop { interval.tick().await; if own_login.is_empty() { own_login = resolve_own_login(&client, &source).await; } let hint = poll_once(&source, &client, &socket, &mut delivered, &own_login).await; if let Some(secs) = hint.filter(|s| *s > cadence) { debug!( secs, "forge_notify: github asked for a slower poll — re-arming" ); cadence = secs; interval = tokio::time::interval(Duration::from_secs(cadence)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); interval.tick().await; } } }