mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
A pr-reviewer review of an org-authored PR never reached GitHub. The agent side ran correctly (claim → read-only diff → review → post_pr_review → completed + CEO notify), but the GitHub publish 422'd with "Can not request changes on your own pull request": the PR was authored by the same account that owns the project PAT. post_pr_review posts best-effort after the DB transition, so the failure was logged and swallowed — the task completed and the CEO was notified "reviewed" while the PR showed no review. GitHub forbids APPROVE / REQUEST_CHANGES on your own PR but DOES allow a plain COMMENT review. The org's internal PRs (and any PR the PAT owner opened) hit this. Retry once as a COMMENT review on the self-review 422 so the review actually lands; the verdict is already stated in the body. The external/fork-PR path (different author) is unchanged — REQUEST_CHANGES succeeds there and the fallback never fires. Tests: self-review 422 downgrades to COMMENT and returns the COMMENT result; a failing COMMENT retry still surfaces GitError with no infinite loop; the existing non-self 422 still raises.
150 lines
5.1 KiB
Python
150 lines
5.1 KiB
Python
"""GitService.post_pr_review — posts ONE review to a PR (httpx fully mocked).
|
|
|
|
Verifies the request shape (the first ``/pulls/{n}/reviews`` call in the
|
|
codebase), Bearer auth, and that GitHub/token failures raise ``GitError`` so the
|
|
calling side-effect can surface them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from roboco.services.git import GitError, GitService
|
|
|
|
_PR = 42
|
|
|
|
|
|
def _service() -> GitService:
|
|
session = MagicMock()
|
|
session.execute = AsyncMock()
|
|
return GitService(session)
|
|
|
|
|
|
def _bind(svc: GitService, name: str, value: object) -> None:
|
|
object.__setattr__(svc, name, value)
|
|
|
|
|
|
def _resp(
|
|
*, status_code: int, json_payload: dict[str, Any] | None = None, text: str = ""
|
|
) -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
resp.is_success = 200 <= status_code < 300 # noqa: PLR2004
|
|
resp.json.return_value = json_payload or {}
|
|
resp.text = text
|
|
return resp
|
|
|
|
|
|
def _client(post_resp: MagicMock) -> MagicMock:
|
|
client = MagicMock()
|
|
client.__aenter__ = AsyncMock(return_value=client)
|
|
client.__aexit__ = AsyncMock(return_value=False)
|
|
client.post = AsyncMock(return_value=post_resp)
|
|
return client
|
|
|
|
|
|
def _patch_project() -> Any:
|
|
fake = MagicMock()
|
|
fake.get_by_slug = AsyncMock(
|
|
return_value=MagicMock(git_url="https://github.com/acme/repo.git")
|
|
)
|
|
return patch("roboco.services.git.get_project_service", return_value=fake)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_pr_review_posts_request_changes() -> None:
|
|
svc = _service()
|
|
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
|
client = _client(
|
|
_resp(status_code=200, json_payload={"id": 1, "state": "CHANGES_REQUESTED"})
|
|
)
|
|
with (
|
|
_patch_project(),
|
|
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
|
):
|
|
out = await svc.post_pr_review("roboco", _PR, "Please fix X.")
|
|
client.post.assert_awaited_once()
|
|
call = client.post.await_args
|
|
assert f"/pulls/{_PR}/reviews" in call.args[0]
|
|
assert call.kwargs["json"] == {"body": "Please fix X.", "event": "REQUEST_CHANGES"}
|
|
assert call.kwargs["headers"]["Authorization"] == "Bearer tok"
|
|
assert out["state"] == "CHANGES_REQUESTED"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_pr_review_raises_on_missing_token() -> None:
|
|
svc = _service()
|
|
_bind(svc, "_token_for_project", AsyncMock(return_value=None))
|
|
with _patch_project(), pytest.raises(GitError):
|
|
await svc.post_pr_review("roboco", _PR, "body")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_pr_review_raises_on_github_error() -> None:
|
|
svc = _service()
|
|
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
|
client = _client(_resp(status_code=422, text="Unprocessable"))
|
|
with (
|
|
_patch_project(),
|
|
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
|
pytest.raises(GitError),
|
|
):
|
|
await svc.post_pr_review("roboco", _PR, "body")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_pr_review_self_review_falls_back_to_comment() -> None:
|
|
"""A 422 'own pull request' downgrades REQUEST_CHANGES to a COMMENT review."""
|
|
svc = _service()
|
|
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
|
client = MagicMock()
|
|
client.__aenter__ = AsyncMock(return_value=client)
|
|
client.__aexit__ = AsyncMock(return_value=False)
|
|
# First POST (REQUEST_CHANGES) 422s as a self-review; the COMMENT retry wins.
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_resp(
|
|
status_code=422,
|
|
text='{"errors":["Review Can not request changes on your own '
|
|
'pull request"]}',
|
|
),
|
|
_resp(status_code=200, json_payload={"id": 9, "state": "COMMENTED"}),
|
|
]
|
|
)
|
|
with (
|
|
_patch_project(),
|
|
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
|
):
|
|
out = await svc.post_pr_review("roboco", _PR, "Please fix X.")
|
|
assert client.post.await_count == 2 # noqa: PLR2004
|
|
first, second = client.post.await_args_list
|
|
assert first.kwargs["json"]["event"] == "REQUEST_CHANGES"
|
|
assert second.kwargs["json"]["event"] == "COMMENT"
|
|
assert second.kwargs["json"]["body"] == "Please fix X."
|
|
assert out["state"] == "COMMENTED"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_pr_review_self_review_comment_failure_raises() -> None:
|
|
"""If the COMMENT retry also fails, the error surfaces (no infinite loop)."""
|
|
svc = _service()
|
|
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
|
client = MagicMock()
|
|
client.__aenter__ = AsyncMock(return_value=client)
|
|
client.__aexit__ = AsyncMock(return_value=False)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_resp(status_code=422, text='["...on your own pull request"]'),
|
|
_resp(status_code=500, text="boom"),
|
|
]
|
|
)
|
|
with (
|
|
_patch_project(),
|
|
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
|
pytest.raises(GitError),
|
|
):
|
|
await svc.post_pr_review("roboco", _PR, "body")
|
|
assert client.post.await_count == 2 # noqa: PLR2004
|