mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 06:23:59 -05:00
Forgejo integration (#11111)
Co-authored-by: johba <admin@noreply.localhost> Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: johba <johba@harb.eth> Co-authored-by: enyst <engel.nyst@gmail.com> Co-authored-by: Graham Neubig <neubig@gmail.com> Co-authored-by: MrGeorgen <65063405+MrGeorgen@users.noreply.github.com> Co-authored-by: MrGeorgen <moinl6162@gmail.com>
This commit is contained in:
@@ -15,7 +15,7 @@ from openhands.integrations.utils import validate_provider_token
|
||||
from openhands.resolver.interfaces.bitbucket import BitbucketIssueHandler
|
||||
from openhands.resolver.interfaces.issue import Issue
|
||||
from openhands.resolver.interfaces.issue_definitions import ServiceContextIssue
|
||||
from openhands.resolver.send_pull_request import send_pull_request
|
||||
from openhands.resolver.send_pull_request import PR_SIGNATURE, send_pull_request
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.routes.secrets import check_provider_tokens
|
||||
from openhands.server.settings import POSTProviderModel
|
||||
@@ -219,7 +219,7 @@ def test_send_pull_request_bitbucket(
|
||||
mock_service_context.assert_called_once()
|
||||
|
||||
# Verify create_pull_request was called with the correct data
|
||||
expected_body = 'This pull request fixes #123.\n\nAutomatic fix generated by [OpenHands](https://github.com/OpenHands/OpenHands/) 🙌'
|
||||
expected_body = f'This pull request fixes #123.\n\n{PR_SIGNATURE}'
|
||||
mock_service.create_pull_request.assert_called_once_with(
|
||||
{
|
||||
'title': 'Test PR',
|
||||
@@ -353,8 +353,9 @@ class TestBitbucketProviderDomain(unittest.TestCase):
|
||||
# Provider Token Validation Tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_provider_token_with_bitbucket_token():
|
||||
"""Test that validate_provider_token correctly identifies a Bitbucket token
|
||||
and doesn't try to validate it as GitHub or GitLab.
|
||||
"""Test that validate_provider_token correctly identifies a Bitbucket token.
|
||||
|
||||
Ensures GitHub and GitLab validators are not invoked.
|
||||
"""
|
||||
# Mock the service classes to avoid actual API calls
|
||||
with (
|
||||
@@ -392,9 +393,7 @@ async def test_validate_provider_token_with_bitbucket_token():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_provider_tokens_with_only_bitbucket():
|
||||
"""Test that check_provider_tokens doesn't try to validate GitHub or GitLab tokens
|
||||
when only a Bitbucket token is provided.
|
||||
"""
|
||||
"""Test that check_provider_tokens ignores GitHub/GitLab tokens when only Bitbucket is provided."""
|
||||
# Create a mock validate_provider_token function
|
||||
mock_validate = AsyncMock()
|
||||
mock_validate.return_value = ProviderType.BITBUCKET
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for Forgejo integration with send_pull_request."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from openhands.integrations.service_types import ProviderType as ServiceProviderType
|
||||
from openhands.resolver.interfaces.issue import Issue
|
||||
from openhands.resolver.send_pull_request import PR_SIGNATURE, send_pull_request
|
||||
|
||||
|
||||
@patch('openhands.resolver.send_pull_request.ServiceContextIssue')
|
||||
@patch('openhands.resolver.send_pull_request.ForgejoIssueHandler')
|
||||
@patch('subprocess.run')
|
||||
def test_send_pull_request_forgejo(
|
||||
mock_run, mock_forgejo_handler, mock_service_context
|
||||
):
|
||||
"""Ensure we can build and submit a Forgejo pull request."""
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
handler_instance = MagicMock()
|
||||
mock_forgejo_handler.return_value = handler_instance
|
||||
|
||||
service_context_instance = MagicMock()
|
||||
service_context_instance.get_branch_name.return_value = 'openhands-fix-issue-7'
|
||||
service_context_instance.branch_exists.return_value = True
|
||||
service_context_instance.get_default_branch_name.return_value = 'main'
|
||||
service_context_instance.get_clone_url.return_value = (
|
||||
'https://codeberg.org/example/repo.git'
|
||||
)
|
||||
service_context_instance.create_pull_request.return_value = {
|
||||
'html_url': 'https://codeberg.org/example/repo/pulls/42',
|
||||
'number': 42,
|
||||
}
|
||||
service_context_instance._strategy = MagicMock()
|
||||
mock_service_context.return_value = service_context_instance
|
||||
|
||||
issue = Issue(
|
||||
number=7,
|
||||
title='Fix the Forgejo PR flow',
|
||||
owner='example',
|
||||
repo='repo',
|
||||
body='Details about the fix',
|
||||
created_at='2024-01-01T00:00:00Z',
|
||||
updated_at='2024-01-01T00:00:00Z',
|
||||
closed_at=None,
|
||||
head_branch='feature-branch',
|
||||
thread_ids=None,
|
||||
)
|
||||
|
||||
result = send_pull_request(
|
||||
issue=issue,
|
||||
token='forgejo-token',
|
||||
username=None,
|
||||
platform=ServiceProviderType.FORGEJO,
|
||||
patch_dir='/tmp',
|
||||
pr_type='ready',
|
||||
pr_title='Fix the Forgejo PR flow',
|
||||
target_branch='main',
|
||||
)
|
||||
|
||||
assert result == 'https://codeberg.org/example/repo/pulls/42'
|
||||
|
||||
mock_forgejo_handler.assert_called_once_with(
|
||||
'example', 'repo', 'forgejo-token', None, 'codeberg.org'
|
||||
)
|
||||
mock_service_context.assert_called_once_with(handler_instance, None)
|
||||
|
||||
expected_payload = {
|
||||
'title': 'Fix the Forgejo PR flow',
|
||||
'body': f'This pull request fixes #7.\n\n{PR_SIGNATURE}',
|
||||
'head': 'openhands-fix-issue-7',
|
||||
'base': 'main',
|
||||
'draft': False,
|
||||
}
|
||||
service_context_instance.create_pull_request.assert_called_once_with(
|
||||
expected_payload
|
||||
)
|
||||
|
||||
mock_run.assert_called()
|
||||
@@ -4,6 +4,10 @@ from pydantic import SecretStr
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.resolver.interfaces.azure_devops import AzureDevOpsIssueHandler
|
||||
from openhands.resolver.interfaces.forgejo import (
|
||||
ForgejoIssueHandler,
|
||||
ForgejoPRHandler,
|
||||
)
|
||||
from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler
|
||||
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler
|
||||
from openhands.resolver.interfaces.issue_definitions import (
|
||||
@@ -28,7 +32,6 @@ def factory_params(llm_config):
|
||||
'repo': 'test-repo',
|
||||
'token': 'test-token',
|
||||
'username': 'test-user',
|
||||
'base_domain': 'github.com',
|
||||
'llm_config': llm_config,
|
||||
}
|
||||
|
||||
@@ -46,24 +49,76 @@ def azure_factory_params(llm_config):
|
||||
|
||||
|
||||
test_cases = [
|
||||
# platform, issue_type, expected_context_type, expected_handler_type, use_azure_params
|
||||
(ProviderType.GITHUB, 'issue', ServiceContextIssue, GithubIssueHandler, False),
|
||||
(ProviderType.GITHUB, 'pr', ServiceContextPR, GithubPRHandler, False),
|
||||
(ProviderType.GITLAB, 'issue', ServiceContextIssue, GitlabIssueHandler, False),
|
||||
(ProviderType.GITLAB, 'pr', ServiceContextPR, GitlabPRHandler, False),
|
||||
# platform, issue_type, base_domain, expected_context_type, expected_handler_type, use_azure_params
|
||||
(
|
||||
ProviderType.GITHUB,
|
||||
'issue',
|
||||
'github.com',
|
||||
ServiceContextIssue,
|
||||
GithubIssueHandler,
|
||||
False,
|
||||
),
|
||||
(
|
||||
ProviderType.GITHUB,
|
||||
'pr',
|
||||
'github.com',
|
||||
ServiceContextPR,
|
||||
GithubPRHandler,
|
||||
False,
|
||||
),
|
||||
(
|
||||
ProviderType.GITLAB,
|
||||
'issue',
|
||||
'gitlab.com',
|
||||
ServiceContextIssue,
|
||||
GitlabIssueHandler,
|
||||
False,
|
||||
),
|
||||
(
|
||||
ProviderType.GITLAB,
|
||||
'pr',
|
||||
'gitlab.com',
|
||||
ServiceContextPR,
|
||||
GitlabPRHandler,
|
||||
False,
|
||||
),
|
||||
(
|
||||
ProviderType.FORGEJO,
|
||||
'issue',
|
||||
'codeberg.org',
|
||||
ServiceContextIssue,
|
||||
ForgejoIssueHandler,
|
||||
False,
|
||||
),
|
||||
(
|
||||
ProviderType.FORGEJO,
|
||||
'pr',
|
||||
'codeberg.org',
|
||||
ServiceContextPR,
|
||||
ForgejoPRHandler,
|
||||
False,
|
||||
),
|
||||
(
|
||||
ProviderType.AZURE_DEVOPS,
|
||||
'issue',
|
||||
'dev.azure.com',
|
||||
ServiceContextIssue,
|
||||
AzureDevOpsIssueHandler,
|
||||
True,
|
||||
),
|
||||
(ProviderType.AZURE_DEVOPS, 'pr', ServiceContextPR, AzureDevOpsIssueHandler, True),
|
||||
(
|
||||
ProviderType.AZURE_DEVOPS,
|
||||
'pr',
|
||||
'dev.azure.com',
|
||||
ServiceContextPR,
|
||||
AzureDevOpsIssueHandler,
|
||||
True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'platform,issue_type,expected_context_type,expected_handler_type,use_azure_params',
|
||||
'platform,issue_type,base_domain,expected_context_type,expected_handler_type,use_azure_params',
|
||||
test_cases,
|
||||
)
|
||||
def test_handler_creation(
|
||||
@@ -71,11 +126,16 @@ def test_handler_creation(
|
||||
azure_factory_params,
|
||||
platform: ProviderType,
|
||||
issue_type: str,
|
||||
base_domain: str,
|
||||
expected_context_type: type,
|
||||
expected_handler_type: type,
|
||||
use_azure_params: bool,
|
||||
):
|
||||
params = azure_factory_params if use_azure_params else factory_params
|
||||
params = (
|
||||
azure_factory_params
|
||||
if use_azure_params
|
||||
else {**factory_params, 'base_domain': base_domain}
|
||||
)
|
||||
factory = IssueHandlerFactory(**params, platform=platform, issue_type=issue_type)
|
||||
|
||||
handler = factory.create()
|
||||
@@ -86,7 +146,10 @@ def test_handler_creation(
|
||||
|
||||
def test_invalid_issue_type(factory_params):
|
||||
factory = IssueHandlerFactory(
|
||||
**factory_params, platform=ProviderType.GITHUB, issue_type='invalid'
|
||||
**factory_params,
|
||||
platform=ProviderType.GITHUB,
|
||||
issue_type='invalid',
|
||||
base_domain='github.com',
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='Invalid issue type: invalid'):
|
||||
|
||||
273
tests/unit/test_forgejo_service.py
Normal file
273
tests/unit/test_forgejo_service.py
Normal file
@@ -0,0 +1,273 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.forgejo.forgejo_service import ForgejoService
|
||||
from openhands.integrations.service_types import (
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
User,
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def forgejo_service():
|
||||
return ForgejoService(token=SecretStr('test_token'))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user(forgejo_service):
|
||||
# Mock response data
|
||||
mock_user_data = {
|
||||
'id': 1,
|
||||
'username': 'test_user',
|
||||
'avatar_url': 'https://codeberg.org/avatar/test_user',
|
||||
'full_name': 'Test User',
|
||||
'email': 'test@example.com',
|
||||
'organization': 'Test Org',
|
||||
}
|
||||
|
||||
# Mock the _make_request method
|
||||
forgejo_service._make_request = AsyncMock(return_value=(mock_user_data, {}))
|
||||
|
||||
# Call the method
|
||||
user = await forgejo_service.get_user()
|
||||
|
||||
# Verify the result
|
||||
assert isinstance(user, User)
|
||||
assert user.id == '1'
|
||||
assert user.login == 'test_user'
|
||||
assert user.avatar_url == 'https://codeberg.org/avatar/test_user'
|
||||
assert user.name == 'Test User'
|
||||
assert user.email == 'test@example.com'
|
||||
assert user.company == 'Test Org'
|
||||
|
||||
# Verify the _fetch_data call
|
||||
forgejo_service._make_request.assert_called_once_with(
|
||||
f'{forgejo_service.BASE_URL}/user'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_repositories(forgejo_service):
|
||||
# Mock response data
|
||||
mock_repos_data = {
|
||||
'data': [
|
||||
{
|
||||
'id': 1,
|
||||
'full_name': 'test_user/repo1',
|
||||
'stars_count': 10,
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'full_name': 'test_user/repo2',
|
||||
'stars_count': 20,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
# Mock the _fetch_data method
|
||||
forgejo_service._make_request = AsyncMock(return_value=(mock_repos_data, {}))
|
||||
|
||||
# Call the method
|
||||
repos = await forgejo_service.search_repositories(
|
||||
'test', 10, 'updated', 'desc', public=False, app_mode=AppMode.OSS
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert len(repos) == 2
|
||||
assert all(isinstance(repo, Repository) for repo in repos)
|
||||
assert repos[0].id == '1'
|
||||
assert repos[0].full_name == 'test_user/repo1'
|
||||
assert repos[0].stargazers_count == 10
|
||||
assert repos[0].git_provider == ProviderType.FORGEJO
|
||||
assert repos[1].id == '2'
|
||||
assert repos[1].full_name == 'test_user/repo2'
|
||||
assert repos[1].stargazers_count == 20
|
||||
assert repos[1].git_provider == ProviderType.FORGEJO
|
||||
|
||||
# Verify the _fetch_data call
|
||||
forgejo_service._make_request.assert_called_once_with(
|
||||
f'{forgejo_service.BASE_URL}/repos/search',
|
||||
{
|
||||
'q': 'test',
|
||||
'limit': 10,
|
||||
'sort': 'updated',
|
||||
'order': 'desc',
|
||||
'mode': 'source',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_repositories(forgejo_service):
|
||||
# Mock response data for first page
|
||||
mock_repos_data_page1 = [
|
||||
{
|
||||
'id': 1,
|
||||
'full_name': 'test_user/repo1',
|
||||
'stars_count': 10,
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'full_name': 'test_user/repo2',
|
||||
'stars_count': 20,
|
||||
},
|
||||
]
|
||||
|
||||
# Mock response data for second page
|
||||
mock_repos_data_page2 = [
|
||||
{
|
||||
'id': 3,
|
||||
'full_name': 'test_user/repo3',
|
||||
'stars_count': 30,
|
||||
},
|
||||
]
|
||||
|
||||
# Mock the _fetch_data method to return different data for different pages
|
||||
forgejo_service._make_request = AsyncMock()
|
||||
forgejo_service._make_request.side_effect = [
|
||||
(
|
||||
mock_repos_data_page1,
|
||||
{'Link': '<https://codeberg.org/api/v1/user/repos?page=2>; rel="next"'},
|
||||
),
|
||||
(mock_repos_data_page2, {'Link': ''}),
|
||||
]
|
||||
|
||||
# Call the method
|
||||
repos = await forgejo_service.get_all_repositories('updated', AppMode.OSS)
|
||||
|
||||
# Verify the result
|
||||
assert len(repos) == 3
|
||||
assert all(isinstance(repo, Repository) for repo in repos)
|
||||
assert repos[0].id == '1'
|
||||
assert repos[0].full_name == 'test_user/repo1'
|
||||
assert repos[0].stargazers_count == 10
|
||||
assert repos[0].git_provider == ProviderType.FORGEJO
|
||||
assert repos[1].id == '2'
|
||||
assert repos[1].full_name == 'test_user/repo2'
|
||||
assert repos[1].stargazers_count == 20
|
||||
assert repos[1].git_provider == ProviderType.FORGEJO
|
||||
assert repos[2].id == '3'
|
||||
assert repos[2].full_name == 'test_user/repo3'
|
||||
assert repos[2].stargazers_count == 30
|
||||
assert repos[2].git_provider == ProviderType.FORGEJO
|
||||
|
||||
# Verify the _fetch_data calls
|
||||
assert forgejo_service._make_request.call_count == 2
|
||||
forgejo_service._make_request.assert_any_call(
|
||||
f'{forgejo_service.BASE_URL}/user/repos',
|
||||
{'page': '1', 'limit': '100', 'sort': 'updated'},
|
||||
)
|
||||
forgejo_service._make_request.assert_any_call(
|
||||
f'{forgejo_service.BASE_URL}/user/repos',
|
||||
{'page': '2', 'limit': '100', 'sort': 'updated'},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_request_success(forgejo_service):
|
||||
# Mock httpx.AsyncClient
|
||||
mock_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = {'key': 'value'}
|
||||
mock_response.headers = {'Link': 'next_link', 'Content-Type': 'application/json'}
|
||||
mock_client.__aenter__.return_value.get.return_value = mock_response
|
||||
|
||||
# Patch httpx.AsyncClient
|
||||
with patch('httpx.AsyncClient', return_value=mock_client):
|
||||
# Call the method
|
||||
result, headers = await forgejo_service._make_request(
|
||||
'https://test.url', {'param': 'value'}
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == {'key': 'value'}
|
||||
assert headers == {'Link': 'next_link'}
|
||||
mock_response.raise_for_status.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_request_auth_error(forgejo_service):
|
||||
# Mock httpx.AsyncClient
|
||||
mock_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
'401 Unauthorized', request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_client.__aenter__.return_value.get.return_value = mock_response
|
||||
|
||||
# Patch httpx.AsyncClient
|
||||
with patch('httpx.AsyncClient', return_value=mock_client):
|
||||
# Call the method and expect an exception
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await forgejo_service._make_request('https://test.url', {'param': 'value'})
|
||||
|
||||
# Verify the exception
|
||||
assert 'Invalid forgejo token' in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_request_other_error(forgejo_service):
|
||||
# Mock httpx.AsyncClient
|
||||
mock_client = AsyncMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
'500 Server Error', request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_client.__aenter__.return_value.get.return_value = mock_response
|
||||
|
||||
# Patch httpx.AsyncClient
|
||||
with patch('httpx.AsyncClient', return_value=mock_client):
|
||||
# Call the method and expect an exception
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await forgejo_service._make_request('https://test.url', {'param': 'value'})
|
||||
|
||||
# Verify the exception
|
||||
assert 'Unknown error' in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pull_request(forgejo_service):
|
||||
mock_response = {'index': 42, 'html_url': 'https://example/pr/42'}
|
||||
forgejo_service._make_request = AsyncMock(return_value=(mock_response, {}))
|
||||
|
||||
data = {'owner': 'org', 'repo': 'project', 'title': 'Add feature'}
|
||||
result = await forgejo_service.create_pull_request(data.copy())
|
||||
|
||||
assert result['number'] == 42
|
||||
forgejo_service._make_request.assert_awaited_once_with(
|
||||
f'{forgejo_service.BASE_URL}/repos/org/project/pulls',
|
||||
{'title': 'Add feature'},
|
||||
method=RequestMethod.POST,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_reviewers(forgejo_service):
|
||||
forgejo_service._make_request = AsyncMock(return_value=({}, {}))
|
||||
|
||||
await forgejo_service.request_reviewers('org/project', 5, ['alice'])
|
||||
|
||||
forgejo_service._make_request.assert_awaited_once_with(
|
||||
f'{forgejo_service.BASE_URL}/repos/org/project/pulls/5/requested_reviewers',
|
||||
{'reviewers': ['alice']},
|
||||
method=RequestMethod.POST,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_reviewers_empty_list(forgejo_service):
|
||||
forgejo_service._make_request = AsyncMock()
|
||||
|
||||
await forgejo_service.request_reviewers('org/project', 5, [])
|
||||
|
||||
forgejo_service._make_request.assert_not_called()
|
||||
Reference in New Issue
Block a user