diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index 98a87fc9f3..317afd6a50 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -95,6 +95,13 @@ CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA = Annotated[ class ProviderHandler: + # Class variable for provider domains + PROVIDER_DOMAINS: dict[ProviderType, str] = { + ProviderType.GITHUB: 'github.com', + ProviderType.GITLAB: 'gitlab.com', + ProviderType.BITBUCKET: 'bitbucket.org', + } + def __init__( self, provider_tokens: PROVIDER_TOKEN_TYPE, @@ -399,3 +406,53 @@ class ProviderHandler: other_branches.append(branch) return main_branches + other_branches + + async def get_authenticated_git_url(self, repo_name: str) -> str: + """Get an authenticated git URL for a repository. + + Args: + repo_name: Repository name (owner/repo) + + Returns: + Authenticated git URL if credentials are available, otherwise regular HTTPS URL + """ + try: + repository = await self.verify_repo_provider(repo_name) + except AuthenticationError: + raise Exception('Git provider authentication issue when getting remote URL') + + provider = repository.git_provider + repo_name = repository.full_name + + domain = self.PROVIDER_DOMAINS[provider] + + # If provider tokens are provided, use the host from the token if available + if self.provider_tokens and provider in self.provider_tokens: + domain = self.provider_tokens[provider].host or domain + + # Try to use token if available, otherwise use public URL + if self.provider_tokens and provider in self.provider_tokens: + git_token = self.provider_tokens[provider].token + if git_token: + token_value = git_token.get_secret_value() + if provider == ProviderType.GITLAB: + remote_url = ( + f'https://oauth2:{token_value}@{domain}/{repo_name}.git' + ) + elif provider == ProviderType.BITBUCKET: + # For Bitbucket, handle username:app_password format + if ':' in token_value: + # App token format: username:app_password + remote_url = f'https://{token_value}@{domain}/{repo_name}.git' + else: + # Access token format: use x-token-auth + remote_url = f'https://x-token-auth:{token_value}@{domain}/{repo_name}.git' + else: + # GitHub + remote_url = f'https://{token_value}@{domain}/{repo_name}.git' + else: + remote_url = f'https://{domain}/{repo_name}.git' + else: + remote_url = f'https://{domain}/{repo_name}.git' + + return remote_url diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 1729160e4c..084ec208cb 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -49,7 +49,6 @@ from openhands.integrations.provider import ( ProviderHandler, ProviderType, ) -from openhands.integrations.service_types import AuthenticationError from openhands.microagent import ( BaseMicroagent, load_microagents_from_dir, @@ -381,8 +380,8 @@ class Runtime(FileEditRuntimeMixin): ) return '' - remote_repo_url = await self._get_authenticated_git_url( - selected_repository, git_provider_tokens + remote_repo_url = await self.provider_handler.get_authenticated_git_url( + selected_repository ) if not remote_repo_url: @@ -601,68 +600,6 @@ fi return loaded_microagents - async def _get_authenticated_git_url( - self, repo_name: str, git_provider_tokens: PROVIDER_TOKEN_TYPE | None - ) -> str: - """Get an authenticated git URL for a repository. - - Args: - repo_path: Repository name (owner/repo) - - Returns: - Authenticated git URL if credentials are available, otherwise regular HTTPS URL - """ - - try: - provider_handler = ProviderHandler( - git_provider_tokens or MappingProxyType({}) - ) - repository = await provider_handler.verify_repo_provider(repo_name) - except AuthenticationError: - raise Exception('Git provider authentication issue when getting remote URL') - - provider = repository.git_provider - repo_name = repository.full_name - - provider_domains = { - ProviderType.GITHUB: 'github.com', - ProviderType.GITLAB: 'gitlab.com', - ProviderType.BITBUCKET: 'bitbucket.org', - } - - domain = provider_domains[provider] - - # If git_provider_tokens is provided, use the host from the token if available - if git_provider_tokens and provider in git_provider_tokens: - domain = git_provider_tokens[provider].host or domain - - # Try to use token if available, otherwise use public URL - if git_provider_tokens and provider in git_provider_tokens: - git_token = git_provider_tokens[provider].token - if git_token: - token_value = git_token.get_secret_value() - if provider == ProviderType.GITLAB: - remote_url = ( - f'https://oauth2:{token_value}@{domain}/{repo_name}.git' - ) - elif provider == ProviderType.BITBUCKET: - # For Bitbucket, handle username:app_password format - if ':' in token_value: - # App token format: username:app_password - remote_url = f'https://{token_value}@{domain}/{repo_name}.git' - else: - # Access token format: use x-token-auth - remote_url = f'https://x-token-auth:{token_value}@{domain}/{repo_name}.git' - else: - # GitHub - remote_url = f'https://{token_value}@{domain}/{repo_name}.git' - else: - remote_url = f'https://{domain}/{repo_name}.git' - else: - remote_url = f'https://{domain}/{repo_name}.git' - - return remote_url - def _is_gitlab_repository(self, repo_name: str) -> bool: """Check if a repository is hosted on GitLab. @@ -760,10 +697,9 @@ fi # Get authenticated URL and do a shallow clone (--depth 1) for efficiency try: remote_url = call_async_from_sync( - self._get_authenticated_git_url, + self.provider_handler.get_authenticated_git_url, GENERAL_TIMEOUT, org_openhands_repo, - self.git_provider_tokens, ) except Exception as e: self.log( diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py index beee38185a..0d483414f6 100644 --- a/openhands/server/routes/git.py +++ b/openhands/server/routes/git.py @@ -1,6 +1,15 @@ +import os +import shutil +import subprocess +import tempfile +from datetime import datetime +from pathlib import Path +from types import MappingProxyType +from typing import cast + from fastapi import APIRouter, Depends, status from fastapi.responses import JSONResponse -from pydantic import SecretStr +from pydantic import BaseModel, SecretStr from openhands.core.logger import openhands_logger as logger from openhands.integrations.provider import ( @@ -10,11 +19,14 @@ from openhands.integrations.provider import ( from openhands.integrations.service_types import ( AuthenticationError, Branch, + ProviderType, Repository, SuggestedTask, UnknownException, User, ) +from openhands.microagent import load_microagents_from_dir +from openhands.microagent.types import InputMetadata from openhands.server.dependencies import get_dependencies from openhands.server.shared import server_config from openhands.server.user_auth import ( @@ -229,3 +241,326 @@ async def get_repository_branches( content='Git provider token required. (such as GitHub).', status_code=status.HTTP_401_UNAUTHORIZED, ) + + +class MicroagentResponse(BaseModel): + """Response model for microagents endpoint.""" + + name: str + type: str + content: str + triggers: list[str] = [] + inputs: list[InputMetadata] = [] + tools: list[str] = [] + created_at: datetime + git_provider: ProviderType + + +def _get_file_creation_time(repo_dir: Path, file_path: Path) -> datetime: + """Get the creation time of a file from Git history. + + Args: + repo_dir: The root directory of the Git repository + file_path: The path to the file relative to the repository root + + Returns: + datetime: The timestamp when the file was first added to the repository + """ + try: + # Get the relative path from the repository root + relative_path = file_path.relative_to(repo_dir) + + # Use git log to get the first commit that added this file + # --follow: follow renames and moves + # --reverse: show commits in reverse chronological order (oldest first) + # --format=%ct: show commit timestamp in Unix format + # -1: limit to 1 result (the first commit) + cmd = [ + 'git', + 'log', + '--follow', + '--reverse', + '--format=%ct', + '-1', + str(relative_path), + ] + + result = subprocess.run( + cmd, cwd=repo_dir, capture_output=True, text=True, timeout=10 + ) + + if result.returncode == 0 and result.stdout.strip(): + # Parse Unix timestamp and convert to datetime + timestamp = int(result.stdout.strip()) + return datetime.fromtimestamp(timestamp) + else: + logger.warning( + f'Failed to get creation time for {relative_path}: {result.stderr}' + ) + # Fallback to current time if git log fails + return datetime.now() + + except Exception as e: + logger.warning(f'Error getting creation time for {file_path}: {str(e)}') + # Fallback to current time if there's any error + return datetime.now() + + +async def _verify_repository_access( + repository_name: str, + provider_tokens: PROVIDER_TOKEN_TYPE | None, + access_token: SecretStr | None, + user_id: str | None, +) -> Repository: + """Verify repository access and return repository information. + + Args: + repository_name: Repository name in the format 'owner/repo' + provider_tokens: Provider tokens for authentication + access_token: Access token for external authentication + user_id: User ID for authentication + + Returns: + Repository object with provider information + + Raises: + AuthenticationError: If authentication fails + """ + provider_handler = ProviderHandler( + provider_tokens=provider_tokens + or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})), + external_auth_token=access_token, + external_auth_id=user_id, + ) + + repository = await provider_handler.verify_repo_provider(repository_name) + logger.info( + f'Detected git provider: {repository.git_provider} for repository: {repository_name}' + ) + return repository + + +def _clone_repository(remote_url: str, repository_name: str) -> Path: + """Clone repository to temporary directory. + + Args: + remote_url: Authenticated git URL for cloning + repository_name: Repository name for error messages + + Returns: + Path to the cloned repository directory + + Raises: + RuntimeError: If cloning fails + """ + temp_dir = tempfile.mkdtemp() + repo_dir = Path(temp_dir) / 'repo' + + clone_cmd = ['git', 'clone', '--depth', '1', remote_url, str(repo_dir)] + + # Set environment variable to avoid interactive prompts + env = os.environ.copy() + env['GIT_TERMINAL_PROMPT'] = '0' + + result = subprocess.run( + clone_cmd, + capture_output=True, + text=True, + env=env, + timeout=30, # 30 second timeout + ) + + if result.returncode != 0: + # Clean up on failure + shutil.rmtree(temp_dir, ignore_errors=True) + error_msg = f'Failed to clone repository: {result.stderr}' + logger.error(f'Failed to clone repository {repository_name}: {result.stderr}') + raise RuntimeError(error_msg) + + return repo_dir + + +def _extract_repo_name(repository_name: str) -> str: + """Extract the actual repository name from the full repository path. + + Args: + repository_name: Repository name in format 'owner/repo' or 'domain/owner/repo' + + Returns: + The actual repository name (last part after the last '/') + """ + return repository_name.split('/')[-1] + + +def _process_microagents( + repo_dir: Path, + repository_name: str, + git_provider: ProviderType, +) -> list[MicroagentResponse]: + """Process microagents from the cloned repository. + + Args: + repo_dir: Path to the cloned repository directory + repository_name: Repository name for logging + git_provider: Git provider type + + Returns: + List of microagents found in the repository + """ + # Extract the actual repository name from the full path + actual_repo_name = _extract_repo_name(repository_name) + + # Determine the microagents directory based on git provider and repository name + if git_provider != ProviderType.GITLAB and actual_repo_name == '.openhands': + # For non-GitLab providers with repository name ".openhands", scan "microagents" folder + microagents_dir = repo_dir / 'microagents' + elif git_provider == ProviderType.GITLAB and actual_repo_name == 'openhands-config': + # For GitLab with repository name "openhands-config", scan "microagents" folder + microagents_dir = repo_dir / 'microagents' + else: + # Default behavior: look for .openhands/microagents directory + microagents_dir = repo_dir / '.openhands' / 'microagents' + + if not microagents_dir.exists(): + logger.info( + f'No microagents directory found in {repository_name} at {microagents_dir}' + ) + return [] + + # Load microagents from the directory + repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir) + + # Prepare response + microagents = [] + + # Add repo microagents + for name, r_agent in repo_agents.items(): + # Get the actual creation time from Git + agent_file_path = Path(r_agent.source) + created_at = _get_file_creation_time(repo_dir, agent_file_path) + + microagents.append( + MicroagentResponse( + name=name, + type='repo', + content=r_agent.content, + triggers=[], + inputs=r_agent.metadata.inputs, + tools=( + [server.name for server in r_agent.metadata.mcp_tools.stdio_servers] + if r_agent.metadata.mcp_tools + else [] + ), + created_at=created_at, + git_provider=git_provider, + ) + ) + + # Add knowledge microagents + for name, k_agent in knowledge_agents.items(): + # Get the actual creation time from Git + agent_file_path = Path(k_agent.source) + created_at = _get_file_creation_time(repo_dir, agent_file_path) + + microagents.append( + MicroagentResponse( + name=name, + type='knowledge', + content=k_agent.content, + triggers=k_agent.triggers, + inputs=k_agent.metadata.inputs, + tools=( + [server.name for server in k_agent.metadata.mcp_tools.stdio_servers] + if k_agent.metadata.mcp_tools + else [] + ), + created_at=created_at, + git_provider=git_provider, + ) + ) + + logger.info(f'Found {len(microagents)} microagents in {repository_name}') + return microagents + + +@app.get( + '/repository/{repository_name:path}/microagents', + response_model=list[MicroagentResponse], +) +async def get_repository_microagents( + repository_name: str, + provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens), + access_token: SecretStr | None = Depends(get_access_token), + user_id: str | None = Depends(get_user_id), +) -> list[MicroagentResponse] | JSONResponse: + """Scan the microagents directory of a repository and return the list of microagents. + + The microagents directory location depends on the git provider and actual repository name: + - If git provider is not GitLab and actual repository name is ".openhands": scans "microagents" folder + - If git provider is GitLab and actual repository name is "openhands-config": scans "microagents" folder + - Otherwise: scans ".openhands/microagents" folder + + Args: + repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo' + provider_tokens: Provider tokens for authentication + access_token: Access token for external authentication + user_id: User ID for authentication + + Returns: + List of microagents found in the repository's microagents directory + """ + repo_dir = None + + try: + # Verify repository access and get provider information + repository = await _verify_repository_access( + repository_name, provider_tokens, access_token, user_id + ) + + # Construct authenticated git URL using provider handler + provider_handler = ProviderHandler( + provider_tokens=provider_tokens + or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})), + external_auth_token=access_token, + external_auth_id=user_id, + ) + remote_url = await provider_handler.get_authenticated_git_url(repository_name) + + # Clone repository + repo_dir = _clone_repository(remote_url, repository_name) + + # Process microagents + microagents = _process_microagents( + repo_dir, repository_name, repository.git_provider + ) + + return microagents + + except AuthenticationError as e: + logger.info( + f'Returning 401 Unauthorized - Authentication error for user_id: {user_id}, error: {str(e)}' + ) + return JSONResponse( + content=str(e), + status_code=status.HTTP_401_UNAUTHORIZED, + ) + + except RuntimeError as e: + return JSONResponse( + content=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + except Exception as e: + logger.error( + f'Error scanning repository {repository_name}: {str(e)}', exc_info=True + ) + return JSONResponse( + content=f'Error scanning repository: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + finally: + # Clean up temporary directory + if repo_dir and repo_dir.parent.exists(): + shutil.rmtree(repo_dir.parent, ignore_errors=True) diff --git a/tests/unit/test_get_repository_microagents.py b/tests/unit/test_get_repository_microagents.py new file mode 100644 index 0000000000..53d99c6b10 --- /dev/null +++ b/tests/unit/test_get_repository_microagents.py @@ -0,0 +1,812 @@ +import os +import shutil +import tempfile +from datetime import datetime +from pathlib import Path +from types import MappingProxyType +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import SecretStr + +from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig +from openhands.integrations.provider import ProviderToken, ProviderType +from openhands.integrations.service_types import ( + AuthenticationError, + Repository, +) +from openhands.microagent.microagent import KnowledgeMicroagent, RepoMicroagent +from openhands.microagent.types import InputMetadata, MicroagentMetadata, MicroagentType +from openhands.server.routes.git import app as git_app +from openhands.server.user_auth import ( + get_access_token, + get_provider_tokens, + get_user_id, +) + + +@pytest.fixture +def test_client(): + """Create a test client for the git API.""" + app = FastAPI() + app.include_router(git_app) + + # Mock SESSION_API_KEY to None to disable authentication in tests + with patch.dict(os.environ, {'SESSION_API_KEY': ''}, clear=False): + # Clear the SESSION_API_KEY to disable auth dependency + with patch('openhands.server.dependencies._SESSION_API_KEY', None): + # Override the FastAPI dependencies directly + def mock_get_provider_tokens(): + return MappingProxyType( + { + ProviderType.GITHUB: ProviderToken( + token=SecretStr('ghp_test_token'), host='github.com' + ) + } + ) + + def mock_get_access_token(): + return None + + def mock_get_user_id(): + return 'test_user' + + # Override the dependencies in the app + app.dependency_overrides[get_provider_tokens] = mock_get_provider_tokens + app.dependency_overrides[get_access_token] = mock_get_access_token + app.dependency_overrides[get_user_id] = mock_get_user_id + + yield TestClient(app) + + +@pytest.fixture +def mock_provider_tokens(): + """Create mock provider tokens for testing.""" + return MappingProxyType( + { + ProviderType.GITHUB: ProviderToken( + token=SecretStr('ghp_test_token'), host='github.com' + ), + ProviderType.GITLAB: ProviderToken( + token=SecretStr('glpat_test_token'), host='gitlab.com' + ), + ProviderType.BITBUCKET: ProviderToken( + token=SecretStr('test_token'), host='bitbucket.org' + ), + } + ) + + +@pytest.fixture +def mock_repo_microagent(): + """Create a mock repository microagent.""" + return RepoMicroagent( + name='test_repo_agent', + content='This is a test repository microagent for testing purposes.', + metadata=MicroagentMetadata( + name='test_repo_agent', + type=MicroagentType.REPO_KNOWLEDGE, + inputs=[ + InputMetadata( + name='query', + type='str', + description='Search query for the repository', + ) + ], + mcp_tools=MCPConfig( + stdio_servers=[ + MCPStdioServerConfig(name='git', command='git'), + MCPStdioServerConfig(name='file_editor', command='editor'), + ] + ), + ), + source='test_source', + type=MicroagentType.REPO_KNOWLEDGE, + ) + + +@pytest.fixture +def mock_knowledge_microagent(): + """Create a mock knowledge microagent.""" + return KnowledgeMicroagent( + name='test_knowledge_agent', + content='This is a test knowledge microagent for testing purposes.', + metadata=MicroagentMetadata( + name='test_knowledge_agent', + type=MicroagentType.KNOWLEDGE, + inputs=[ + InputMetadata( + name='topic', type='str', description='Topic to search for' + ) + ], + mcp_tools=MCPConfig( + stdio_servers=[ + MCPStdioServerConfig(name='search', command='search'), + MCPStdioServerConfig(name='fetch', command='fetch'), + ] + ), + ), + source='test_source', + type=MicroagentType.KNOWLEDGE, + triggers=['test', 'knowledge', 'search'], + ) + + +@pytest.fixture +def temp_microagents_dir(): + """Create a temporary directory with microagents for testing.""" + temp_dir = tempfile.mkdtemp() + microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents' + microagents_dir.mkdir(parents=True, exist_ok=True) + + # Create sample microagent files + repo_agent_file = microagents_dir / 'test_repo_agent.md' + repo_agent_file.write_text( + """--- +name: test_repo_agent +type: repo_knowledge +inputs: + - name: query + type: str + description: Search query for the repository +mcp_tools: + stdio_servers: + - name: git + command: git + - name: file_editor + command: editor +--- + +This is a test repository microagent for testing purposes. +""" + ) + + knowledge_agent_file = microagents_dir / 'test_knowledge_agent.md' + knowledge_agent_file.write_text( + """--- +name: test_knowledge_agent +type: knowledge +triggers: [test, knowledge, search] +inputs: + - name: topic + type: str + description: Topic to search for +mcp_tools: + stdio_servers: + - name: search + command: search + - name: fetch + command: fetch +--- + +This is a test knowledge microagent for testing purposes. +""" + ) + + yield temp_dir + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture +def mock_repository(): + """Create a mock repository for testing.""" + return Repository( + id='123456', + full_name='test/repo', + git_provider=ProviderType.GITHUB, + is_public=True, + stargazers_count=100, + ) + + +@pytest.fixture +def mock_provider_handler(mock_repository): + """Create a mock provider handler for testing.""" + handler = MagicMock() + handler.verify_repo_provider = AsyncMock(return_value=mock_repository) + handler.get_authenticated_git_url = AsyncMock( + return_value='https://ghp_test_token@github.com/test/repo.git' + ) + return handler + + +@pytest.fixture +def mock_subprocess_result(): + """Create a mock subprocess result for testing.""" + result = MagicMock() + result.returncode = 0 + result.stderr = '' + return result + + +@pytest.fixture +def mock_microagents_data(mock_repo_microagent, mock_knowledge_microagent): + """Create mock microagents data for testing.""" + return ( + {'test_repo_agent': mock_repo_microagent}, + {'test_knowledge_agent': mock_knowledge_microagent}, + ) + + +class TestGetRepositoryMicroagents: + """Test cases for the get_repository_microagents API endpoint.""" + + @pytest.mark.asyncio + @patch( + 'openhands.server.routes.git._get_file_creation_time', + return_value=datetime.now(), + ) + @patch('openhands.server.routes.git.tempfile.mkdtemp') + @patch('openhands.server.routes.git.load_microagents_from_dir') + @patch('openhands.server.routes.git.subprocess.run') + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_success( + self, + mock_provider_handler_class, + mock_subprocess_run, + mock_load_microagents, + mock_mkdtemp, + mock_get_file_creation_time, + test_client, + mock_provider_tokens, + mock_repo_microagent, + mock_knowledge_microagent, + temp_microagents_dir, + mock_microagents_data, + ): + """Test successful retrieval of microagents from a repository.""" + # Setup mocks + test_client.app.dependency_overrides[get_provider_tokens] = ( + lambda: mock_provider_tokens + ) + + mock_provider_handler = MagicMock() + mock_repository = Repository( + id='123456', + full_name='test/repo', + git_provider=ProviderType.GITHUB, + is_public=True, + stargazers_count=100, + ) + mock_provider_handler.verify_repo_provider = AsyncMock( + return_value=mock_repository + ) + mock_provider_handler.get_authenticated_git_url = AsyncMock( + return_value='https://ghp_test_token@github.com/test/repo.git' + ) + mock_provider_handler_class.return_value = mock_provider_handler + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stderr = '' + mock_subprocess_run.return_value = mock_result + + mock_load_microagents.return_value = mock_microagents_data + mock_mkdtemp.return_value = temp_microagents_dir + + # Execute test + response = test_client.get('/api/user/repository/test/repo/microagents') + + # Assertions + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + + # Check repo microagent + repo_agent = next(m for m in data if m['name'] == 'test_repo_agent') + assert repo_agent['type'] == 'repo' + assert ( + repo_agent['content'] + == 'This is a test repository microagent for testing purposes.' + ) + assert repo_agent['triggers'] == [] + assert len(repo_agent['inputs']) == 1 + assert repo_agent['inputs'][0]['name'] == 'query' + assert repo_agent['tools'] == ['git', 'file_editor'] + assert 'created_at' in repo_agent + assert 'git_provider' in repo_agent + assert repo_agent['git_provider'] == 'github' + + # Check knowledge microagent + knowledge_agent = next(m for m in data if m['name'] == 'test_knowledge_agent') + assert knowledge_agent['type'] == 'knowledge' + assert ( + knowledge_agent['content'] + == 'This is a test knowledge microagent for testing purposes.' + ) + assert knowledge_agent['triggers'] == mock_knowledge_microagent.triggers + assert len(knowledge_agent['inputs']) == 1 + assert knowledge_agent['inputs'][0]['name'] == 'topic' + assert knowledge_agent['tools'] == ['search', 'fetch'] + assert 'created_at' in knowledge_agent + assert 'git_provider' in knowledge_agent + assert knowledge_agent['git_provider'] == 'github' + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_authentication_error( + self, mock_provider_handler_class, test_client, mock_provider_tokens + ): + """Test authentication error when verifying repository.""" + # Setup mocks + test_client.app.dependency_overrides[get_provider_tokens] = ( + lambda: mock_provider_tokens + ) + + mock_provider_handler = MagicMock() + mock_provider_handler.verify_repo_provider = AsyncMock( + side_effect=AuthenticationError('Invalid credentials') + ) + mock_provider_handler_class.return_value = mock_provider_handler + + # Execute test + response = test_client.get('/api/user/repository/test/repo/microagents') + + # Assertions + assert response.status_code == 401 + assert response.json() == 'Invalid credentials' + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.subprocess.run') + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_clone_failure( + self, + mock_provider_handler_class, + mock_subprocess_run, + test_client, + mock_provider_tokens, + ): + """Test error when git clone fails.""" + # Setup mocks + test_client.app.dependency_overrides[get_provider_tokens] = ( + lambda: mock_provider_tokens + ) + + mock_provider_handler = MagicMock() + mock_repository = Repository( + id='123456', + full_name='test/repo', + git_provider=ProviderType.GITHUB, + is_public=True, + stargazers_count=100, + ) + mock_provider_handler.verify_repo_provider = AsyncMock( + return_value=mock_repository + ) + mock_provider_handler.get_authenticated_git_url = AsyncMock( + return_value='https://ghp_test_token@github.com/test/repo.git' + ) + mock_provider_handler_class.return_value = mock_provider_handler + + # Mock subprocess.run to fail + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stderr = 'Repository not found' + mock_subprocess_run.return_value = mock_result + + # Execute test + response = test_client.get('/api/user/repository/test/repo/microagents') + + # Assertions + assert response.status_code == 500 + assert 'Failed to clone repository' in response.json() + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.tempfile.mkdtemp') + @patch('openhands.server.routes.git.subprocess.run') + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_no_microagents_directory( + self, + mock_provider_handler_class, + mock_subprocess_run, + mock_mkdtemp, + test_client, + mock_provider_tokens, + ): + """Test when repository has no .openhands/microagents directory.""" + # Setup mocks + test_client.app.dependency_overrides[get_provider_tokens] = ( + lambda: mock_provider_tokens + ) + + mock_provider_handler = MagicMock() + mock_repository = Repository( + id='123456', + full_name='test/repo', + git_provider=ProviderType.GITHUB, + is_public=True, + stargazers_count=100, + ) + mock_provider_handler.verify_repo_provider = AsyncMock( + return_value=mock_repository + ) + mock_provider_handler.get_authenticated_git_url = AsyncMock( + return_value='https://ghp_test_token@github.com/test/repo.git' + ) + mock_provider_handler_class.return_value = mock_provider_handler + + # Mock subprocess.run for successful clone + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stderr = '' + mock_subprocess_run.return_value = mock_result + + # Mock tempfile.mkdtemp to return a path that doesn't have microagents + temp_dir = '/tmp/test_no_microagents' + mock_mkdtemp.return_value = temp_dir + + # Execute test + response = test_client.get('/api/user/repository/test/repo/microagents') + + # Assertions + assert response.status_code == 200 + data = response.json() + assert data == [] + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.load_microagents_from_dir') + @patch('openhands.server.routes.git.subprocess.run') + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_empty_directory( + self, + mock_provider_handler_class, + mock_subprocess_run, + mock_load_microagents, + test_client, + mock_provider_tokens, + ): + """Test when microagents directory exists but is empty.""" + # Setup mocks + test_client.app.dependency_overrides[get_provider_tokens] = ( + lambda: mock_provider_tokens + ) + + mock_provider_handler = MagicMock() + mock_repository = Repository( + id='123456', + full_name='test/repo', + git_provider=ProviderType.GITHUB, + is_public=True, + stargazers_count=100, + ) + mock_provider_handler.verify_repo_provider = AsyncMock( + return_value=mock_repository + ) + mock_provider_handler.get_authenticated_git_url = AsyncMock( + return_value='https://ghp_test_token@github.com/test/repo.git' + ) + mock_provider_handler_class.return_value = mock_provider_handler + + # Mock subprocess.run for successful clone + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stderr = '' + mock_subprocess_run.return_value = mock_result + + # Mock load_microagents_from_dir to return empty results + mock_load_microagents.return_value = ({}, {}) + + # Execute test + response = test_client.get('/api/user/repository/test/repo/microagents') + + # Assertions + assert response.status_code == 200 + data = response.json() + assert data == [] + + @pytest.mark.asyncio + @patch( + 'openhands.server.routes.git._get_file_creation_time', + return_value=datetime.now(), + ) + @patch('openhands.server.routes.git.tempfile.mkdtemp') + @patch('openhands.server.routes.git.load_microagents_from_dir') + @patch('openhands.server.routes.git.subprocess.run') + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_different_providers( + self, + mock_provider_handler_class, + mock_subprocess_run, + mock_load_microagents, + mock_mkdtemp, + mock_get_file_creation_time, + test_client, + mock_repo_microagent, + ): + """Test microagents endpoint with GitHub provider.""" + # Setup mocks + provider_tokens = MappingProxyType( + { + ProviderType.GITHUB: ProviderToken( + token=SecretStr('ghp_test_token'), host='github.com' + ) + } + ) + test_client.app.dependency_overrides[get_provider_tokens] = ( + lambda: provider_tokens + ) + + mock_provider_handler = MagicMock() + mock_repository = Repository( + id='123456', + full_name='test/repo', + git_provider=ProviderType.GITHUB, + is_public=True, + stargazers_count=100, + ) + mock_provider_handler.verify_repo_provider = AsyncMock( + return_value=mock_repository + ) + mock_provider_handler.get_authenticated_git_url = AsyncMock( + return_value='https://ghp_test_token@github.com/test/repo.git' + ) + mock_provider_handler_class.return_value = mock_provider_handler + + # Mock subprocess.run for successful clone + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stderr = '' + mock_subprocess_run.return_value = mock_result + + # Create temporary directory with microagents + temp_dir = tempfile.mkdtemp() + microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents' + microagents_dir.mkdir(parents=True, exist_ok=True) + + # Mock load_microagents_from_dir + mock_repo_agents = {'test_repo_agent': mock_repo_microagent} + mock_knowledge_agents = {} + mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents) + mock_mkdtemp.return_value = temp_dir + + try: + # Execute test + response = test_client.get('/api/user/repository/test/repo/microagents') + + # Assertions + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]['name'] == 'test_repo_agent' + assert data[0]['type'] == 'repo' + assert 'created_at' in data[0] + assert 'git_provider' in data[0] + assert data[0]['git_provider'] == 'github' + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + @pytest.mark.asyncio + @patch( + 'openhands.server.routes.git._get_file_creation_time', + return_value=datetime.now(), + ) + @patch('openhands.server.routes.git.tempfile.mkdtemp') + @patch('openhands.server.routes.git.load_microagents_from_dir') + @patch('openhands.server.routes.git.subprocess.run') + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_with_external_auth( + self, + mock_provider_handler_class, + mock_subprocess_run, + mock_load_microagents, + mock_mkdtemp, + mock_get_file_creation_time, + test_client, + mock_provider_tokens, + mock_repo_microagent, + ): + """Test microagents endpoint with external authentication.""" + # Setup mocks + test_client.app.dependency_overrides[get_provider_tokens] = ( + lambda: mock_provider_tokens + ) + test_client.app.dependency_overrides[get_access_token] = lambda: SecretStr( + 'external_token' + ) + test_client.app.dependency_overrides[get_user_id] = lambda: 'external_user' + + mock_provider_handler = MagicMock() + mock_repository = Repository( + id='123456', + full_name='test/repo', + git_provider=ProviderType.GITHUB, + is_public=True, + stargazers_count=100, + ) + mock_provider_handler.verify_repo_provider = AsyncMock( + return_value=mock_repository + ) + mock_provider_handler.get_authenticated_git_url = AsyncMock( + return_value='https://ghp_test_token@github.com/test/repo.git' + ) + mock_provider_handler_class.return_value = mock_provider_handler + + # Mock subprocess.run for successful clone + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stderr = '' + mock_subprocess_run.return_value = mock_result + + # Create temporary directory with microagents + temp_dir = tempfile.mkdtemp() + microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents' + microagents_dir.mkdir(parents=True, exist_ok=True) + + # Mock load_microagents_from_dir + mock_repo_agents = {'test_repo_agent': mock_repo_microagent} + mock_knowledge_agents = {} + mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents) + mock_mkdtemp.return_value = temp_dir + + try: + # Execute test + response = test_client.get('/api/user/repository/test/repo/microagents') + + # Assertions + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]['name'] == 'test_repo_agent' + assert 'created_at' in data[0] + assert 'git_provider' in data[0] + assert data[0]['git_provider'] == 'github' + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_generic_exception( + self, mock_provider_handler_class, test_client, mock_provider_tokens + ): + """Test handling of generic exceptions.""" + # Setup mocks + test_client.app.dependency_overrides[get_provider_tokens] = ( + lambda: mock_provider_tokens + ) + + mock_provider_handler = MagicMock() + mock_provider_handler.verify_repo_provider = AsyncMock( + side_effect=Exception('Unexpected error') + ) + mock_provider_handler_class.return_value = mock_provider_handler + + # Execute test + response = test_client.get('/api/user/repository/test/repo/microagents') + + # Assertions + assert response.status_code == 500 + assert 'Error scanning repository' in response.json() + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.subprocess.run') + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_timeout( + self, + mock_provider_handler_class, + mock_subprocess_run, + test_client, + mock_provider_tokens, + ): + """Test timeout handling during git clone.""" + # Setup mocks + test_client.app.dependency_overrides[get_provider_tokens] = ( + lambda: mock_provider_tokens + ) + + mock_provider_handler = MagicMock() + mock_repository = Repository( + id='123456', + full_name='test/repo', + git_provider=ProviderType.GITHUB, + is_public=True, + stargazers_count=100, + ) + mock_provider_handler.verify_repo_provider = AsyncMock( + return_value=mock_repository + ) + mock_provider_handler.get_authenticated_git_url = AsyncMock( + return_value='https://ghp_test_token@github.com/test/repo.git' + ) + mock_provider_handler_class.return_value = mock_provider_handler + + # Mock subprocess.run to raise timeout + import subprocess + + mock_subprocess_run.side_effect = subprocess.TimeoutExpired('git', 30) + + # Execute test + response = test_client.get('/api/user/repository/test/repo/microagents') + + # Assertions + assert response.status_code == 500 + assert 'Error scanning repository' in response.json() + + @pytest.mark.asyncio + @patch( + 'openhands.server.routes.git._get_file_creation_time', + return_value=datetime.now(), + ) + @patch('openhands.server.routes.git.tempfile.mkdtemp') + @patch('openhands.server.routes.git.load_microagents_from_dir') + @patch('openhands.server.routes.git.subprocess.run') + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_microagents_without_mcp_tools( + self, + mock_provider_handler_class, + mock_subprocess_run, + mock_load_microagents, + mock_mkdtemp, + mock_get_file_creation_time, + test_client, + mock_provider_tokens, + ): + """Test microagents without MCP tools.""" + # Setup mocks + test_client.app.dependency_overrides[get_provider_tokens] = ( + lambda: mock_provider_tokens + ) + + # Create microagent without MCP tools + repo_microagent = RepoMicroagent( + name='simple_agent', + content='Simple agent without MCP tools', + metadata=MicroagentMetadata( + name='simple_agent', + type=MicroagentType.REPO_KNOWLEDGE, + inputs=[], + mcp_tools=None, + ), + source='test_source', + type=MicroagentType.REPO_KNOWLEDGE, + ) + + mock_provider_handler = MagicMock() + mock_repository = Repository( + id='123456', + full_name='test/repo', + git_provider=ProviderType.GITHUB, + is_public=True, + stargazers_count=100, + ) + mock_provider_handler.verify_repo_provider = AsyncMock( + return_value=mock_repository + ) + mock_provider_handler.get_authenticated_git_url = AsyncMock( + return_value='https://ghp_test_token@github.com/test/repo.git' + ) + mock_provider_handler_class.return_value = mock_provider_handler + + # Mock subprocess.run for successful clone + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stderr = '' + mock_subprocess_run.return_value = mock_result + + # Create temporary directory with microagents + temp_dir = tempfile.mkdtemp() + microagents_dir = Path(temp_dir) / 'repo' / '.openhands' / 'microagents' + microagents_dir.mkdir(parents=True, exist_ok=True) + + # Mock load_microagents_from_dir + mock_repo_agents = {'simple_agent': repo_microagent} + mock_knowledge_agents = {} + mock_load_microagents.return_value = (mock_repo_agents, mock_knowledge_agents) + mock_mkdtemp.return_value = temp_dir + + try: + # Execute test + response = test_client.get('/api/user/repository/test/repo/microagents') + + # Assertions + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]['name'] == 'simple_agent' + assert data[0]['tools'] == [] + assert 'created_at' in data[0] + assert 'git_provider' in data[0] + assert data[0]['git_provider'] == 'github' + finally: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/tests/unit/test_runtime_gitlab_microagents.py b/tests/unit/test_runtime_gitlab_microagents.py index 39172333c9..a363f4ed01 100644 --- a/tests/unit/test_runtime_gitlab_microagents.py +++ b/tests/unit/test_runtime_gitlab_microagents.py @@ -6,6 +6,8 @@ from unittest.mock import MagicMock, patch import pytest +from openhands.core.config import OpenHandsConfig, SandboxConfig +from openhands.events import EventStream from openhands.integrations.service_types import ProviderType, Repository from openhands.microagent.microagent import ( RepoMicroagent, @@ -17,8 +19,20 @@ class MockRuntime(Runtime): """Mock runtime for testing.""" def __init__(self, workspace_root: Path): + # Create a minimal config for testing + config = OpenHandsConfig() + config.workspace_mount_path_in_sandbox = str(workspace_root) + config.sandbox = SandboxConfig() + + # Create a mock event stream + event_stream = MagicMock(spec=EventStream) + + # Initialize the parent class properly + super().__init__( + config=config, event_stream=event_stream, sid='test', git_provider_tokens={} + ) + self._workspace_root = workspace_root - self.git_provider_tokens = {} self._logs = [] @property