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:
parent
3059523172
commit
0db83c40a0
15 changed files with 971 additions and 412 deletions
|
|
@ -70,6 +70,63 @@ file `0600` owned by the agent (so the container can read it) — the same
|
|||
credential-injection path as forge/matrix tokens. See
|
||||
[hivectl → GitHub](tools/hivectl.md#github).
|
||||
|
||||
## Notifications
|
||||
|
||||
`hive-github-notify` polls github.com for the agent, turning each unread
|
||||
notification thread into a todo. It is a **separate binary and a
|
||||
separate systemd unit** from the internal forge's poller
|
||||
(`hive-forge-notify`, see [forge.md](forge.md#notification-poller-hive-forge-notifysrcnotifyrs)),
|
||||
installed by `nix/agent-modules/github.nix` under
|
||||
`hyperhive.github.enable`. Both binaries ship from the one
|
||||
`hive-forge-notify` derivation, so the unit is a second `ExecStart`
|
||||
path, not a new package.
|
||||
|
||||
Two units rather than one daemon with two loops, because it puts the
|
||||
decision in nix: a hive built without this module has **no github poller
|
||||
in its closure at all**, which is what makes GitHub access separable
|
||||
rather than merely switched off. It is also why this is not a cargo
|
||||
feature — a feature would unify across the workspace and cost every
|
||||
crate its build cache.
|
||||
|
||||
At runtime the poller needs the PAT above. No PAT, no polling: the unit
|
||||
logs why and exits 0, which is why it is `Restart = on-failure` and
|
||||
never `always` — a clean exit on a PAT-less agent must not become a
|
||||
restart loop.
|
||||
|
||||
Forgejo's notifications API is modelled on GitHub's, so one tolerant
|
||||
parse serves both: `id`, `repository.full_name`,
|
||||
`subject {title,url,latest_comment_url}` and `updated_at` line up field
|
||||
for field. The two real differences are absorbed by lenient
|
||||
deserializers — GitHub sends the thread id as a *string* where Forgejo
|
||||
sends a number, and says `PullRequest` where Forgejo says `Pull`. Todo
|
||||
keys are prefixed `gh:<id>` so a github thread id cannot collide with a
|
||||
forge one.
|
||||
|
||||
Two host differences worth knowing before touching this code:
|
||||
|
||||
- **Auth scheme, not just value.** Forgejo takes
|
||||
`Authorization: token <t>`; GitHub wants `Bearer <t>` plus `Accept:
|
||||
application/vnd.github+json`, `X-GitHub-Api-Version` and a
|
||||
`User-Agent`. Sending Forgejo's form to GitHub does not error — it
|
||||
authenticates as *nobody* and silently drops to the unauthenticated
|
||||
rate limit. The cheap way to tell the two apart is the rate-limit
|
||||
header: `x-ratelimit-remaining` near 5000 is an authenticated user,
|
||||
near 60 is anonymous.
|
||||
- **GitHub sets the cadence.** It returns `X-Poll-Interval` (60s in
|
||||
practice, slower than our own tick) and rate-limits callers who ignore
|
||||
it, so the loop re-arms to the server's interval whenever that is
|
||||
*slower* than ours. A hint faster than our own tick is not a reason to
|
||||
poll harder.
|
||||
|
||||
⚠️ **This needs the `notifications` scope on the PAT.** A token minted
|
||||
for `gh` + `git push` typically carries `repo` only, which is enough to
|
||||
push and open PRs but **not** to read the notification stream (nor to
|
||||
mark a thread read, which is the same scope). A PAT without it doesn't
|
||||
break anything: the poller logs the refusal and stays quiet, and the
|
||||
agent simply never gets GitHub wakes. If an agent's GitHub
|
||||
notifications never arrive, check the token's scopes first — the
|
||||
symptom is silence, not an error.
|
||||
|
||||
## Security
|
||||
|
||||
- Use a **dedicated bot account**, never a human's.
|
||||
|
|
|
|||
Loading…
Reference in a new issue