- approvals.py: PlanBoard opens one issue per project and posts each plan revision as a comment carrying its rev tag. The state store keeps only a pointer, so gate state can be re-derived from Gitea after a state-volume loss — external truth wins - Gitea comments are a second approval source: revision binding, the typed word approve, and first-approval-wins work exactly as over transport, with a per-channel allowlist because a Gitea login is not a Matrix MXID - Outcomes are echoed to the other channel, so the two records cannot silently disagree about what was approved - A Gitea outage degrades durability of the record, not the ability to approve: planning proceeds and Matrix remains a complete channel - Live test now asserts the server refuses the agent's own merge and its push to the protected branch. That property was previously true only because our code did not attempt it; branch protection makes it structural, and this test fails if the protection is ever relaxed - Fixed a race in the process-group test: killing a group is asynchronous and the grandchild is an orphan, so its exit is not synchronised with our return; the test now waits for death Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
81 lines
2.7 KiB
Python
81 lines
2.7 KiB
Python
"""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
|
|
]
|