Files
dongarandClaude Opus 5.5 a1eeab1bc7
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m55s
CI / e2e (pull_request) Successful in 4m45s
test_trust_live: ride out a transient Gitea error while waiting for the dismissal
A dropped connection or a 5xx inside the wait is retried until the
deadline; any other error fails at once (review on #164).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-18 13:16:10 +00:00

117 lines
4.5 KiB
Python

"""The trust dial's hard wall, on a real Gitea (D4, D5; M3.3): with the
protection a `merge: approved` project has, the agent's own token can't
merge its PR until an operator approves the PR as it is now; it can then;
and a commit pushed after the approval needs approving again: at once by the
agent's own check, and by Gitea once it has dismissed the approval."""
import base64
import re
import time
import uuid
from pathlib import Path
import pytest
from e2e_support import fresh_repo, operator_gitea
from nisse_core.gitea import GiteaClient, GiteaError
from nisse_dev.config import AgentConfig
pytestmark = pytest.mark.integration
# How long Gitea gets to dismiss an approval after a push (#162).
DISMISS_WAIT_S = 30.0
def add_file(gitea: GiteaClient, repo: str, branch: str, path: str) -> None:
gitea._request(
"POST",
f"/repos/{repo}/contents/{path}",
json={
"content": base64.b64encode(f"{path}\n".encode()).decode(),
"message": f"add {path}",
"branch": branch,
},
)
def approve(operator: GiteaClient, repo: str, pr: int) -> None:
operator._request(
"POST", f"/repos/{repo}/pulls/{pr}/reviews", json={"event": "APPROVED"}
)
def wait_for_dismissal(gitea: GiteaClient, repo: str, pr: int) -> None:
"""Gitea dismisses a stale approval in a job it queues on the push, not
in the push itself, and its merge check counts what isn't dismissed yet.
A merge tried before that job has run can still go through (#162)."""
deadline = time.monotonic() + DISMISS_WAIT_S
while True:
try:
if not gitea.approvals(repo, pr): # dismissed ones aren't listed
return
except GiteaError as exc:
# A busy Gitea may drop a call or answer 5xx; ask again. Anything
# else, such as a 4xx, is a real failure.
if not transient(exc) or time.monotonic() > deadline:
raise
assert time.monotonic() < deadline, (
f"Gitea didn't dismiss the approval within {DISMISS_WAIT_S}s"
)
time.sleep(0.5)
def transient(exc: GiteaError) -> bool:
"""A dropped connection or a 5xx: worth asking again."""
text = str(exc)
return text.startswith("gitea request failed") or bool(
re.search(r" returned 5\d\d:", text)
)
def test_merging_needs_the_operators_approval_of_the_pr_as_it_is(
tmp_path: Path, live_config: AgentConfig, gitea: GiteaClient
) -> None:
operator = operator_gitea(live_config.gitea_url)
run = uuid.uuid4().hex[:8]
with fresh_repo(operator, run, tmp_path / "keys", agent=gitea.whoami()) as repo:
name = repo.name
# The agent's PR, its branch made through the API.
gitea.branch_with_file(
name, base="main", branch="m-1", path="a.txt", text="a\n", message="a"
)
pr = gitea.open_pull_request(name, head="m-1", base="main", title="a", body="")
with pytest.raises(GiteaError):
gitea.merge_pull_request(name, pr.number) # no approval yet
approve(operator, name, pr.number)
[approval] = gitea.approvals(name, pr.number)
head = gitea.pull_request(name, pr.number).head_sha
assert (approval.author, approval.commit) == (operator.whoami(), head)
gitea.merge_pull_request(name, pr.number) # approved: the agent may
assert gitea.pull_request(name, pr.number).merged
# Approved, then changed: the approval no longer stands.
gitea.branch_with_file(
name, base="main", branch="m-2", path="b.txt", text="b\n", message="b"
)
second = gitea.open_pull_request(
name, head="m-2", base="main", title="b", body=""
)
approve(operator, name, second.number)
approved = gitea.pull_request(name, second.number).head_sha
add_file(gitea, name, "m-2", "c.txt") # a fix pushed after the approval
# At once, with no waiting: the PR's head is read from the branch,
# so the agent sees the new commit, and the approval is for the old
# one. Its own check (an approval at the head, not stale) holds off.
after = gitea.pull_request(name, second.number).head_sha
assert after != approved
assert not [
a
for a in gitea.approvals(name, second.number)
if a.commit == after and not a.stale
]
# Gitea's own refusal comes once it has dismissed the approval.
wait_for_dismissal(gitea, name, second.number)
with pytest.raises(GiteaError):
gitea.merge_pull_request(name, second.number)