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:
johba
2025-12-27 21:57:31 +01:00
committed by GitHub
parent cb1d1f8a0d
commit f8e4b5562e
34 changed files with 2110 additions and 93 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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'):

View 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()