Skip to content

Commit c5738da

Browse files
heraldclaude
authored andcommitted
feat(notifications): Forgejo/Gitea PR notifications (parallel REST poller)
Add Forgejo/Gitea PR subscriptions alongside the existing GitHub PR path, mirroring its tool shape but polling the Gitea-style REST API (no GraphQL). The GitHub coupling was only at PRTracker.poll (client.fetch_pr -> snapshot_from_graphql), so the tracker, diff, schedule, split persistence, and dispatch are reused unchanged. New pieces: - lib/forgejo_client.py: async Gitea REST client (httpx). One poll fans out to pulls/{n}, reviews, per-review comments, issue comments, and commit status; ?page=&limit= pagination; `token <PAT>` auth; classified errors. - lib/pr_errors.py: provider-agnostic PR-fetch taxonomy (PRNotFound/RateLimited/ Auth/Transient). github_client's GitHub* errors become thin subclasses so the daemon's tracker loop catches both providers through the same bases. - pr_monitor.snapshot_from_forgejo(): normalizes Gitea JSON into the same transport-agnostic snapshot diff() consumes (Gitea->canonical mapping table in the docstring). Review states normalized at the boundary (REQUEST_CHANGES-> CHANGES_REQUESTED, COMMENT->COMMENTED); PENDING/REQUEST_REVIEW excluded. Resolve/unresolve synthesized from each inline comment's `resolver`. - provider dimension: PRTracker gains provider/base_url; TRACKERS + on-disk dirs keyed by a provider-prefixed storage_key (github stays unprefixed -> existing trackers/dirs/notification ids byte-identical; no migration). state.json persists provider + base_url; load_trackers reads them (absent -> github). - wire: additive SUBSCRIBE/UNSUBSCRIBE/LIST_FORGEJO_PR verbs (reuse existing reply frames). The daemon dispatch gains a terminal else -> ERROR so an unknown verb from a newer relay fails loudly instead of hanging on a dropped reply. The relay's 3 forgejo_* tools degrade a timeout / "unknown message type" to a clear "daemon doesn't support Forgejo yet" message. Deploy daemon-first. - daemon builds a ForgejoClient only when FORGEJO_API_URL is configured; otherwise forgejo subscriptions are rejected and forgejo trackers on disk are skipped (never crash the poll loop). Config: FORGEJO_API_URL + FORGEJO_TOKEN. v1 covers reviews, inline + conversation comments, commit status, new commits, merge/close, and resolve/unresolve; timeline facets (labels/reviewer/draft/force-push) are deferred (Gitea REST has no timeline). Tests: FakeForgejo REST harness + 38 unit tests (snapshot/diff, client pagination/ fan-out/error-classification, storage roundtrip) + 2 e2e flows. 276 non-slow pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 75bae4d commit c5738da

11 files changed

Lines changed: 1901 additions & 49 deletions

‎notifications/daemon/notifications-daemon.py‎

Lines changed: 117 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,11 @@
5555
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib"))
5656

5757
import agent_registry # noqa: E402
58+
import forgejo_client # noqa: E402
5859
import github_client # noqa: E402
5960
import messaging # noqa: E402
6061
import message_topic # noqa: E402
62+
import pr_errors # noqa: E402
6163
import pr_monitor # noqa: E402
6264
import pr_schedule # noqa: E402
6365
import scheduler # noqa: E402
@@ -234,6 +236,25 @@ def _alloc_ordinal() -> int:
234236

235237

236238
GH: github_client.GitHubClient | None = None
239+
# The Forgejo/Gitea client, built in main() only when FORGEJO_API_URL is configured;
240+
# None means this daemon does not serve Forgejo PR subscriptions.
241+
FJ: forgejo_client.ForgejoClient | None = None
242+
243+
244+
def _pr_client(provider: str):
245+
"""The PR client for a provider, or None if that provider isn't wired here."""
246+
return FJ if provider == "forgejo" else GH
247+
248+
249+
def _pr_clients() -> dict:
250+
"""The {provider: client} map for load_trackers — only configured providers, so a
251+
tracker whose provider is unconfigured is skipped rather than loaded with no client."""
252+
clients: dict = {"github": GH}
253+
if FJ is not None and FJ.configured:
254+
clients["forgejo"] = FJ
255+
return clients
256+
257+
237258
# Shared secret each relay must present (Authorization: Bearer <token>) to connect.
238259
# Computed once at startup; relays compute the same value from NOTIFICATIONS_DATA_DIR.
239260
TOKEN = ""
@@ -359,7 +380,7 @@ async def _dispatch_loop(conn: Connection) -> None:
359380
# away, surface a one-time "history truncated" notice ahead of the
360381
# surviving events, so they know to check the PR for what was lost.
361382
missed = tracker.missed.get(session_id, 0)
362-
trunc_id = f"trunc:{tracker.key}:{session_id}"
383+
trunc_id = f"trunc:{tracker.storage_key}:{session_id}"
363384
if missed > 0 and trunc_id not in conn.inflight:
364385
content = (
365386
f"⚠️ {tracker.key}: {missed} earlier update(s) were dropped "
@@ -388,7 +409,7 @@ async def _dispatch_loop(conn: Connection) -> None:
388409
event_id = event["id"]
389410
if event_id in acked:
390411
continue
391-
nid = f"pr:{tracker.key}:{event_id}"
412+
nid = f"pr:{tracker.storage_key}:{event_id}"
392413
if nid in conn.inflight:
393414
continue
394415
payload = {
@@ -481,6 +502,16 @@ async def _handle(websocket) -> None:
481502
elif kind == wsproto.LIST_PR_SUBSCRIPTIONS:
482503
await _handle_list_pr_subscriptions(websocket, conn, msg)
483504

505+
elif kind == wsproto.SUBSCRIBE_FORGEJO_PR:
506+
await _handle_subscribe(websocket, conn, msg, provider="forgejo")
507+
508+
elif kind == wsproto.UNSUBSCRIBE_FORGEJO_PR:
509+
_handle_unsubscribe(conn, msg, provider="forgejo")
510+
await _send(websocket, wsproto.UNSUBSCRIBED, msg, pr=_msg_key(msg))
511+
512+
elif kind == wsproto.LIST_FORGEJO_PR_SUBSCRIPTIONS:
513+
await _handle_list_pr_subscriptions(websocket, conn, msg, provider="forgejo")
514+
484515
elif kind == wsproto.REGISTER_AGENT:
485516
await _handle_register_agent(websocket, conn, msg)
486517

@@ -522,6 +553,17 @@ async def _handle(websocket) -> None:
522553

523554
elif kind == wsproto.MESSAGE_STATUS:
524555
await _handle_message_status(websocket, conn, msg)
556+
557+
else:
558+
# No silent drops: an unknown type means a newer relay is speaking a verb
559+
# this daemon predates. Reply ERROR so the skew fails loudly on the relay
560+
# (which otherwise blocks on a reply that never comes) instead of hanging.
561+
await _send(
562+
websocket,
563+
wsproto.ERROR,
564+
msg,
565+
error=f"unknown message type: {kind}",
566+
)
525567
except ConnectionClosed:
526568
pass
527569
finally:
@@ -555,9 +597,10 @@ def _handle_ack(conn: Connection, msg: dict) -> None:
555597
if not nid:
556598
return
557599
if isinstance(nid, str) and nid.startswith("trunc:"):
558-
# trunc:{key}:{sid} — key holds '/' and '#' but never ':', so rpartition on
559-
# ':' cleanly splits the trailing session id off the key. Acking the notice
560-
# clears the missed counter until the next truncation drops more events.
600+
# trunc:{storage_key}:{sid} — the trailing session id never contains ':', so
601+
# rpartition(':') peels it off cleanly, leaving the storage key (which may carry
602+
# a "<provider>:" prefix for non-github PRs, plus '/' and '#') intact for lookup.
603+
# Acking the notice clears the missed counter until the next truncation.
561604
key, _, session_id = nid[len("trunc:") :].rpartition(":")
562605
tracker = TRACKERS.get(key)
563606
if tracker is not None and session_id in tracker.subscribers:
@@ -602,27 +645,53 @@ def _msg_key(msg: dict) -> str:
602645
return pr_monitor.pr_key(msg.get("owner"), msg.get("repo"), msg.get("number"))
603646

604647

605-
async def _handle_subscribe(websocket, conn: Connection, msg: dict) -> None:
648+
def _msg_storage_key(msg: dict, provider: str) -> str:
649+
return pr_monitor.storage_key(
650+
provider, msg.get("owner"), msg.get("repo"), msg.get("number")
651+
)
652+
653+
654+
async def _handle_subscribe(
655+
websocket, conn: Connection, msg: dict, provider: str = "github"
656+
) -> None:
606657
session_id = conn.session_id or msg.get("session_id")
607658
owner, repo, number = msg.get("owner"), msg.get("repo"), msg.get("number")
608659
if not session_id or not owner or not repo or number is None:
609660
await _send(
610661
websocket, wsproto.ERROR, msg, error="missing session id or PR reference"
611662
)
612663
return
613-
key = pr_monitor.pr_key(owner, repo, number)
614-
tracker = TRACKERS.get(key)
664+
client = _pr_client(provider)
665+
if client is None or (provider == "forgejo" and not client.configured):
666+
await _send(
667+
websocket,
668+
wsproto.ERROR,
669+
msg,
670+
error="Forgejo PR monitoring is not configured on this daemon "
671+
"(set FORGEJO_API_URL / FORGEJO_TOKEN).",
672+
)
673+
return
674+
key = pr_monitor.pr_key(owner, repo, number) # display ref for replies/errors
675+
skey = pr_monitor.storage_key(provider, owner, repo, number) # TRACKERS/dir key
676+
tracker = TRACKERS.get(skey)
615677

616678
if tracker is None:
617-
tracker = pr_monitor.PRTracker(owner, repo, int(number), GH)
679+
tracker = pr_monitor.PRTracker(
680+
owner,
681+
repo,
682+
int(number),
683+
client,
684+
provider=provider,
685+
base_url=client.base_url if provider == "forgejo" else None,
686+
)
618687
try:
619688
summary = await tracker.initial_poll()
620689
except Exception as exc: # noqa: BLE001 - report any fetch failure to the agent
621690
await _send(
622691
websocket, wsproto.ERROR, msg, error=f"could not fetch {key}: {exc}"
623692
)
624693
return
625-
TRACKERS[key] = tracker
694+
TRACKERS[skey] = tracker
626695
tracker.next_poll_at = time.time() + _poll_delay(
627696
tracker
628697
) # baseline done; schedule first real poll
@@ -666,10 +735,10 @@ async def _handle_subscribe(websocket, conn: Connection, msg: dict) -> None:
666735
)
667736

668737

669-
def _handle_unsubscribe(conn: Connection, msg: dict) -> None:
738+
def _handle_unsubscribe(conn: Connection, msg: dict, provider: str = "github") -> None:
670739
session_id = conn.session_id or msg.get("session_id")
671-
key = _msg_key(msg)
672-
tracker = TRACKERS.get(key)
740+
skey = _msg_storage_key(msg, provider)
741+
tracker = TRACKERS.get(skey)
673742
if tracker is not None and session_id in tracker.subscribers:
674743
tracker.subscribers.discard(session_id)
675744
tracker.acked.pop(session_id, None)
@@ -684,19 +753,21 @@ def _handle_unsubscribe(conn: Connection, msg: dict) -> None:
684753
tracker.idle_since = time.time()
685754
pr_monitor.save_state(tracker)
686755
else:
687-
_remove_tracker(key)
756+
_remove_tracker(skey)
688757

689758

690-
async def _handle_list_pr_subscriptions(websocket, conn: Connection, msg: dict) -> None:
759+
async def _handle_list_pr_subscriptions(
760+
websocket, conn: Connection, msg: dict, provider: str = "github"
761+
) -> None:
691762
session_id = conn.session_id or msg.get("session_id")
692763
items = [
693764
{
694-
"pr": key,
765+
"pr": t.key, # human-facing ref (owner/repo#number), not the storage key
695766
"merged": t.merged,
696767
"pending": len(t.unacked_for(session_id)),
697768
}
698-
for key, t in TRACKERS.items()
699-
if session_id in t.subscribers
769+
for t in TRACKERS.values()
770+
if session_id in t.subscribers and t.provider == provider
700771
]
701772
await _send(websocket, wsproto.SUBSCRIPTIONS_RESULT, msg, items=items)
702773

@@ -1409,7 +1480,7 @@ def _finalize_terminal(tracker: pr_monitor.PRTracker, session_id: str) -> None:
14091480
tracker.missed.pop(session_id, None)
14101481
pr_monitor.delete_subscriber(tracker, session_id)
14111482
if not tracker.subscribers:
1412-
_remove_tracker(tracker.key)
1483+
_remove_tracker(tracker.storage_key)
14131484

14141485

14151486
def _poll_delay(tracker: pr_monitor.PRTracker) -> float:
@@ -1503,7 +1574,7 @@ async def _tracker_loop(tracker: pr_monitor.PRTracker) -> None:
15031574
tracker.consecutive_no_update = (
15041575
0 if added else tracker.consecutive_no_update + 1
15051576
)
1506-
except github_client.GitHubNotFound as exc:
1577+
except pr_errors.PRNotFound as exc:
15071578
_emit(
15081579
tracker,
15091580
pr_monitor.synthetic_event(
@@ -1517,22 +1588,30 @@ async def _tracker_loop(tracker: pr_monitor.PRTracker) -> None:
15171588
)
15181589
pr_monitor.save_state(tracker)
15191590
return
1520-
except github_client.GitHubRateLimited as exc:
1591+
except pr_errors.PRRateLimited as exc:
15211592
wait = max(1.0, exc.reset_at - time.time())
15221593
print(
15231594
f"notifications daemon: {tracker.key} rate limited; waiting {int(wait)}s",
15241595
file=sys.stderr,
15251596
)
15261597
delay = wait + random.uniform(1.0, 15.0) # defer; not a "no update"
1527-
except github_client.GitHubAuthError as exc:
1598+
except pr_errors.PRAuthError as exc:
15281599
if not tracker.auth_notified:
1600+
label = {"github": "GitHub", "forgejo": "Forgejo"}.get(
1601+
tracker.provider, tracker.provider
1602+
)
1603+
token_env = {
1604+
"github": "GITHUB_TOKEN",
1605+
"forgejo": "FORGEJO_TOKEN",
1606+
}.get(tracker.provider, "the provider token")
15291607
_emit(
15301608
tracker,
15311609
pr_monitor.synthetic_event(
15321610
"pr_auth_error",
15331611
"high",
1534-
f"GitHub access to {tracker.key} failed ({exc}). Polling is paused until "
1535-
"the daemon's GITHUB_TOKEN is fixed (restart the daemon with a valid token).",
1612+
f"{label} access to {tracker.key} failed ({exc}). Polling is paused "
1613+
f"until the daemon's {token_env} is fixed (restart the daemon with a "
1614+
"valid token).",
15361615
tracker.key,
15371616
f"auth_error:{tracker.key}",
15381617
),
@@ -1554,7 +1633,11 @@ async def _tracker_loop(tracker: pr_monitor.PRTracker) -> None:
15541633

15551634
if delay is None:
15561635
delay = _poll_delay(tracker)
1557-
throttle_until = GH.should_throttle() if GH is not None else None
1636+
throttle_until = (
1637+
tracker.client.should_throttle()
1638+
if tracker.client is not None
1639+
else None
1640+
)
15581641
if throttle_until is not None:
15591642
delay = max(
15601643
delay, throttle_until - time.time() + random.uniform(1.0, 15.0)
@@ -1607,11 +1690,17 @@ def filter(self, record: logging.LogRecord) -> bool:
16071690

16081691

16091692
async def main() -> None:
1610-
global GH, TOKEN
1693+
global GH, FJ, TOKEN
16111694
TOKEN = wsproto.token() # auto-creates <NOTIFICATIONS_DATA_DIR>/token if needed
16121695
GH = github_client.GitHubClient()
1613-
for tracker in pr_monitor.load_trackers(GH):
1614-
TRACKERS[tracker.key] = tracker
1696+
# Forgejo is optional: only served when an instance URL is configured. A daemon
1697+
# without it rejects forgejo_* subscriptions (and skips any forgejo trackers on
1698+
# disk) rather than failing to start.
1699+
FJ = forgejo_client.ForgejoClient()
1700+
if not FJ.configured:
1701+
FJ = None
1702+
for tracker in pr_monitor.load_trackers(_pr_clients()):
1703+
TRACKERS[tracker.storage_key] = tracker
16151704
tracker.task = asyncio.create_task(_tracker_loop(tracker))
16161705
asyncio.create_task(_reaper_loop())
16171706

0 commit comments

Comments
 (0)