diff --git a/openhands/integrations/bitbucket/bitbucket_service.py b/openhands/integrations/bitbucket/bitbucket_service.py index 989c98069a..d236bf7498 100644 --- a/openhands/integrations/bitbucket/bitbucket_service.py +++ b/openhands/integrations/bitbucket/bitbucket_service.py @@ -5,6 +5,7 @@ from typing import Any import httpx from pydantic import SecretStr +from openhands.core.logger import openhands_logger as logger from openhands.integrations.service_types import ( BaseGitService, Branch, @@ -13,9 +14,11 @@ from openhands.integrations.service_types import ( ProviderType, Repository, RequestMethod, + ResourceNotFoundError, SuggestedTask, User, ) +from openhands.microagent.types import MicroagentContentResponse from openhands.server.types import AppMode from openhands.utils.import_utils import get_impl @@ -64,6 +67,50 @@ class BitBucketService(BaseGitService, GitService): """Get latest working token of the user.""" return self.token + async def _get_cursorrules_url(self, repository: str) -> str: + """Get the URL for checking .cursorrules file.""" + # Get repository details to get the main branch + repo_details = await self.get_repository_details_from_repo_name(repository) + if not repo_details.main_branch: + raise ResourceNotFoundError( + f'Main branch not found for repository {repository}. ' + f'This repository may be empty or have no default branch configured.' + ) + return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/.cursorrules' + + async def _get_microagents_directory_url( + self, repository: str, microagents_path: str + ) -> str: + """Get the URL for checking microagents directory.""" + # Get repository details to get the main branch + repo_details = await self.get_repository_details_from_repo_name(repository) + if not repo_details.main_branch: + raise ResourceNotFoundError( + f'Main branch not found for repository {repository}. ' + f'This repository may be empty or have no default branch configured.' + ) + return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{microagents_path}' + + def _get_microagents_directory_params(self, microagents_path: str) -> dict | None: + """Get parameters for the microagents directory request. Return None if no parameters needed.""" + return None + + def _is_valid_microagent_file(self, item: dict) -> bool: + """Check if an item represents a valid microagent file.""" + return ( + item['type'] == 'commit_file' + and item['path'].endswith('.md') + and not item['path'].endswith('README.md') + ) + + def _get_file_name_from_item(self, item: dict) -> str: + """Extract file name from directory item.""" + return item['path'].split('/')[-1] + + def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str: + """Extract file path from directory item.""" + return item['path'] + def _has_token_expired(self, status_code: int) -> bool: return status_code == 401 @@ -286,6 +333,8 @@ class BitBucketService(BaseGitService, GitService): data, _ = await self._make_request(url) uuid = data.get('uuid', '') + main_branch = data.get('mainbranch', {}).get('name') + return Repository( id=uuid, full_name=f'{data.get("workspace", {}).get("slug", "")}/{data.get("slug", "")}', @@ -298,6 +347,7 @@ class BitBucketService(BaseGitService, GitService): if data.get('workspace', {}).get('is_private') is False else OwnerType.USER ), + main_branch=main_branch, ) async def get_branches(self, repository: str) -> list[Branch]: @@ -385,6 +435,41 @@ class BitBucketService(BaseGitService, GitService): # Return the URL to the pull request return data.get('links', {}).get('html', {}).get('href', '') + async def get_microagent_content( + self, repository: str, file_path: str + ) -> MicroagentContentResponse: + """Fetch individual file content from Bitbucket repository. + + Args: + repository: Repository name in format 'workspace/repo_slug' + file_path: Path to the file within the repository + + Returns: + MicroagentContentResponse with parsed content and triggers + + Raises: + RuntimeError: If file cannot be fetched or doesn't exist + """ + # Step 1: Get repository details using existing method + repo_details = await self.get_repository_details_from_repo_name(repository) + + if not repo_details.main_branch: + logger.warning( + f'No main branch found in repository info for {repository}. ' + f'Repository response: mainbranch field missing' + ) + raise ResourceNotFoundError( + f'Main branch not found for repository {repository}. ' + f'This repository may be empty or have no default branch configured.' + ) + + # Step 2: Get file content using the main branch + file_url = f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{file_path}' + response, _ = await self._make_request(file_url) + + # Parse the content to extract triggers from frontmatter + return self._parse_microagent_content(response, file_path) + bitbucket_service_cls = os.environ.get( 'OPENHANDS_BITBUCKET_SERVICE_CLS', diff --git a/openhands/integrations/github/github_service.py b/openhands/integrations/github/github_service.py index def04bb11a..f5ca51c2d1 100644 --- a/openhands/integrations/github/github_service.py +++ b/openhands/integrations/github/github_service.py @@ -1,3 +1,4 @@ +import base64 import json import os from datetime import datetime @@ -24,6 +25,7 @@ from openhands.integrations.service_types import ( UnknownException, User, ) +from openhands.microagent.types import MicroagentContentResponse from openhands.server.types import AppMode from openhands.utils.import_utils import get_impl @@ -88,6 +90,36 @@ class GitHubService(BaseGitService, GitService): async def get_latest_token(self) -> SecretStr | None: return self.token + async def _get_cursorrules_url(self, repository: str) -> str: + """Get the URL for checking .cursorrules file.""" + return f'{self.BASE_URL}/repos/{repository}/contents/.cursorrules' + + async def _get_microagents_directory_url( + self, repository: str, microagents_path: str + ) -> str: + """Get the URL for checking microagents directory.""" + return f'{self.BASE_URL}/repos/{repository}/contents/{microagents_path}' + + def _is_valid_microagent_file(self, item: dict) -> bool: + """Check if an item represents a valid microagent file.""" + return ( + item['type'] == 'file' + and item['name'].endswith('.md') + and item['name'] != 'README.md' + ) + + def _get_file_name_from_item(self, item: dict) -> str: + """Extract file name from directory item.""" + return item['name'] + + def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str: + """Extract file path from directory item.""" + return f'{microagents_path}/{item["name"]}' + + def _get_microagents_directory_params(self, microagents_path: str) -> dict | None: + """Get parameters for the microagents directory request. Return None if no parameters needed.""" + return None + async def _make_request( self, url: str, @@ -537,6 +569,29 @@ class GitHubService(BaseGitService, GitService): # Return the HTML URL of the created PR return response['html_url'] + async def get_microagent_content( + self, repository: str, file_path: str + ) -> MicroagentContentResponse: + """Fetch individual file content from GitHub repository. + + Args: + repository: Repository name in format 'owner/repo' + file_path: Path to the file within the repository + + Returns: + MicroagentContentResponse with parsed content and triggers + + Raises: + RuntimeError: If file cannot be fetched or doesn't exist + """ + file_url = f'{self.BASE_URL}/repos/{repository}/contents/{file_path}' + + file_data, _ = await self._make_request(file_url) + file_content = base64.b64decode(file_data['content']).decode('utf-8') + + # Parse the content to extract triggers from frontmatter + return self._parse_microagent_content(file_content, file_path) + github_service_cls = os.environ.get( 'OPENHANDS_GITHUB_SERVICE_CLS', diff --git a/openhands/integrations/gitlab/gitlab_service.py b/openhands/integrations/gitlab/gitlab_service.py index ca70d629d3..34916da404 100644 --- a/openhands/integrations/gitlab/gitlab_service.py +++ b/openhands/integrations/gitlab/gitlab_service.py @@ -17,6 +17,7 @@ from openhands.integrations.service_types import ( UnknownException, User, ) +from openhands.microagent.types import MicroagentContentResponse from openhands.server.types import AppMode from openhands.utils.import_utils import get_impl @@ -81,6 +82,40 @@ class GitLabService(BaseGitService, GitService): async def get_latest_token(self) -> SecretStr | None: return self.token + async def _get_cursorrules_url(self, repository: str) -> str: + """Get the URL for checking .cursorrules file.""" + project_id = self._extract_project_id(repository) + return ( + f'{self.BASE_URL}/projects/{project_id}/repository/files/.cursorrules/raw' + ) + + async def _get_microagents_directory_url( + self, repository: str, microagents_path: str + ) -> str: + """Get the URL for checking microagents directory.""" + project_id = self._extract_project_id(repository) + return f'{self.BASE_URL}/projects/{project_id}/repository/tree' + + def _get_microagents_directory_params(self, microagents_path: str) -> dict: + """Get parameters for the microagents directory request.""" + return {'path': microagents_path, 'recursive': 'true'} + + def _is_valid_microagent_file(self, item: dict) -> bool: + """Check if an item represents a valid microagent file.""" + return ( + item['type'] == 'blob' + and item['name'].endswith('.md') + and item['name'] != 'README.md' + ) + + def _get_file_name_from_item(self, item: dict) -> str: + """Extract file name from directory item.""" + return item['name'] + + def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str: + """Extract file path from directory item.""" + return item['path'] + async def _make_request( self, url: str, @@ -117,7 +152,11 @@ class GitLabService(BaseGitService, GitService): if 'Link' in response.headers: headers['Link'] = response.headers['Link'] - return response.json(), headers + content_type = response.headers.get('Content-Type', '') + if 'application/json' in content_type: + return response.json(), headers + else: + return response.text, headers except httpx.HTTPStatusError as e: raise self.handle_http_status_error(e) @@ -523,6 +562,55 @@ class GitLabService(BaseGitService, GitService): return response['web_url'] + def _extract_project_id(self, repository: str) -> str: + """Extract project_id from repository name for GitLab API calls. + + Args: + repository: Repository name in format 'owner/repo' or 'domain/owner/repo' + + Returns: + URL-encoded project ID for GitLab API + """ + if '/' in repository: + parts = repository.split('/') + if len(parts) >= 3 and '.' in parts[0]: + # Self-hosted GitLab: 'domain/owner/repo' -> 'owner/repo' + project_id = '/'.join(parts[1:]).replace('/', '%2F') + else: + # Regular GitLab: 'owner/repo' -> 'owner/repo' + project_id = repository.replace('/', '%2F') + else: + project_id = repository + + return project_id + + async def get_microagent_content( + self, repository: str, file_path: str + ) -> MicroagentContentResponse: + """Fetch individual file content from GitLab repository. + + Args: + repository: Repository name in format 'owner/repo' or 'domain/owner/repo' + file_path: Path to the file within the repository + + Returns: + MicroagentContentResponse with parsed content and triggers + + Raises: + RuntimeError: If file cannot be fetched or doesn't exist + """ + # Extract project_id from repository name + project_id = self._extract_project_id(repository) + + encoded_file_path = file_path.replace('/', '%2F') + base_url = f'{self.BASE_URL}/projects/{project_id}' + file_url = f'{base_url}/repository/files/{encoded_file_path}/raw' + + response, _ = await self._make_request(file_url) + + # Parse the content to extract triggers from frontmatter + return self._parse_microagent_content(response, file_path) + gitlab_service_cls = os.environ.get( 'OPENHANDS_GITLAB_SERVICE_CLS', diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py index 317afd6a50..ed89a41f17 100644 --- a/openhands/integrations/provider.py +++ b/openhands/integrations/provider.py @@ -22,11 +22,14 @@ from openhands.integrations.service_types import ( AuthenticationError, Branch, GitService, + MicroagentParseError, ProviderType, Repository, + ResourceNotFoundError, SuggestedTask, User, ) +from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse from openhands.server.types import AppMode @@ -407,6 +410,104 @@ class ProviderHandler: return main_branches + other_branches + async def get_microagents(self, repository: str) -> list[MicroagentResponse]: + """Get microagents from a repository using the appropriate service. + + Args: + repository: Repository name in the format 'owner/repo' + + Returns: + List of microagents found in the repository + + Raises: + AuthenticationError: If authentication fails + """ + # Try all available providers in order + errors = [] + for provider in self.provider_tokens: + try: + service = self._get_service(provider) + result = await service.get_microagents(repository) + # Only return early if we got a non-empty result + if result: + return result + # If we got an empty array, continue checking other providers + logger.debug( + f'No microagents found on {provider} for {repository}, trying other providers' + ) + except Exception as e: + errors.append(f'{provider.value}: {str(e)}') + logger.warning( + f'Error fetching microagents from {provider} for {repository}: {e}' + ) + + # If all providers failed or returned empty results, return empty array + if errors: + logger.error( + f'Failed to fetch microagents for {repository} with all available providers. Errors: {"; ".join(errors)}' + ) + raise AuthenticationError(f'Unable to fetch microagents for {repository}') + + # All providers returned empty arrays + return [] + + async def get_microagent_content( + self, repository: str, file_path: str + ) -> MicroagentContentResponse: + """Get content of a specific microagent file from a repository. + + Args: + repository: Repository name in the format 'owner/repo' + file_path: Path to the microagent file within the repository + + Returns: + MicroagentContentResponse with parsed content and triggers + + Raises: + AuthenticationError: If authentication fails + """ + # Try all available providers in order + errors = [] + for provider in self.provider_tokens: + try: + service = self._get_service(provider) + result = await service.get_microagent_content(repository, file_path) + # If we got content, return it immediately + if result: + return result + # If we got empty content, continue checking other providers + logger.debug( + f'No content found on {provider} for {repository}/{file_path}, trying other providers' + ) + except ResourceNotFoundError: + logger.debug( + f'File not found on {provider} for {repository}/{file_path}, trying other providers' + ) + continue + except MicroagentParseError as e: + # Parsing errors are specific to the provider, add to errors list + errors.append(f'{provider.value}: {str(e)}') + logger.warning( + f'Error parsing microagent content from {provider} for {repository}: {e}' + ) + except Exception as e: + # For other errors (auth, rate limit, etc.), add to errors list + errors.append(f'{provider.value}: {str(e)}') + logger.warning( + f'Error fetching microagent content from {provider} for {repository}: {e}' + ) + + # If all providers failed or returned empty results, raise an error + if errors: + logger.error( + f'Failed to fetch microagent content for {repository} with all available providers. Errors: {"; ".join(errors)}' + ) + + # All providers returned empty content or file not found + raise AuthenticationError( + f'Microagent file {file_path} not found in {repository}' + ) + async def get_authenticated_git_url(self, repo_name: str) -> str: """Get an authenticated git URL for a repository. diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py index 5e348e78d2..75a08538d5 100644 --- a/openhands/integrations/service_types.py +++ b/openhands/integrations/service_types.py @@ -1,5 +1,7 @@ from abc import ABC, abstractmethod +from datetime import datetime from enum import Enum +from pathlib import Path from typing import Any, Protocol from httpx import AsyncClient, HTTPError, HTTPStatusError @@ -7,6 +9,8 @@ from jinja2 import Environment, FileSystemLoader from pydantic import BaseModel, SecretStr from openhands.core.logger import openhands_logger as logger +from openhands.microagent.microagent import BaseMicroagent +from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse from openhands.server.types import AppMode @@ -132,6 +136,7 @@ class Repository(BaseModel): owner_type: OwnerType | None = ( None # Whether the repository is owned by a user or organization ) + main_branch: str | None = None # The main/default branch of the repository class AuthenticationError(ValueError): @@ -152,6 +157,18 @@ class RateLimitError(ValueError): pass +class ResourceNotFoundError(ValueError): + """Raised when a requested resource (file, directory, etc.) is not found.""" + + pass + + +class MicroagentParseError(ValueError): + """Raised when there is an error parsing a microagent file.""" + + pass + + class RequestMethod(Enum): POST = 'post' GET = 'get' @@ -171,6 +188,38 @@ class BaseGitService(ABC): method: RequestMethod = RequestMethod.GET, ) -> tuple[Any, dict]: ... + @abstractmethod + async def _get_cursorrules_url(self, repository: str) -> str: + """Get the URL for checking .cursorrules file.""" + ... + + @abstractmethod + async def _get_microagents_directory_url( + self, repository: str, microagents_path: str + ) -> str: + """Get the URL for checking microagents directory.""" + ... + + @abstractmethod + def _get_microagents_directory_params(self, microagents_path: str) -> dict | None: + """Get parameters for the microagents directory request. Return None if no parameters needed.""" + ... + + @abstractmethod + def _is_valid_microagent_file(self, item: dict) -> bool: + """Check if an item represents a valid microagent file.""" + ... + + @abstractmethod + def _get_file_name_from_item(self, item: dict) -> str: + """Extract file name from directory item.""" + ... + + @abstractmethod + def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str: + """Extract file path from directory item.""" + ... + async def execute_request( self, client: AsyncClient, @@ -185,9 +234,15 @@ class BaseGitService(ABC): def handle_http_status_error( self, e: HTTPStatusError - ) -> AuthenticationError | RateLimitError | UnknownException: + ) -> ( + AuthenticationError | RateLimitError | ResourceNotFoundError | UnknownException + ): if e.response.status_code == 401: return AuthenticationError(f'Invalid {self.provider} token') + elif e.response.status_code == 404: + return ResourceNotFoundError( + f'Resource not found on {self.provider} API: {e}' + ) elif e.response.status_code == 429: logger.warning(f'Rate limit exceeded on {self.provider} API: {e}') return RateLimitError('GitHub API rate limit exceeded') @@ -199,6 +254,184 @@ class BaseGitService(ABC): logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}') return UnknownException(f'HTTP error {type(e).__name__} : {e}') + def _determine_microagents_path(self, repository_name: str) -> str: + """Determine the microagents directory path based on repository name.""" + actual_repo_name = repository_name.split('/')[-1] + + # Check for special repository names that use a different structure + if actual_repo_name == '.openhands' or actual_repo_name == 'openhands-config': + # For repository name ".openhands", scan "microagents" folder + return 'microagents' + else: + # Default behavior: look for .openhands/microagents directory + return '.openhands/microagents' + + def _create_microagent_response( + self, file_name: str, path: str + ) -> MicroagentResponse: + """Create a microagent response from basic file information.""" + # Extract name without extension + name = file_name.replace('.md', '').replace('.cursorrules', 'cursorrules') + + return MicroagentResponse( + name=name, + path=path, + created_at=datetime.now(), + ) + + def _parse_microagent_content( + self, content: str, file_path: str + ) -> MicroagentContentResponse: + """Parse microagent content and extract triggers using BaseMicroagent.load. + + Args: + content: Raw microagent file content + file_path: Path to the file (used for microagent loading) + + Returns: + MicroagentContentResponse with parsed content and triggers + + Raises: + MicroagentParseError: If the microagent file cannot be parsed + """ + try: + # Use BaseMicroagent.load to properly parse the content + # Create a temporary path object for the file + temp_path = Path(file_path) + + # Load the microagent using the existing infrastructure + microagent = BaseMicroagent.load(path=temp_path, file_content=content) + + # Extract triggers from the microagent's metadata + triggers = microagent.metadata.triggers + + # Return the MicroagentContentResponse + return MicroagentContentResponse( + content=microagent.content, + path=file_path, + triggers=triggers, + git_provider=self.provider, + ) + + except Exception as e: + logger.error(f'Error parsing microagent content for {file_path}: {str(e)}') + raise MicroagentParseError( + f'Failed to parse microagent file {file_path}: {str(e)}' + ) + + async def _fetch_cursorrules_content(self, repository: str) -> Any | None: + """Fetch .cursorrules file content from the repository via API. + + Args: + repository: Repository name in format specific to the provider + + Returns: + Raw API response content if .cursorrules file exists, None otherwise + """ + cursorrules_url = await self._get_cursorrules_url(repository) + cursorrules_response, _ = await self._make_request(cursorrules_url) + return cursorrules_response + + async def _check_cursorrules_file( + self, repository: str + ) -> MicroagentResponse | None: + """Check for .cursorrules file in the repository and return microagent response if found. + + Args: + repository: Repository name in format specific to the provider + + Returns: + MicroagentResponse for .cursorrules file if found, None otherwise + """ + try: + cursorrules_content = await self._fetch_cursorrules_content(repository) + if cursorrules_content: + return self._create_microagent_response('.cursorrules', '.cursorrules') + except ResourceNotFoundError: + logger.debug(f'No .cursorrules file found in {repository}') + except Exception as e: + logger.warning(f'Error checking .cursorrules file in {repository}: {e}') + + return None + + async def _process_microagents_directory( + self, repository: str, microagents_path: str + ) -> list[MicroagentResponse]: + """Process microagents directory and return list of microagent responses. + + Args: + repository: Repository name in format specific to the provider + microagents_path: Path to the microagents directory + + Returns: + List of MicroagentResponse objects found in the directory + """ + microagents = [] + + try: + directory_url = await self._get_microagents_directory_url( + repository, microagents_path + ) + directory_params = self._get_microagents_directory_params(microagents_path) + response, _ = await self._make_request(directory_url, directory_params) + + # Handle different response structures + items = response + if isinstance(response, dict) and 'values' in response: + # Bitbucket format + items = response['values'] + elif isinstance(response, dict) and 'nodes' in response: + # GraphQL format (if used) + items = response['nodes'] + + for item in items: + if self._is_valid_microagent_file(item): + try: + file_name = self._get_file_name_from_item(item) + file_path = self._get_file_path_from_item( + item, microagents_path + ) + microagents.append( + self._create_microagent_response(file_name, file_path) + ) + except Exception as e: + logger.warning( + f'Error processing microagent {item.get("name", "unknown")}: {str(e)}' + ) + except ResourceNotFoundError: + logger.info( + f'No microagents directory found in {repository} at {microagents_path}' + ) + except Exception as e: + logger.warning(f'Error fetching microagents directory: {str(e)}') + + return microagents + + async def get_microagents(self, repository: str) -> list[MicroagentResponse]: + """Generic implementation of get_microagents that works across all providers. + + Args: + repository: Repository name in format specific to the provider + + Returns: + List of microagents found in the repository (without content for performance) + """ + microagents_path = self._determine_microagents_path(repository) + microagents = [] + + # Step 1: Check for .cursorrules file + cursorrules_microagent = await self._check_cursorrules_file(repository) + if cursorrules_microagent: + microagents.append(cursorrules_microagent) + + # Step 2: Check for microagents directory and process .md files + directory_microagents = await self._process_microagents_directory( + repository, microagents_path + ) + microagents.extend(directory_microagents) + + return microagents + class GitService(Protocol): """Protocol defining the interface for Git service providers""" @@ -248,3 +481,17 @@ class GitService(Protocol): async def get_branches(self, repository: str) -> list[Branch]: """Get branches for a repository""" + + async def get_microagents(self, repository: str) -> list[MicroagentResponse]: + """Get microagents from a repository""" + ... + + async def get_microagent_content( + self, repository: str, file_path: str + ) -> MicroagentContentResponse: + """Get content of a specific microagent file + + Returns: + MicroagentContentResponse with parsed content and triggers + """ + ... diff --git a/openhands/microagent/types.py b/openhands/microagent/types.py index 8817f66f54..276876172e 100644 --- a/openhands/microagent/types.py +++ b/openhands/microagent/types.py @@ -1,3 +1,4 @@ +from datetime import datetime from enum import Enum from pydantic import BaseModel, Field @@ -34,3 +35,25 @@ class MicroagentMetadata(BaseModel): mcp_tools: MCPConfig | None = ( None # optional, for microagents that provide additional MCP tools ) + + +class MicroagentResponse(BaseModel): + """Response model for microagents endpoint. + + Note: This model only includes basic metadata that can be determined + without parsing microagent content. Use the separate content API + to get detailed microagent information. + """ + + name: str + path: str + created_at: datetime + + +class MicroagentContentResponse(BaseModel): + """Response model for individual microagent content endpoint.""" + + content: str + path: str + triggers: list[str] = [] + git_provider: str | None = None diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py index afce241b87..c25f0dedb5 100644 --- a/openhands/server/routes/git.py +++ b/openhands/server/routes/git.py @@ -1,15 +1,9 @@ -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 import APIRouter, Depends, Query, status from fastapi.responses import JSONResponse -from pydantic import BaseModel, SecretStr +from pydantic import SecretStr from openhands.core.logger import openhands_logger as logger from openhands.integrations.provider import ( @@ -19,14 +13,15 @@ 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.microagent.types import ( + MicroagentContentResponse, + MicroagentResponse, +) from openhands.server.dependencies import get_dependencies from openhands.server.shared import server_config from openhands.server.user_auth import ( @@ -243,144 +238,6 @@ async def get_repository_branches( ) -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 - path: str # Path to the microagent in the Git provider (e.g., ".openhands/microagents/tell-me-a-joke") - - -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. @@ -393,99 +250,6 @@ def _extract_repo_name(repository_name: str) -> str: 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, - path=str(agent_file_path.relative_to(repo_dir)), - ) - ) - - # 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, - path=str(agent_file_path.relative_to(repo_dir)), - ) - ) - - logger.info(f'Found {len(microagents)} microagents in {repository_name}') - return microagents - - @app.get( '/repository/{repository_name:path}/microagents', response_model=list[MicroagentResponse], @@ -503,6 +267,9 @@ async def get_repository_microagents( - If git provider is GitLab and actual repository name is "openhands-config": scans "microagents" folder - Otherwise: scans ".openhands/microagents" folder + Note: This API returns microagent metadata without content for performance. + Use the separate content API to fetch individual microagent content. + Args: repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo' provider_tokens: Provider tokens for authentication @@ -510,33 +277,21 @@ async def get_repository_microagents( user_id: User ID for authentication Returns: - List of microagents found in the repository's microagents directory + List of microagents found in the repository's microagents directory (without content) """ - 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 + # Create provider handler for API authentication 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 - ) + # Fetch microagents using the provider handler + microagents = await provider_handler.get_microagents(repository_name) + logger.info(f'Found {len(microagents)} microagents in {repository_name}') return microagents except AuthenticationError as e: @@ -563,7 +318,76 @@ async def get_repository_microagents( 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) + +@app.get( + '/repository/{repository_name:path}/microagents/content', + response_model=MicroagentContentResponse, +) +async def get_repository_microagent_content( + repository_name: str, + file_path: str = Query( + ..., description='Path to the microagent file within the repository' + ), + 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), +) -> MicroagentContentResponse | JSONResponse: + """Fetch the content of a specific microagent file from a repository. + + Args: + repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo' + file_path: Query parameter - Path to the microagent file within the repository + provider_tokens: Provider tokens for authentication + access_token: Access token for external authentication + user_id: User ID for authentication + + Returns: + Microagent file content and metadata + + Example: + GET /api/user/repository/owner/repo/microagents/content?file_path=.openhands/microagents/my-agent.md + """ + try: + # Create provider handler for API authentication + provider_handler = ProviderHandler( + provider_tokens=provider_tokens + or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})), + external_auth_token=access_token, + external_auth_id=user_id, + ) + + # Fetch file content using the provider handler + response = await provider_handler.get_microagent_content( + repository_name, file_path + ) + + logger.info( + f'Retrieved content for microagent {file_path} from {repository_name}' + ) + + return response + + 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 fetching microagent content from {repository_name}/{file_path}: {str(e)}', + exc_info=True, + ) + return JSONResponse( + content=f'Error fetching microagent content: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/tests/unit/test_get_repository_microagents.py b/tests/unit/test_get_repository_microagents.py index bf26028152..1194ff6f6c 100644 --- a/tests/unit/test_get_repository_microagents.py +++ b/tests/unit/test_get_repository_microagents.py @@ -1,24 +1,18 @@ -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 +from unittest.mock import AsyncMock, patch +from urllib.parse import quote 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.microagent.types import MicroagentContentResponse from openhands.server.routes.git import app as git_app from openhands.server.user_auth import ( get_access_token, @@ -33,120 +27,78 @@ def test_client(): 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' - ) - } - ) + # Override the FastAPI dependencies directly + def mock_get_provider_tokens(): + 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('bb_test_token'), host='bitbucket.org' + ), + } + ) - def mock_get_access_token(): - return None + def mock_get_access_token(): + return None - def mock_get_user_id(): - return 'test_user' + 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 + # 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) + 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' - ), - } +def mock_github_repository(): + """Create a mock GitHub 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_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='.openhands/microagents/test_repo_agent.md', - type=MicroagentType.REPO_KNOWLEDGE, +def mock_gitlab_repository(): + """Create a mock GitLab repository for testing.""" + return Repository( + id='123456', + full_name='test/repo', + git_provider=ProviderType.GITLAB, + is_public=True, + stargazers_count=100, ) @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='.openhands/microagents/test_knowledge_agent.md', - type=MicroagentType.KNOWLEDGE, - triggers=['test', 'knowledge', 'search'], +def mock_bitbucket_repository(): + """Create a mock Bitbucket repository for testing.""" + return Repository( + id='123456', + full_name='test/repo', + git_provider=ProviderType.BITBUCKET, + is_public=True, + stargazers_count=100, ) @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 +def sample_microagent_content(): + """Sample microagent file content.""" + return """--- +name: test_agent +type: repo inputs: - name: query type: str @@ -159,197 +111,51 @@ mcp_tools: command: editor --- -This is a test repository microagent for testing purposes. -""" - ) +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 + +@pytest.fixture +def sample_cursorrules_content(): + """Sample .cursorrules file content.""" + return """--- +name: cursor_rules +type: repo --- -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}, - ) +These are cursor rules for the repository.""" 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( + async def test_get_microagents_github_success( self, - mock_provider_handler_class, - mock_subprocess_run, - mock_load_microagents, - mock_mkdtemp, - mock_get_file_creation_time, + mock_provider_handler_cls, test_client, - mock_provider_tokens, - mock_repo_microagent, - mock_knowledge_microagent, - temp_microagents_dir, - mock_microagents_data, + mock_github_repository, + sample_microagent_content, + sample_cursorrules_content, ): - """Test successful retrieval of microagents from a repository.""" + """Test successful retrieval of microagents from GitHub repository.""" # Setup mocks - test_client.app.dependency_overrides[get_provider_tokens] = ( - lambda: mock_provider_tokens - ) + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler - 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 - - # Create mock microagents with proper absolute paths - repo_agent_with_path = 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=str( - Path(temp_microagents_dir) - / 'repo' - / '.openhands' - / 'microagents' - / 'test_repo_agent.md' - ), - type=MicroagentType.REPO_KNOWLEDGE, - ) - - knowledge_agent_with_path = 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=str( - Path(temp_microagents_dir) - / 'repo' - / '.openhands' - / 'microagents' - / 'test_knowledge_agent.md' - ), - type=MicroagentType.KNOWLEDGE, - triggers=['test', 'knowledge', 'search'], - ) - - mock_microagents_data_with_paths = ( - {'test_repo_agent': repo_agent_with_path}, - {'test_knowledge_agent': knowledge_agent_with_path}, - ) - - mock_load_microagents.return_value = mock_microagents_data_with_paths - mock_mkdtemp.return_value = temp_microagents_dir + # Mock the get_microagents method to return sample data + mock_provider_handler.get_microagents.return_value = [ + { + 'name': 'test_agent', + 'path': '.openhands/microagents/test_agent.md', + 'created_at': '2024-01-01T00:00:00', + }, + { + 'name': 'cursorrules', + 'path': '.cursorrules', + 'created_at': '2024-01-01T00:00:00', + }, + ] # Execute test response = test_client.get('/api/user/repository/test/repo/microagents') @@ -357,60 +163,123 @@ class TestGetRepositoryMicroagents: # Assertions assert response.status_code == 200 data = response.json() - assert len(data) == 2 + assert len(data) == 2 # .cursorrules + 1 .md file - # 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' - assert 'path' in repo_agent - assert repo_agent['path'] == '.openhands/microagents/test_repo_agent.md' + # Check that basic fields are present (content is excluded for performance) + for microagent in data: + assert 'name' in microagent + assert 'path' in microagent + assert 'created_at' in microagent + # Content field should not be present in listing API + assert 'content' not in microagent + # Type and other detailed fields are no longer included in listing API + assert 'type' not in microagent + assert 'triggers' not in microagent + assert 'inputs' not in microagent + assert 'tools' not in microagent - # 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' - assert 'path' in knowledge_agent - assert ( - knowledge_agent['path'] == '.openhands/microagents/test_knowledge_agent.md' - ) + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_gitlab_success( + self, + mock_provider_handler_cls, + test_client, + mock_gitlab_repository, + ): + """Test successful retrieval of microagents from GitLab repository.""" + # Setup mocks + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler + + # Mock the get_microagents method to return sample data + mock_provider_handler.get_microagents.return_value = [ + { + 'name': 'test_agent', + 'path': '.openhands/microagents/test_agent.md', + 'created_at': '2024-01-01T00:00:00', + } + ] + + # 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 # Only 1 .md file + assert 'content' not in data[0] # Content should not be present in listing API + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_bitbucket_success( + self, + mock_provider_handler_cls, + test_client, + mock_bitbucket_repository, + ): + """Test successful retrieval of microagents from Bitbucket repository.""" + # Setup mocks + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler + + # Mock the get_microagents method to return sample data + mock_provider_handler.get_microagents.return_value = [ + { + 'name': 'test_agent', + 'path': '.openhands/microagents/test_agent.md', + 'created_at': '2024-01-01T00:00:00', + } + ] + + # 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 # Only 1 .md file + assert 'content' not in data[0] # Content should not be present in listing API + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_no_directory_found( + self, + mock_provider_handler_cls, + test_client, + mock_github_repository, + ): + """Test when microagents directory is not found.""" + # Setup mocks + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler + + # Mock the get_microagents method to return empty list + mock_provider_handler.get_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.ProviderHandler') async def test_get_microagents_authentication_error( - self, mock_provider_handler_class, test_client, mock_provider_tokens + self, + mock_provider_handler_cls, + test_client, ): - """Test authentication error when verifying repository.""" + """Test authentication error.""" # Setup mocks - test_client.app.dependency_overrides[get_provider_tokens] = ( - lambda: mock_provider_tokens - ) + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler - mock_provider_handler = MagicMock() - mock_provider_handler.verify_repo_provider = AsyncMock( - side_effect=AuthenticationError('Invalid credentials') + # Mock the get_microagents method to raise AuthenticationError + mock_provider_handler.get_microagents.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') @@ -419,755 +288,276 @@ class TestGetRepositoryMicroagents: assert response.status_code == 401 assert response.json() == 'Invalid credentials' + +class TestGetRepositoryMicroagentContent: + """Test cases for the get_repository_microagent_content API endpoint.""" + @pytest.mark.asyncio - @patch('openhands.server.routes.git.subprocess.run') @patch('openhands.server.routes.git.ProviderHandler') - async def test_get_microagents_clone_failure( + async def test_get_microagent_content_github_success( self, - mock_provider_handler_class, - mock_subprocess_run, + mock_provider_handler_cls, test_client, - mock_provider_tokens, + sample_microagent_content, ): - """Test error when git clone fails.""" + """Test successful retrieval of microagent content from GitHub.""" # 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) - - # Create mock microagents with proper absolute paths - repo_agent_with_path = 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=str( - Path(temp_dir) - / 'repo' - / '.openhands' - / 'microagents' - / 'test_repo_agent.md' - ), - type=MicroagentType.REPO_KNOWLEDGE, - ) - - mock_repo_agents = {'test_repo_agent': repo_agent_with_path} - 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' - assert 'path' in data[0] - assert data[0]['path'] == '.openhands/microagents/test_repo_agent.md' - 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) - - # Create mock microagents with proper absolute paths - repo_agent_with_path = 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=str( - Path(temp_dir) - / 'repo' - / '.openhands' - / 'microagents' - / 'test_repo_agent.md' - ), - type=MicroagentType.REPO_KNOWLEDGE, - ) - - mock_repo_agents = {'test_repo_agent': repo_agent_with_path} - 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' - assert 'path' in data[0] - assert data[0]['path'] == '.openhands/microagents/test_repo_agent.md' - 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 - ) - - 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) - - # 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=str( - Path(temp_dir) - / 'repo' - / '.openhands' - / 'microagents' - / 'simple_agent.md' - ), - type=MicroagentType.REPO_KNOWLEDGE, - ) - - # 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' - assert 'path' in data[0] - assert data[0]['path'] == '.openhands/microagents/simple_agent.md' - 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_path_field_variations( - self, - mock_provider_handler_class, - mock_subprocess_run, - mock_load_microagents, - mock_mkdtemp, - mock_get_file_creation_time, - test_client, - mock_provider_tokens, - ): - """Test path field with different microagent file locations and structures.""" - # 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 - - # 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) - - # Create microagents with different source paths - repo_microagent_deep = RepoMicroagent( - name='deep_agent', - content='Agent in nested directory', - metadata=MicroagentMetadata( - name='deep_agent', - type=MicroagentType.REPO_KNOWLEDGE, - inputs=[], - mcp_tools=None, - ), - source=str( - Path(temp_dir) - / 'repo' - / '.openhands' - / 'microagents' - / 'nested' - / 'deep_agent.md' - ), - type=MicroagentType.REPO_KNOWLEDGE, - ) - - knowledge_microagent_root = KnowledgeMicroagent( - name='root_agent', - content='Agent in root microagents directory', - metadata=MicroagentMetadata( - name='root_agent', - type=MicroagentType.KNOWLEDGE, - inputs=[], - mcp_tools=None, - ), - source=str( - Path(temp_dir) / 'repo' / '.openhands' / 'microagents' / 'root_agent.md' - ), - type=MicroagentType.KNOWLEDGE, - triggers=[], - ) - - # Mock load_microagents_from_dir - mock_repo_agents = {'deep_agent': repo_microagent_deep} - mock_knowledge_agents = {'root_agent': knowledge_microagent_root} - 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) == 2 - - # Check repo microagent with nested path - repo_agent = next(m for m in data if m['name'] == 'deep_agent') - assert repo_agent['type'] == 'repo' - assert 'path' in repo_agent - assert repo_agent['path'] == '.openhands/microagents/nested/deep_agent.md' - - # Check knowledge microagent with root path - knowledge_agent = next(m for m in data if m['name'] == 'root_agent') - assert knowledge_agent['type'] == 'knowledge' - assert 'path' in knowledge_agent - assert knowledge_agent['path'] == '.openhands/microagents/root_agent.md' - - 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_path_field_gitlab_structure( - self, - mock_provider_handler_class, - mock_subprocess_run, - mock_load_microagents, - mock_mkdtemp, - mock_get_file_creation_time, - test_client, - mock_provider_tokens, - ): - """Test path field with GitLab repository structure (openhands-config).""" - # Setup mocks with GitLab provider - provider_tokens = MappingProxyType( - { - ProviderType.GITLAB: ProviderToken( - token=SecretStr('glpat_test_token'), host='gitlab.com' - ) - } - ) - test_client.app.dependency_overrides[get_provider_tokens] = ( - lambda: provider_tokens - ) - - mock_provider_handler = MagicMock() - mock_repository = Repository( - id='123456', - full_name='test/openhands-config', - git_provider=ProviderType.GITLAB, - 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://glpat_test_token@gitlab.com/test/openhands-config.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 GitLab structure - temp_dir = tempfile.mkdtemp() - microagents_dir = Path(temp_dir) / 'repo' / 'microagents' - microagents_dir.mkdir(parents=True, exist_ok=True) - - # Create microagent for GitLab structure - repo_microagent = RepoMicroagent( - name='gitlab_agent', - content='Agent in GitLab repository', - metadata=MicroagentMetadata( - name='gitlab_agent', - type=MicroagentType.REPO_KNOWLEDGE, - inputs=[], - mcp_tools=None, - ), - source=str(Path(temp_dir) / 'repo' / 'microagents' / 'gitlab_agent.md'), - type=MicroagentType.REPO_KNOWLEDGE, - ) - - # Mock load_microagents_from_dir - mock_repo_agents = {'gitlab_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/openhands-config/microagents' + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler + + # Mock the get_microagent_content method + mock_provider_handler.get_microagent_content.return_value = ( + MicroagentContentResponse( + content=sample_microagent_content, + path='.openhands/microagents/test_agent.md', + triggers=['test', 'agent'], ) + ) - # Assertions - assert response.status_code == 200 - data = response.json() - assert len(data) == 1 - assert data[0]['name'] == 'gitlab_agent' - assert data[0]['type'] == 'repo' - assert 'path' in data[0] - assert data[0]['path'] == 'microagents/gitlab_agent.md' - assert 'git_provider' in data[0] - assert data[0]['git_provider'] == 'gitlab' + # Execute test + file_path = '.openhands/microagents/test_agent.md' + response = test_client.get( + f'/api/user/repository/test/repo/microagents/content?file_path={quote(file_path)}' + ) - finally: - shutil.rmtree(temp_dir, ignore_errors=True) + # Assertions + assert response.status_code == 200 + data = response.json() + assert 'content' in data + assert data['content'] == sample_microagent_content + assert data['path'] == file_path + assert 'triggers' in data + assert data['triggers'] == ['test', 'agent'] + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagent_content_gitlab_success( + self, + mock_provider_handler_cls, + test_client, + sample_microagent_content, + ): + """Test successful retrieval of microagent content from GitLab.""" + # Setup mocks + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler + + # Mock the get_microagent_content method + mock_provider_handler.get_microagent_content.return_value = ( + MicroagentContentResponse( + content=sample_microagent_content, + path='.openhands/microagents/test_agent.md', + triggers=['test', 'agent'], + ) + ) + + # Execute test + file_path = '.openhands/microagents/test_agent.md' + response = test_client.get( + f'/api/user/repository/test/repo/microagents/content?file_path={quote(file_path)}' + ) + + # Assertions + assert response.status_code == 200 + data = response.json() + assert data['content'] == sample_microagent_content + assert data['path'] == file_path + assert 'triggers' in data + assert data['triggers'] == ['test', 'agent'] + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagent_content_bitbucket_success( + self, + mock_provider_handler_cls, + test_client, + sample_microagent_content, + ): + """Test successful retrieval of microagent content from Bitbucket.""" + # Setup mocks + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler + + # Mock the get_microagent_content method + mock_provider_handler.get_microagent_content.return_value = ( + MicroagentContentResponse( + content=sample_microagent_content, + path='.openhands/microagents/test_agent.md', + triggers=['test', 'agent'], + ) + ) + + # Execute test + file_path = '.openhands/microagents/test_agent.md' + response = test_client.get( + f'/api/user/repository/test/repo/microagents/content?file_path={quote(file_path)}' + ) + + # Assertions + assert response.status_code == 200 + data = response.json() + assert data['content'] == sample_microagent_content + assert data['path'] == file_path + assert 'triggers' in data + assert data['triggers'] == ['test', 'agent'] + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagent_content_file_not_found( + self, + mock_provider_handler_cls, + test_client, + mock_github_repository, + ): + """Test when microagent file is not found.""" + # Setup mocks + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler + + # Mock the get_microagent_content method to raise RuntimeError + mock_provider_handler.get_microagent_content.side_effect = RuntimeError( + 'File not found' + ) + + # Execute test + file_path = '.openhands/microagents/nonexistent.md' + response = test_client.get( + f'/api/user/repository/test/repo/microagents/content?file_path={quote(file_path)}' + ) + + # Assertions + assert response.status_code == 500 + assert 'File not found' in response.json() + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagent_content_authentication_error( + self, + mock_provider_handler_cls, + test_client, + ): + """Test authentication error for content API.""" + # Setup mocks + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler + + # Mock the get_microagent_content method to raise AuthenticationError + mock_provider_handler.get_microagent_content.side_effect = AuthenticationError( + 'Invalid credentials' + ) + + # Execute test + file_path = '.openhands/microagents/test_agent.md' + response = test_client.get( + f'/api/user/repository/test/repo/microagents/content?file_path={quote(file_path)}' + ) + + # Assertions + assert response.status_code == 401 + assert response.json() == 'Invalid credentials' + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagent_content_cursorrules( + self, + mock_provider_handler_cls, + test_client, + sample_cursorrules_content, + ): + """Test retrieval of .cursorrules file content.""" + # Setup mocks + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler + + # Mock the get_microagent_content method + mock_provider_handler.get_microagent_content.return_value = ( + MicroagentContentResponse( + content=sample_cursorrules_content, + path='.cursorrules', + triggers=['cursor', 'rules'], + ) + ) + + # Execute test + file_path = '.cursorrules' + response = test_client.get( + f'/api/user/repository/test/repo/microagents/content?file_path={quote(file_path)}' + ) + + # Assertions + assert response.status_code == 200 + data = response.json() + assert data['content'] == sample_cursorrules_content + assert data['path'] == file_path + assert 'triggers' in data + assert data['triggers'] == ['cursor', 'rules'] + + +class TestSpecialRepositoryStructures: + """Test cases for special repository structures.""" + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_openhands_repo_structure( + self, + mock_provider_handler_cls, + test_client, + ): + """Test microagents from .openhands repository structure.""" + # Setup mocks + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler + + # Mock the get_microagents method to return sample data for .openhands repo + mock_provider_handler.get_microagents.return_value = [ + { + 'name': 'test_agent', + 'path': 'microagents/test_agent.md', # Should be in microagents folder, not .openhands/microagents + 'created_at': '2024-01-01T00:00:00', + } + ] + + # Execute test + response = test_client.get('/api/user/repository/test/.openhands/microagents') + + # Assertions + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert ( + data[0]['path'] == 'microagents/test_agent.md' + ) # Should be in microagents folder, not .openhands/microagents + + @pytest.mark.asyncio + @patch('openhands.server.routes.git.ProviderHandler') + async def test_get_microagents_gitlab_openhands_config_structure( + self, + mock_provider_handler_cls, + test_client, + ): + """Test microagents from GitLab openhands-config repository structure.""" + # Setup mocks + mock_provider_handler = AsyncMock() + mock_provider_handler_cls.return_value = mock_provider_handler + + # Mock the get_microagents method to return sample data for openhands-config repo + mock_provider_handler.get_microagents.return_value = [ + { + 'name': 'test_agent', + 'path': 'microagents/test_agent.md', # Should be in microagents folder, not .openhands/microagents + 'created_at': '2024-01-01T00:00:00', + } + ] + + # Execute test + response = test_client.get( + '/api/user/repository/test/openhands-config/microagents' + ) + + # Assertions + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert ( + data[0]['path'] == 'microagents/test_agent.md' + ) # Should be in microagents folder, not .openhands/microagents