//! github.com's half of the poller — the protocol code that only this //! binary links. //! //! Plain `reqwest`: the typed Forgejo client cannot address a different //! API surface, and GitHub's payloads differ enough (string thread ids, //! its own `subject.type` vocabulary, no `subject.state`) that the //! tolerant parse in the shared library is the right common layer, not //! the client. use std::sync::atomic::{AtomicBool, Ordering}; use hive_forge_notify::notify::UNREAD_FETCH_LIMIT; use hive_forge_notify::source::Source; use tracing::{debug, warn}; /// GitHub's REST base. `docs/integrations/github.md` scopes the integration to /// github.com only — the same constraint the `gh` wrapper and the git /// credential helper already carry — so this is a constant rather than /// another operator-entered URL. pub const API_BASE: &str = "https://api.github.com"; /// Value of the `X-GitHub-Api-Version` header. GitHub dates its REST /// versions; pinning one means a future default bump cannot silently /// reshape the payloads this daemon parses. const API_VERSION: &str = "2022-11-28"; /// `User-Agent` for GitHub calls. GitHub rejects requests without one. const USER_AGENT: &str = "hyperhive-forge-notify"; /// github.com, via a personal access token. pub struct GithubSource { /// [`API_BASE`] everywhere but the tests, which point it at a local stub. api_base: String, token: String, /// Set once a 401 has been logged at `warn`. A source holds one token, /// so this caps the warning at one per token value. warned_unauthorized: AtomicBool, } impl GithubSource { pub fn new(api_base: &str, token: &str) -> Self { Self { api_base: api_base.to_owned(), token: token.to_owned(), warned_unauthorized: AtomicBool::new(false), } } } impl Source for GithubSource { /// Namespaced, so a github thread id cannot collide with a forge one /// on a shared todo key. fn key_prefix(&self) -> &'static str { "gh:" } fn name(&self) -> &'static str { "github" } /// `Bearer`, not Forgejo's `token` — plus the version and user-agent /// headers GitHub requires. Sending the wrong scheme does not error: /// it authenticates as *nobody* and silently drops to the /// unauthenticated rate limit, which is why the rate-limit header is /// the only cheap way to tell the two apart. fn authorize(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder { rb.header("Authorization", format!("Bearer {}", self.token)) .header("Accept", "application/vnd.github+json") .header("X-GitHub-Api-Version", API_VERSION) .header("User-Agent", USER_AGENT) } async fn own_login(&self, client: &reqwest::Client) -> String { let url = format!("{}/user", self.api_base); let Ok(resp) = self.authorize(client.get(&url)).send().await else { return String::new(); }; if !resp.status().is_success() { return String::new(); } resp.json::() .await .ok() .and_then(|v| v["login"].as_str().map(str::to_owned)) .unwrap_or_default() } async fn list_unread( &self, client: &reqwest::Client, ) -> Option<(Vec, Option)> { let url = format!( "{}/notifications?all=false&per_page={UNREAD_FETCH_LIMIT}", self.api_base ); let resp = match self.authorize(client.get(&url)).send().await { Ok(resp) => resp, Err(e) => { debug!("forge_notify: github poll request failed: {e}"); return None; } }; // Read the cadence hint before the status check: GitHub sends it // on an empty page too, and that is exactly the tick where we // most want to learn we are polling too fast. let poll_interval = resp .headers() .get("x-poll-interval") .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()); let status = resp.status(); if !status.is_success() { // 403 with a rate-limit body and 401 on a revoked PAT are // both "stay quiet and retry", not fatal: the unit must not // restart-loop on a credential the operator fixes out of band. // A 401 is the one an operator must act on, so it is a `warn`. if status == reqwest::StatusCode::UNAUTHORIZED && !self.warned_unauthorized.swap(true, Ordering::Relaxed) { warn!( "forge_notify: github refused the PAT (401) — no notifications \ until github-token holds a working one" ); } else { debug!("forge_notify: github poll status {status}"); } return None; } let raw = resp.text().await.ok()?; match serde_json::from_str(&raw) { Ok(values) => Some((values, poll_interval)), Err(e) => { warn!("forge_notify: github response parse error: {e}"); None } } } async fn mark_read(&self, client: &reqwest::Client, id: &str) { let url = format!("{}/notifications/threads/{id}", self.api_base); match self.authorize(client.patch(&url)).send().await { Ok(resp) if resp.status().is_success() => { debug!(%id, "forge_notify: marked read (github)"); } Ok(resp) => { warn!(%id, status = %resp.status(), "forge_notify: github mark-read non-2xx — notification will resurface"); } Err(e) => { warn!(%id, error = ?e, "forge_notify: github mark-read failed — notification will resurface"); } } } }