298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""Integration tests for the full webhook -> review -> comment flow."""
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from arbiter.config import Settings
|
|
from arbiter.integrations import (
|
|
ARBITER_MARKER,
|
|
Comment,
|
|
CommitStatus,
|
|
GitHubClient,
|
|
Platform,
|
|
)
|
|
from arbiter.models import Verdict
|
|
from arbiter.worker.tasks import (
|
|
_post_or_update_comment,
|
|
_verdict_to_status,
|
|
get_platform_client,
|
|
)
|
|
|
|
|
|
class TestVerdictToStatus:
|
|
def test_approve_is_success(self) -> None:
|
|
assert _verdict_to_status(Verdict.APPROVE) == CommitStatus.SUCCESS
|
|
|
|
def test_request_changes_is_failure(self) -> None:
|
|
assert _verdict_to_status(Verdict.REQUEST_CHANGES) == CommitStatus.FAILURE
|
|
|
|
def test_comment_is_success(self) -> None:
|
|
assert _verdict_to_status(Verdict.COMMENT) == CommitStatus.SUCCESS
|
|
|
|
|
|
class TestGetPlatformClient:
|
|
def test_github_client_with_token(self) -> None:
|
|
settings = MagicMock(spec=Settings)
|
|
settings.github_token = MagicMock()
|
|
settings.github_token.get_secret_value.return_value = "test-token"
|
|
settings.github_base_url = "https://api.github.com"
|
|
settings.integration_timeout = 30
|
|
settings.integration_max_retries = 3
|
|
|
|
client = get_platform_client(Platform.GITHUB, settings)
|
|
|
|
assert client is not None
|
|
assert isinstance(client, GitHubClient)
|
|
|
|
def test_github_client_without_token(self) -> None:
|
|
settings = MagicMock(spec=Settings)
|
|
settings.github_token = None
|
|
|
|
client = get_platform_client(Platform.GITHUB, settings)
|
|
|
|
assert client is None
|
|
|
|
def test_gitlab_client_without_token(self) -> None:
|
|
settings = MagicMock(spec=Settings)
|
|
settings.gitlab_token = None
|
|
|
|
client = get_platform_client(Platform.GITLAB, settings)
|
|
|
|
assert client is None
|
|
|
|
|
|
class TestIntegrationFlow:
|
|
@pytest.mark.asyncio
|
|
async def test_webhook_to_queue_includes_platform(self, test_client: Any) -> None:
|
|
payload = {
|
|
"action": "opened",
|
|
"pull_request": {
|
|
"number": 1,
|
|
"title": "Test PR",
|
|
"base": {"sha": "base123"},
|
|
"head": {"sha": "head456"},
|
|
"user": {"login": "testuser"},
|
|
"draft": False,
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
}
|
|
|
|
with patch("arbiter.api.routes.webhooks.enqueue_review") as mock_enqueue:
|
|
mock_enqueue.return_value = "job-123"
|
|
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json=payload,
|
|
headers={"X-GitHub-Event": "pull_request"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_enqueue.assert_called_once()
|
|
call_kwargs = mock_enqueue.call_args.kwargs
|
|
assert call_kwargs.get("platform") == "github"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gitlab_webhook_passes_platform(self, test_client: Any) -> None:
|
|
payload = {
|
|
"object_kind": "merge_request",
|
|
"object_attributes": {
|
|
"action": "open",
|
|
"iid": 1,
|
|
"title": "Test MR",
|
|
"target_branch": "main",
|
|
"last_commit": {"id": "head456"},
|
|
"work_in_progress": False,
|
|
},
|
|
"project": {"path_with_namespace": "owner/repo"},
|
|
"user": {"username": "testuser"},
|
|
}
|
|
|
|
with patch("arbiter.api.routes.webhooks.enqueue_review") as mock_enqueue:
|
|
mock_enqueue.return_value = "job-123"
|
|
|
|
response = await test_client.post(
|
|
"/webhooks/gitlab",
|
|
json=payload,
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
mock_enqueue.assert_called_once()
|
|
call_kwargs = mock_enqueue.call_args.kwargs
|
|
assert call_kwargs.get("platform") == "gitlab"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_worker_fetches_diff_from_platform(self) -> None:
|
|
mock_client = AsyncMock()
|
|
mock_client.get_pr_diff.return_value = "mock diff content"
|
|
mock_client.close = AsyncMock()
|
|
|
|
with (
|
|
patch("arbiter.worker.tasks.get_platform_client", return_value=mock_client),
|
|
patch("arbiter.worker.tasks.get_settings") as mock_settings,
|
|
patch("arbiter.worker.tasks.async_session_factory"),
|
|
patch("arbiter.worker.tasks._run_review_pipeline"),
|
|
):
|
|
settings = MagicMock()
|
|
settings.update_status = False
|
|
settings.post_comments = False
|
|
mock_settings.return_value = settings
|
|
|
|
# We can't easily run the full process_review without more mocking,
|
|
# but we can test the client creation and diff fetching logic
|
|
from arbiter.worker.tasks import detect_platform
|
|
|
|
platform = detect_platform("owner/repo", "github")
|
|
assert platform == Platform.GITHUB
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_worker_posts_comment_on_completion(self) -> None:
|
|
mock_client = AsyncMock()
|
|
mock_client.post_comment.return_value = "https://github.com/comment/123"
|
|
mock_client.update_commit_status = AsyncMock()
|
|
mock_client.close = AsyncMock()
|
|
|
|
# Verify the formatter produces valid output
|
|
from arbiter.deliberation import DeliberationResult
|
|
from arbiter.integrations import ReviewCommentFormatter
|
|
from arbiter.models import Verdict
|
|
|
|
result = DeliberationResult(
|
|
findings=[],
|
|
verdict=Verdict.APPROVE,
|
|
verdict_confidence=0.95,
|
|
verdict_reasoning="All good",
|
|
)
|
|
|
|
formatter = ReviewCommentFormatter()
|
|
comment = formatter.format(result)
|
|
|
|
assert "Approve" in comment
|
|
assert len(comment) > 0
|
|
|
|
# Simulate posting
|
|
await mock_client.post_comment("owner/repo", 1, comment)
|
|
mock_client.post_comment.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_worker_updates_status_on_error(self) -> None:
|
|
mock_client = AsyncMock()
|
|
mock_client.update_commit_status = AsyncMock()
|
|
|
|
from arbiter.integrations import CommitStatus
|
|
from arbiter.worker.tasks import _update_status_safe
|
|
|
|
await _update_status_safe(
|
|
mock_client,
|
|
"owner/repo",
|
|
"abc123",
|
|
CommitStatus.ERROR,
|
|
"Review failed",
|
|
"arbiter",
|
|
)
|
|
|
|
mock_client.update_commit_status.assert_called_once_with(
|
|
repository="owner/repo",
|
|
sha="abc123",
|
|
status=CommitStatus.ERROR,
|
|
description="Review failed",
|
|
context="arbiter",
|
|
target_url=None,
|
|
)
|
|
|
|
|
|
class TestIdempotentComments:
|
|
@pytest.mark.asyncio
|
|
async def test_updates_existing_arbiter_comment(self) -> None:
|
|
mock_client = AsyncMock()
|
|
existing_comment = Comment(
|
|
id="456",
|
|
body=f"Old Arbiter review {ARBITER_MARKER}",
|
|
author="arbiter-bot",
|
|
url="https://github.com/owner/repo/pull/1#comment-456",
|
|
created_at=datetime.now(UTC),
|
|
)
|
|
mock_client.get_comments.return_value = [
|
|
Comment(
|
|
id="123",
|
|
body="Regular comment",
|
|
author="human",
|
|
url="https://github.com/owner/repo/pull/1#comment-123",
|
|
created_at=datetime.now(UTC),
|
|
),
|
|
existing_comment,
|
|
]
|
|
mock_client.update_comment.return_value = "https://github.com/owner/repo/pull/1#comment-456"
|
|
|
|
new_body = f"Updated review {ARBITER_MARKER}"
|
|
url = await _post_or_update_comment(mock_client, "owner/repo", 1, new_body)
|
|
|
|
assert url == "https://github.com/owner/repo/pull/1#comment-456"
|
|
mock_client.get_comments.assert_called_once_with("owner/repo", 1)
|
|
mock_client.update_comment.assert_called_once_with("owner/repo", 1, "456", new_body)
|
|
mock_client.post_comment.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_posts_new_when_no_existing_comment(self) -> None:
|
|
mock_client = AsyncMock()
|
|
mock_client.get_comments.return_value = [
|
|
Comment(
|
|
id="123",
|
|
body="Regular comment without marker",
|
|
author="human",
|
|
url="https://github.com/owner/repo/pull/1#comment-123",
|
|
created_at=datetime.now(UTC),
|
|
),
|
|
]
|
|
mock_client.post_comment.return_value = "https://github.com/owner/repo/pull/1#comment-789"
|
|
|
|
new_body = f"New review {ARBITER_MARKER}"
|
|
url = await _post_or_update_comment(mock_client, "owner/repo", 1, new_body)
|
|
|
|
assert url == "https://github.com/owner/repo/pull/1#comment-789"
|
|
mock_client.get_comments.assert_called_once_with("owner/repo", 1)
|
|
mock_client.post_comment.assert_called_once_with("owner/repo", 1, new_body)
|
|
mock_client.update_comment.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_posts_new_when_no_comments_exist(self) -> None:
|
|
mock_client = AsyncMock()
|
|
mock_client.get_comments.return_value = []
|
|
mock_client.post_comment.return_value = "https://github.com/owner/repo/pull/1#comment-789"
|
|
|
|
new_body = f"New review {ARBITER_MARKER}"
|
|
url = await _post_or_update_comment(mock_client, "owner/repo", 1, new_body)
|
|
|
|
assert url == "https://github.com/owner/repo/pull/1#comment-789"
|
|
mock_client.post_comment.assert_called_once_with("owner/repo", 1, new_body)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_falls_back_to_post_on_get_comments_failure(self) -> None:
|
|
from arbiter.integrations import IntegrationError
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get_comments.side_effect = IntegrationError("API error")
|
|
mock_client.post_comment.return_value = "https://github.com/owner/repo/pull/1#comment-789"
|
|
|
|
new_body = f"New review {ARBITER_MARKER}"
|
|
url = await _post_or_update_comment(mock_client, "owner/repo", 1, new_body)
|
|
|
|
assert url == "https://github.com/owner/repo/pull/1#comment-789"
|
|
mock_client.post_comment.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_on_post_failure(self) -> None:
|
|
from arbiter.integrations import IntegrationError
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.get_comments.return_value = []
|
|
mock_client.post_comment.side_effect = IntegrationError("API error")
|
|
|
|
url = await _post_or_update_comment(
|
|
mock_client, "owner/repo", 1, f"Review {ARBITER_MARKER}"
|
|
)
|
|
|
|
assert url is None
|