"""Plan externalisation and Gitea-backed approvals (spec A4, Part H). Plans are posted as comments on a per-project Gitea issue. The approval comment — its author, timestamp, and revision tag — is the durable off-box record, authenticated by Gitea's own auth. The state store keeps only a pointer to it, so boot reconciliation can re-derive gate state from Gitea even after the state volume is lost: external truth wins. Approvals arriving here bind to a revision exactly as they do over transport (A4). The first valid approval on any channel wins; the orchestrator echoes the outcome to the other channel so the records converge. """ from dataclasses import dataclass from nisse_dev.gitea import Comment, GiteaClient, GiteaError APPROVAL_HINT = ( "Reply `approve {tag}` to approve this revision, or comment with feedback " "to reject it." ) @dataclass(frozen=True) class PlanPost: issue: int comment_id: int class PlanBoard: """The Gitea side of plan gating: one issue per project, one comment per plan revision.""" def __init__(self, client: GiteaClient, repo: str) -> None: self._client = client self._repo = repo @property def repo(self) -> str: return self._repo def open_project_issue(self, project: str, task_id: str) -> int: return self._client.create_issue( self._repo, title=f"nisse-dev: {project}", body=( f"Plan gate for project `{project}` (task `{task_id}`).\n\n" "Each plan revision is posted here as a comment. Approving a " "revision by comment is equivalent to approving it in Matrix; " "the first valid approval on either channel wins." ), ) def post_plan(self, issue: int, gate: str, rev: int, plan: str) -> PlanPost: tag = f"rev-{rev}" body = "\n".join( [ f"## Plan for `{gate}` ({tag})", "", plan, "", "---", APPROVAL_HINT.format(tag=tag), ] ) comment = self._client.post_comment(self._repo, issue, body) return PlanPost(issue=issue, comment_id=comment.id) def echo(self, issue: int, text: str) -> None: """Mirror an outcome decided on another channel, so the Gitea record does not silently disagree with Matrix.""" try: self._client.post_comment(self._repo, issue, text) except GiteaError: pass def comments_since(self, issue: int, after_id: int) -> list[Comment]: return [ c for c in self._client.list_comments(self._repo, issue) if c.id > after_id ]