"""
Tests for GitHub PR review methods in GitHubTracker.
Tests cover:
- submit_pull_request_review
- get_pull_request_comments
- update_review_comment
- resolve_review_thread
"""
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from unittest import IsolatedAsyncioTestCase
import httpx
from uuid import uuid4
from preloop.sync.exceptions import (
TrackerAuthenticationError,
TrackerConnectionError,
TrackerResponseError,
)
from preloop.sync.trackers.github import GitHubTracker
@pytest.mark.asyncio
class TestSubmitPullRequestReview(IsolatedAsyncioTestCase):
"""Tests for submit_pull_request_review method."""
async def test_submit_review_approve_success(self):
"""Test successfully submitting an approval review."""
# Arrange
review_response = {
"id": 12345,
"node_id": "PRR_kwDOAbcdef",
"state": "APPROVED",
"body": "Looks good to me!",
"user": {"login": "reviewer"},
"submitted_at": "2023-01-15T10:00:00Z",
"html_url": "https://github.com/owner/repo/pull/1#pullrequestreview-12345",
}
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._request = AsyncMock(return_value=review_response)
# Act
result = await tracker.submit_pull_request_review(
pr_number="1",
body="Looks good to me!",
event="APPROVE",
)
# Assert
self.assertEqual(result["id"], "12345")
self.assertEqual(result["state"], "APPROVED")
self.assertEqual(result["body"], "Looks good to me!")
self.assertEqual(result["user"], "reviewer")
tracker._request.assert_called_once_with(
"POST",
"/repos/testowner/testrepo/pulls/1/reviews",
data={"body": "Looks good to me!", "event": "APPROVE"},
)
async def test_submit_review_request_changes_with_comments(self):
"""Test submitting a request changes review with inline comments."""
# Arrange
review_response = {
"id": 12346,
"node_id": "PRR_kwDOAbcdefg",
"state": "CHANGES_REQUESTED",
"body": "Please fix these issues.",
"user": {"login": "reviewer"},
"submitted_at": "2023-01-15T11:00:00Z",
"html_url": "https://github.com/owner/repo/pull/1#pullrequestreview-12346",
}
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._request = AsyncMock(return_value=review_response)
comments = [
{
"path": "src/main.py",
"line": 10,
"body": "This variable name is unclear",
},
{
"path": "src/utils.py",
"line": 25,
"body": "Missing docstring",
"side": "LEFT",
},
]
# Act
result = await tracker.submit_pull_request_review(
pr_number="1",
body="Please fix these issues.",
event="REQUEST_CHANGES",
comments=comments,
)
# Assert
self.assertEqual(result["id"], "12346")
self.assertEqual(result["state"], "CHANGES_REQUESTED")
# Verify the request was made with properly formatted comments
call_args = tracker._request.call_args
self.assertEqual(call_args[0][0], "POST")
self.assertEqual(call_args[0][1], "/repos/testowner/testrepo/pulls/1/reviews")
request_data = call_args[1]["data"]
self.assertEqual(request_data["event"], "REQUEST_CHANGES")
self.assertEqual(len(request_data["comments"]), 2)
# Check first comment (default side: RIGHT)
self.assertEqual(request_data["comments"][0]["path"], "src/main.py")
self.assertEqual(request_data["comments"][0]["line"], 10)
self.assertEqual(request_data["comments"][0]["side"], "RIGHT")
# Check second comment (explicit side: LEFT)
self.assertEqual(request_data["comments"][1]["side"], "LEFT")
async def test_submit_review_comment_only(self):
"""Test submitting a comment-only review (no approval/rejection)."""
# Arrange
review_response = {
"id": 12347,
"node_id": "PRR_kwDOAbcdefgh",
"state": "COMMENTED",
"body": "Just some observations.",
"user": {"login": "reviewer"},
"submitted_at": "2023-01-15T12:00:00Z",
"html_url": "https://github.com/owner/repo/pull/1#pullrequestreview-12347",
}
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._request = AsyncMock(return_value=review_response)
# Act
result = await tracker.submit_pull_request_review(
pr_number="1",
body="Just some observations.",
event="COMMENT",
)
# Assert
self.assertEqual(result["state"], "COMMENTED")
async def test_submit_review_missing_connection_details(self):
"""Test error when connection details are missing."""
# Arrange
tracker = GitHubTracker(str(uuid4()), "api-key", {})
# Act & Assert
with self.assertRaises(TrackerResponseError) as context:
await tracker.submit_pull_request_review(
pr_number="1",
body="Test",
event="APPROVE",
)
self.assertIn("Owner/repo not found", str(context.exception))
async def test_submit_review_extracts_pr_number_from_formats(self):
"""Test that PR number is correctly extracted from various formats."""
# Arrange
review_response = {
"id": 12345,
"node_id": "PRR_kwDOAbcdef",
"state": "APPROVED",
"body": "LGTM",
"user": {"login": "reviewer"},
}
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._request = AsyncMock(return_value=review_response)
# Test with "owner/repo#123" format
await tracker.submit_pull_request_review(
pr_number="testowner/testrepo#42",
body="LGTM",
event="APPROVE",
)
call_args = tracker._request.call_args
self.assertIn("/pulls/42/", call_args[0][1])
async def test_submit_review_api_error(self):
"""Test handling of API errors."""
# Arrange
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._request = AsyncMock(
side_effect=TrackerResponseError("422 Unprocessable Entity")
)
# Act & Assert
with self.assertRaises(TrackerResponseError):
await tracker.submit_pull_request_review(
pr_number="1",
body="Test",
event="APPROVE",
)
@pytest.mark.asyncio
class TestGetPullRequestComments(IsolatedAsyncioTestCase):
"""Tests for get_pull_request_comments method."""
async def test_get_comments_success(self):
"""Test successfully fetching PR comments."""
# Arrange
review_comments = [
{
"id": 1001,
"node_id": "PRRC_1001",
"user": {"login": "reviewer1"},
"body": "Inline comment",
"path": "src/main.py",
"line": 10,
"original_line": 8,
"side": "RIGHT",
"diff_hunk": "@@ -5,10 +5,15 @@",
"commit_id": "abc123",
"in_reply_to_id": None,
"created_at": "2023-01-15T10:00:00Z",
"updated_at": "2023-01-15T10:00:00Z",
"html_url": "https://github.com/owner/repo/pull/1#discussion_r1001",
}
]
issue_comments = [
{
"id": 2001,
"node_id": "IC_2001",
"user": {"login": "commenter1"},
"body": "General comment",
"created_at": "2023-01-15T11:00:00Z",
"updated_at": "2023-01-15T11:00:00Z",
"html_url": "https://github.com/owner/repo/pull/1#issuecomment-2001",
}
]
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._make_request = AsyncMock(side_effect=[review_comments, issue_comments])
# Act
result = await tracker.get_pull_request_comments(pr_number="1")
# Assert
self.assertEqual(len(result), 2)
# Check review comment
review_comment = next(c for c in result if c["type"] == "review_comment")
self.assertEqual(review_comment["id"], "1001")
self.assertEqual(review_comment["author"], "reviewer1")
self.assertEqual(review_comment["body"], "Inline comment")
self.assertEqual(review_comment["path"], "src/main.py")
self.assertEqual(review_comment["line"], 10)
self.assertEqual(review_comment["side"], "RIGHT")
# Check issue comment
issue_comment = next(c for c in result if c["type"] == "issue_comment")
self.assertEqual(issue_comment["id"], "2001")
self.assertEqual(issue_comment["author"], "commenter1")
self.assertEqual(issue_comment["body"], "General comment")
self.assertIsNone(issue_comment["path"])
self.assertIsNone(issue_comment["line"])
async def test_get_comments_with_author_filter(self):
"""Test filtering comments by author."""
# Arrange
review_comments = [
{
"id": 1001,
"node_id": "PRRC_1001",
"user": {"login": "reviewer1"},
"body": "Comment from reviewer1",
"path": "src/main.py",
"line": 10,
"created_at": "2023-01-15T10:00:00Z",
"updated_at": "2023-01-15T10:00:00Z",
},
{
"id": 1002,
"node_id": "PRRC_1002",
"user": {"login": "reviewer2"},
"body": "Comment from reviewer2",
"path": "src/utils.py",
"line": 20,
"created_at": "2023-01-15T10:30:00Z",
"updated_at": "2023-01-15T10:30:00Z",
},
]
issue_comments = [
{
"id": 2001,
"node_id": "IC_2001",
"user": {"login": "reviewer1"},
"body": "Another from reviewer1",
"created_at": "2023-01-15T11:00:00Z",
"updated_at": "2023-01-15T11:00:00Z",
}
]
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._make_request = AsyncMock(side_effect=[review_comments, issue_comments])
# Act
result = await tracker.get_pull_request_comments(
pr_number="1",
filter_author="reviewer1",
)
# Assert - should only have comments from reviewer1
self.assertEqual(len(result), 2)
for comment in result:
self.assertEqual(comment["author"], "reviewer1")
async def test_get_comments_no_comments(self):
"""Test when PR has no comments."""
# Arrange
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._make_request = AsyncMock(side_effect=[[], []])
# Act
result = await tracker.get_pull_request_comments(pr_number="1")
# Assert
self.assertEqual(len(result), 0)
async def test_get_comments_missing_connection_details(self):
"""Test error when connection details are missing."""
# Arrange
tracker = GitHubTracker(str(uuid4()), "api-key", {})
# Act & Assert
with self.assertRaises(TrackerResponseError) as context:
await tracker.get_pull_request_comments(pr_number="1")
self.assertIn("Owner/repo not found", str(context.exception))
async def test_get_comments_api_error(self):
"""Test handling of API errors."""
# Arrange
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._make_request = AsyncMock(
side_effect=TrackerResponseError("404 Not Found")
)
# Act & Assert
with self.assertRaises(TrackerResponseError):
await tracker.get_pull_request_comments(pr_number="1")
@pytest.mark.asyncio
class TestUpdateReviewComment(IsolatedAsyncioTestCase):
"""Tests for update_review_comment method."""
async def test_update_comment_success(self):
"""Test successfully updating a review comment."""
# Arrange
updated_comment = {
"id": 1001,
"node_id": "PRRC_1001",
"user": {"login": "reviewer1"},
"body": "Updated comment text",
"path": "src/main.py",
"line": 10,
"side": "RIGHT",
"created_at": "2023-01-15T10:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"html_url": "https://github.com/owner/repo/pull/1#discussion_r1001",
}
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._request = AsyncMock(return_value=updated_comment)
# Act
result = await tracker.update_review_comment(
comment_id="1001",
body="Updated comment text",
)
# Assert
self.assertEqual(result["id"], "1001")
self.assertEqual(result["body"], "Updated comment text")
self.assertEqual(result["author"], "reviewer1")
self.assertEqual(result["path"], "src/main.py")
self.assertEqual(result["line"], 10)
tracker._request.assert_called_once_with(
"PATCH",
"/repos/testowner/testrepo/pulls/comments/1001",
data={"body": "Updated comment text"},
)
async def test_update_comment_missing_connection_details(self):
"""Test error when connection details are missing."""
# Arrange
tracker = GitHubTracker(str(uuid4()), "api-key", {})
# Act & Assert
with self.assertRaises(TrackerResponseError) as context:
await tracker.update_review_comment(
comment_id="1001",
body="Updated text",
)
self.assertIn("Owner/repo not found", str(context.exception))
async def test_update_comment_not_found(self):
"""Test error when comment doesn't exist."""
# Arrange
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._request = AsyncMock(side_effect=TrackerResponseError("404 Not Found"))
# Act & Assert
with self.assertRaises(TrackerResponseError):
await tracker.update_review_comment(
comment_id="99999",
body="Updated text",
)
@pytest.mark.asyncio
class TestResolveReviewThread(IsolatedAsyncioTestCase):
"""Tests for resolve_review_thread method."""
@patch("preloop.sync.trackers.github.httpx.AsyncClient")
async def test_resolve_thread_success(self, mock_client_class):
"""Test successfully resolving a review thread."""
# Arrange
graphql_response = {
"data": {
"resolveReviewThread": {
"thread": {
"id": "PRRT_kwDOAbcdef",
"isResolved": True,
"viewerCanResolve": False,
"viewerCanUnresolve": True,
}
}
}
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = graphql_response
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
# Act
result = await tracker.resolve_review_thread(
thread_id="PRRT_kwDOAbcdef",
resolved=True,
)
# Assert
self.assertEqual(result["id"], "PRRT_kwDOAbcdef")
self.assertTrue(result["is_resolved"])
self.assertFalse(result["viewer_can_resolve"])
self.assertTrue(result["viewer_can_unresolve"])
# Verify GraphQL mutation was called
call_args = mock_client.post.call_args
self.assertEqual(call_args[0][0], "https://api.github.com/graphql")
request_body = call_args[1]["json"]
self.assertIn("resolveReviewThread", request_body["query"])
self.assertEqual(request_body["variables"]["threadId"], "PRRT_kwDOAbcdef")
@patch("preloop.sync.trackers.github.httpx.AsyncClient")
async def test_unresolve_thread_success(self, mock_client_class):
"""Test successfully unresolving a review thread."""
# Arrange
graphql_response = {
"data": {
"unresolveReviewThread": {
"thread": {
"id": "PRRT_kwDOAbcdef",
"isResolved": False,
"viewerCanResolve": True,
"viewerCanUnresolve": False,
}
}
}
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = graphql_response
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
# Act
result = await tracker.resolve_review_thread(
thread_id="PRRT_kwDOAbcdef",
resolved=False,
)
# Assert
self.assertEqual(result["id"], "PRRT_kwDOAbcdef")
self.assertFalse(result["is_resolved"])
# Verify GraphQL mutation was called with unresolve
call_args = mock_client.post.call_args
request_body = call_args[1]["json"]
self.assertIn("unresolveReviewThread", request_body["query"])
@patch("preloop.sync.trackers.github.httpx.AsyncClient")
async def test_resolve_thread_authentication_error(self, mock_client_class):
"""Test error when authentication fails."""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 401
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
# Act & Assert
with self.assertRaises(TrackerAuthenticationError):
await tracker.resolve_review_thread(
thread_id="PRRT_kwDOAbcdef",
resolved=True,
)
@patch("preloop.sync.trackers.github.httpx.AsyncClient")
async def test_resolve_thread_graphql_error(self, mock_client_class):
"""Test handling of GraphQL errors."""
# Arrange
graphql_response = {
"errors": [{"message": "Could not resolve to a PullRequestReviewThread"}]
}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = graphql_response
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
# Act & Assert
with self.assertRaises(TrackerResponseError) as context:
await tracker.resolve_review_thread(
thread_id="invalid_thread",
resolved=True,
)
self.assertIn("GraphQL errors", str(context.exception))
@patch("preloop.sync.trackers.github.httpx.AsyncClient")
async def test_resolve_thread_connection_error(self, mock_client_class):
"""Test handling of connection errors."""
# Arrange
mock_client = AsyncMock()
mock_client.post.side_effect = httpx.RequestError(
"Connection failed", request=MagicMock()
)
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client_class.return_value = mock_client
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
# Act & Assert
with self.assertRaises(TrackerConnectionError):
await tracker.resolve_review_thread(
thread_id="PRRT_kwDOAbcdef",
resolved=True,
)
@pytest.mark.asyncio
class TestPRReviewIntegration(IsolatedAsyncioTestCase):
"""Integration-style tests for PR review workflow."""
async def test_full_review_workflow(self):
"""Test a complete review workflow: get comments, update, submit review."""
# Arrange
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
# Mock responses for the workflow
existing_comments = [
{
"id": 1001,
"node_id": "PRRC_1001",
"user": {"login": "bot"},
"body": "Original comment",
"path": "src/main.py",
"line": 10,
"created_at": "2023-01-15T10:00:00Z",
"updated_at": "2023-01-15T10:00:00Z",
}
]
updated_comment = {
"id": 1001,
"node_id": "PRRC_1001",
"user": {"login": "bot"},
"body": "Updated comment",
"path": "src/main.py",
"line": 10,
"created_at": "2023-01-15T10:00:00Z",
"updated_at": "2023-01-15T11:00:00Z",
}
review_response = {
"id": 12345,
"node_id": "PRR_kwDOAbcdef",
"state": "APPROVED",
"body": "All issues addressed!",
"user": {"login": "reviewer"},
}
# Set up mocks
tracker._make_request = AsyncMock(side_effect=[existing_comments, []])
tracker._request = AsyncMock(side_effect=[updated_comment, review_response])
# Act - Step 1: Get existing comments by bot
comments = await tracker.get_pull_request_comments(
pr_number="1",
filter_author="bot",
)
# Act - Step 2: Update the bot's comment
if comments:
await tracker.update_review_comment(
comment_id=comments[0]["id"],
body="Updated comment",
)
# Act - Step 3: Submit final review
result = await tracker.submit_pull_request_review(
pr_number="1",
body="All issues addressed!",
event="APPROVE",
)
# Assert
self.assertEqual(len(comments), 1)
self.assertEqual(comments[0]["author"], "bot")
self.assertEqual(result["state"], "APPROVED")
@pytest.mark.asyncio
class TestGetReviewComments(IsolatedAsyncioTestCase):
"""Tests for get_review_comments method."""
async def test_get_review_comments_success(self):
"""Test successfully fetching comments for a specific review."""
# Arrange
review_comments = [
{
"id": 1001,
"node_id": "PRRC_1001",
"user": {"login": "reviewer1"},
"body": "Inline comment on review",
"path": "src/main.py",
"line": 10,
"position": 5,
"created_at": "2023-01-15T10:00:00Z",
"updated_at": "2023-01-15T10:00:00Z",
"html_url": "https://github.com/owner/repo/pull/1#discussion_r1001",
}
]
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._request = AsyncMock(return_value=review_comments)
# Act
result = await tracker.get_review_comments(
pr_number="1",
review_id="12345",
)
# Assert
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["id"], "1001")
self.assertEqual(result[0]["body"], "Inline comment on review")
self.assertEqual(result[0]["author"], "reviewer1")
self.assertEqual(result[0]["path"], "src/main.py")
self.assertEqual(result[0]["line"], 10)
# Verify API call
tracker._request.assert_called_once_with(
"GET", "/repos/testowner/testrepo/pulls/1/reviews/12345/comments"
)
async def test_get_review_comments_empty(self):
"""Test fetching comments when review has no comments."""
# Arrange
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._request = AsyncMock(return_value=[])
# Act
result = await tracker.get_review_comments(
pr_number="1",
review_id="12345",
)
# Assert
self.assertEqual(len(result), 0)
async def test_get_review_comments_missing_connection_details(self):
"""Test error when connection details are missing."""
# Arrange
tracker = GitHubTracker(str(uuid4()), "api-key", {})
# Act & Assert
with self.assertRaises(TrackerResponseError) as context:
await tracker.get_review_comments(
pr_number="1",
review_id="12345",
)
self.assertIn("Owner/repo not found", str(context.exception))
async def test_get_review_comments_api_error(self):
"""Test handling of API errors."""
# Arrange
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._request = AsyncMock(side_effect=TrackerResponseError("404 Not Found"))
# Act & Assert
with self.assertRaises(TrackerResponseError):
await tracker.get_review_comments(
pr_number="1",
review_id="99999",
)
async def test_get_review_comments_extracts_pr_number_from_formats(self):
"""Test that PR number is correctly extracted from various formats."""
# Arrange
connection_details = {"owner": "testowner", "repo": "testrepo"}
tracker = GitHubTracker(str(uuid4()), "api-key", connection_details)
tracker._request = AsyncMock(return_value=[])
# Test with "owner/repo#123" format
await tracker.get_review_comments(
pr_number="testowner/testrepo#42",
review_id="12345",
)
call_args = tracker._request.call_args
self.assertIn("/pulls/42/", call_args[0][1])