forked from cardosofelipe/fast-next-template
Implements GitHub API provider following the same pattern as Gitea: - Full PR operations (create, get, list, merge, update, close) - Branch operations via API - Comment and label management - Reviewer request support - Rate limit error handling Server enhancements: - Auto-detect provider from repository URL (github.com vs custom Gitea) - Initialize GitHub provider when token is configured - Health check includes both provider statuses - Token selection based on repo URL for clone/push operations Refs: #110 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
584 lines
19 KiB
Python
584 lines
19 KiB
Python
"""
|
|
Tests for GitHub provider implementation.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from exceptions import APIError, AuthenticationError
|
|
from models import MergeStrategy, PRState
|
|
from providers.github import GitHubProvider
|
|
|
|
|
|
class TestGitHubProviderBasics:
|
|
"""Tests for GitHubProvider basic operations."""
|
|
|
|
def test_provider_name(self):
|
|
"""Test provider name is github."""
|
|
provider = GitHubProvider(token="test-token")
|
|
assert provider.name == "github"
|
|
|
|
def test_parse_repo_url_https(self):
|
|
"""Test parsing HTTPS repo URL."""
|
|
provider = GitHubProvider(token="test-token")
|
|
|
|
owner, repo = provider.parse_repo_url("https://github.com/owner/repo.git")
|
|
|
|
assert owner == "owner"
|
|
assert repo == "repo"
|
|
|
|
def test_parse_repo_url_https_no_git(self):
|
|
"""Test parsing HTTPS URL without .git suffix."""
|
|
provider = GitHubProvider(token="test-token")
|
|
|
|
owner, repo = provider.parse_repo_url("https://github.com/owner/repo")
|
|
|
|
assert owner == "owner"
|
|
assert repo == "repo"
|
|
|
|
def test_parse_repo_url_ssh(self):
|
|
"""Test parsing SSH repo URL."""
|
|
provider = GitHubProvider(token="test-token")
|
|
|
|
owner, repo = provider.parse_repo_url("git@github.com:owner/repo.git")
|
|
|
|
assert owner == "owner"
|
|
assert repo == "repo"
|
|
|
|
def test_parse_repo_url_invalid(self):
|
|
"""Test error on invalid URL."""
|
|
provider = GitHubProvider(token="test-token")
|
|
|
|
with pytest.raises(ValueError, match="Unable to parse"):
|
|
provider.parse_repo_url("invalid-url")
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_github_httpx_client():
|
|
"""Create a mock httpx client for GitHub provider tests."""
|
|
from unittest.mock import AsyncMock
|
|
|
|
mock_response = AsyncMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json = MagicMock(return_value={})
|
|
mock_response.text = ""
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.request = AsyncMock(return_value=mock_response)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
mock_client.post = AsyncMock(return_value=mock_response)
|
|
mock_client.patch = AsyncMock(return_value=mock_response)
|
|
mock_client.put = AsyncMock(return_value=mock_response)
|
|
mock_client.delete = AsyncMock(return_value=mock_response)
|
|
|
|
return mock_client
|
|
|
|
|
|
@pytest.fixture
|
|
async def github_provider(test_settings, mock_github_httpx_client):
|
|
"""Create a GitHubProvider with mocked HTTP client."""
|
|
provider = GitHubProvider(
|
|
token=test_settings.github_token,
|
|
settings=test_settings,
|
|
)
|
|
provider._client = mock_github_httpx_client
|
|
|
|
yield provider
|
|
|
|
await provider.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def github_pr_data():
|
|
"""Sample PR data from GitHub API."""
|
|
return {
|
|
"number": 42,
|
|
"title": "Test PR",
|
|
"body": "This is a test pull request",
|
|
"state": "open",
|
|
"head": {"ref": "feature-branch"},
|
|
"base": {"ref": "main"},
|
|
"user": {"login": "test-user"},
|
|
"created_at": "2024-01-15T10:00:00Z",
|
|
"updated_at": "2024-01-15T12:00:00Z",
|
|
"merged_at": None,
|
|
"closed_at": None,
|
|
"html_url": "https://github.com/owner/repo/pull/42",
|
|
"labels": [{"name": "enhancement"}],
|
|
"assignees": [{"login": "assignee1"}],
|
|
"requested_reviewers": [{"login": "reviewer1"}],
|
|
"mergeable": True,
|
|
"draft": False,
|
|
}
|
|
|
|
|
|
class TestGitHubProviderConnection:
|
|
"""Tests for GitHub provider connection."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_connected(self, github_provider, mock_github_httpx_client):
|
|
"""Test connection check."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value={"login": "test-user"}
|
|
)
|
|
|
|
result = await github_provider.is_connected()
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_connected_no_token(self, test_settings):
|
|
"""Test connection fails without token."""
|
|
provider = GitHubProvider(
|
|
token="",
|
|
settings=test_settings,
|
|
)
|
|
|
|
result = await provider.is_connected()
|
|
assert result is False
|
|
|
|
await provider.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_authenticated_user(self, github_provider, mock_github_httpx_client):
|
|
"""Test getting authenticated user."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value={"login": "test-user"}
|
|
)
|
|
|
|
user = await github_provider.get_authenticated_user()
|
|
|
|
assert user == "test-user"
|
|
|
|
|
|
class TestGitHubProviderRepoOperations:
|
|
"""Tests for GitHub repository operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_repo_info(self, github_provider, mock_github_httpx_client):
|
|
"""Test getting repository info."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value={
|
|
"name": "repo",
|
|
"full_name": "owner/repo",
|
|
"default_branch": "main",
|
|
}
|
|
)
|
|
|
|
result = await github_provider.get_repo_info("owner", "repo")
|
|
|
|
assert result["name"] == "repo"
|
|
assert result["default_branch"] == "main"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_default_branch(self, github_provider, mock_github_httpx_client):
|
|
"""Test getting default branch."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value={"default_branch": "develop"}
|
|
)
|
|
|
|
branch = await github_provider.get_default_branch("owner", "repo")
|
|
|
|
assert branch == "develop"
|
|
|
|
|
|
class TestGitHubPROperations:
|
|
"""Tests for GitHub PR operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_pr(self, github_provider, mock_github_httpx_client):
|
|
"""Test creating a pull request."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value={
|
|
"number": 42,
|
|
"html_url": "https://github.com/owner/repo/pull/42",
|
|
}
|
|
)
|
|
|
|
result = await github_provider.create_pr(
|
|
owner="owner",
|
|
repo="repo",
|
|
title="Test PR",
|
|
body="Test body",
|
|
source_branch="feature",
|
|
target_branch="main",
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.pr_number == 42
|
|
assert result.pr_url == "https://github.com/owner/repo/pull/42"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_pr_with_draft(self, github_provider, mock_github_httpx_client):
|
|
"""Test creating a draft PR."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value={
|
|
"number": 43,
|
|
"html_url": "https://github.com/owner/repo/pull/43",
|
|
}
|
|
)
|
|
|
|
result = await github_provider.create_pr(
|
|
owner="owner",
|
|
repo="repo",
|
|
title="Draft PR",
|
|
body="Draft body",
|
|
source_branch="feature",
|
|
target_branch="main",
|
|
draft=True,
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.pr_number == 43
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_pr_with_options(self, github_provider, mock_github_httpx_client):
|
|
"""Test creating PR with labels, assignees, reviewers."""
|
|
mock_responses = [
|
|
{"number": 44, "html_url": "https://github.com/owner/repo/pull/44"}, # Create PR
|
|
[{"name": "enhancement"}], # POST add labels
|
|
{}, # POST add assignees
|
|
{}, # POST request reviewers
|
|
]
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(side_effect=mock_responses)
|
|
|
|
result = await github_provider.create_pr(
|
|
owner="owner",
|
|
repo="repo",
|
|
title="Test PR",
|
|
body="Test body",
|
|
source_branch="feature",
|
|
target_branch="main",
|
|
labels=["enhancement"],
|
|
assignees=["user1"],
|
|
reviewers=["reviewer1"],
|
|
)
|
|
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_pr(self, github_provider, mock_github_httpx_client, github_pr_data):
|
|
"""Test getting a pull request."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value=github_pr_data
|
|
)
|
|
|
|
result = await github_provider.get_pr("owner", "repo", 42)
|
|
|
|
assert result.success is True
|
|
assert result.pr["number"] == 42
|
|
assert result.pr["title"] == "Test PR"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_pr_not_found(self, github_provider, mock_github_httpx_client):
|
|
"""Test getting non-existent PR."""
|
|
mock_github_httpx_client.request.return_value.status_code = 404
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(return_value=None)
|
|
|
|
result = await github_provider.get_pr("owner", "repo", 999)
|
|
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_prs(self, github_provider, mock_github_httpx_client, github_pr_data):
|
|
"""Test listing pull requests."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value=[github_pr_data, github_pr_data]
|
|
)
|
|
|
|
result = await github_provider.list_prs("owner", "repo")
|
|
|
|
assert result.success is True
|
|
assert len(result.pull_requests) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_prs_with_state_filter(self, github_provider, mock_github_httpx_client, github_pr_data):
|
|
"""Test listing PRs with state filter."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value=[github_pr_data]
|
|
)
|
|
|
|
result = await github_provider.list_prs(
|
|
"owner", "repo", state=PRState.OPEN
|
|
)
|
|
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_pr(self, github_provider, mock_github_httpx_client, github_pr_data):
|
|
"""Test merging a pull request."""
|
|
# Merge returns sha, then get_pr returns the PR data, then delete branch
|
|
mock_responses = [
|
|
{"sha": "merge-commit-sha", "merged": True}, # PUT merge
|
|
github_pr_data, # GET PR for branch info
|
|
None, # DELETE branch
|
|
]
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
side_effect=mock_responses
|
|
)
|
|
|
|
result = await github_provider.merge_pr(
|
|
"owner", "repo", 42,
|
|
merge_strategy=MergeStrategy.SQUASH,
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.merge_commit_sha == "merge-commit-sha"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_pr_rebase(self, github_provider, mock_github_httpx_client, github_pr_data):
|
|
"""Test merging with rebase strategy."""
|
|
mock_responses = [
|
|
{"sha": "rebase-commit-sha", "merged": True}, # PUT merge
|
|
github_pr_data, # GET PR for branch info
|
|
None, # DELETE branch
|
|
]
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
side_effect=mock_responses
|
|
)
|
|
|
|
result = await github_provider.merge_pr(
|
|
"owner", "repo", 42,
|
|
merge_strategy=MergeStrategy.REBASE,
|
|
)
|
|
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_pr(self, github_provider, mock_github_httpx_client, github_pr_data):
|
|
"""Test updating a pull request."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value=github_pr_data
|
|
)
|
|
|
|
result = await github_provider.update_pr(
|
|
"owner", "repo", 42,
|
|
title="Updated Title",
|
|
body="Updated body",
|
|
)
|
|
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_pr(self, github_provider, mock_github_httpx_client, github_pr_data):
|
|
"""Test closing a pull request."""
|
|
github_pr_data["state"] = "closed"
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value=github_pr_data
|
|
)
|
|
|
|
result = await github_provider.close_pr("owner", "repo", 42)
|
|
|
|
assert result.success is True
|
|
|
|
|
|
class TestGitHubBranchOperations:
|
|
"""Tests for GitHub branch operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_branch(self, github_provider, mock_github_httpx_client):
|
|
"""Test getting branch info."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value={
|
|
"name": "main",
|
|
"commit": {"sha": "abc123"},
|
|
}
|
|
)
|
|
|
|
result = await github_provider.get_branch("owner", "repo", "main")
|
|
|
|
assert result["name"] == "main"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_remote_branch(self, github_provider, mock_github_httpx_client):
|
|
"""Test deleting a remote branch."""
|
|
mock_github_httpx_client.request.return_value.status_code = 204
|
|
|
|
result = await github_provider.delete_remote_branch("owner", "repo", "old-branch")
|
|
|
|
assert result is True
|
|
|
|
|
|
class TestGitHubCommentOperations:
|
|
"""Tests for GitHub comment operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_pr_comment(self, github_provider, mock_github_httpx_client):
|
|
"""Test adding a comment to a PR."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value={"id": 1, "body": "Test comment"}
|
|
)
|
|
|
|
result = await github_provider.add_pr_comment(
|
|
"owner", "repo", 42, "Test comment"
|
|
)
|
|
|
|
assert result["body"] == "Test comment"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_pr_comments(self, github_provider, mock_github_httpx_client):
|
|
"""Test listing PR comments."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value=[
|
|
{"id": 1, "body": "Comment 1"},
|
|
{"id": 2, "body": "Comment 2"},
|
|
]
|
|
)
|
|
|
|
result = await github_provider.list_pr_comments("owner", "repo", 42)
|
|
|
|
assert len(result) == 2
|
|
|
|
|
|
class TestGitHubLabelOperations:
|
|
"""Tests for GitHub label operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_labels(self, github_provider, mock_github_httpx_client):
|
|
"""Test adding labels to a PR."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value=[{"name": "bug"}, {"name": "urgent"}]
|
|
)
|
|
|
|
result = await github_provider.add_labels(
|
|
"owner", "repo", 42, ["bug", "urgent"]
|
|
)
|
|
|
|
assert "bug" in result
|
|
assert "urgent" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_label(self, github_provider, mock_github_httpx_client):
|
|
"""Test removing a label from a PR."""
|
|
mock_responses = [
|
|
None, # DELETE label
|
|
{"labels": []}, # GET issue
|
|
]
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(side_effect=mock_responses)
|
|
|
|
result = await github_provider.remove_label(
|
|
"owner", "repo", 42, "bug"
|
|
)
|
|
|
|
assert isinstance(result, list)
|
|
|
|
|
|
class TestGitHubReviewerOperations:
|
|
"""Tests for GitHub reviewer operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_review(self, github_provider, mock_github_httpx_client):
|
|
"""Test requesting review from users."""
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(return_value={})
|
|
|
|
result = await github_provider.request_review(
|
|
"owner", "repo", 42, ["reviewer1", "reviewer2"]
|
|
)
|
|
|
|
assert result == ["reviewer1", "reviewer2"]
|
|
|
|
|
|
class TestGitHubErrorHandling:
|
|
"""Tests for error handling in GitHub provider."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authentication_error(self, github_provider, mock_github_httpx_client):
|
|
"""Test handling authentication errors."""
|
|
mock_github_httpx_client.request.return_value.status_code = 401
|
|
|
|
with pytest.raises(AuthenticationError):
|
|
await github_provider._request("GET", "/user")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_permission_denied(self, github_provider, mock_github_httpx_client):
|
|
"""Test handling permission denied errors."""
|
|
mock_github_httpx_client.request.return_value.status_code = 403
|
|
mock_github_httpx_client.request.return_value.text = "Permission denied"
|
|
|
|
with pytest.raises(AuthenticationError, match="Insufficient permissions"):
|
|
await github_provider._request("GET", "/protected")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_error(self, github_provider, mock_github_httpx_client):
|
|
"""Test handling rate limit errors."""
|
|
mock_github_httpx_client.request.return_value.status_code = 403
|
|
mock_github_httpx_client.request.return_value.text = "API rate limit exceeded"
|
|
|
|
with pytest.raises(APIError, match="rate limit"):
|
|
await github_provider._request("GET", "/user")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_error(self, github_provider, mock_github_httpx_client):
|
|
"""Test handling general API errors."""
|
|
mock_github_httpx_client.request.return_value.status_code = 500
|
|
mock_github_httpx_client.request.return_value.text = "Internal Server Error"
|
|
mock_github_httpx_client.request.return_value.json = MagicMock(
|
|
return_value={"message": "Server error"}
|
|
)
|
|
|
|
with pytest.raises(APIError):
|
|
await github_provider._request("GET", "/error")
|
|
|
|
|
|
class TestGitHubPRParsing:
|
|
"""Tests for PR data parsing."""
|
|
|
|
def test_parse_pr_open(self, github_provider, github_pr_data):
|
|
"""Test parsing open PR."""
|
|
pr_info = github_provider._parse_pr(github_pr_data)
|
|
|
|
assert pr_info.number == 42
|
|
assert pr_info.state == PRState.OPEN
|
|
assert pr_info.title == "Test PR"
|
|
assert pr_info.source_branch == "feature-branch"
|
|
assert pr_info.target_branch == "main"
|
|
|
|
def test_parse_pr_merged(self, github_provider, github_pr_data):
|
|
"""Test parsing merged PR."""
|
|
github_pr_data["merged_at"] = "2024-01-16T10:00:00Z"
|
|
|
|
pr_info = github_provider._parse_pr(github_pr_data)
|
|
|
|
assert pr_info.state == PRState.MERGED
|
|
|
|
def test_parse_pr_closed(self, github_provider, github_pr_data):
|
|
"""Test parsing closed PR."""
|
|
github_pr_data["state"] = "closed"
|
|
github_pr_data["closed_at"] = "2024-01-16T10:00:00Z"
|
|
|
|
pr_info = github_provider._parse_pr(github_pr_data)
|
|
|
|
assert pr_info.state == PRState.CLOSED
|
|
|
|
def test_parse_pr_draft(self, github_provider, github_pr_data):
|
|
"""Test parsing draft PR."""
|
|
github_pr_data["draft"] = True
|
|
|
|
pr_info = github_provider._parse_pr(github_pr_data)
|
|
|
|
assert pr_info.draft is True
|
|
|
|
def test_parse_datetime_iso(self, github_provider):
|
|
"""Test parsing ISO datetime strings."""
|
|
dt = github_provider._parse_datetime("2024-01-15T10:30:00Z")
|
|
|
|
assert dt.year == 2024
|
|
assert dt.month == 1
|
|
assert dt.day == 15
|
|
|
|
def test_parse_datetime_none(self, github_provider):
|
|
"""Test parsing None datetime returns now."""
|
|
dt = github_provider._parse_datetime(None)
|
|
|
|
assert dt is not None
|
|
assert dt.tzinfo is not None
|
|
|
|
def test_parse_pr_with_null_body(self, github_provider, github_pr_data):
|
|
"""Test parsing PR with null body."""
|
|
github_pr_data["body"] = None
|
|
|
|
pr_info = github_provider._parse_pr(github_pr_data)
|
|
|
|
assert pr_info.body == ""
|