Add PR comment intake and the review loop (B2) — step 6b
CI / checks (push) Successful in 42s
CI / checks (pull_request) Successful in 43s

- 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:
dongarandClaude Opus 5 committed 2026-09-07 21:59:50 +00:00
1 parent 3117565928
commit affdb9e8aa
6 files changed
+540 -1

No files matched your search

+32 -1
View File
@@ -10,7 +10,12 @@ an idempotent transition reconciled against external truth.
from dataclasses import dataclass
from nisse_dev.git import GitRepo, authenticated_url
from nisse_dev.gitea import GiteaClient, PullRequest, pull_request_reconciler
from nisse_dev.gitea import (
GiteaClient,
GiteaError,
PullRequest,
pull_request_reconciler,
)
from nisse_dev.reconcile import Reconciler
from nisse_dev.state import StateStore
@@ -86,6 +91,32 @@ class MilestonePublisher:
self._store.commit_intent(intent_id, cause=f"opened PR #{pr.number}")
return PublishResult(pr, f"opened PR #{pr.number}: {pr.url}")
def comment(self, index: int, body: str) -> None:
"""Reply on the PR. Courtesy only — the pushed commit is the real
answer — so a failure here must not derail the review loop."""
try:
self._client.post_comment(self._repo, index, body)
except GiteaError:
pass
def push_followup(
self, *, task_id: str, milestone: int, message: str
) -> str | None:
"""Push a fix commit onto an open milestone's branch.
No intent is recorded: the PR already exists, so a crash mid-push
costs a repeated push of the same commit, which is harmless. That is
the difference from publish(), where the side effect is a new PR.
"""
branch = self.branch_for(task_id, milestone)
if self._git.current_branch() != branch:
self._git.checkout_new_branch(branch)
sha = self._git.commit_all(message)
if sha is None:
return None
self._git.push(self._remote, branch, token=self._token)
return sha
def _pr_body(milestone: int, plan: str, service_url: str | None) -> str:
parts = [