"""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 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 gitea.approvals(repo, pr): # dismissed ones aren't listed assert time.monotonic() < deadline, ( f"Gitea didn't dismiss the approval within {DISMISS_WAIT_S}s" ) time.sleep(0.5) 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)