hyperhive/hive-forge-notify/src/source.rs
atlas 0db83c40a0 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.
2026-07-31 17:23:18 +02:00

70 lines
3.4 KiB
Rust

//! The per-host half of the poller, as a trait.
//!
//! Everything except three host-specific calls — list unread, mark read,
//! resolve own login — is host-agnostic, so a host is described by this
//! trait and implemented **inside the binary that polls it**: the Forgejo
//! impl lives in `src/bin/hive-forge-notify/`, the GitHub one in
//! `src/bin/hive-github-notify/`. Neither binary links the other's
//! protocol code, and this library names no host at all.
//!
//! Implementors hand [`notify`](crate::notify) the same raw JSON either
//! way. Forgejo's notifications API is modelled on GitHub's, which is why
//! one shared parse works: `id` / `repository.full_name` / `subject`
//! `{title,url,latest_comment_url}` / `updated_at` line up field for
//! field, and the differences are absorbed by the lenient deserializers
//! in `notify` rather than a second parse path.
use std::future::Future;
/// A polled notification host.
///
/// Methods return `impl Future` rather than being `async fn` so the
/// returned futures carry an explicit `Send` bound — the poll loops move
/// them across tasks.
pub trait Source {
/// Namespace for this host's todo keys, so two hosts handing out the
/// same numeric thread id cannot collide on one todo.
///
/// The internal forge deliberately uses an **empty** namespace: its
/// keys stay the bare thread ids they have always been, because
/// renaming them would orphan every in-flight forge todo on the first
/// restart after a deploy.
fn key_prefix(&self) -> &'static str;
/// Human-readable host name for log lines.
fn name(&self) -> &'static str;
/// Apply this host's auth — and any headers it requires — to an
/// outgoing request. Used by the shared enrichment fetches, which
/// follow URLs the server handed us and so must authenticate as
/// whichever host produced them.
///
/// Hosts differ in *scheme*, not just value, and getting it wrong is
/// silent: a request that authenticates as nobody still returns 200,
/// just on the unauthenticated rate limit.
fn authorize(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder;
/// Resolve the account's own login, for self-notification filtering.
/// Returns the empty string on any failure; the caller treats that as
/// "filtering disabled" and retries on the next tick.
fn own_login(&self, client: &reqwest::Client) -> impl Future<Output = String> + Send;
/// Fetch the unread notification page as raw JSON values, plus the
/// server's requested minimum seconds between polls when it states
/// one (a host that says nothing returns `None` and the caller keeps
/// its own cadence).
///
/// Raw rather than typed so one unrepresentable field in one item
/// cannot poison the whole page; per-item parsing happens in
/// [`notify::parse_notification`](crate::notify). `None` means the
/// fetch failed — the caller skips this tick.
fn list_unread(
&self,
client: &reqwest::Client,
) -> impl Future<Output = Option<(Vec<serde_json::Value>, Option<u64>)>> + Send;
/// Mark a notification thread read on this host. Best-effort: a
/// failure leaves the thread unread so it resurfaces next tick, which
/// the in-process dedupe map then suppresses from re-waking the agent.
fn mark_read(&self, client: &reqwest::Client, id: &str) -> impl Future<Output = ()> + Send;
}