"""Gitea comment intake (architecture D5). Polling, not webhooks: the agent VM stays outbound-only (D7), so nothing needs to reach in. Intake is structured JSON straight from the API and never routed through a chat bridge. Outage posture, per L5: back off exponentially, cap at a few minutes, retry indefinitely, and log. Polling is pure intake — an outage delays a reaction but must never fail a task or force a state transition. """ import threading from collections.abc import Callable, Iterable from nisse_dev.gitea import Comment, GiteaClient, GiteaError from nisse_dev.oplog import OpLog DEFAULT_INTERVAL_S = 20.0 INITIAL_BACKOFF_S = 5.0 MAX_BACKOFF_S = 300.0 class CommentPoller: def __init__( self, client: GiteaClient, repo: str, on_comment: Callable[[Comment], None], *, oplog: OpLog | None = None, ignore_authors: Iterable[str] = (), interval_s: float = DEFAULT_INTERVAL_S, initial_backoff_s: float = INITIAL_BACKOFF_S, max_backoff_s: float = MAX_BACKOFF_S, ) -> None: self._client = client self._repo = repo self._on_comment = on_comment self._oplog = oplog self._ignore = set(ignore_authors) self._interval_s = interval_s self._initial_backoff_s = initial_backoff_s self._max_backoff_s = max_backoff_s self._seen: set[int] = set() # Comments at or below this ID were handled before this poller # existed (a restart, or a watch attached again); Gitea's comment IDs # only grow, so one number says it all. self._after = 0 self._stop = threading.Event() self._thread: threading.Thread | None = None def prime(self, index: int) -> int: """Mark existing comments as seen without delivering them, and return the highest ID marked (0 if none). A poller that starts by replaying the whole thread would re-act to feedback already addressed — including its own earlier replies. """ highest = 0 try: for comment in self._client.list_comments(self._repo, index): self._seen.add(comment.id) highest = max(highest, comment.id) except GiteaError: # Nothing seen means the first successful poll delivers the # backlog once. Better than refusing to start. pass return max(highest, self._after) def resume_after(self, comment_id: int) -> None: """Deliver only comments newer than comment_id: the ones posted while nothing was watching are caught up, the handled ones are not replayed. Needs no request, so an outage cannot cause a replay.""" self._after = comment_id def poll_once(self, index: int) -> list[Comment]: """One pass; returns what was newly delivered. Separated from the loop so behaviour is testable without threads or sleeps.""" delivered: list[Comment] = [] for comment in self._client.list_comments(self._repo, index): if comment.id in self._seen or comment.id <= self._after: continue self._seen.add(comment.id) if comment.author in self._ignore: continue # the agent's own replies are not new input delivered.append(comment) self._on_comment(comment) return delivered def start(self, index: int) -> None: self._stop.clear() self._thread = threading.Thread(target=self._loop, args=(index,), daemon=True) self._thread.start() def stop(self) -> None: self._stop.set() if self._thread is not None: self._thread.join(timeout=self._interval_s + 5) self._thread = None def _loop(self, index: int) -> None: backoff = self._initial_backoff_s while not self._stop.is_set(): try: self.poll_once(index) except GiteaError as exc: if self._oplog is not None: self._oplog.emit( "gitea.poll_failed", cause="gitea unreachable", error=str(exc)[:300], retry_in_s=backoff, ) self._stop.wait(backoff) backoff = min(backoff * 2, self._max_backoff_s) continue backoff = self._initial_backoff_s self._stop.wait(self._interval_s)