"""Gitea threads across restarts and interruptions (#58, #59). Uses the real CommentPoller against a fake Gitea, with its thread replaced by explicit polls, so the cursor logic under test is the real one. """ import io from dataclasses import dataclass from pathlib import Path from typing import Any from nisse_dev.control import ( AWAIT_ARCH_SIGNOFF, AWAIT_INPUT, AWAIT_MILESTONE_SIGNOFF, MILESTONE_PLANNING, PR_OPEN, Orchestrator, StubAgent, ) from nisse_dev.executor import ExecutorOutcome from nisse_dev.gitea import Comment, PullRequest from nisse_dev.oplog import OpLog from nisse_dev.poller import CommentPoller from nisse_dev.publish import PublishResult from nisse_dev.state import StateStore from nisse_dev.transport import InMemoryTransport AGENT = "nisse-bot" PLAN_ISSUE = 10 PR = 20 class FakeGitea: """Comment threads by issue number; IDs grow across all threads.""" def __init__(self) -> None: self.threads: dict[int, list[Comment]] = {} self._next_id = 100 def add(self, index: int, author: str, body: str) -> int: self._next_id += 1 self.threads.setdefault(index, []).append( Comment(id=self._next_id, author=author, body=body, created_at="t") ) return self._next_id def list_comments(self, repo: str, index: int, since: str | None = None) -> list: return list(self.threads.get(index, [])) @dataclass class Posted: comment_id: int class Board: def __init__(self, gitea: FakeGitea) -> None: self._gitea = gitea def open_project_issue(self, project: str, task_id: str) -> int: return PLAN_ISSUE def post_plan(self, issue: int, gate: str, rev: int, plan: str) -> Posted: return Posted(self._gitea.add(issue, AGENT, f"{gate} rev-{rev}: {plan}")) def echo(self, issue: int, text: str) -> None: self._gitea.add(issue, AGENT, text) class Executor: def __init__(self) -> None: self.runs = 0 self.outcome = ExecutorOutcome("completed", "done") def run(self, transcript: object, milestone: int) -> ExecutorOutcome: self.runs += 1 return self.outcome class Publisher: def __init__(self) -> None: self.followups = 0 def reconcilers(self) -> dict[str, Any]: return {} def publish(self, **kwargs: Any) -> PublishResult: return PublishResult( PullRequest(number=PR, state="open", head="h", url="u"), f"opened PR #{PR}" ) def push_followup(self, **kwargs: Any) -> str | None: self.followups += 1 return "abcdef1234" def comment(self, index: int, body: str) -> None: pass class Agent: """One orchestrator process: a restart builds a new one on the same store and the same Gitea.""" def __init__(self, tmp_path: Path, gitea: FakeGitea, executor: Executor) -> None: self.store = StateStore( tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()) ) self.transport = InMemoryTransport() self.pollers: dict[int, CommentPoller] = {} self.publisher = Publisher() def factory(handler: Any) -> CommentPoller: poller = CommentPoller(gitea, "o/r", handler, ignore_authors=[AGENT]) agent = self def start(index: int) -> None: # no thread: tests poll explicitly agent.pollers[index] = poller poller.start = start # type: ignore[method-assign] poller.stop = lambda: None # type: ignore[method-assign] return poller self.orch = Orchestrator( self.store, self.transport, StubAgent(), operators={"don"}, gitea_operators={"don"}, board=Board(gitea), executor=executor, publisher=self.publisher, poller_factory=factory, ) self.orch.start() @property def state(self) -> str: state = self.store.get_state() assert state is not None return state.state def poll(self, index: int) -> list[Comment]: return self.pollers[index].poll_once(index) def to_pr_open(agent: Agent) -> None: for reply in ("use demo", "approve", "approve", "start milestone 1"): agent.transport.receive("don", reply) assert agent.state == PR_OPEN def test_an_approval_posted_while_down_is_applied_after_a_restart( tmp_path: Path, ) -> None: gitea = FakeGitea() agent = Agent(tmp_path, gitea, Executor()) agent.transport.receive("don", "use demo") assert agent.state == AWAIT_ARCH_SIGNOFF gitea.add(PLAN_ISSUE, "don", "approve") # while the agent is down agent = Agent(tmp_path, gitea, Executor()) [delivered] = agent.poll(PLAN_ISSUE) assert delivered.body == "approve" assert agent.state == AWAIT_MILESTONE_SIGNOFF def test_a_handled_approval_is_not_replayed_after_a_restart(tmp_path: Path) -> None: gitea = FakeGitea() agent = Agent(tmp_path, gitea, Executor()) agent.transport.receive("don", "use demo") gitea.add(PLAN_ISSUE, "don", "approve") agent.poll(PLAN_ISSUE) # arch approved; the milestone plan is posted assert agent.state == AWAIT_MILESTONE_SIGNOFF agent = Agent(tmp_path, gitea, Executor()) assert agent.poll(PLAN_ISSUE) == [] assert agent.state == AWAIT_MILESTONE_SIGNOFF def test_a_new_revision_ignores_an_approval_posted_before_it(tmp_path: Path) -> None: gitea = FakeGitea() agent = Agent(tmp_path, gitea, Executor()) agent.transport.receive("don", "use demo") agent.transport.receive("don", "needs tests") # rejects: rev-2 is posted assert agent.state == AWAIT_ARCH_SIGNOFF stale = gitea.add(PLAN_ISSUE, "don", "approve") agent.transport.receive("don", "and docs") # rev-3 posted after it assert all(c.id != stale for c in agent.poll(PLAN_ISSUE)) assert agent.state == AWAIT_ARCH_SIGNOFF def test_data_from_before_cursors_skips_old_comments_on_restart( tmp_path: Path, ) -> None: gitea = FakeGitea() agent = Agent(tmp_path, gitea, Executor()) agent.transport.receive("don", "use demo") gitea.add(PLAN_ISSUE, "don", "approve") # an old approval, say of rev-1 state = agent.store.get_state() state.data["board"].pop("last_comment") # as saved before this change agent.store.transition( state.state, cause="test", project=state.project, data=state.data ) agent = Agent(tmp_path, gitea, Executor()) assert agent.poll(PLAN_ISSUE) == [] assert agent.state == AWAIT_ARCH_SIGNOFF def test_a_restart_in_pr_open_resumes_and_catches_up_on_review( tmp_path: Path, ) -> None: gitea = FakeGitea() agent = Agent(tmp_path, gitea, Executor()) to_pr_open(agent) gitea.add(PR, "don", "please add a test") # while the agent is down agent = Agent(tmp_path, gitea, Executor()) assert agent.state == PR_OPEN # no restart question assert "restarted with PR #20 open" in agent.transport.outbound[-1].text [delivered] = agent.poll(PR) assert delivered.body == "please add a test" assert agent.publisher.followups == 1 assert agent.state == PR_OPEN def test_a_failed_review_response_returns_to_the_open_pr(tmp_path: Path) -> None: gitea = FakeGitea() executor = Executor() agent = Agent(tmp_path, gitea, executor) to_pr_open(agent) executor.outcome = ExecutorOutcome("escalate", "stuck") gitea.add(PR, "don", "please add a test") agent.poll(PR) assert agent.state == AWAIT_INPUT # A comment while it waits is not lost: back in PR_OPEN it is delivered. executor.outcome = ExecutorOutcome("completed", "done") gitea.add(PR, "don", "try again") agent.poll(PR) assert agent.state == AWAIT_INPUT agent.transport.receive("don", "ok") assert agent.state == PR_OPEN [delivered] = agent.poll(PR) assert delivered.body == "try again" assert agent.publisher.followups == 1 agent.transport.receive("don", "next milestone") assert agent.state in (MILESTONE_PLANNING, AWAIT_MILESTONE_SIGNOFF)