- poller.py: CommentPoller polls PR comments rather than taking webhooks, keeping the VM outbound-only (B3). On an outage it backs off exponentially, caps, and retries indefinitely while logging: polling is pure intake, so an outage delays a reaction but never fails a task or forces a transition - prime() marks the existing thread as seen without delivering it, so starting a watch does not re-address feedback already handled - Review loop: a comment in PR_OPEN enters the canonical transcript, the executor responds, and the fix is pushed to the same branch with a reply on the PR. No intent is recorded for a follow-up push — the PR already exists, so a repeated push is harmless, unlike opening a second PR - Comment authors are checked against a separate Gitea operator allowlist: a PR comment is an instruction channel into an agent that writes code, so it is operator-scoped, and a Gitea login is not a Matrix MXID (A4 per-channel allowlists) - Orchestrator now holds a lock: transport and the poller deliver on separate threads, and state transitions must not interleave - Backoff timings are injectable, so the outage path is tested in milliseconds instead of being untestable Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
4.0 KiB
Python
133 lines
4.0 KiB
Python
import io
|
|
import json
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from nisse_dev.gitea import Comment, GiteaError
|
|
from nisse_dev.oplog import OpLog
|
|
from nisse_dev.poller import MAX_BACKOFF_S, CommentPoller
|
|
|
|
|
|
class FakeGitea:
|
|
def __init__(self, pages: list[list[Comment]] | None = None) -> None:
|
|
self.pages = pages or []
|
|
self.calls = 0
|
|
self.fail_times = 0
|
|
|
|
def list_comments(self, repo: str, index: int, since: str | None = None):
|
|
self.calls += 1
|
|
if self.fail_times > 0:
|
|
self.fail_times -= 1
|
|
raise GiteaError("gitea unreachable")
|
|
if not self.pages:
|
|
return []
|
|
return self.pages.pop(0)
|
|
|
|
|
|
def comment(cid: int, author: str = "don", body: str = "please fix") -> Comment:
|
|
return Comment(id=cid, author=author, body=body, created_at="t")
|
|
|
|
|
|
def build(client, **kw) -> tuple[CommentPoller, list[Comment]]:
|
|
seen: list[Comment] = []
|
|
poller = CommentPoller(client, "o/r", seen.append, **kw)
|
|
return poller, seen
|
|
|
|
|
|
def test_new_comments_are_delivered_once() -> None:
|
|
client = FakeGitea([[comment(1)], [comment(1), comment(2)]])
|
|
poller, seen = build(client)
|
|
poller.poll_once(1)
|
|
poller.poll_once(1)
|
|
assert [c.id for c in seen] == [1, 2] # id 1 not redelivered
|
|
|
|
|
|
def test_prime_suppresses_the_existing_backlog() -> None:
|
|
# Starting a watch must not replay feedback already addressed.
|
|
client = FakeGitea([[comment(1), comment(2)], [comment(1), comment(2), comment(3)]])
|
|
poller, seen = build(client)
|
|
poller.prime(1)
|
|
poller.poll_once(1)
|
|
assert [c.id for c in seen] == [3]
|
|
|
|
|
|
def test_own_comments_are_ignored() -> None:
|
|
client = FakeGitea([[comment(1, author="nisse-dev-testing"), comment(2, "don")]])
|
|
poller, seen = build(client, ignore_authors={"nisse-dev-testing"})
|
|
poller.poll_once(1)
|
|
assert [c.id for c in seen] == [2]
|
|
|
|
|
|
def test_ignored_comments_are_not_redelivered_later() -> None:
|
|
client = FakeGitea(
|
|
[
|
|
[comment(1, author="bot")],
|
|
[comment(1, author="bot"), comment(2, "don")],
|
|
]
|
|
)
|
|
poller, seen = build(client, ignore_authors={"bot"})
|
|
poller.poll_once(1)
|
|
poller.poll_once(1)
|
|
assert [c.id for c in seen] == [2]
|
|
|
|
|
|
def test_outage_does_not_raise_out_of_the_loop() -> None:
|
|
stream = io.StringIO()
|
|
client = FakeGitea([[comment(1)]])
|
|
client.fail_times = 2
|
|
poller, seen = build(
|
|
client,
|
|
oplog=OpLog("nisse-dev", stream),
|
|
interval_s=0.01,
|
|
initial_backoff_s=0.01,
|
|
)
|
|
poller.start(1)
|
|
deadline = time.monotonic() + 5
|
|
while not seen and time.monotonic() < deadline:
|
|
time.sleep(0.05)
|
|
poller.stop()
|
|
|
|
# It recovered and delivered rather than dying on the outage (B2: an
|
|
# outage delays a reaction, never fails a task).
|
|
assert [c.id for c in seen] == [1]
|
|
assert "gitea.poll_failed" in stream.getvalue()
|
|
|
|
|
|
def test_prime_survives_an_outage() -> None:
|
|
client = FakeGitea()
|
|
client.fail_times = 1
|
|
poller, _ = build(client)
|
|
poller.prime(1) # must not raise
|
|
|
|
|
|
def test_backoff_grows_then_caps() -> None:
|
|
client = FakeGitea()
|
|
client.fail_times = 50
|
|
stream = io.StringIO()
|
|
poller, _ = build(
|
|
client,
|
|
oplog=OpLog("nisse-dev", stream),
|
|
interval_s=0.001,
|
|
initial_backoff_s=0.001,
|
|
max_backoff_s=0.008,
|
|
)
|
|
poller.start(1)
|
|
time.sleep(0.3)
|
|
poller.stop()
|
|
|
|
delays = [json.loads(line)["retry_in_s"] for line in stream.getvalue().splitlines()]
|
|
assert delays[:4] == [0.001, 0.002, 0.004, 0.008]
|
|
assert max(delays) == 0.008 # capped, and retried indefinitely (B2)
|
|
assert MAX_BACKOFF_S <= 300.0 # the shipped cap is "a few minutes"
|
|
|
|
|
|
@pytest.mark.parametrize("author", ["don", "someone-else"])
|
|
def test_poller_does_not_filter_by_author_itself(author: str) -> None:
|
|
# Authorisation belongs to the orchestrator's allowlist, not here: the
|
|
# poller is pure intake.
|
|
client = FakeGitea([[comment(1, author=author)]])
|
|
poller, seen = build(client)
|
|
poller.poll_once(1)
|
|
assert len(seen) == 1
|