Watch
0
0
Fork
You've already forked hyperhive
0

feat(#2642): a github.com notification poller alongside the forge one

hive-forge-notify grows a second binary, hive-github-notify. The two
share the notification half of the job — tolerant parse, classification,
formatting, dedupe, todo delivery — and nothing else: each binary owns
its host's protocol outright.

Two binaries rather than one multi-source daemon, and rather than a
cargo feature. A feature would unify across the workspace and cost every
crate its build cache. Two binaries keep the decision in nix: forge.nix
installs the forge unit, github.nix installs the github one under
hyperhive.github.enable, so a hive built without that module has no
github poller in its closure at all — GitHub access is separable (a
tier, a policy boundary), not merely switched off. Both binaries ship
from the existing derivation, so packages.nix is untouched.

The split is real at the code level too, not just at the unit level.
source.rs is a trait; the impls live in the binaries that use them, so
neither binary links the other's protocol code and the library names no
host at all. The forge-only assigned-issue rollup moves into the forge
binary for the same reason: it asks the forge what is assigned to this
agent, which is not a notification-protocol concern.

At runtime the github unit needs a PAT at <state>/github-token, the same
dashboard-provisioned token the gh wrapper and the git credential helper
already use. No PAT: it logs why and exits 0, which is why the unit is
Restart=on-failure and not always.

Forgejo's notifications API is modelled on GitHub's, so one tolerant
parse serves both — the differences (string thread ids, PullRequest vs
Pull) are absorbed by lenient deserializers rather than a second parse
path. Thread ids normalise to String at the parse boundary; they are
only ever opaque keys. Todo keys gain a per-source prefix so the two
hosts cannot collide, and the forge's is deliberately empty to keep
existing forge todo keys stable across the deploy that lands this.

The github loop honours the server's X-Poll-Interval, re-arming only
when the server asks for a slower cadence than ours; the hint is read
before the status check, because it arrives on error and empty pages too
and that is exactly when it matters. Reading the notification stream
needs the notifications scope on the PAT, which a token minted for push
access typically lacks; the failure mode is silence, so docs/github.md
says so explicitly.
This commit is contained in:
atlas 2026-07-31 12:05:02 +02:00 • committed by mara
commit 0db83c40a0
15 changed files with 971 additions and 412 deletions

View file

@ -0,0 +1,132 @@
//! 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 hive_forge_notify::notify::UNREAD_FETCH_LIMIT;
use hive_forge_notify::source::Source;
use tracing::{debug, warn};
/// GitHub's REST base. `docs/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.
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 {
token: String,
}
impl GithubSource {
pub fn new(token: &str) -> Self {
Self {
token: token.to_owned(),
}
}
}
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!("{API_BASE}/user");
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!("{API_BASE}/notifications?all=false&per_page={UNREAD_FETCH_LIMIT}");
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.
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!("{API_BASE}/notifications/threads/{id}");
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");
}
}
}
}