M3.3: the trust dial (the agent merges approved PRs)
A project's trust.merge is operator (the default) or approved (L3, D5, #65). Set to approved, the harness, never the model, merges a PR in the PR watch once an approving review from a gitea.operators account is on its current commit, its checks pass (none at all counts; pending waits) and it's mergeable; the item then goes on as after any merge. Each reason to hold off (failed checks, conflicts, a refused merge) is said once per commit. Gitea enforces it: protection requires approvals from an operators-only whitelist and dismisses stale ones. At start, for each approved project, the agent checks by behaviour that its own unapproved merge is refused, with a throwaway branch and PR made through the API (never the workspace); if it isn't refused, the operator merges until a restart. - gitea: approvals, combined check status, a PR's head commit and mergeability, API-made branches and their deletion. - Test and bring-up repositories get the protection. - Tests: the decision (unit), the hard wall on a real Gitea (test_trust_live), an e2e test where the operator only approves, and a walk property, merges_only_approved, checked against a mutant that ignores which commit was approved. Closes #65 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
21cbf1682c
commit
ab469b60bb
17 files changed
+733
-11
No files matched your search
@@ -14,7 +14,14 @@ from nisse_dev.cancel import Cancel
|
||||
from nisse_dev.canonical import ToolCall
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.gitea import Comment, GiteaError, Issue, PullRequest, ReviewNote
|
||||
from nisse_dev.gitea import (
|
||||
Approval,
|
||||
Comment,
|
||||
GiteaError,
|
||||
Issue,
|
||||
PullRequest,
|
||||
ReviewNote,
|
||||
)
|
||||
from nisse_dev.loop.agent import LoopAgent
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import ANSWER, MERGE, WAITING, loop_of
|
||||
@@ -62,6 +69,10 @@ class Forge:
|
||||
self.deps: dict[int, list[int]] = {} # issue -> the issues it depends on
|
||||
self.assigned: list[int] = [] # issues the agent assigned to itself
|
||||
self.reassigned: dict[int, str] = {} # issue -> when assigned again
|
||||
# The trust dial (M3.3): reviews, check statuses, merges by the agent.
|
||||
self.approved: dict[int, list[Approval]] = {}
|
||||
self.status: dict[str, str] = {} # commit -> combined check state
|
||||
self.agent_merged: list[int] = []
|
||||
|
||||
# intake
|
||||
def assigned_issues(self, repo: str, assignee: str) -> list[Issue]:
|
||||
@@ -91,7 +102,40 @@ class Forge:
|
||||
# PRs
|
||||
def _pr(self, n: int) -> PullRequest:
|
||||
p = self.prs[n]
|
||||
return PullRequest(n, p["state"], p["head"], f"https://g/pr/{n}", p["merged"])
|
||||
return PullRequest(
|
||||
n,
|
||||
p["state"],
|
||||
p["head"],
|
||||
f"https://g/pr/{n}",
|
||||
p["merged"],
|
||||
head_sha=self.head_sha(n),
|
||||
)
|
||||
|
||||
def head_sha(self, n: int) -> str:
|
||||
ref = f"refs/heads/{self.prs[n]['head']}"
|
||||
found = subprocess.run(
|
||||
["git", "--git-dir", str(self.bare), "rev-parse", "--verify", "-q", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return found.stdout.strip()
|
||||
|
||||
def approve(self, n: int, by: str = "don", sha: str = "") -> None:
|
||||
"""A review approving the PR as it is now (or at ``sha``)."""
|
||||
self.approved.setdefault(n, []).append(Approval(by, sha or self.head_sha(n)))
|
||||
|
||||
def approvals(self, repo: str, index: int) -> list[Approval]:
|
||||
return self.approved.get(index, [])
|
||||
|
||||
def checks(self, repo: str, sha: str) -> str:
|
||||
return self.status.get(sha, "none")
|
||||
|
||||
def branch_with_file(self, repo: str, *, base, branch, path, text, message) -> None:
|
||||
git("--git-dir", str(self.bare), "update-ref", f"refs/heads/{branch}", base)
|
||||
|
||||
def delete_branch(self, repo: str, name: str) -> None:
|
||||
git("--git-dir", str(self.bare), "update-ref", "-d", f"refs/heads/{name}")
|
||||
|
||||
def find_pull_request(self, repo: str, head: str) -> PullRequest | None:
|
||||
for n, p in self.prs.items():
|
||||
@@ -150,8 +194,15 @@ class Forge:
|
||||
return {"protected": self.protected, "user_can_push": not self.protected}
|
||||
|
||||
def merge_pull_request(self, repo: str, index: int) -> None:
|
||||
if self.protected:
|
||||
"""As protection would have it: an operator's approval of the
|
||||
PR's current commit, or no merge."""
|
||||
head = self.head_sha(index)
|
||||
if self.protected and not any(
|
||||
a.author == "don" and a.commit == head and not a.stale
|
||||
for a in self.approved.get(index, [])
|
||||
):
|
||||
raise GiteaError("gitea POST merge returned 405: approvals needed")
|
||||
self.agent_merged.append(index)
|
||||
self.merge(index)
|
||||
|
||||
# what the operator does in Gitea
|
||||
|
||||
Reference in new issue
Block a user