The poller read `<state>/github-token` once at start, so after a PAT rotation it kept polling with the revoked token until the unit restarted. It now re-reads the file on every tick, as `hive-forge-notify` does: a different non-empty value rebuilds the GitHub source, clears the own-login cache, and logs one info line. A missing or empty file keeps the token in use. A 401 from the notifications endpoint is now logged at warn, once per token value; other non-2xx statuses stay at debug. Part of #5017 (slice G).
154 lines
5.9 KiB
Rust
154 lines
5.9 KiB
Rust
//! 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::<serde_json::Value>()
|
|
.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<serde_json::Value>, Option<u64>)> {
|
|
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::<u64>().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");
|
|
}
|
|
}
|
|
}
|
|
}
|