CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
Development Plan M0.1. Every section reference in code, tests, specs/, the example config, the tuning guide, and the CLAUDE.md and README files now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps it). The plan's old-to-new table goes with it. Mostly mechanical, with a per-site choice where one old section split: A4 became L2 where it meant stop/abandon/the loop and L3 where it meant approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2 became D4 for outbound Gitea and publishing and D5 for review intake; 'A3 step N' became the matching step of L8's boot sequence. Comments describing behaviour the architecture drops (planning always escalating, thresholds tuned at an old build step) now describe the current code and point at the Development Plan. docs/Architecture.md gains one line the rewrite missed: intake outages delay but never fail work (old B2), now in L5, where the poller and Matrix code cite it. Comments and docs only; no behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
185 lines
6.7 KiB
Python
185 lines
6.7 KiB
Python
"""Reacting to PR review comments while in PR_OPEN (architecture D5)."""
|
|
|
|
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
|