//! `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`, fetched from the swarm secret store by //! `nix/agent-modules/github-token.nix` — 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. Once polling, it re-reads //! `github-token` on every tick, so a rotated PAT takes effect on the next //! poll without a restart. //! //! ⚠️ 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 agent's store fetch writes the token /// out of band and it 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 mut pat = Pat::new(source::API_BASE, token_path, token); let mut own_login = resolve_own_login(&client, &pat.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; let hint = poll_tick(&mut pat, &mut own_login, &client, &socket, &mut delivered).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; } } } /// One poll: pick up a rotated PAT, resolve the own login if it is not /// known for the PAT in use, then poll. Returns the server's poll-interval /// hint. async fn poll_tick( pat: &mut Pat, own_login: &mut String, client: &reqwest::Client, socket: &std::path::Path, delivered: &mut HashMap, ) -> Option { if pat.reread() { info!("forge_notify: github token changed on disk; using the new one"); own_login.clear(); } if own_login.is_empty() { *own_login = resolve_own_login(client, &pat.source).await; } poll_once(&pat.source, client, socket, delivered, own_login).await } /// The PAT in use and the source built from it. The agent's store fetch /// replaces `github-token` by atomic rename when the PAT rotates and never /// deletes it, so a missing or empty file means "not readable right now" /// and keeps the current token. struct Pat { api_base: String, path: std::path::PathBuf, token: String, source: GithubSource, } impl Pat { fn new(api_base: &str, path: impl Into, token: String) -> Self { let source = GithubSource::new(api_base, &token); Self { api_base: api_base.to_owned(), path: path.into(), token, source, } } /// Re-read the file and swap to its token if it differs. `true` when /// the token changed. fn reread(&mut self) -> bool { match hive_forge_notify::read_first_token(std::slice::from_ref(&self.path)) { Some(t) if t != self.token => { self.source = GithubSource::new(&self.api_base, &t); self.token = t; true } _ => false, } } } #[cfg(test)] mod tests { use std::collections::HashMap; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::sync::mpsc; use super::{Pat, poll_tick}; fn scratch(tag: &str) -> std::path::PathBuf { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_nanos()); let dir = std::env::temp_dir().join(format!("hive-github-notify-{tag}-{ts}")); std::fs::create_dir_all(&dir).expect("create scratch dir"); dir } /// A stand-in for api.github.com on loopback. Answers `/user` with a /// login and `/notifications` with an empty page, and reports each /// request as `(path, Authorization header)`. async fn stub() -> (String, mpsc::UnboundedReceiver<(String, String)>) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let base = format!("http://{}", listener.local_addr().expect("addr")); let (tx, rx) = mpsc::unbounded_channel(); tokio::spawn(async move { loop { let (mut sock, _) = listener.accept().await.expect("accept"); let mut buf = Vec::new(); let mut chunk = [0u8; 1024]; while !buf.windows(4).any(|w| w == b"\r\n\r\n") { let n = sock.read(&mut chunk).await.expect("read"); if n == 0 { break; } buf.extend_from_slice(&chunk[..n]); } let head = String::from_utf8_lossy(&buf).into_owned(); let path = head .split_whitespace() .nth(1) .unwrap_or_default() .to_owned(); let auth = head .lines() .find_map(|l| { let (k, v) = l.split_once(':')?; k.eq_ignore_ascii_case("authorization") .then(|| v.trim().to_owned()) }) .unwrap_or_default(); let body = if path.starts_with("/user") { r#"{"login":"me"}"# } else { "[]" }; let reply = format!( "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ content-length: {}\r\nconnection: close\r\n\r\n{body}", body.len() ); let _ = sock.write_all(reply.as_bytes()).await; let _ = tx.send((path, auth)); } }); (base, rx) } /// Every request one tick made, in order. fn drain(rx: &mut mpsc::UnboundedReceiver<(String, String)>) -> Vec<(String, String)> { std::iter::from_fn(|| rx.try_recv().ok()).collect() } fn poll(path: &str) -> bool { path.starts_with("/notifications") } #[tokio::test] async fn a_rotated_token_is_sent_on_the_next_poll() { let (base, mut rx) = stub().await; let dir = scratch("rotate"); let path = dir.join("github-token"); std::fs::write(&path, "old\n").expect("write"); let mut pat = Pat::new(&base, &path, "old".to_owned()); let mut own_login = String::new(); let mut delivered = HashMap::new(); let client = reqwest::Client::new(); let socket = dir.join("no-socket"); poll_tick(&mut pat, &mut own_login, &client, &socket, &mut delivered).await; let first = drain(&mut rx); assert!(first.iter().any(|(p, a)| poll(p) && a == "Bearer old")); assert_eq!(own_login, "me"); std::fs::write(&path, "new\n").expect("write"); poll_tick(&mut pat, &mut own_login, &client, &socket, &mut delivered).await; let second = drain(&mut rx); assert!(second.iter().all(|(_, a)| a == "Bearer new"), "{second:?}"); assert!(second.iter().any(|(p, _)| poll(p)), "{second:?}"); // The own login is resolved again, under the new token. assert!(second.iter().any(|(p, _)| p == "/user"), "{second:?}"); let _ = std::fs::remove_dir_all(&dir); } #[tokio::test] async fn an_empty_or_missing_file_keeps_the_token_in_use() { let (base, mut rx) = stub().await; let dir = scratch("keep"); let path = dir.join("github-token"); let mut pat = Pat::new(&base, &path, "old".to_owned()); let mut own_login = String::new(); let mut delivered = HashMap::new(); let client = reqwest::Client::new(); let socket = dir.join("no-socket"); std::fs::write(&path, "\n").expect("write"); poll_tick(&mut pat, &mut own_login, &client, &socket, &mut delivered).await; let empty = drain(&mut rx); assert!(empty.iter().any(|(p, _)| poll(p)), "{empty:?}"); assert!(empty.iter().all(|(_, a)| a == "Bearer old"), "{empty:?}"); std::fs::remove_file(&path).expect("remove"); poll_tick(&mut pat, &mut own_login, &client, &socket, &mut delivered).await; let missing = drain(&mut rx); assert!(missing.iter().any(|(p, _)| poll(p)), "{missing:?}"); assert!( missing.iter().all(|(_, a)| a == "Bearer old"), "{missing:?}" ); let _ = std::fs::remove_dir_all(&dir); } }