Add PR comment intake and the review loop (B2) — step 6b
- 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>
This commit is contained in:
1 parent
3117565928
commit
affdb9e8aa
6 files changed
+540
-1
No files matched your search
@@ -0,0 +1,184 @@
|
||||
"""Reacting to PR review comments while in PR_OPEN (spec B2)."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nisse_dev.canonical import CanonicalEvent, StoredEvent, ToolCall
|
||||
from nisse_dev.control import AWAIT_INPUT, PR_OPEN, Orchestrator
|
||||
from nisse_dev.executor import ExecutorOutcome
|
||||
from nisse_dev.gitea import Comment, GiteaError, PullRequest
|
||||
from nisse_dev.llm import RoleMap
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.planner import LLMPlanningAgent
|
||||
from nisse_dev.publish import PublishResult
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.transport import InMemoryTransport
|
||||
|
||||
ROLES = RoleMap(executor="x", planner="y", delegate="y", consultant="y")
|
||||
|
||||
|
||||
class ScriptedClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def complete(self, events, tools, model, system=None) -> list[CanonicalEvent]:
|
||||
self.calls += 1
|
||||
return [
|
||||
ToolCall(
|
||||
call_id=f"c{self.calls}",
|
||||
tool="submit_plan",
|
||||
arguments={"type": "x", "content": f"plan {self.calls}"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class RecordingExecutor:
|
||||
def __init__(self, outcomes: list[str] | None = None) -> None:
|
||||
self.runs: list[int] = []
|
||||
self.seen_events: list[list[StoredEvent]] = []
|
||||
# One entry per run, so a test can let the milestone succeed and
|
||||
# only the review response fail.
|
||||
self._outcomes = outcomes or []
|
||||
self.store: StateStore | None = None
|
||||
|
||||
def run(self, milestone: int) -> ExecutorOutcome:
|
||||
self.runs.append(milestone)
|
||||
if self.store is not None:
|
||||
self.seen_events.append(self.store.read_events())
|
||||
status = self._outcomes.pop(0) if self._outcomes else "completed"
|
||||
return ExecutorOutcome(status, f"executor said {status}")
|
||||
|
||||
|
||||
class RecordingPublisher:
|
||||
def __init__(self, *, sha: str | None = "abc12345", error: Exception | None = None):
|
||||
self._sha = sha
|
||||
self._error = error
|
||||
self.followups: list[dict[str, Any]] = []
|
||||
self.comments: list[tuple[int, str]] = []
|
||||
|
||||
def reconcilers(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def publish(self, **kwargs: Any) -> PublishResult:
|
||||
return PublishResult(
|
||||
PullRequest(number=42, state="open", head="h", url="u"), "opened PR #42: u"
|
||||
)
|
||||
|
||||
def push_followup(self, **kwargs: Any) -> str | None:
|
||||
self.followups.append(kwargs)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._sha
|
||||
|
||||
def comment(self, index: int, body: str) -> None:
|
||||
self.comments.append((index, body))
|
||||
|
||||
|
||||
def build(
|
||||
tmp_path: Path,
|
||||
executor: Any,
|
||||
publisher: Any,
|
||||
gitea_operators: set[str] | None = None,
|
||||
) -> tuple[Orchestrator, StateStore, InMemoryTransport]:
|
||||
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
if hasattr(executor, "store"):
|
||||
executor.store = store
|
||||
transport = InMemoryTransport()
|
||||
orch = Orchestrator(
|
||||
store,
|
||||
transport,
|
||||
LLMPlanningAgent(ScriptedClient(), store, ROLES),
|
||||
operators={"don"},
|
||||
roles=ROLES,
|
||||
executor=executor,
|
||||
publisher=publisher,
|
||||
gitea_operators=gitea_operators if gitea_operators is not None else {"don"},
|
||||
)
|
||||
orch.start()
|
||||
transport.receive("don", "use demo")
|
||||
transport.receive("don", "approve")
|
||||
transport.receive("don", "approve")
|
||||
transport.receive("don", "start milestone 1")
|
||||
return orch, store, transport
|
||||
|
||||
|
||||
def review(
|
||||
cid: int = 5, author: str = "don", body: str = "rename the module"
|
||||
) -> Comment:
|
||||
return Comment(id=cid, author=author, body=body, created_at="t")
|
||||
|
||||
|
||||
def test_review_comment_triggers_a_fix_and_a_push(tmp_path: Path) -> None:
|
||||
executor, publisher = RecordingExecutor(), RecordingPublisher()
|
||||
orch, store, transport = build(tmp_path, executor, publisher)
|
||||
assert store.get_state().state == PR_OPEN
|
||||
|
||||
orch.handle_review_comment(review())
|
||||
|
||||
assert executor.runs == [1, 1] # milestone run, then the review response
|
||||
assert publisher.followups[0]["milestone"] == 1
|
||||
assert "abc12345" in transport.outbound[-1].text
|
||||
assert publisher.comments[0][0] == 42 # replied on the PR
|
||||
|
||||
|
||||
def test_comment_enters_the_transcript_before_the_executor_runs(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
executor, publisher = RecordingExecutor(), RecordingPublisher()
|
||||
orch, _, _ = build(tmp_path, executor, publisher)
|
||||
orch.handle_review_comment(review(body="please add a test"))
|
||||
|
||||
# The executor must see the feedback as conversation, not as an
|
||||
# out-of-band instruction it was never shown.
|
||||
texts = [getattr(e.event, "text", "") for e in executor.seen_events[-1]]
|
||||
assert any("please add a test" in t for t in texts)
|
||||
|
||||
|
||||
def test_non_operator_comments_are_ignored(tmp_path: Path) -> None:
|
||||
executor, publisher = RecordingExecutor(), RecordingPublisher()
|
||||
orch, store, _ = build(tmp_path, executor, publisher)
|
||||
orch.handle_review_comment(review(author="drive-by"))
|
||||
|
||||
assert executor.runs == [1] # only the original milestone run
|
||||
assert publisher.followups == []
|
||||
assert store.get_state().state == PR_OPEN
|
||||
|
||||
|
||||
def test_comments_outside_pr_open_are_ignored(tmp_path: Path) -> None:
|
||||
executor, publisher = RecordingExecutor(), RecordingPublisher()
|
||||
orch, _store, transport = build(tmp_path, executor, publisher)
|
||||
transport.receive("don", "project complete") # back to IDLE
|
||||
|
||||
orch.handle_review_comment(review())
|
||||
assert executor.runs == [1]
|
||||
|
||||
|
||||
def test_failed_review_response_gates_to_the_operator(tmp_path: Path) -> None:
|
||||
# Milestone succeeds; the response to the review comment does not.
|
||||
executor = RecordingExecutor(outcomes=["completed", "escalate"])
|
||||
publisher = RecordingPublisher()
|
||||
orch, store, transport = build(tmp_path, executor, publisher)
|
||||
orch.handle_review_comment(review())
|
||||
|
||||
assert store.get_state().state == AWAIT_INPUT
|
||||
assert "could not address the review comment" in transport.outbound[-1].text
|
||||
assert publisher.followups == []
|
||||
|
||||
|
||||
def test_no_code_change_needed_is_reported(tmp_path: Path) -> None:
|
||||
executor, publisher = RecordingExecutor(), RecordingPublisher(sha=None)
|
||||
orch, _, transport = build(tmp_path, executor, publisher)
|
||||
orch.handle_review_comment(review(body="just curious, why sqlite?"))
|
||||
|
||||
assert "no code change was needed" in transport.outbound[-1].text
|
||||
|
||||
|
||||
def test_push_failure_is_reported_without_losing_pr_open(tmp_path: Path) -> None:
|
||||
executor = RecordingExecutor()
|
||||
publisher = RecordingPublisher(error=GiteaError("push rejected"))
|
||||
orch, store, transport = build(tmp_path, executor, publisher)
|
||||
orch.handle_review_comment(review())
|
||||
|
||||
assert "could not push the fix" in transport.outbound[-1].text
|
||||
assert store.get_state().state == PR_OPEN # still reviewable, can retry
|
||||
Reference in new issue
Block a user