5555sys .path .insert (0 , str (Path (__file__ ).resolve ().parent .parent / "lib" ))
5656
5757import agent_registry # noqa: E402
58+ import forgejo_client # noqa: E402
5859import github_client # noqa: E402
5960import messaging # noqa: E402
6061import message_topic # noqa: E402
62+ import pr_errors # noqa: E402
6163import pr_monitor # noqa: E402
6264import pr_schedule # noqa: E402
6365import scheduler # noqa: E402
@@ -234,6 +236,25 @@ def _alloc_ordinal() -> int:
234236
235237
236238GH : 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.
239260TOKEN = ""
@@ -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
14151486def _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
16091692async 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