Compare commits

..

17 Commits

Author SHA1 Message Date
enyst a4fb254974 Remove old SaaS extension example
The old example used environment variables which goes against
the new factory-based approach. The new external_repo_extension.py
example shows the proper way to extend OpenHands.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-02 21:44:53 +00:00
enyst ad54be39c3 Implement factory-based extensibility system
Replace environment variable configuration with factory pattern:

- Add openhands.server.factory.create_openhands_app() for clean extensibility
- Remove environment variable dependencies from context_provider.py
- Simplify app.py to use factory pattern for backward compatibility
- Create comprehensive external repo extension example
- Add detailed migration guide for external repositories

Key benefits:
- External repos can extend OpenHands without environment variables
- Clean dependency injection through FastAPI factory pattern
- No import-time side effects or global state conflicts
- Proper separation between OpenHands core and extensions
- Easy testing and mocking of contexts

External repos can now do:
  app = create_openhands_app(context_factory=MyContext)

Instead of setting environment variables before import.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-02 21:44:40 +00:00
enyst 955741dce3 Fix code quality issues in context system
- Remove global variable _context_class, use environment variable instead
- Fix circular import by moving ServerContext import to top level
- Update documentation to use 'extensibility' instead of 'SaaS-specific'
- Rename example class from SaaSServerContext to CustomServerContext
- Use OPENHANDS_SERVER_CONTEXT_CLASS environment variable for configuration

This addresses the code smells identified in the review:
1. No more global variables - uses standard environment variable pattern
2. No more circular imports - clean module organization
3. More generic terminology for broader extensibility

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-02 21:33:01 +00:00
enyst 34265ec6fd Refactor server globals to ServerContext system for SaaS extensibility
- Replace global variables in shared.py with dependency injection pattern
- Add ServerContext abstract base class with 9 dependency methods
- Implement DefaultServerContext maintaining exact backward compatibility
- Add context provider system with get_server_context() for FastAPI
- Enable SaaS extensibility with custom context implementations
- Maintain full backward compatibility using lazy loading with __getattr__
- Add comprehensive SaaS extension example with multi-tenant support
- Include detailed migration guide and documentation
- All existing code continues to work with deprecation warnings

This enables SaaS implementations to run from external repos without
import-time dependencies or CI/CD issues while providing clean
multi-tenant architecture.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-02 21:20:22 +00:00
Hiep Le 49d37119a9 chore(frontend): remove feature flag (microagent management) (#10769) 2025-09-02 19:46:09 +00:00
Jamie Chicago cfd416c29f feat: update welcome-good-first-issue.yml (#10766) 2025-09-02 19:41:58 +00:00
Ray Myers c052dd7da5 chore - Update license for enterprise folder (#10761) 2025-09-02 18:48:45 +00:00
Ryan H. Tran 3f77b8229a Add support for AGENTS.md files in microagent system (#10528)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-03 02:18:38 +08:00
Tim O'Farrell 8d13c9f328 UI for determining if llm options are enabled (#10665)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-09-02 12:09:55 -06:00
mamoodi f46b112f17 Add more troubleshooting for linux (#10704) 2025-09-02 14:02:16 -04:00
mamoodi 44dc7f9e9b Release 0.55.0 (#10657) 2025-09-02 13:49:02 -04:00
Hiep Le 00eaa7a6e1 refactor(frontend): remove the branch dropdown from the learn this repo modal (microagent management) (#10755) 2025-09-02 22:34:00 +07:00
Hiep Le 9f1d6963b8 feat(frontend): support pagination when loading repositories (microagent management) (#10708) 2025-09-02 17:03:58 +04:00
Rohit Malhotra f61fa93596 Fix fragile URL parsing in Bitbucket service search_repositories method (#10733)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-01 02:59:24 -04:00
Rohit Malhotra 3e87c08631 refactor: introduce HTTPClient protocol for git service integrations (#10731)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-01 01:44:31 -04:00
Rohit Malhotra 21f3ef540f refactor: Apply GitHub mixins pattern to BitBucket service (#10728)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-31 16:09:41 -04:00
Rohit Malhotra 61a93d010c Refactor GitLab service into modular mixins pattern (#10727)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-31 19:10:40 +00:00
80 changed files with 4864 additions and 2291 deletions
@@ -45,6 +45,7 @@ jobs:
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
"Feel free to join our developer community on [Slack](dub.sh/openhands). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
"🙌 Happy hacking! 🙌\n\n" +
"<!-- auto-comment:good-first-issue -->"
});
+1 -1
View File
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.54-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.55-nikolaik`
## Develop inside Docker container
+7 -2
View File
@@ -1,7 +1,12 @@
The MIT License (MIT)
Portions of this software are licensed as follows:
* All content that resides under the enterprise/ directory is licensed under the license defined in "enterprise/LICENSE".
* Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below.
=====================
Copyright © 2023
The MIT License (MIT)
Copyright © 2025
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
+277
View File
@@ -0,0 +1,277 @@
# Migration Guide: From Shared Globals to Context System
This guide explains how to migrate from the deprecated `openhands.server.shared` globals to the new context system.
## Overview
The new context system replaces global variables with dependency injection, providing:
- **Better testability**: Easy to mock dependencies in tests
- **SaaS extensibility**: Custom contexts for multi-tenant scenarios
- **Per-request contexts**: Different configurations per request
- **No import-time side effects**: Lazy initialization of dependencies
- **Type safety**: Better IDE support and type checking
## Quick Migration
### Before (Deprecated)
```python
from openhands.server.shared import config, server_config, file_store, sio
def my_function():
# Use global variables
workspace_dir = config.workspace_dir
app_mode = server_config.app_mode
file_store.save_file(...)
```
### After (Recommended)
```python
from fastapi import Depends, Request
from openhands.server.context import get_server_context, ServerContext
@app.get('/my-endpoint')
async def my_endpoint(
request: Request,
context: ServerContext = Depends(get_server_context)
):
# Use context instead of globals
config = context.get_config()
server_config = context.get_server_config()
file_store = context.get_file_store()
workspace_dir = config.workspace_dir
app_mode = server_config.app_mode
file_store.save_file(...)
```
## Detailed Migration Steps
### 1. Route Handlers
**Before:**
```python
from openhands.server.shared import config, conversation_manager
@app.post('/conversations')
async def create_conversation(request: ConversationRequest):
conversation = conversation_manager.create_conversation(
request.user_id,
config.default_agent
)
return conversation
```
**After:**
```python
from fastapi import Depends
from openhands.server.context import get_server_context, ServerContext
@app.post('/conversations')
async def create_conversation(
request: ConversationRequest,
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
conversation_manager = context.get_conversation_manager()
conversation = conversation_manager.create_conversation(
request.user_id,
config.default_agent
)
return conversation
```
### 2. Service Classes
**Before:**
```python
from openhands.server.shared import file_store, monitoring_listener
class MyService:
def process_file(self, file_path: str):
content = file_store.read(file_path)
monitoring_listener.log_event('file_processed')
return content
```
**After:**
```python
from openhands.server.context import ServerContext
class MyService:
def __init__(self, context: ServerContext):
self.context = context
def process_file(self, file_path: str):
file_store = self.context.get_file_store()
monitoring_listener = self.context.get_monitoring_listener()
content = file_store.read(file_path)
monitoring_listener.log_event('file_processed')
return content
# In route handler:
@app.post('/process')
async def process_endpoint(
request: ProcessRequest,
context: ServerContext = Depends(get_server_context)
):
service = MyService(context)
return service.process_file(request.file_path)
```
### 3. Store Classes
**Before:**
```python
from openhands.server.shared import SettingsStoreImpl
def get_user_settings(user_id: str):
store = SettingsStoreImpl(user_id)
return store.load()
```
**After:**
```python
from openhands.server.context import ServerContext
def get_user_settings(user_id: str, context: ServerContext):
SettingsStoreClass = context.get_settings_store_class()
store = SettingsStoreClass(user_id)
return store.load()
# In route handler:
@app.get('/settings/{user_id}')
async def get_settings(
user_id: str,
context: ServerContext = Depends(get_server_context)
):
return get_user_settings(user_id, context)
```
### 4. Testing
**Before:**
```python
import pytest
from unittest.mock import patch
def test_my_function():
with patch('openhands.server.shared.config') as mock_config:
mock_config.workspace_dir = '/test'
result = my_function()
assert result == expected
```
**After:**
```python
import pytest
from openhands.server.context import create_server_context
class MockServerContext:
def get_config(self):
mock_config = Mock()
mock_config.workspace_dir = '/test'
return mock_config
def test_my_function():
context = MockServerContext()
result = my_function(context)
assert result == expected
```
## SaaS Extension Example
The new context system makes it easy to extend OpenHands for SaaS scenarios:
```python
from openhands.server.context import ServerContext, set_context_class
class SaaSServerContext(ServerContext):
def __init__(self, user_id: str, org_id: str):
self.user_id = user_id
self.org_id = org_id
def get_file_store(self):
# Return tenant-isolated file store
return MultiTenantFileStore(self.user_id, self.org_id)
def get_server_config(self):
# Return SaaS-specific configuration
return SaaSServerConfig(org_id=self.org_id)
# Configure globally
set_context_class('myapp.context.SaaSServerContext')
# Use in routes with tenant context
@app.get('/tenant/{org_id}/files')
async def get_tenant_files(
org_id: str,
context: SaaSServerContext = Depends(get_server_context)
):
file_store = context.get_file_store()
return file_store.list_files()
```
## Migration Checklist
- [ ] Replace `from openhands.server.shared import ...` with context injection
- [ ] Update route handlers to use `Depends(get_server_context)`
- [ ] Modify service classes to accept `ServerContext` parameter
- [ ] Update tests to use mock contexts instead of patching globals
- [ ] Remove direct imports of shared globals
- [ ] Test that all functionality still works
## Backward Compatibility
The old `openhands.server.shared` module still works but is deprecated. It will show deprecation warnings when imported. The globals are now implemented using the new context system internally.
## Benefits After Migration
1. **Better Testing**: Easy to mock dependencies without patching globals
2. **Type Safety**: Better IDE support and type checking
3. **Extensibility**: Easy to create custom contexts for different scenarios
4. **Performance**: Lazy initialization reduces startup time
5. **Maintainability**: Clear dependency relationships
## Common Issues
### Issue: Import errors during migration
**Solution**: Make sure to import the context system correctly:
```python
from openhands.server.context import get_server_context, ServerContext
```
### Issue: Context not available in non-route functions
**Solution**: Pass the context as a parameter:
```python
def helper_function(data: str, context: ServerContext):
config = context.get_config()
# ... use config
```
### Issue: Testing becomes more complex
**Solution**: Create reusable mock contexts:
```python
# test_utils.py
class TestServerContext(ServerContext):
def __init__(self):
self.mock_config = create_mock_config()
self.mock_file_store = create_mock_file_store()
def get_config(self):
return self.mock_config
def get_file_store(self):
return self.mock_file_store
```
## Getting Help
If you encounter issues during migration:
1. Check the examples in `examples/saas_extension.py`
2. Look at the implementation in `openhands/server/context/`
3. Review existing route handlers that have been migrated
4. Create an issue if you find bugs or need clarification
+3 -3
View File
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.54
docker.all-hands.dev/all-hands-ai/openhands:0.55
```
</details>
+3 -3
View File
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.54
docker.all-hands.dev/all-hands-ai/openhands:0.55
```
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
+3 -3
View File
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.54
docker.all-hands.dev/all-hands-ai/openhands:0.55
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
+230
View File
@@ -0,0 +1,230 @@
# OpenHands Server Context Refactoring Plan
## Problem Statement
The current OpenHands architecture has globals in `server/shared.py` that are initialized at import time based on environment variables. This creates several issues for the SaaS version:
1. **Import-time dependencies**: All globals are created when modules are imported
2. **Hard to extend**: SaaS can't easily override or extend components
3. **CI/CD issues**: Everything depends on env vars being set correctly at import time
4. **Per-user behavior**: Difficult to implement per-user/per-request behavior
5. **Outside repo issues**: Hard to run SaaS from outside repo due to import dependencies
## Current Problematic Globals
From `openhands/server/shared.py`:
- `config: OpenHandsConfig` - Core app configuration
- `server_config: ServerConfig` - Server-specific configuration
- `file_store: FileStore` - File storage implementation
- `sio: socketio.AsyncServer` - Socket.IO server instance
- `conversation_manager` - Conversation management implementation
- `monitoring_listener` - Monitoring implementation
- `SettingsStoreImpl`, `SecretsStoreImpl`, `ConversationStoreImpl` - Storage implementations
## Solution: ServerContext Pattern
### 1. Create ServerContext Base Class
Create `openhands/server/context/server_context.py`:
```python
from abc import ABC, abstractmethod
from typing import Optional
import socketio
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.config.server_config import ServerConfig
from openhands.storage.files import FileStore
# ... other imports
class ServerContext(ABC):
"""Base class for server context that holds all server dependencies.
This replaces the global variables in shared.py and allows for:
- Dependency injection
- Easy extensibility for SaaS
- Per-request contexts
- Testability
"""
def __init__(self):
self._config: Optional[OpenHandsConfig] = None
self._server_config: Optional[ServerConfig] = None
self._file_store: Optional[FileStore] = None
# ... other cached instances
@abstractmethod
def get_config(self) -> OpenHandsConfig:
"""Get the OpenHands configuration"""
@abstractmethod
def get_server_config(self) -> ServerConfig:
"""Get the server configuration"""
@abstractmethod
def get_file_store(self) -> FileStore:
"""Get the file store implementation"""
# ... other abstract methods for all current globals
```
### 2. Create Default Implementation
Create `openhands/server/context/default_server_context.py`:
```python
class DefaultServerContext(ServerContext):
"""Default implementation that maintains current behavior"""
def get_config(self) -> OpenHandsConfig:
if self._config is None:
self._config = load_openhands_config()
return self._config
def get_server_config(self) -> ServerConfig:
if self._server_config is None:
self._server_config = load_server_config()
return self._server_config
# ... implement all methods with current logic
```
### 3. Context Provider System
Create `openhands/server/context/context_provider.py`:
```python
from fastapi import Request
from openhands.utils.import_utils import get_impl
_context_class: Optional[str] = None
def set_context_class(context_class: str):
"""Set the server context class to use"""
global _context_class
_context_class = context_class
async def get_server_context(request: Request) -> ServerContext:
"""Get server context from request, with caching"""
context = getattr(request.state, 'server_context', None)
if context:
return context
# Use configured context class or default
context_cls_name = _context_class or 'openhands.server.context.default_server_context.DefaultServerContext'
context_cls = get_impl(ServerContext, context_cls_name)
context = context_cls()
request.state.server_context = context
return context
```
### 4. Update Shared.py (Backward Compatibility)
Keep `shared.py` for backward compatibility but make it use the context:
```python
# openhands/server/shared.py
from openhands.server.context.default_server_context import DefaultServerContext
# Create default context for backward compatibility
_default_context = DefaultServerContext()
# Expose globals for backward compatibility
config = _default_context.get_config()
server_config = _default_context.get_server_config()
file_store = _default_context.get_file_store()
# ... etc
```
### 5. Update Routes to Use Context
Update all route files to use dependency injection:
```python
# Example: openhands/server/routes/settings.py
from openhands.server.context import get_server_context
@app.get('/settings')
async def get_settings(
request: Request,
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
# ... use config instead of importing from shared
```
## Benefits for SaaS
### 1. Easy Extension
SaaS can create their own context:
```python
# In SaaS repo: saas/server_context.py
from openhands.server.context import ServerContext
class SaaSServerContext(ServerContext):
def get_server_config(self) -> ServerConfig:
# Return SaaS-specific config with enterprise features
return SaaSServerConfig()
def get_conversation_manager(self) -> ConversationManager:
# Return multi-tenant conversation manager
return MultiTenantConversationManager()
```
### 2. Per-Request Contexts
SaaS can implement per-user contexts:
```python
class PerUserServerContext(ServerContext):
def __init__(self, user_id: str, org_id: str):
super().__init__()
self.user_id = user_id
self.org_id = org_id
def get_file_store(self) -> FileStore:
# Return user-specific file store
return UserFileStore(self.user_id, self.org_id)
```
### 3. No Import-Time Dependencies
SaaS can run without setting environment variables at import time:
```python
# In SaaS startup
from openhands.server.context import set_context_class
set_context_class('saas.server_context.SaaSServerContext')
```
## Migration Strategy
### Phase 1: Create Context System
1. Create ServerContext base class and default implementation
2. Create context provider system
3. Update shared.py for backward compatibility
### Phase 2: Update Routes Gradually
1. Update one route at a time to use context injection
2. Test each route to ensure no regressions
3. Keep backward compatibility during transition
### Phase 3: Clean Up
1. Remove globals from shared.py once all routes are updated
2. Update documentation
3. Create examples for SaaS extension
## Implementation Order
1. `openhands/server/context/server_context.py` - Base class
2. `openhands/server/context/default_server_context.py` - Default implementation
3. `openhands/server/context/context_provider.py` - Provider system
4. `openhands/server/context/__init__.py` - Public API
5. Update `openhands/server/shared.py` for backward compatibility
6. Update routes one by one to use context injection
7. Update tests to use context system
8. Documentation and examples
This approach provides a clean migration path while maintaining backward compatibility and enabling the SaaS extensibility requirements.
+206
View File
@@ -0,0 +1,206 @@
# OpenHands Server Globals Refactoring - Summary
## Overview
Successfully refactored OpenHands server globals in `shared.py` and `server_config.py` to enable SaaS extensibility without import-time dependencies. The refactoring introduces a dependency injection pattern using a `ServerContext` system that maintains backward compatibility while enabling multi-tenant SaaS scenarios.
## Problem Solved
### Before Refactoring
- **Global variables on import**: `shared.py` created globals like `config`, `server_config`, `file_store`, `sio`, etc. on module import
- **Import-time side effects**: Loading the module triggered configuration loading and dependency initialization
- **SaaS integration issues**: External SaaS repos had CI/CD problems due to environment variable dependencies
- **Testing difficulties**: Hard to mock dependencies due to global state
- **No extensibility**: Impossible to customize behavior for different tenants or environments
### After Refactoring
- **Dependency injection**: Clean `ServerContext` pattern with lazy initialization
- **No import-time side effects**: Dependencies only loaded when actually needed
- **SaaS extensibility**: Easy to create custom contexts for multi-tenant scenarios
- **Better testability**: Easy to mock contexts for testing
- **Backward compatibility**: Existing code continues to work with deprecation warnings
## Architecture Changes
### New Context System
```
openhands/server/context/
├── __init__.py # Public API
├── server_context.py # Abstract base class
├── default_server_context.py # Default implementation
└── context_provider.py # Dependency injection system
```
### Key Components
1. **ServerContext (Abstract Base Class)**
- Defines interface for all server dependencies
- 9 abstract methods for different dependency types
- Extensible for SaaS implementations
2. **DefaultServerContext**
- Maintains exact behavior of original shared.py
- Lazy initialization of all dependencies
- No import-time side effects
3. **Context Provider System**
- `get_server_context()` for FastAPI dependency injection
- `set_context_class()` for global configuration
- `create_server_context()` for testing/CLI usage
4. **Backward Compatibility Layer**
- `shared.py` now uses `__getattr__` for lazy loading
- All existing imports continue to work
- Deprecation warnings guide migration
## SaaS Extensibility
### Multi-Tenant Context Example
```python
class SaaSServerContext(ServerContext):
def __init__(self, user_id: str, org_id: str):
self.user_id = user_id
self.org_id = org_id
def get_file_store(self):
# Return tenant-isolated file store
return MultiTenantFileStore(self.user_id, self.org_id)
def get_server_config(self):
# Return SaaS-specific configuration
return SaaSServerConfig(org_id=self.org_id)
# Configure globally
set_context_class('myapp.context.SaaSServerContext')
```
### Benefits for SaaS
- **Per-tenant isolation**: Different storage, config, and features per organization
- **Enterprise features**: Easy to add billing, advanced monitoring, etc.
- **Scalable architecture**: Context per request enables horizontal scaling
- **Clean separation**: SaaS code stays in external repo, extends OpenHands cleanly
## Migration Path
### For OpenHands Core
- **Phase 1**: Refactoring complete, backward compatibility maintained
- **Phase 2**: Gradually migrate routes to use dependency injection
- **Phase 3**: Remove deprecated shared.py (future release)
### For SaaS Implementations
- **Immediate**: Can use new context system for new features
- **Gradual**: Migrate existing code using migration guide
- **Benefits**: Cleaner architecture, better testing, easier deployment
## Files Created/Modified
### New Files
- `openhands/server/context/__init__.py` - Public API
- `openhands/server/context/server_context.py` - Abstract base class
- `openhands/server/context/default_server_context.py` - Default implementation
- `openhands/server/context/context_provider.py` - Dependency injection
- `examples/saas_extension.py` - SaaS extension example
- `MIGRATION_GUIDE.md` - Detailed migration instructions
- `test_refactor.py` - Comprehensive test suite
### Modified Files
- `openhands/server/shared.py` - Backward compatibility layer
## Testing Results
Comprehensive test suite with 5 test categories:
1.**Context System**: Import, creation, class switching
2.**Backward Compatibility**: Lazy loading, attribute access
3.**Abstract Base Class**: Proper abstraction, required methods
4.**Default Context**: Instantiation, method availability
5.**SaaS Example**: Multi-tenant context structure
**Result: 5/5 tests passed** 🎉
## Usage Examples
### New Way (Recommended)
```python
from fastapi import Depends
from openhands.server.context import get_server_context, ServerContext
@app.get('/conversations')
async def get_conversations(
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
conversation_manager = context.get_conversation_manager()
return conversation_manager.list_conversations()
```
### Old Way (Still Works)
```python
from openhands.server.shared import config, conversation_manager
@app.get('/conversations')
async def get_conversations():
# Shows deprecation warning but works
return conversation_manager.list_conversations()
```
### SaaS Extension
```python
# In SaaS application startup
from openhands.server.context import set_context_class
set_context_class('myapp.context.SaaSServerContext')
# Routes automatically get tenant-aware context
@app.get('/tenant/{org_id}/conversations')
async def get_tenant_conversations(
org_id: str,
context: SaaSServerContext = Depends(get_server_context)
):
# context.org_id and context.user_id available
# All dependencies are tenant-isolated
conversation_manager = context.get_conversation_manager()
return conversation_manager.list_conversations()
```
## Benefits Achieved
### For OpenHands Core
-**Better Architecture**: Clean dependency injection pattern
-**Improved Testing**: Easy to mock dependencies
-**No Breaking Changes**: Full backward compatibility
-**Performance**: Lazy loading reduces startup time
-**Type Safety**: Better IDE support and type checking
### For SaaS Implementations
-**Multi-Tenancy**: Per-organization contexts and isolation
-**Extensibility**: Easy to add enterprise features
-**Clean Integration**: No need to fork OpenHands
-**Deployment Flexibility**: Can run from external repos
-**CI/CD Fixes**: No more environment variable dependencies
### For Development
-**Maintainability**: Clear dependency relationships
-**Debugging**: Easier to trace dependency issues
-**Documentation**: Clear migration path and examples
-**Future-Proof**: Extensible architecture for new features
## Next Steps
1. **Immediate**: Refactoring is complete and tested
2. **Short-term**: Begin migrating core routes to use dependency injection
3. **Medium-term**: SaaS implementations can adopt new context system
4. **Long-term**: Remove deprecated shared.py in future major release
## Conclusion
The refactoring successfully addresses all the original problems:
-**Import-time dependencies** → ✅ **Lazy initialization**
-**Global state pollution** → ✅ **Clean dependency injection**
-**SaaS integration issues** → ✅ **Multi-tenant context system**
-**Testing difficulties** → ✅ **Easy mocking and testing**
-**No extensibility** → ✅ **Pluggable context implementations**
The new architecture enables OpenHands to support SaaS scenarios while maintaining full backward compatibility and improving the overall codebase quality.
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.54-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.55-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+272
View File
@@ -0,0 +1,272 @@
# OpenHands Extensibility Migration Guide
This guide explains how to migrate from the old global variable approach to the new factory-based extensibility system.
## Overview
OpenHands has been refactored to eliminate import-time dependencies on environment variables and global state. This enables external repositories to cleanly extend OpenHands without configuration conflicts.
## The Problem We Solved
### Before (Problematic)
```python
# In OpenHands shared.py - loaded at import time
config = Config() # Reads environment variables
server_config = ServerConfig() # More environment variables
# External repos had to:
# 1. Set environment variables before importing OpenHands
# 2. Deal with global state conflicts
# 3. Couldn't easily override specific behaviors
```
### After (Clean)
```python
# External repos can now:
from openhands.server.factory import create_openhands_app
app = create_openhands_app(
context_factory=lambda: MyCustomContext(),
include_oss_routes=False
)
```
## Migration Paths
### 1. For External Repositories (Recommended)
**Old Way (Don't do this):**
```python
# external_repo/main.py
import os
os.environ['OPENHANDS_CONFIG_CLS'] = 'my_config.MyConfig'
os.environ['CONVERSATION_MANAGER_CLASS'] = 'my_manager.MyManager'
from openhands.server.app import app # Imports with global state
```
**New Way (Recommended):**
```python
# external_repo/main.py
from openhands.server.factory import create_openhands_app
from external_repo.context import ExternalRepoContext
def create_app():
return create_openhands_app(
context_factory=lambda: ExternalRepoContext(),
include_oss_routes=False, # Skip OSS-specific routes
title='My Enterprise Platform'
)
app = create_app()
# Add your own routes
@app.get('/enterprise/dashboard')
async def dashboard():
return {'status': 'enterprise'}
```
### 2. For OpenHands Core Development
**Old Way:**
```python
# In route handlers
from openhands.server.shared import config, server_config
@app.get('/example')
async def example_route():
storage_path = config.workspace_base
app_mode = server_config.app_mode
```
**New Way:**
```python
# In route handlers
from fastapi import Depends
from openhands.server.context import get_server_context, ServerContext
@app.get('/example')
async def example_route(
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
server_config = context.get_server_config()
storage_path = config.workspace_base
app_mode = server_config.app_mode
```
## Custom Context Implementation
### Step 1: Create Your Context Class
```python
# my_extension/context.py
from openhands.server.context.server_context import ServerContext
class MyCustomContext(ServerContext):
def __init__(self, tenant_id: str = 'default'):
super().__init__()
self.tenant_id = tenant_id
def get_config(self):
"""Override with tenant-specific configuration."""
config = super().get_config()
config.workspace_base = f'/data/tenants/{self.tenant_id}/workspace'
return config
def get_server_config(self):
"""Override server configuration."""
server_config = super().get_server_config()
server_config.app_mode = 'ENTERPRISE'
server_config.enable_billing = True
return server_config
```
### Step 2: Create Your FastAPI App
```python
# my_extension/app.py
from openhands.server.factory import create_openhands_app
from my_extension.context import MyCustomContext
def create_my_app():
# Option A: Extend OpenHands app directly
app = create_openhands_app(
context_factory=lambda: MyCustomContext(),
title='My Enterprise Platform'
)
# Add your routes
@app.get('/enterprise/status')
async def enterprise_status():
return {'mode': 'enterprise'}
return app
# Option B: Create your own app and mount OpenHands
from fastapi import FastAPI
def create_my_app_with_mount():
main_app = FastAPI(title='My Platform')
openhands_app = create_openhands_app(
context_factory=lambda: MyCustomContext()
)
main_app.mount('/openhands', openhands_app)
@main_app.get('/my-dashboard')
async def dashboard():
return {'dashboard': 'data'}
return main_app
```
### Step 3: Run Your Application
```python
# my_extension/main.py
import uvicorn
from my_extension.app import create_my_app
if __name__ == '__main__':
app = create_my_app()
uvicorn.run(app, host='0.0.0.0', port=8000)
```
## Advanced Patterns
### Multi-Tenant Context
```python
class MultiTenantContext(ServerContext):
def __init__(self, request: Request):
super().__init__()
# Extract tenant from request
self.tenant_id = request.headers.get('X-Tenant-ID', 'default')
def get_file_store(self):
# Return tenant-isolated file store
return TenantFileStore(tenant_id=self.tenant_id)
# Use with factory
def create_tenant_context(request: Request):
return MultiTenantContext(request)
app = create_openhands_app(
context_factory=create_tenant_context
)
```
### Custom Lifespan Management
```python
from contextlib import asynccontextmanager
@asynccontextmanager
async def my_lifespan(app: FastAPI):
# Startup
print("Starting my custom services...")
await initialize_my_database()
yield
# Shutdown
print("Shutting down my custom services...")
await cleanup_my_database()
app = create_openhands_app(
context_factory=MyContext,
custom_lifespan=my_lifespan
)
```
## Testing Your Extension
```python
# tests/test_my_extension.py
from fastapi.testclient import TestClient
from my_extension.app import create_my_app
def test_my_extension():
app = create_my_app()
client = TestClient(app)
# Test your custom routes
response = client.get('/enterprise/status')
assert response.status_code == 200
assert response.json()['mode'] == 'enterprise'
# Test OpenHands routes still work
response = client.get('/api/health')
assert response.status_code == 200
```
## Benefits of the New Approach
1. **No Environment Variables**: Configuration is done through code, not environment variables
2. **Clean Separation**: External repos don't modify OpenHands globals
3. **Dependency Injection**: Proper FastAPI dependency injection patterns
4. **Testability**: Easy to mock contexts for testing
5. **Flexibility**: Can create multiple apps with different configurations
6. **No Import-Time Side Effects**: Safe to import OpenHands modules
## Backward Compatibility
The old `openhands.server.shared` module still works but is deprecated. It will show deprecation warnings and should be migrated to the new context system.
## Common Pitfalls
1. **Don't set environment variables**: Use the factory pattern instead
2. **Don't import `openhands.server.app` directly**: Use the factory to create your own app
3. **Don't modify global state**: Use dependency injection through contexts
4. **Don't forget to override dependencies**: Use `app.dependency_overrides` if needed
## Getting Help
If you need help migrating your extension, please:
1. Check the examples in `examples/external_repo_extension.py`
2. Look at the test cases for patterns
3. Open an issue with your specific use case
The new system is designed to be more flexible and maintainable while enabling clean extensibility for all types of OpenHands deployments.
+2 -2
View File
@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -122,7 +122,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
python -m openhands.cli.entry --override-cli-mode true
```
+2 -2
View File
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +73,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+4 -4
View File
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.54
docker.all-hands.dev/all-hands-ai/openhands:0.55
```
2. Wait until the server is running (see log below):
```
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.54
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.55
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
+3 -3
View File
@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
<Accordion title="Docker Command (Click to expand)">
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.54
docker.all-hands.dev/all-hands-ai/openhands:0.55
```
</Accordion>
@@ -46,7 +46,14 @@ When running on Linux, you might run into the error `ERROR:root:<class 'httpx.Co
**Resolution**
* Add the `--network host` to the docker run command.
If you installed Docker from your distributions package repository (e.g., docker.io on Debian/Ubuntu), be aware that
these packages can sometimes be outdated or include changes that cause compatibility issues. try reinstalling Docker
[using the official instructions](https://docs.docker.com/engine/install/) to ensure you are running a compatible version.
If that does not solve the issue, try incrementally adding the following parameters to the docker run command:
* `--network host`
* `-e SANDBOX_USE_HOST_NETWORK=true`
* `-e DOCKER_HOST_ADDR=127.0.0.1`
### Internal Server Error. Ports are not available
+89
View File
@@ -0,0 +1,89 @@
# PolyForm Free Trial License 1.0.0
## Acceptance
In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
## Copyright License
The licensor grants you a copyright license for the software
to do everything you might do with the software that would
otherwise infringe the licensor's copyright in it for any
permitted purpose. However, you may only make changes or
new works based on the software according to [Changes and New
Works License](#changes-and-new-works-license), and you may
not distribute copies of the software.
## Changes and New Works License
The licensor grants you an additional copyright license to
make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## Free Trial
Use of the software for more than 30 days per calendar year is not allowed without a commercial license.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
If you violate any of these terms, or do anything with the
software not covered by your licenses, all your licenses
end immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control of,
or are under common control with that organization. **Control**
means ownership of substantially all the assets of an entity,
or the power to direct its management and policies by vote,
contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
+217
View File
@@ -0,0 +1,217 @@
"""Example of how an external repository can extend OpenHands.
This demonstrates the proper way for external repositories to build upon OpenHands
without relying on environment variables or global state. The external repo can:
1. Create its own FastAPI app with custom context
2. Add its own routes and middleware
3. Include OpenHands routes as needed
4. Override specific behaviors through dependency injection
This approach eliminates the need for environment variable configuration
and allows clean separation between OpenHands core and extensions.
"""
from contextlib import asynccontextmanager
from typing import AsyncIterator, Optional
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from openhands.server.context.server_context import ServerContext
from openhands.server.factory import create_openhands_app
# Step 1: Create your custom ServerContext
class ExternalRepoContext(ServerContext):
"""Custom context for external repository with enterprise features."""
def __init__(self, tenant_id: str = 'default', user_id: Optional[str] = None):
super().__init__()
self.tenant_id = tenant_id
self.user_id = user_id
self._custom_config = None
def get_config(self):
"""Override config with tenant-specific settings."""
config = super().get_config()
# Add tenant-specific configuration
config.update({
'tenant_id': self.tenant_id,
'custom_storage_path': f'/data/tenants/{self.tenant_id}',
'custom_feature_flags': {
'enterprise_features': True,
'advanced_analytics': True,
}
})
return config
def get_server_config(self):
"""Override server config for enterprise deployment."""
server_config = super().get_server_config()
# Customize for enterprise
server_config.app_mode = 'ENTERPRISE' # Custom app mode
server_config.enable_billing = True
server_config.hide_llm_settings = False
return server_config
def get_file_store(self):
"""Use tenant-isolated file storage."""
# In a real implementation, this would return a tenant-aware file store
file_store = super().get_file_store()
# Customize file store for tenant isolation
return file_store
# Step 2: Create your custom lifespan (optional)
@asynccontextmanager
async def external_repo_lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Custom lifespan for external repo initialization."""
print("🚀 Starting external repo services...")
# Initialize your custom services here
# e.g., database connections, external API clients, etc.
yield
print("🛑 Shutting down external repo services...")
# Cleanup your custom services here
# Step 3: Create context factory for your needs
def create_external_context(tenant_id: str = 'default') -> ExternalRepoContext:
"""Factory function to create context instances."""
return ExternalRepoContext(tenant_id=tenant_id)
# Step 4: Create your FastAPI app with OpenHands integration
def create_external_app() -> FastAPI:
"""Create the external repository's FastAPI application."""
# Option A: Create OpenHands app with your custom context
openhands_app = create_openhands_app(
context_factory=lambda: create_external_context(),
include_oss_routes=False, # Skip OSS routes for enterprise
custom_lifespan=external_repo_lifespan,
title='My Enterprise Platform',
description='Enterprise platform built on OpenHands'
)
# Option B: Create your own app and mount OpenHands
main_app = FastAPI(
title='My Enterprise Platform',
description='Enterprise platform with OpenHands integration',
version='1.0.0'
)
# Add your custom routes
@main_app.get('/enterprise/status')
async def enterprise_status():
return {'status': 'running', 'mode': 'enterprise'}
@main_app.get('/enterprise/tenant/{tenant_id}/info')
async def tenant_info(
tenant_id: str,
request: Request,
# Use dependency injection to get context
context: ServerContext = Depends(lambda r: create_external_context(tenant_id))
):
config = context.get_config()
return {
'tenant_id': tenant_id,
'storage_path': config.get('custom_storage_path'),
'features': config.get('custom_feature_flags', {})
}
# Add custom middleware
@main_app.middleware('http')
async def tenant_middleware(request: Request, call_next):
# Extract tenant from header or path
tenant_id = request.headers.get('X-Tenant-ID', 'default')
request.state.tenant_id = tenant_id
response = await call_next(request)
response.headers['X-Tenant-ID'] = tenant_id
return response
# Mount OpenHands app at a subpath
main_app.mount('/openhands', openhands_app)
return main_app
# Step 5: Alternative approach - extend OpenHands app directly
def create_extended_openhands_app() -> FastAPI:
"""Alternative: extend OpenHands app directly with custom routes."""
app = create_openhands_app(
context_factory=lambda: create_external_context(),
custom_lifespan=external_repo_lifespan
)
# Add your routes to the OpenHands app
@app.get('/api/enterprise/dashboard')
async def enterprise_dashboard(
request: Request,
context: ServerContext = Depends(lambda r: create_external_context())
):
config = context.get_config()
return {
'dashboard_data': 'enterprise_metrics',
'tenant_features': config.get('custom_feature_flags', {})
}
return app
# Example usage in external repo's main.py
if __name__ == '__main__':
import uvicorn
# Choose your approach
app = create_external_app() # Full custom app with OpenHands mounted
# app = create_extended_openhands_app() # Extended OpenHands app
# Run the server
uvicorn.run(
app,
host='0.0.0.0',
port=8000,
reload=True
)
# Example of how to test the integration
def test_external_integration():
"""Test that the external integration works correctly."""
from fastapi.testclient import TestClient
app = create_external_app()
client = TestClient(app)
# Test custom routes
response = client.get('/enterprise/status')
assert response.status_code == 200
assert response.json()['mode'] == 'enterprise'
# Test tenant-specific routes
response = client.get('/enterprise/tenant/acme-corp/info')
assert response.status_code == 200
data = response.json()
assert data['tenant_id'] == 'acme-corp'
assert 'enterprise_features' in data['features']
# Test OpenHands routes still work
response = client.get('/openhands/api/health')
assert response.status_code == 200
print("✅ All integration tests passed!")
if __name__ == '__main__':
# Run tests
test_external_integration()
@@ -18,6 +18,7 @@ const mockUseGitRepositories = vi.fn();
const mockUseConfig = vi.fn();
const mockUseRepositoryMicroagents = vi.fn();
const mockUseMicroagentManagementConversations = vi.fn();
const mockUseSearchRepositories = vi.fn();
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => mockUseUserProviders(),
@@ -40,6 +41,10 @@ vi.mock("#/hooks/query/use-microagent-management-conversations", () => ({
mockUseMicroagentManagementConversations(),
}));
vi.mock("#/hooks/query/use-search-repositories", () => ({
useSearchRepositories: () => mockUseSearchRepositories(),
}));
describe("MicroagentManagement", () => {
const RouterStub = createRoutesStub([
{
@@ -219,6 +224,12 @@ describe("MicroagentManagement", () => {
isError: false,
});
mockUseSearchRepositories.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
// Setup default mock for retrieveUserGitRepositories
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
data: [...mockRepositories],
@@ -743,17 +754,24 @@ describe("MicroagentManagement", () => {
});
await user.type(searchInput, "nonexistent");
// No repositories should be visible
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("user/repo2/.openhands"),
).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
// Wait for debounced search to complete (300ms debounce + buffer)
await new Promise((resolve) => setTimeout(resolve, 400));
// Wait for the search to complete and check that no repositories are visible
await waitFor(() => {
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
expect(
screen.queryByText("user/repo2/.openhands"),
).not.toBeInTheDocument();
expect(
screen.queryByText("org/repo3/.openhands"),
).not.toBeInTheDocument();
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
expect(
screen.queryByText("user/TestRepository"),
).not.toBeInTheDocument();
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
});
});
it("should handle special characters in search", async () => {
@@ -1272,11 +1290,14 @@ describe("MicroagentManagement", () => {
// Add microagent integration tests
describe("Add microagent functionality", () => {
beforeEach(() => {
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue(({ branches: [
{ name: "main", commit_sha: "abc123", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: [
{ name: "main", commit_sha: "abc123", protected: false },
].length }) );
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: [{ name: "main", commit_sha: "abc123", protected: false }]
.length,
});
});
it("should render add microagent button", async () => {
@@ -1962,11 +1983,14 @@ describe("MicroagentManagement", () => {
};
beforeEach(() => {
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue(({ branches: [
{ name: "main", commit_sha: "abc123", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: [
{ name: "main", commit_sha: "abc123", protected: false },
].length }) );
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: [{ name: "main", commit_sha: "abc123", protected: false }]
.length,
});
});
it("should render update microagent modal when updateMicroagentModalVisible is true", async () => {
@@ -2521,64 +2545,6 @@ describe("MicroagentManagement", () => {
screen.queryByTestId("learn-this-repo-trigger"),
).not.toBeInTheDocument();
});
it("should handle API call for branches when learn this repo modal opens", async () => {
// Mock branch API
const branchesSpy = vi
.spyOn(OpenHands, "getRepositoryBranches")
.mockResolvedValue({
branches: [
{ name: "main", commit_sha: "abc123", protected: false },
{ name: "develop", commit_sha: "def456", protected: false },
],
has_next_page: false,
current_page: 1,
per_page: 30,
total_count: 2,
});
// Mock other APIs
const getRepositoryMicroagentsSpy = vi.spyOn(
OpenHands,
"getRepositoryMicroagents",
);
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
getRepositoryMicroagentsSpy.mockResolvedValue([]);
searchConversationsSpy.mockResolvedValue([]);
// Test with direct Redux state that has modal visible
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
max_budget_per_task: null,
usage: null,
},
microagentManagement: {
selectedMicroagentItem: null,
addMicroagentModalVisible: false,
updateMicroagentModalVisible: false,
learnThisRepoModalVisible: true, // Modal should be visible
selectedRepository: {
id: "1",
full_name: "test-org/test-repo",
git_provider: "github",
is_public: true,
owner_type: "user",
pushed_at: "2021-10-01T12:00:00Z",
},
personalRepositories: [],
organizationRepositories: [],
repositories: [],
},
},
});
// The branches API should be called when the modal is visible
await waitFor(() => {
expect(branchesSpy).toHaveBeenCalledWith("test-org/test-repo");
});
});
});
// Learn something new button functionality tests
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.54.0",
"version": "0.55.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.54.0",
"version": "0.55.0",
"dependencies": {
"@heroui/react": "^2.8.2",
"@heroui/use-infinite-scroll": "^2.2.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.54.0",
"version": "0.55.0",
"private": true,
"type": "module",
"engines": {
+8
View File
@@ -31,6 +31,7 @@ import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
import { SubscriptionAccess } from "#/types/billing";
class OpenHands {
private static currentConversation: Conversation | null = null;
@@ -433,6 +434,13 @@ class OpenHands {
return data.credits;
}
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
const { data } = await openHands.get<SubscriptionAccess | null>(
"/api/billing/subscription-access",
);
return data;
}
static async getGitUser(): Promise<GitUser> {
const response = await openHands.get<GitUser>("/api/user/info");
-2
View File
@@ -49,13 +49,11 @@ export interface GetConfigResponse {
APP_SLUG?: string;
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
STRIPE_PUBLISHABLE_KEY?: string;
PROVIDERS_CONFIGURED?: Provider[];
AUTH_URL?: string;
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
HIDE_MICROAGENT_MANAGEMENT?: boolean;
ENABLE_JIRA: boolean;
ENABLE_JIRA_DC: boolean;
ENABLE_LINEAR: boolean;
@@ -288,7 +288,6 @@ export function MicroagentManagementContent() {
conversationInstructions: formData.query,
repository: {
name: repositoryName,
branch: formData.selectedBranch,
gitProvider,
},
createMicroagent,
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { FaCircleInfo } from "react-icons/fa6";
@@ -10,13 +10,6 @@ import { RootState } from "#/store";
import XIcon from "#/icons/x.svg?react";
import { cn, getRepoMdCreatePrompt } from "#/utils/utils";
import { LearnThisRepoFormData } from "#/types/microagent-management";
import { Branch } from "#/types/git";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import {
BranchDropdown,
BranchLoadingState,
BranchErrorState,
} from "../home/repository-selection";
interface MicroagentManagementLearnThisRepoModalProps {
onConfirm: (formData: LearnThisRepoFormData) => void;
@@ -32,47 +25,11 @@ export function MicroagentManagementLearnThisRepoModal({
const { t } = useTranslation();
const [query, setQuery] = useState<string>("");
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = useRef<boolean>(false);
const {
data: branches,
isLoading: isLoadingBranches,
isError: isBranchesError,
} = useRepositoryBranches(selectedRepository?.full_name || null);
const branchesItems = branches?.map((branch) => ({
key: branch.name,
label: branch.name,
}));
// Auto-select main or master branch if it exists.
useEffect(() => {
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches
) {
// Look for main or master branch
const mainBranch = branches.find((branch) => branch.name === "main");
const masterBranch = branches.find((branch) => branch.name === "master");
// Select main if it exists, otherwise select master if it exists
if (mainBranch) {
setSelectedBranch(mainBranch);
} else if (masterBranch) {
setSelectedBranch(masterBranch);
}
}
}, [branches, isLoadingBranches, selectedBranch]);
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -83,7 +40,6 @@ export function MicroagentManagementLearnThisRepoModal({
onConfirm({
query: finalQuery,
selectedBranch: selectedBranch?.name || "",
});
};
@@ -95,66 +51,9 @@ export function MicroagentManagementLearnThisRepoModal({
onConfirm({
query: finalQuery,
selectedBranch: selectedBranch?.name || "",
});
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
// Reset the manually cleared flag when a branch is explicitly selected
branchManuallyClearedRef.current = false;
};
const handleBranchInputChange = (value: string) => {
// Clear the selected branch if the input is empty or contains only whitespace
// This fixes the issue where users can't delete the entire default branch name
if (value === "" || value.trim() === "") {
setSelectedBranch(null);
// Set the flag to indicate that the branch was manually cleared
branchManuallyClearedRef.current = true;
} else {
// Reset the flag when the user starts typing again
branchManuallyClearedRef.current = false;
}
};
// Render the appropriate UI for branch selector based on the loading/error state
const renderBranchSelector = () => {
if (!selectedRepository) {
return (
<BranchDropdown
items={[]}
onSelectionChange={() => {}}
onInputChange={() => {}}
isDisabled
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
}
if (isLoadingBranches) {
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
}
if (isBranchesError) {
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
}
return (
<BranchDropdown
items={branchesItems || []}
onSelectionChange={handleBranchSelection}
onInputChange={handleBranchInputChange}
isDisabled={false}
selectedKey={selectedBranch?.name}
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
};
return (
<ModalBackdrop onClose={onCancel}>
<ModalBody
@@ -200,9 +99,6 @@ export function MicroagentManagementLearnThisRepoModal({
onSubmit={onSubmit}
className="flex flex-col gap-6 w-full"
>
<div data-testid="branch-selector-container">
{renderBranchSelector()}
</div>
<label
htmlFor="query-input"
className="flex flex-col gap-2 w-full text-sm font-normal"
@@ -245,16 +141,9 @@ export function MicroagentManagementLearnThisRepoModal({
variant="primary"
onClick={handleConfirm}
testId="confirm-button"
isDisabled={
isLoading ||
isLoadingBranches ||
!selectedBranch ||
isBranchesError
}
isDisabled={isLoading}
>
{isLoading || isLoadingBranches
? t(I18nKey.HOME$LOADING)
: t(I18nKey.MICROAGENT$LAUNCH)}
{isLoading ? t(I18nKey.HOME$LOADING) : t(I18nKey.MICROAGENT$LAUNCH)}
</BrandButton>
</div>
</ModalBody>
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { Accordion, AccordionItem } from "@heroui/react";
import { Accordion, AccordionItem, Spinner } from "@heroui/react";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { GitRepository } from "#/types/git";
import { TabType } from "#/types/microagent-management";
@@ -11,16 +11,30 @@ import { MicroagentManagementAccordionTitle } from "./microagent-management-acco
type MicroagentManagementRepositoriesProps = {
repositories: GitRepository[];
tabType: TabType;
isSearchLoading?: boolean;
};
export function MicroagentManagementRepositories({
repositories,
tabType,
isSearchLoading = false,
}: MicroagentManagementRepositoriesProps) {
const { t } = useTranslation();
const numberOfRepoMicroagents = repositories.length;
// Show spinner when search is in progress, regardless of repository count
if (isSearchLoading) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-8">
<Spinner size="sm" />
<span className="text-sm text-white">
{t("HOME$SEARCHING_REPOSITORIES")}
</span>
</div>
);
}
if (numberOfRepoMicroagents === 0) {
if (tabType === "personal") {
return (
@@ -5,7 +5,13 @@ import { MicroagentManagementRepositories } from "./microagent-management-reposi
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
export function MicroagentManagementSidebarTabs() {
interface MicroagentManagementSidebarTabsProps {
isSearchLoading?: boolean;
}
export function MicroagentManagementSidebarTabs({
isSearchLoading = false,
}: MicroagentManagementSidebarTabsProps) {
const { t } = useTranslation();
const { repositories, personalRepositories, organizationRepositories } =
@@ -29,18 +35,21 @@ export function MicroagentManagementSidebarTabs() {
<MicroagentManagementRepositories
repositories={personalRepositories}
tabType="personal"
isSearchLoading={isSearchLoading}
/>
</Tab>
<Tab key="repositories" title={t(I18nKey.COMMON$REPOSITORIES)}>
<MicroagentManagementRepositories
repositories={repositories}
tabType="repositories"
isSearchLoading={isSearchLoading}
/>
</Tab>
<Tab key="organizations" title={t(I18nKey.COMMON$ORGANIZATIONS)}>
<MicroagentManagementRepositories
repositories={organizationRepositories}
tabType="organizations"
isSearchLoading={isSearchLoading}
/>
</Tab>
</Tabs>
@@ -5,6 +5,7 @@ import { Spinner } from "@heroui/react";
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
import { useGitRepositories } from "#/hooks/query/use-git-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown";
import {
setPersonalRepositories,
@@ -16,6 +17,7 @@ import { Provider } from "#/types/settings";
import { cn } from "#/utils/utils";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { I18nKey } from "#/i18n/declaration";
import { useDebounce } from "#/hooks/use-debounce";
interface MicroagentManagementSidebarProps {
isSmallerScreen?: boolean;
@@ -31,17 +33,29 @@ export function MicroagentManagementSidebar({
);
const [searchQuery, setSearchQuery] = useState("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: repositories, isLoading } = useGitRepositories({
// Use Git repositories hook with pagination for infinite scrolling
const {
data: repositories,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
} = useGitRepositories({
provider: selectedProvider,
pageSize: 200,
pageSize: 30, // Load 30 repositories per page
enabled: !!selectedProvider,
});
// Server-side search functionality
const { data: searchResults, isLoading: isSearchLoading } =
useSearchRepositories(debouncedSearchQuery, selectedProvider, 500); // Increase page size to 500 to to retrieve all search results. This should be optimized in the future.
// Auto-select provider if there's only one
useEffect(() => {
if (providers.length > 0 && !selectedProvider) {
@@ -54,23 +68,31 @@ export function MicroagentManagementSidebar({
setSearchQuery("");
};
// Filter repositories based on search query
// Filter repositories based on search query and available data
const filteredRepositories = useMemo(() => {
if (!repositories?.pages) return null;
// If we have search results, use them directly (no filtering needed)
if (debouncedSearchQuery && searchResults && searchResults.length > 0) {
return searchResults;
}
// If no search query or no search results, use paginated repositories
if (!repositories?.pages) return [];
// Flatten all pages to get all repositories
const allRepositories = repositories.pages.flatMap((page) => page.data);
if (!searchQuery.trim()) {
// If no search query, return all repositories
if (!debouncedSearchQuery.trim()) {
return allRepositories;
}
const sanitizedQuery = sanitizeQuery(searchQuery);
// Fallback to client-side filtering if search didn't return results
const sanitizedQuery = sanitizeQuery(debouncedSearchQuery);
return allRepositories.filter((repository: GitRepository) => {
const sanitizedRepoName = sanitizeQuery(repository.full_name);
return sanitizedRepoName.includes(sanitizedQuery);
});
}, [repositories, searchQuery, selectedProvider]);
}, [repositories, debouncedSearchQuery, searchResults]);
useEffect(() => {
if (!filteredRepositories?.length) {
@@ -104,12 +126,28 @@ export function MicroagentManagementSidebar({
dispatch(setRepositories(otherRepos));
}, [filteredRepositories, selectedProvider, dispatch]);
// Handle scroll to bottom for pagination
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
// Only enable pagination when not searching
if (debouncedSearchQuery && searchResults) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10;
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
return (
<div
className={cn(
"w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col",
isSmallerScreen && "w-full border-none",
)}
onScroll={handleScroll}
>
<MicroagentManagementSidebarHeader />
@@ -131,18 +169,26 @@ export function MicroagentManagementSidebar({
<label htmlFor="repository-search" className="sr-only">
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
</label>
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed h-10 box-shadow-none outline-none",
<div className="relative">
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed h-10 box-shadow-none outline-none",
"pr-10", // Space for spinner
)}
/>
{isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<Spinner size="sm" />
</div>
)}
/>
</div>
</div>
{isLoading ? (
@@ -153,7 +199,19 @@ export function MicroagentManagementSidebar({
</span>
</div>
) : (
<MicroagentManagementSidebarTabs />
<>
<MicroagentManagementSidebarTabs isSearchLoading={isSearchLoading} />
{/* Show loading indicator for pagination (only when not searching) */}
{isFetchingNextPage && !debouncedSearchQuery && (
<div className="flex justify-center pt-2">
<Spinner size="sm" />
<span className="text-sm text-white ml-2">
{t("HOME$LOADING_MORE_REPOSITORIES")}
</span>
</div>
)}
</>
)}
</div>
);
@@ -37,9 +37,6 @@ export function Sidebar() {
const shouldHideLlmSettings =
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && config?.APP_MODE === "saas";
const shouldHideMicroagentManagement =
config?.FEATURE_FLAGS.HIDE_MICROAGENT_MANAGEMENT;
React.useEffect(() => {
if (shouldHideLlmSettings) return;
@@ -83,11 +80,9 @@ export function Sidebar() {
}
disabled={settings?.EMAIL_VERIFIED === false}
/>
{!shouldHideMicroagentManagement && (
<MicroagentManagementButton
disabled={settings?.EMAIL_VERIFIED === false}
/>
)}
<MicroagentManagementButton
disabled={settings?.EMAIL_VERIFIED === false}
/>
</div>
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
import UnionIcon from "#/icons/union.svg?react";
import RobotIcon from "#/icons/robot.svg?react";
interface MicroagentManagementButtonProps {
disabled?: boolean;
@@ -22,7 +22,7 @@ export function MicroagentManagementButton({
testId="microagent-management-button"
disabled={disabled}
>
<UnionIcon />
<RobotIcon width={28} height={28} />
</TooltipButton>
);
}
@@ -5,11 +5,16 @@ import { Provider } from "#/types/settings";
export function useSearchRepositories(
query: string,
selectedProvider?: Provider | null,
pageSize: number = 3,
) {
return useQuery({
queryKey: ["repositories", "search", query, selectedProvider],
queryKey: ["repositories", "search", query, selectedProvider, pageSize],
queryFn: () =>
OpenHands.searchGitRepositories(query, 3, selectedProvider || undefined),
OpenHands.searchGitRepositories(
query,
pageSize,
selectedProvider || undefined,
),
enabled: !!query && !!selectedProvider,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
export const useSubscriptionAccess = () => {
const { data: config } = useConfig();
const isOnTosPage = useIsOnTosPage();
return useQuery({
queryKey: ["user", "subscription_access"],
queryFn: OpenHands.getSubscriptionAccess,
enabled:
!isOnTosPage &&
config?.APP_MODE === "saas" &&
config?.FEATURE_FLAGS?.ENABLE_BILLING,
});
};
+2
View File
@@ -85,6 +85,8 @@ export enum I18nKey {
HOME$CONNECT_TO_REPOSITORY_TOOLTIP = "HOME$CONNECT_TO_REPOSITORY_TOOLTIP",
HOME$LOADING = "HOME$LOADING",
HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES",
HOME$SEARCHING_REPOSITORIES = "HOME$SEARCHING_REPOSITORIES",
HOME$LOADING_MORE_REPOSITORIES = "HOME$LOADING_MORE_REPOSITORIES",
HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES",
HOME$LOADING_BRANCHES = "HOME$LOADING_BRANCHES",
HOME$FAILED_TO_LOAD_BRANCHES = "HOME$FAILED_TO_LOAD_BRANCHES",
+46 -14
View File
@@ -1359,6 +1359,38 @@
"de": "Repositories werden geladen...",
"uk": "Завантаження репозиторіїв..."
},
"HOME$SEARCHING_REPOSITORIES": {
"en": "Searching repositories...",
"ja": "リポジトリを検索中...",
"zh-CN": "搜索仓库中...",
"zh-TW": "搜尋儲存庫中...",
"ko-KR": "저장소 검색 중...",
"no": "Søker i repositories...",
"it": "Ricerca repository in corso...",
"pt": "Pesquisando repositórios...",
"es": "Buscando repositorios...",
"ar": "جار البحث في المستودعات...",
"fr": "Recherche de dépôts...",
"tr": "Depolar aranıyor...",
"de": "Repositories werden durchsucht...",
"uk": "Пошук репозиторіїв..."
},
"HOME$LOADING_MORE_REPOSITORIES": {
"en": "Loading more repositories...",
"ja": "さらに多くのリポジトリを読み込み中...",
"zh-CN": "加载更多仓库中...",
"zh-TW": "載入更多儲存庫中...",
"ko-KR": "더 많은 저장소 로딩 중...",
"no": "Laster flere repositories...",
"it": "Caricamento di altri repository...",
"pt": "Carregando mais repositórios...",
"es": "Cargando más repositorios...",
"ar": "جار تحميل المزيد من المستودعات...",
"fr": "Chargement de plus de dépôts...",
"tr": "Daha fazla depolar yükleniyor...",
"de": "Weitere Repositories werden geladen...",
"uk": "Завантаження більше репозиторіїв..."
},
"HOME$FAILED_TO_LOAD_REPOSITORIES": {
"en": "Failed to load repositories",
"ja": "リポジトリの読み込みに失敗しました",
@@ -11952,20 +11984,20 @@
"uk": "Бажаєте, щоб OpenHands розпочав нову розмову, щоб допомогти вам зрозуміти цей репозиторій?"
},
"MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO": {
"en": "What would you like to know about this repository?",
"ja": "このリポジトリについて何を知りたいですか?",
"zh-CN": "您想了解此存储库的哪些内容?",
"zh-TW": "您想了解此存儲庫的哪些內容?",
"ko-KR": "이 저장소에 대해 무엇을 알고 싶으신가요?",
"no": "Hva vil du vite om dette depotet?",
"it": "Cosa vorresti sapere su questo repository?",
"pt": "O que você gostaria de saber sobre este repositório?",
"es": "¿Qué te gustaría saber sobre este repositorio?",
"ar": "ماذا تريد أن تعرف عن هذا المستودع؟",
"fr": "Que souhaitez-vous savoir sur ce dépôt ?",
"tr": "Bu depo hakkında ne bilmek istersiniz?",
"de": "Was möchten Sie über dieses Repository wissen?",
"uk": "Що ви хотіли б дізнатися про цей репозиторій?"
"en": "What would you like to know about this repository? (optional)",
"ja": "このリポジトリについて知りたいことは何ですか?(任意)",
"zh-CN": "您想了解此存储库的哪些内容?(可选)",
"zh-TW": "您想了解此存儲庫的哪些內容?(選填)",
"ko-KR": "이 저장소에 대해 무엇을 알고 싶으신가요? (선택 사항)",
"no": "Hva vil du vite om dette depotet? (valgfritt)",
"it": "Cosa vorresti sapere su questo repository? (opzionale)",
"pt": "O que você gostaria de saber sobre este repositório? (opcional)",
"es": "¿Qué te gustaría saber sobre este repositorio? (opcional)",
"ar": "ماذا ترغب في معرفته عن هذا المستودع؟ (اختياري)",
"fr": "Que souhaitez-vous savoir sur ce dépôt ? (facultatif)",
"tr": "Bu depo hakkında ne bilmek istersiniz? (isteğe bağlı)",
"de": "Was möchten Sie über dieses Repository wissen? (optional)",
"uk": "Що ви хотіли б дізнатися про цей репозиторій? (необов'язково)"
},
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO": {
"en": "Describe what you would like to know about this repository.",
+4
View File
@@ -0,0 +1,4 @@
<svg width="47" height="42" viewBox="0 0 47 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.58409 21.782C2.01747 21.782 1.48418 22.0153 1.11755 22.3819C0.750916 22.7486 0.517578 23.3152 0.517578 23.8485V28.0148C0.517578 28.5814 0.750916 29.1147 1.11755 29.4813C1.51751 29.8813 2.05081 30.0813 2.58409 30.0813C3.11737 30.0813 3.684 29.8479 4.05063 29.4813C4.45059 29.0814 4.65055 28.5481 4.65055 28.0148V23.8485C4.65055 23.2819 4.41726 22.7486 4.05063 22.3819C3.684 22.0153 3.11737 21.782 2.58409 21.782ZM44.2802 21.782C43.7136 21.782 43.1803 22.0153 42.8137 22.3819C42.4471 22.7486 42.2138 23.3152 42.2138 23.8485V28.0148C42.2138 28.5814 42.4471 29.1147 42.8137 29.4813C43.2137 29.8813 43.747 30.0813 44.2802 30.0813C44.8135 30.0813 45.3801 29.8479 45.7468 29.4813C46.1467 29.0814 46.3467 28.5481 46.3467 28.0148V23.8485C46.3467 23.2819 46.1134 22.7486 45.7468 22.3819C45.3801 22.0153 44.8135 21.782 44.2802 21.782ZM21.349 10.3164H13.0164C11.3499 10.3164 9.75011 10.983 8.58355 12.1496C7.41699 13.3161 6.75037 14.916 6.75037 16.5825V35.3474C6.75037 37.0139 7.41699 38.6138 8.58355 39.7804C9.75011 40.9469 11.3499 41.6135 13.0164 41.6135H33.8812C35.5477 41.6135 37.1476 40.9469 38.3141 39.7804C39.4807 38.6138 40.1473 37.0139 40.1473 35.3474V16.5825C40.1473 14.916 39.4807 13.3161 38.3141 12.1496C37.1476 10.983 35.5477 10.3164 33.8812 10.3164H25.5486M19.8491 14.4827H27.0152ZM31.3148 14.4827H33.8478C34.4145 14.4827 34.9478 14.716 35.3144 15.0826C35.7144 15.4826 35.9144 16.0159 35.9144 16.5492V35.3141C35.9144 35.8807 35.681 36.3807 35.3144 36.7806C34.9144 37.1806 34.3811 37.3806 33.8478 37.3806H12.9831C12.4165 37.3806 11.8832 37.1473 11.5166 36.7806C11.1166 36.3807 10.9167 35.8474 10.9167 35.3141V16.5492C10.9167 15.9825 11.1499 15.4493 11.5166 15.0826C11.9165 14.6827 12.4498 14.4827 12.9831 14.4827H15.5162M17.7494 21.782C17.1828 21.782 16.6495 22.0153 16.2828 22.3819C15.9162 22.7486 15.6829 23.3152 15.6829 23.8485V28.0148C15.6829 28.5814 15.9162 29.1147 16.2828 29.4813C16.6828 29.8813 17.2161 30.0813 17.7494 30.0813C18.2827 30.0813 18.8492 29.8479 19.2159 29.4813C19.6158 29.0814 19.8158 28.5481 19.8158 28.0148V23.8485C19.8158 23.2819 19.5825 22.7486 19.2159 22.3819C18.8492 22.0153 18.2827 21.782 17.7494 21.782ZM29.0816 21.782C28.515 21.782 27.9817 22.0153 27.6151 22.3819C27.2485 22.7486 27.0152 23.3152 27.0152 23.8485V28.0148C27.0152 28.5814 27.2485 29.1147 27.6151 29.4813C28.0151 29.8813 28.5484 30.0813 29.0816 30.0813C29.6149 30.0813 30.1816 29.8479 30.5482 29.4813C30.9481 29.0814 31.1481 28.5481 31.1481 28.0148V23.8485C31.1481 23.2819 30.9148 22.7486 30.5482 22.3819C30.1816 22.0153 29.6149 21.782 29.0816 21.782Z" fill="currentColor"/>
<path d="M23.4122 0.851806C22.7122 0.851806 22.0123 1.05179 21.4123 1.45175C20.8124 1.85171 20.3791 2.41834 20.0791 3.05162C19.8125 3.71822 19.7458 4.41814 19.8792 5.11808C20.0125 5.81801 20.3458 6.4513 20.8457 6.95125C21.3457 7.45121 21.979 7.78451 22.6789 7.91783C23.3788 8.05115 24.1121 7.98448 24.7454 7.71783C25.412 7.45119 25.9452 6.98459 26.3452 6.38464C26.7452 5.7847 26.9452 5.11807 26.9452 4.38481C26.9452 3.41823 26.5785 2.51833 25.8786 1.85172C25.1786 1.18512 24.2787 0.785156 23.3455 0.785156L23.4122 0.851806Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

-1
View File
@@ -169,7 +169,6 @@ export const handlers = [
APP_MODE: mockSaas ? "saas" : "oss",
GITHUB_CLIENT_ID: "fake-github-client-id",
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
STRIPE_PUBLISHABLE_KEY: "",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: mockSaas,
+15 -1
View File
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { NavLink, Outlet, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import SettingsIcon from "#/icons/settings.svg?react";
@@ -8,6 +9,7 @@ import { Route } from "./+types/settings";
import OpenHands from "#/api/open-hands";
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/open-hands.types";
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
const SAAS_ONLY_PATHS = [
"/settings/user",
@@ -62,10 +64,22 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
function SettingsScreen() {
const { t } = useTranslation();
const { data: config } = useConfig();
const { data: subscriptionAccess } = useSubscriptionAccess();
const isSaas = config?.APP_MODE === "saas";
// this is used to determine which settings are available in the UI
const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
const navItems = useMemo(() => {
const items = [];
if (isSaas) {
if (subscriptionAccess) {
items.push({ to: "/settings", text: "SETTINGS$NAV_LLM" });
}
items.push(...SAAS_NAV_ITEMS);
} else {
items.push(...OSS_NAV_ITEMS);
}
return items;
}, [isSaas, !!subscriptionAccess]);
return (
<main
+6
View File
@@ -0,0 +1,6 @@
export type SubscriptionAccess = {
status: "ACTIVE" | "DISABLED";
start_at: string;
end_at: string;
created_at: string;
};
@@ -22,5 +22,4 @@ export interface MicroagentFormData {
export interface LearnThisRepoFormData {
query: string;
selectedBranch: string;
}
View File
@@ -1,32 +1,29 @@
import base64
import os
import re
from typing import Any
import httpx
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.bitbucket.service import (
BitBucketBranchesMixin,
BitBucketFeaturesMixin,
BitBucketPRsMixin,
BitBucketReposMixin,
)
from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
InstallationsService,
OwnerType,
PaginatedBranchesResponse,
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
class BitBucketService(BaseGitService, GitService, InstallationsService):
class BitBucketService(
BitBucketReposMixin,
BitBucketBranchesMixin,
BitBucketPRsMixin,
BitBucketFeaturesMixin,
GitService,
InstallationsService,
):
"""Default implementation of GitService for Bitbucket integration.
This is an extension point in OpenHands that allows applications to customize Bitbucket
@@ -38,10 +35,6 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
The class is instantiated via get_impl() in openhands.server.shared.py.
"""
BASE_URL = 'https://api.bitbucket.org/2.0'
token: SecretStr = SecretStr('')
refresh = False
def __init__(
self,
user_id: str | None = None,
@@ -50,7 +43,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
token: SecretStr | None = None,
external_token_manager: bool = False,
base_domain: str | None = None,
):
) -> None:
self.user_id = user_id
self.external_token_manager = external_token_manager
self.external_auth_id = external_auth_id
@@ -66,695 +59,6 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
def provider(self) -> str:
return ProviderType.BITBUCKET.value
def _extract_owner_and_repo(self, repository: str) -> tuple[str, str]:
"""Extract owner and repo from repository string.
Args:
repository: Repository name in format 'workspace/repo_slug'
Returns:
Tuple of (owner, repo)
Raises:
ValueError: If repository format is invalid
"""
parts = repository.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repository}')
return parts[-2], parts[-1]
async def get_latest_token(self) -> SecretStr | None:
"""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
async def _get_bitbucket_headers(self) -> dict[str, str]:
"""Get headers for Bitbucket API requests."""
token_value = self.token.get_secret_value()
# Check if the token contains a colon, which indicates it's in username:password format
if ':' in token_value:
auth_str = base64.b64encode(token_value.encode()).decode()
return {
'Authorization': f'Basic {auth_str}',
'Accept': 'application/json',
}
else:
return {
'Authorization': f'Bearer {token_value}',
'Accept': 'application/json',
}
async def _make_request(
self,
url: str,
params: dict | None = None,
method: RequestMethod = RequestMethod.GET,
) -> tuple[Any, dict]:
"""Make a request to the Bitbucket API.
Args:
url: The URL to request
params: Optional parameters for the request
method: The HTTP method to use
Returns:
A tuple of (response_data, response_headers)
"""
try:
async with httpx.AsyncClient() as client:
bitbucket_headers = await self._get_bitbucket_headers()
response = await self.execute_request(
client, url, bitbucket_headers, params, method
)
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
bitbucket_headers = await self._get_bitbucket_headers()
response = await self.execute_request(
client=client,
url=url,
headers=bitbucket_headers,
params=params,
method=method,
)
response.raise_for_status()
return response.json(), dict(response.headers)
except httpx.HTTPStatusError as e:
raise self.handle_http_status_error(e)
except httpx.HTTPError as e:
raise self.handle_http_error(e)
async def get_user(self) -> User:
"""Get the authenticated user's information."""
url = f'{self.BASE_URL}/user'
data, _ = await self._make_request(url)
account_id = data.get('account_id', '')
return User(
id=account_id,
login=data.get('username', ''),
avatar_url=data.get('links', {}).get('avatar', {}).get('href', ''),
name=data.get('display_name'),
email=None, # Bitbucket API doesn't return email in this endpoint
)
def _parse_repository(
self, repo: dict, link_header: str | None = None
) -> Repository:
"""Parse a Bitbucket API repository response into a Repository object.
Args:
repo: Repository data from Bitbucket API
link_header: Optional link header for pagination
Returns:
Repository object
"""
repo_id = repo.get('uuid', '')
workspace_slug = repo.get('workspace', {}).get('slug', '')
repo_slug = repo.get('slug', '')
full_name = (
f'{workspace_slug}/{repo_slug}' if workspace_slug and repo_slug else ''
)
is_public = not repo.get('is_private', True)
owner_type = OwnerType.ORGANIZATION
main_branch = repo.get('mainbranch', {}).get('name')
return Repository(
id=repo_id,
full_name=full_name, # type: ignore[arg-type]
git_provider=ProviderType.BITBUCKET,
is_public=is_public,
stargazers_count=None, # Bitbucket doesn't have stars
pushed_at=repo.get('updated_on'),
owner_type=owner_type,
link_header=link_header,
main_branch=main_branch,
)
async def search_repositories(
self, query: str, per_page: int, sort: str, order: str, public: bool
) -> list[Repository]:
"""Search for repositories."""
repositories = []
if public:
# Extract workspace and repo from URL
# URL format: https://{domain}/{workspace}/{repo}/{additional_params}
# Split by '/' and find workspace and repo parts
url_parts = query.split('/')
if len(url_parts) >= 5: # https:, '', domain, workspace, repo
workspace_slug = url_parts[3]
repo_name = url_parts[4]
repo = await self.get_repository_details_from_repo_name(
f'{workspace_slug}/{repo_name}'
)
repositories.append(repo)
return repositories
# Search for repos once workspace prefix exists
if '/' in query:
workspace_slug, repo_query = query.split('/', 1)
return await self.get_paginated_repos(
1, per_page, sort, workspace_slug, repo_query
)
all_installations = await self.get_installations()
# Workspace prefix isn't complete. Search workspace names and repos underneath each workspace
matching_workspace_slugs = [
installation for installation in all_installations if query in installation
]
for workspace_slug in matching_workspace_slugs:
# Get repositories where query matches workspace name
try:
repos = await self.get_paginated_repos(
1, per_page, sort, workspace_slug
)
repositories.extend(repos)
except Exception:
continue
for workspace_slug in all_installations:
# Get repositories in all workspaces where query matches repo name
try:
repos = await self.get_paginated_repos(
1, per_page, sort, workspace_slug, query
)
repositories.extend(repos)
except Exception:
continue
return repositories
async def _get_user_workspaces(self) -> list[dict[str, Any]]:
"""Get all workspaces the user has access to"""
url = f'{self.BASE_URL}/workspaces'
data, _ = await self._make_request(url)
return data.get('values', [])
async def _fetch_paginated_data(
self, url: str, params: dict, max_items: int
) -> list[dict]:
"""Fetch data with pagination support for Bitbucket API.
Args:
url: The API endpoint URL
params: Query parameters for the request
max_items: Maximum number of items to fetch
Returns:
List of data items from all pages
"""
all_items: list[dict] = []
current_url = url
while current_url and len(all_items) < max_items:
response, _ = await self._make_request(current_url, params)
# Extract items from response
page_items = response.get('values', [])
if not page_items: # No more items
break
all_items.extend(page_items)
# Get the next page URL from the response
current_url = response.get('next')
# Clear params for subsequent requests since the next URL already contains all parameters
params = {}
return all_items[:max_items] # Trim to max_items if needed
async def get_installations(
self, query: str | None = None, limit: int = 100
) -> list[str]:
workspaces_url = f'{self.BASE_URL}/workspaces'
params = {}
if query:
params['q'] = f'name~"{query}"'
workspaces = await self._fetch_paginated_data(workspaces_url, params, limit)
installations: list[str] = []
for workspace in workspaces:
installations.append(workspace['slug'])
return installations
async def get_paginated_repos(
self,
page: int,
per_page: int,
sort: str,
installation_id: str | None,
query: str | None = None,
) -> list[Repository]:
"""Get paginated repositories for a specific workspace.
Args:
page: The page number to fetch
per_page: The number of repositories per page
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
Returns:
A list of Repository objects
"""
if not installation_id:
return []
# Convert installation_id to string for use as workspace_slug
workspace_slug = installation_id
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
# Map sort parameter to Bitbucket API compatible values
bitbucket_sort = sort
if sort == 'pushed':
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
elif sort == 'updated':
bitbucket_sort = '-updated_on'
elif sort == 'created':
bitbucket_sort = '-created_on'
elif sort == 'full_name':
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
else:
# Default to most recently updated first
bitbucket_sort = '-updated_on'
params = {
'pagelen': per_page,
'page': page,
'sort': bitbucket_sort,
}
if query:
params['q'] = f'name~"{query}"'
response, headers = await self._make_request(workspace_repos_url, params)
# Extract repositories from the response
repos = response.get('values', [])
# Extract next URL from response
next_link = response.get('next', '')
# Format the link header in a way that the frontend can understand
# The frontend expects a format like: <url>; rel="next"
# where the URL contains a page parameter
formatted_link_header = ''
if next_link:
# Extract the page number from the next URL if possible
page_match = re.search(r'[?&]page=(\d+)', next_link)
if page_match:
next_page = page_match.group(1)
# Format it in a way that extractNextPageFromLink in frontend can parse
formatted_link_header = (
f'<{workspace_repos_url}?page={next_page}>; rel="next"'
)
else:
# If we can't extract the page, just use the next URL as is
formatted_link_header = f'<{next_link}>; rel="next"'
repositories = [
self._parse_repository(repo, link_header=formatted_link_header)
for repo in repos
]
return repositories
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
"""Get repositories for the authenticated user using workspaces endpoint.
This method gets all repositories (both public and private) that the user has access to
by iterating through their workspaces and fetching repositories from each workspace.
This approach is more comprehensive and efficient than the previous implementation
that made separate calls for public and private repositories.
"""
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by Bitbucket API
repositories: list[Repository] = []
# Get user's workspaces with pagination
workspaces_url = f'{self.BASE_URL}/workspaces'
workspaces = await self._fetch_paginated_data(workspaces_url, {}, MAX_REPOS)
for workspace in workspaces:
workspace_slug = workspace.get('slug')
if not workspace_slug:
continue
# Get repositories for this workspace with pagination
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
# Map sort parameter to Bitbucket API compatible values and ensure descending order
# to show most recently changed repos at the top
bitbucket_sort = sort
if sort == 'pushed':
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
bitbucket_sort = (
'-updated_on' # Use negative prefix for descending order
)
elif sort == 'updated':
bitbucket_sort = '-updated_on'
elif sort == 'created':
bitbucket_sort = '-created_on'
elif sort == 'full_name':
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
else:
# Default to most recently updated first
bitbucket_sort = '-updated_on'
params = {
'pagelen': PER_PAGE,
'sort': bitbucket_sort,
}
# Fetch all repositories for this workspace with pagination
workspace_repos = await self._fetch_paginated_data(
workspace_repos_url, params, MAX_REPOS - len(repositories)
)
for repo in workspace_repos:
repositories.append(self._parse_repository(repo))
# Stop if we've reached the maximum number of repositories
if len(repositories) >= MAX_REPOS:
break
# Stop if we've reached the maximum number of repositories
if len(repositories) >= MAX_REPOS:
break
return repositories
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories."""
# TODO: implemented suggested tasks
return []
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
"""Gets all repository details from repository name."""
owner, repo = self._extract_owner_and_repo(repository)
url = f'{self.BASE_URL}/repositories/{owner}/{repo}'
data, _ = await self._make_request(url)
return self._parse_repository(data)
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository."""
owner, repo = self._extract_owner_and_repo(repository)
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
# Set maximum branches to fetch (similar to GitHub/GitLab implementations)
MAX_BRANCHES = 1000
PER_PAGE = 100
params = {
'pagelen': PER_PAGE,
'sort': '-target.date', # Sort by most recent commit date, descending
}
# Fetch all branches with pagination
branch_data = await self._fetch_paginated_data(url, params, MAX_BRANCHES)
branches = []
for branch in branch_data:
branches.append(
Branch(
name=branch.get('name', ''),
commit_sha=branch.get('target', {}).get('hash', ''),
protected=False, # Bitbucket doesn't expose this in the API
last_push_date=branch.get('target', {}).get('date', None),
)
)
return branches
async def get_paginated_branches(
self, repository: str, page: int = 1, per_page: int = 30
) -> PaginatedBranchesResponse:
"""Get branches for a repository with pagination."""
# Extract owner and repo from the repository string (e.g., "owner/repo")
parts = repository.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repository}')
owner = parts[-2]
repo = parts[-1]
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
params = {
'pagelen': per_page,
'page': page,
'sort': '-target.date', # Sort by most recent commit date, descending
}
response, _ = await self._make_request(url, params)
branches = []
for branch in response.get('values', []):
branches.append(
Branch(
name=branch.get('name', ''),
commit_sha=branch.get('target', {}).get('hash', ''),
protected=False, # Bitbucket doesn't expose this in the API
last_push_date=branch.get('target', {}).get('date', None),
)
)
# Bitbucket provides pagination info in the response
has_next_page = response.get('next') is not None
total_count = response.get('size') # Total number of items
return PaginatedBranchesResponse(
branches=branches,
has_next_page=has_next_page,
current_page=page,
per_page=per_page,
total_count=total_count,
)
async def search_branches(
self, repository: str, query: str, per_page: int = 30
) -> list[Branch]:
"""Search branches by name using Bitbucket API with `q` param."""
parts = repository.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repository}')
owner = parts[-2]
repo = parts[-1]
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
# Bitbucket filtering: name ~ "query"
params = {
'pagelen': per_page,
'q': f'name~"{query}"',
'sort': '-target.date',
}
response, _ = await self._make_request(url, params)
branches: list[Branch] = []
for branch in response.get('values', []):
branches.append(
Branch(
name=branch.get('name', ''),
commit_sha=branch.get('target', {}).get('hash', ''),
protected=False,
last_push_date=branch.get('target', {}).get('date', None),
)
)
return branches
async def create_pr(
self,
repo_name: str,
source_branch: str,
target_branch: str,
title: str,
body: str | None = None,
draft: bool = False,
) -> str:
"""Creates a pull request in Bitbucket.
Args:
repo_name: The repository name in the format "workspace/repo"
source_branch: The source branch name
target_branch: The target branch name
title: The title of the pull request
body: The description of the pull request
draft: Whether to create a draft pull request
Returns:
The URL of the created pull request
"""
owner, repo = self._extract_owner_and_repo(repo_name)
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/pullrequests'
payload = {
'title': title,
'description': body or '',
'source': {'branch': {'name': source_branch}},
'destination': {'branch': {'name': target_branch}},
'close_source_branch': False,
'draft': draft,
}
data, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
# Return the URL to the pull request
return data.get('links', {}).get('html', {}).get('href', '')
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific pull request
Args:
repository: Repository name in format 'owner/repo'
pr_number: The pull request number
Returns:
Raw Bitbucket API response for the pull request
"""
url = f'{self.BASE_URL}/repositories/{repository}/pullrequests/{pr_number}'
pr_data, _ = await self._make_request(url)
return pr_data
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)
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a Bitbucket pull request is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The PR number to check
Returns:
True if PR is active (OPEN), False if closed/merged
"""
try:
pr_details = await self.get_pr_details(repository, pr_number)
# Bitbucket API response structure
# https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-get
if 'state' in pr_details:
# Bitbucket state values: OPEN, MERGED, DECLINED, SUPERSEDED
return pr_details['state'] == 'OPEN'
# If we can't determine the state, assume it's active (safer default)
logger.warning(
f'Could not determine Bitbucket PR status for {repository}#{pr_number}. '
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
)
return True
except Exception as e:
logger.warning(
f'Could not determine Bitbucket PR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the PR status, include the conversation to be safe
return True
bitbucket_service_cls = os.environ.get(
'OPENHANDS_BITBUCKET_SERVICE_CLS',
@@ -0,0 +1,13 @@
from .base import BitBucketMixinBase
from .branches import BitBucketBranchesMixin
from .features import BitBucketFeaturesMixin
from .prs import BitBucketPRsMixin
from .repos import BitBucketReposMixin
__all__ = [
'BitBucketMixinBase',
'BitBucketBranchesMixin',
'BitBucketFeaturesMixin',
'BitBucketPRsMixin',
'BitBucketReposMixin',
]
@@ -0,0 +1,247 @@
import base64
from typing import Any
import httpx
from pydantic import SecretStr
from openhands.integrations.protocols.http_client import HTTPClient
from openhands.integrations.service_types import (
BaseGitService,
OwnerType,
ProviderType,
Repository,
RequestMethod,
ResourceNotFoundError,
User,
)
class BitBucketMixinBase(BaseGitService, HTTPClient):
"""
Base mixin for BitBucket service containing common functionality
"""
BASE_URL = 'https://api.bitbucket.org/2.0'
def _extract_owner_and_repo(self, repository: str) -> tuple[str, str]:
"""Extract owner and repo from repository string.
Args:
repository: Repository name in format 'workspace/repo_slug'
Returns:
Tuple of (owner, repo)
Raises:
ValueError: If repository format is invalid
"""
parts = repository.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repository}')
return parts[-2], parts[-1]
async def get_latest_token(self) -> SecretStr | None:
"""Get latest working token of the user."""
return self.token
def _has_token_expired(self, status_code: int) -> bool:
return status_code == 401
async def _get_headers(self) -> dict[str, str]:
"""Get headers for Bitbucket API requests."""
token_value = self.token.get_secret_value()
# Check if the token contains a colon, which indicates it's in username:password format
if ':' in token_value:
auth_str = base64.b64encode(token_value.encode()).decode()
return {
'Authorization': f'Basic {auth_str}',
'Accept': 'application/json',
}
else:
return {
'Authorization': f'Bearer {token_value}',
'Accept': 'application/json',
}
async def _make_request(
self,
url: str,
params: dict | None = None,
method: RequestMethod = RequestMethod.GET,
) -> tuple[Any, dict]:
"""Make a request to the Bitbucket API.
Args:
url: The URL to request
params: Optional parameters for the request
method: The HTTP method to use
Returns:
A tuple of (response_data, response_headers)
"""
try:
async with httpx.AsyncClient() as client:
bitbucket_headers = await self._get_headers()
response = await self.execute_request(
client, url, bitbucket_headers, params, method
)
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
bitbucket_headers = await self._get_headers()
response = await self.execute_request(
client=client,
url=url,
headers=bitbucket_headers,
params=params,
method=method,
)
response.raise_for_status()
return response.json(), dict(response.headers)
except httpx.HTTPStatusError as e:
raise self.handle_http_status_error(e)
except httpx.HTTPError as e:
raise self.handle_http_error(e)
async def _fetch_paginated_data(
self, url: str, params: dict, max_items: int
) -> list[dict]:
"""Fetch data with pagination support for Bitbucket API.
Args:
url: The API endpoint URL
params: Query parameters for the request
max_items: Maximum number of items to fetch
Returns:
List of data items from all pages
"""
all_items: list[dict] = []
current_url = url
while current_url and len(all_items) < max_items:
response, _ = await self._make_request(current_url, params)
# Extract items from response
page_items = response.get('values', [])
all_items.extend(page_items)
# Get next page URL from response
current_url = response.get('next')
# Clear params for subsequent requests as they're included in the next URL
params = {}
return all_items[:max_items]
async def get_user(self) -> User:
"""Get the authenticated user's information."""
url = f'{self.BASE_URL}/user'
data, _ = await self._make_request(url)
account_id = data.get('account_id', '')
return User(
id=account_id,
login=data.get('username', ''),
avatar_url=data.get('links', {}).get('avatar', {}).get('href', ''),
name=data.get('display_name'),
email=None, # Bitbucket API doesn't return email in this endpoint
)
def _parse_repository(
self, repo: dict, link_header: str | None = None
) -> Repository:
"""Parse a Bitbucket API repository response into a Repository object.
Args:
repo: Repository data from Bitbucket API
link_header: Optional link header for pagination
Returns:
Repository object
"""
repo_id = repo.get('uuid', '')
workspace_slug = repo.get('workspace', {}).get('slug', '')
repo_slug = repo.get('slug', '')
full_name = (
f'{workspace_slug}/{repo_slug}' if workspace_slug and repo_slug else ''
)
is_public = not repo.get('is_private', True)
owner_type = OwnerType.ORGANIZATION
main_branch = repo.get('mainbranch', {}).get('name')
return Repository(
id=repo_id,
full_name=full_name, # type: ignore[arg-type]
git_provider=ProviderType.BITBUCKET,
is_public=is_public,
stargazers_count=None, # Bitbucket doesn't have stars
pushed_at=repo.get('updated_on'),
owner_type=owner_type,
link_header=link_header,
main_branch=main_branch,
)
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
"""Get repository details from repository name.
Args:
repository: Repository name in format 'workspace/repo_slug'
Returns:
Repository object with details
"""
url = f'{self.BASE_URL}/repositories/{repository}'
data, _ = await self._make_request(url)
return self._parse_repository(data)
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']
@@ -0,0 +1,116 @@
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
from openhands.integrations.service_types import Branch, PaginatedBranchesResponse
class BitBucketBranchesMixin(BitBucketMixinBase):
"""
Mixin for BitBucket branch-related operations
"""
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository."""
owner, repo = self._extract_owner_and_repo(repository)
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
# Set maximum branches to fetch (similar to GitHub/GitLab implementations)
MAX_BRANCHES = 1000
PER_PAGE = 100
params = {
'pagelen': PER_PAGE,
'sort': '-target.date', # Sort by most recent commit date, descending
}
# Fetch all branches with pagination
branch_data = await self._fetch_paginated_data(url, params, MAX_BRANCHES)
branches = []
for branch in branch_data:
branches.append(
Branch(
name=branch.get('name', ''),
commit_sha=branch.get('target', {}).get('hash', ''),
protected=False, # Bitbucket doesn't expose this in the API
last_push_date=branch.get('target', {}).get('date', None),
)
)
return branches
async def get_paginated_branches(
self, repository: str, page: int = 1, per_page: int = 30
) -> PaginatedBranchesResponse:
"""Get branches for a repository with pagination."""
# Extract owner and repo from the repository string (e.g., "owner/repo")
parts = repository.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repository}')
owner = parts[-2]
repo = parts[-1]
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
params = {
'pagelen': per_page,
'page': page,
'sort': '-target.date', # Sort by most recent commit date, descending
}
response, _ = await self._make_request(url, params)
branches = []
for branch in response.get('values', []):
branches.append(
Branch(
name=branch.get('name', ''),
commit_sha=branch.get('target', {}).get('hash', ''),
protected=False, # Bitbucket doesn't expose this in the API
last_push_date=branch.get('target', {}).get('date', None),
)
)
# Bitbucket provides pagination info in the response
has_next_page = response.get('next') is not None
total_count = response.get('size') # Total number of items
return PaginatedBranchesResponse(
branches=branches,
has_next_page=has_next_page,
current_page=page,
per_page=per_page,
total_count=total_count,
)
async def search_branches(
self, repository: str, query: str, per_page: int = 30
) -> list[Branch]:
"""Search branches by name using Bitbucket API with `q` param."""
parts = repository.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repository}')
owner = parts[-2]
repo = parts[-1]
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
# Bitbucket filtering: name ~ "query"
params = {
'pagelen': per_page,
'q': f'name~"{query}"',
'sort': '-target.date',
}
response, _ = await self._make_request(url, params)
branches: list[Branch] = []
for branch in response.get('values', []):
branches.append(
Branch(
name=branch.get('name', ''),
commit_sha=branch.get('target', {}).get('hash', ''),
protected=False,
last_push_date=branch.get('target', {}).get('date', None),
)
)
return branches
@@ -0,0 +1,45 @@
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
from openhands.integrations.service_types import ResourceNotFoundError
from openhands.microagent.types import MicroagentContentResponse
class BitBucketFeaturesMixin(BitBucketMixinBase):
"""
Mixin for BitBucket feature operations (microagents, cursor rules, etc.)
"""
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)
@@ -0,0 +1,100 @@
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
from openhands.integrations.service_types import RequestMethod
class BitBucketPRsMixin(BitBucketMixinBase):
"""
Mixin for BitBucket pull request operations
"""
async def create_pr(
self,
repo_name: str,
source_branch: str,
target_branch: str,
title: str,
body: str | None = None,
draft: bool = False,
) -> str:
"""Creates a pull request in Bitbucket.
Args:
repo_name: The repository name in the format "workspace/repo"
source_branch: The source branch name
target_branch: The target branch name
title: The title of the pull request
body: The description of the pull request
draft: Whether to create a draft pull request
Returns:
The URL of the created pull request
"""
owner, repo = self._extract_owner_and_repo(repo_name)
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/pullrequests'
payload = {
'title': title,
'description': body or '',
'source': {'branch': {'name': source_branch}},
'destination': {'branch': {'name': target_branch}},
'close_source_branch': False,
'draft': draft,
}
data, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
# Return the URL to the pull request
return data.get('links', {}).get('html', {}).get('href', '')
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific pull request
Args:
repository: Repository name in format 'owner/repo'
pr_number: The pull request number
Returns:
Raw Bitbucket API response for the pull request
"""
url = f'{self.BASE_URL}/repositories/{repository}/pullrequests/{pr_number}'
pr_data, _ = await self._make_request(url)
return pr_data
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a Bitbucket pull request is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The PR number to check
Returns:
True if PR is active (OPEN), False if closed/merged
"""
try:
pr_details = await self.get_pr_details(repository, pr_number)
# Bitbucket API response structure
# https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-get
if 'state' in pr_details:
# Bitbucket state values: OPEN, MERGED, DECLINED, SUPERSEDED
return pr_details['state'] == 'OPEN'
# If we can't determine the state, assume it's active (safer default)
logger.warning(
f'Could not determine Bitbucket PR status for {repository}#{pr_number}. '
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
)
return True
except Exception as e:
logger.warning(
f'Could not determine Bitbucket PR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the PR status, include the conversation to be safe
return True
@@ -0,0 +1,256 @@
import re
from typing import Any
from urllib.parse import urlparse
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
from openhands.integrations.service_types import Repository, SuggestedTask
from openhands.server.types import AppMode
class BitBucketReposMixin(BitBucketMixinBase):
"""
Mixin for BitBucket repository-related operations
"""
async def search_repositories(
self, query: str, per_page: int, sort: str, order: str, public: bool
) -> list[Repository]:
"""Search for repositories."""
repositories = []
if public:
# Extract workspace and repo from URL using robust URL parsing
# URL format: https://{domain}/{workspace}/{repo}/{additional_params}
try:
parsed_url = urlparse(query)
# Remove leading slash and split path into segments
path_segments = [
segment for segment in parsed_url.path.split('/') if segment
]
# We need at least 2 path segments: workspace and repo
if len(path_segments) >= 2:
workspace_slug = path_segments[0]
repo_name = path_segments[1]
repo = await self.get_repository_details_from_repo_name(
f'{workspace_slug}/{repo_name}'
)
repositories.append(repo)
except (ValueError, IndexError):
# If URL parsing fails or doesn't have expected structure,
# return empty list for public search
pass
return repositories
# Search for repos once workspace prefix exists
if '/' in query:
workspace_slug, repo_query = query.split('/', 1)
return await self.get_paginated_repos(
1, per_page, sort, workspace_slug, repo_query
)
all_installations = await self.get_installations()
# Workspace prefix isn't complete. Search workspace names and repos underneath each workspace
matching_workspace_slugs = [
installation for installation in all_installations if query in installation
]
for workspace_slug in matching_workspace_slugs:
# Get repositories where query matches workspace name
try:
repos = await self.get_paginated_repos(
1, per_page, sort, workspace_slug
)
repositories.extend(repos)
except Exception:
continue
for workspace_slug in all_installations:
# Get repositories in all workspaces where query matches repo name
try:
repos = await self.get_paginated_repos(
1, per_page, sort, workspace_slug, query
)
repositories.extend(repos)
except Exception:
continue
return repositories
async def _get_user_workspaces(self) -> list[dict[str, Any]]:
"""Get all workspaces the user has access to"""
url = f'{self.BASE_URL}/workspaces'
data, _ = await self._make_request(url)
return data.get('values', [])
async def get_installations(
self, query: str | None = None, limit: int = 100
) -> list[str]:
workspaces_url = f'{self.BASE_URL}/workspaces'
params = {}
if query:
params['q'] = f'name~"{query}"'
workspaces = await self._fetch_paginated_data(workspaces_url, params, limit)
installations: list[str] = []
for workspace in workspaces:
installations.append(workspace['slug'])
return installations
async def get_paginated_repos(
self,
page: int,
per_page: int,
sort: str,
installation_id: str | None,
query: str | None = None,
) -> list[Repository]:
"""Get paginated repositories for a specific workspace.
Args:
page: The page number to fetch
per_page: The number of repositories per page
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
Returns:
A list of Repository objects
"""
if not installation_id:
return []
# Convert installation_id to string for use as workspace_slug
workspace_slug = installation_id
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
# Map sort parameter to Bitbucket API compatible values
bitbucket_sort = sort
if sort == 'pushed':
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
elif sort == 'updated':
bitbucket_sort = '-updated_on'
elif sort == 'created':
bitbucket_sort = '-created_on'
elif sort == 'full_name':
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
else:
# Default to most recently updated first
bitbucket_sort = '-updated_on'
params = {
'pagelen': per_page,
'page': page,
'sort': bitbucket_sort,
}
if query:
params['q'] = f'name~"{query}"'
response, headers = await self._make_request(workspace_repos_url, params)
# Extract repositories from the response
repos = response.get('values', [])
# Extract next URL from response
next_link = response.get('next', '')
# Format the link header in a way that the frontend can understand
# The frontend expects a format like: <url>; rel="next"
# where the URL contains a page parameter
formatted_link_header = ''
if next_link:
# Extract the page number from the next URL if possible
page_match = re.search(r'[?&]page=(\d+)', next_link)
if page_match:
next_page = page_match.group(1)
# Format it in a way that extractNextPageFromLink in frontend can parse
formatted_link_header = (
f'<{workspace_repos_url}?page={next_page}>; rel="next"'
)
else:
# If we can't extract the page, just use the next URL as is
formatted_link_header = f'<{next_link}>; rel="next"'
repositories = [
self._parse_repository(repo, link_header=formatted_link_header)
for repo in repos
]
return repositories
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
"""Get repositories for the authenticated user using workspaces endpoint.
This method gets all repositories (both public and private) that the user has access to
by iterating through their workspaces and fetching repositories from each workspace.
This approach is more comprehensive and efficient than the previous implementation
that made separate calls for public and private repositories.
"""
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by Bitbucket API
repositories: list[Repository] = []
# Get user's workspaces with pagination
workspaces_url = f'{self.BASE_URL}/workspaces'
workspaces = await self._fetch_paginated_data(workspaces_url, {}, MAX_REPOS)
for workspace in workspaces:
workspace_slug = workspace.get('slug')
if not workspace_slug:
continue
# Get repositories for this workspace with pagination
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
# Map sort parameter to Bitbucket API compatible values and ensure descending order
# to show most recently changed repos at the top
bitbucket_sort = sort
if sort == 'pushed':
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
bitbucket_sort = (
'-updated_on' # Use negative prefix for descending order
)
elif sort == 'updated':
bitbucket_sort = '-updated_on'
elif sort == 'created':
bitbucket_sort = '-created_on'
elif sort == 'full_name':
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
else:
# Default to most recently updated first
bitbucket_sort = '-updated_on'
params = {
'pagelen': PER_PAGE,
'sort': bitbucket_sort,
}
# Fetch all repositories for this workspace with pagination
workspace_repos = await self._fetch_paginated_data(
workspace_repos_url, params, MAX_REPOS - len(repositories)
)
for repo in workspace_repos:
repositories.append(self._parse_repository(repo))
# Stop if we've reached the maximum number of repositories
if len(repositories) >= MAX_REPOS:
break
# Stop if we've reached the maximum number of repositories
if len(repositories) >= MAX_REPOS:
break
return repositories
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories."""
# TODO: implemented suggested tasks
return []
@@ -43,8 +43,6 @@ class GitHubService(
BASE_URL = 'https://api.github.com'
GRAPHQL_URL = 'https://api.github.com/graphql'
token: SecretStr = SecretStr('')
refresh = False
def __init__(
self,
@@ -1,5 +1,6 @@
# openhands/integrations/github/service/__init__.py
from .base import GitHubMixinBase
from .branches_prs import GitHubBranchesMixin
from .features import GitHubFeaturesMixin
from .prs import GitHubPRsMixin
@@ -7,6 +8,7 @@ from .repos import GitHubReposMixin
from .resolver import GitHubResolverMixin
__all__ = [
'GitHubMixinBase',
'GitHubBranchesMixin',
'GitHubFeaturesMixin',
'GitHubPRsMixin',
@@ -1,164 +0,0 @@
from __future__ import annotations
import asyncio
import random
from typing import Any, Mapping
import httpx
from pydantic import SecretStr
from openhands.integrations.service_types import (
AuthenticationError,
RateLimitError,
RequestMethod,
ResourceNotFoundError,
UnknownException,
)
class GitHubAPI:
"""
Thin HTTP/GraphQL wrapper for GitHub with correct base URLs, standard headers,
shared AsyncClient, and basic retry/backoff for 429/5xx.
This component is internal. It does not alter existing behavior until wired
into GitHubService/mixins in subsequent PRs.
"""
def __init__(
self,
*,
base_domain: str | None = None,
token: SecretStr | None = None,
user_agent: str = "OpenHands-GitHubService",
timeout: float = 15.0,
) -> None:
domain = (base_domain or "github.com").strip()
if domain == "github.com":
self.rest_base = "https://api.github.com"
self.graphql_base = "https://api.github.com/graphql"
else:
self.rest_base = f"https://{domain}/api/v3"
self.graphql_base = f"https://{domain}/api/graphql"
self._token = token.get_secret_value() if token else ""
# Shared client for all requests through this API instance
self._client = httpx.AsyncClient(timeout=httpx.Timeout(timeout))
# Standard headers recommended by GitHub
self._base_headers: dict[str, str] = {
"Accept": "application/vnd.github+json",
"User-Agent": user_agent,
"X-GitHub-Api-Version": "2022-11-28",
}
if self._token:
self._base_headers["Authorization"] = f"Bearer {self._token}"
async def aclose(self) -> None:
await self._client.aclose()
async def __aenter__(self) -> "GitHubAPI":
return self
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
await self.aclose()
def set_token(self, token: SecretStr | None) -> None:
self._token = token.get_secret_value() if token else ""
if self._token:
self._base_headers["Authorization"] = f"Bearer {self._token}"
elif "Authorization" in self._base_headers:
del self._base_headers["Authorization"]
@property
def headers(self) -> Mapping[str, str]:
return dict(self._base_headers)
def _full_url(self, path_or_url: str) -> str:
if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
return path_or_url
if not path_or_url.startswith("/"):
path_or_url = "/" + path_or_url
return f"{self.rest_base}{path_or_url}"
async def request(
self,
method: RequestMethod | str = RequestMethod.GET,
path_or_url: str = "/",
*,
params: dict | None = None,
json: dict | None = None,
extra_headers: Mapping[str, str] | None = None,
max_retries: int = 2,
backoff_base: float = 0.25,
) -> tuple[Any, dict[str, str]]:
"""
Perform a REST request with basic retry for 429 and 5xx.
Returns (json_body, response_headers).
"""
url = self._full_url(path_or_url)
headers = {**self._base_headers, **(extra_headers or {})}
meth = method.value if isinstance(method, RequestMethod) else method.lower()
attempt = 0
while True:
try:
resp = await self._client.request(meth, url, headers=headers, params=params, json=json)
# Map errors consistently
if resp.status_code == 401:
raise AuthenticationError("Invalid github token")
if resp.status_code == 404:
raise ResourceNotFoundError(f"Resource not found on GitHub API: {url}")
if resp.status_code in (429, 500, 502, 503, 504):
if attempt < max_retries:
delay = backoff_base * (2**attempt) + random.uniform(0, 0.1)
attempt += 1
await asyncio.sleep(delay)
continue
raise RateLimitError("GitHub API rate limit or transient error")
resp.raise_for_status()
headers_out: dict[str, str] = {}
# copy interesting headers (Link, RateLimit, etc.) if present
if "Link" in resp.headers:
headers_out["Link"] = resp.headers["Link"]
if "X-RateLimit-Remaining" in resp.headers:
headers_out["X-RateLimit-Remaining"] = resp.headers["X-RateLimit-Remaining"]
if "X-RateLimit-Reset" in resp.headers:
headers_out["X-RateLimit-Reset"] = resp.headers["X-RateLimit-Reset"]
return resp.json(), headers_out
except (httpx.HTTPError) as e: # network errors
if attempt < max_retries:
delay = backoff_base * (2**attempt) + random.uniform(0, 0.1)
attempt += 1
await asyncio.sleep(delay)
continue
raise UnknownException(f"HTTP error {type(e).__name__}: {e}") from e
async def graphql(
self,
query: str,
variables: dict[str, Any] | None = None,
*,
extra_headers: Mapping[str, str] | None = None,
) -> dict[str, Any]:
headers = {**self._base_headers, **(extra_headers or {})}
resp = await self._client.post(
self.graphql_base,
headers=headers,
json={"query": query, "variables": variables or {}},
)
if resp.status_code == 401:
raise AuthenticationError("Invalid github token")
if resp.status_code == 404:
raise ResourceNotFoundError("GraphQL endpoint not found")
if resp.status_code in (429, 500, 502, 503, 504):
raise RateLimitError("GitHub API rate limit or transient error")
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict) and "errors" in data:
raise UnknownException(f"GraphQL query error: {data['errors']}")
if not isinstance(data, dict):
raise UnknownException("Unexpected GraphQL response type")
return data
@@ -4,6 +4,7 @@ from typing import Any, cast
import httpx
from pydantic import SecretStr
from openhands.integrations.protocols.http_client import HTTPClient
from openhands.integrations.service_types import (
BaseGitService,
RequestMethod,
@@ -12,19 +13,15 @@ from openhands.integrations.service_types import (
)
class GitHubMixinBase(BaseGitService):
class GitHubMixinBase(BaseGitService, HTTPClient):
"""
Declares common attributes and method signatures used across mixins.
"""
BASE_URL: str
GRAPHQL_URL: str
token: SecretStr
refresh: bool
external_auth_id: str | None
base_domain: str | None
async def _get_github_headers(self) -> dict:
async def _get_headers(self) -> dict:
"""Retrieve the GH Token from settings store to construct the headers."""
if not self.token:
latest_token = await self.get_latest_token()
@@ -47,7 +44,7 @@ class GitHubMixinBase(BaseGitService):
) -> tuple[Any, dict]: # type: ignore[override]
try:
async with httpx.AsyncClient() as client:
github_headers = await self._get_github_headers()
github_headers = await self._get_headers()
# Make initial request
response = await self.execute_request(
@@ -61,7 +58,7 @@ class GitHubMixinBase(BaseGitService):
# Handle token refresh if needed
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
github_headers = await self._get_github_headers()
github_headers = await self._get_headers()
response = await self.execute_request(
client=client,
url=url,
@@ -87,7 +84,7 @@ class GitHubMixinBase(BaseGitService):
) -> dict[str, Any]:
try:
async with httpx.AsyncClient() as client:
github_headers = await self._get_github_headers()
github_headers = await self._get_headers()
response = await client.post(
self.GRAPHQL_URL,
+21 -865
View File
@@ -1,33 +1,33 @@
import os
from datetime import datetime
from typing import Any
import httpx
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.service import (
GitLabBranchesMixin,
GitLabFeaturesMixin,
GitLabPRsMixin,
GitLabReposMixin,
GitLabResolverMixin,
)
from openhands.integrations.service_types import (
BaseGitService,
Branch,
Comment,
GitService,
OwnerType,
PaginatedBranchesResponse,
ProviderType,
Repository,
RequestMethod,
SuggestedTask,
TaskType,
UnknownException,
User,
)
from openhands.microagent.types import MicroagentContentResponse
from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class GitLabService(BaseGitService, GitService):
"""Default implementation of GitService for GitLab integration.
class GitLabService(
GitLabBranchesMixin,
GitLabFeaturesMixin,
GitLabPRsMixin,
GitLabReposMixin,
GitLabResolverMixin,
BaseGitService,
GitService,
):
"""
Assembled GitLab service class combining mixins by feature area.
TODO: This doesn't seem a good candidate for the get_impl() pattern. What are the abstract methods we should actually separate and implement here?
This is an extension point in OpenHands that allows applications to customize GitLab
@@ -41,8 +41,6 @@ class GitLabService(BaseGitService, GitService):
BASE_URL = 'https://gitlab.com/api/v4'
GRAPHQL_URL = 'https://gitlab.com/api/graphql'
token: SecretStr = SecretStr('')
refresh = False
def __init__(
self,
@@ -52,9 +50,11 @@ class GitLabService(BaseGitService, GitService):
token: SecretStr | None = None,
external_token_manager: bool = False,
base_domain: str | None = None,
):
) -> None:
self.user_id = user_id
self.external_token_manager = external_token_manager
self.external_auth_id = external_auth_id
self.external_auth_token = external_auth_token
if token:
self.token = token
@@ -74,850 +74,6 @@ class GitLabService(BaseGitService, GitService):
def provider(self) -> str:
return ProviderType.GITLAB.value
async def _get_gitlab_headers(self) -> dict[str, Any]:
"""Retrieve the GitLab Token to construct the headers"""
if not self.token:
latest_token = await self.get_latest_token()
if latest_token:
self.token = latest_token
return {
'Authorization': f'Bearer {self.token.get_secret_value()}',
}
def _has_token_expired(self, status_code: int) -> bool:
return status_code == 401
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,
params: dict | None = None,
method: RequestMethod = RequestMethod.GET,
) -> tuple[Any, dict]:
try:
async with httpx.AsyncClient() as client:
gitlab_headers = await self._get_gitlab_headers()
# Make initial request
response = await self.execute_request(
client=client,
url=url,
headers=gitlab_headers,
params=params,
method=method,
)
# Handle token refresh if needed
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
gitlab_headers = await self._get_gitlab_headers()
response = await self.execute_request(
client=client,
url=url,
headers=gitlab_headers,
params=params,
method=method,
)
response.raise_for_status()
headers = {}
if 'Link' in response.headers:
headers['Link'] = response.headers['Link']
if 'X-Total' in response.headers:
headers['X-Total'] = response.headers['X-Total']
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)
except httpx.HTTPError as e:
raise self.handle_http_error(e)
async def execute_graphql_query(
self, query: str, variables: dict[str, Any] | None = None
) -> Any:
"""Execute a GraphQL query against the GitLab GraphQL API
Args:
query: The GraphQL query string
variables: Optional variables for the GraphQL query
Returns:
The data portion of the GraphQL response
"""
if variables is None:
variables = {}
try:
async with httpx.AsyncClient() as client:
gitlab_headers = await self._get_gitlab_headers()
# Add content type header for GraphQL
gitlab_headers['Content-Type'] = 'application/json'
payload = {
'query': query,
'variables': variables if variables is not None else {},
}
response = await client.post(
self.GRAPHQL_URL, headers=gitlab_headers, json=payload
)
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
gitlab_headers = await self._get_gitlab_headers()
gitlab_headers['Content-Type'] = 'application/json'
response = await client.post(
self.GRAPHQL_URL, headers=gitlab_headers, json=payload
)
response.raise_for_status()
result = response.json()
# Check for GraphQL errors
if 'errors' in result:
error_message = result['errors'][0].get(
'message', 'Unknown GraphQL error'
)
raise UnknownException(f'GraphQL error: {error_message}')
return result.get('data')
except httpx.HTTPStatusError as e:
raise self.handle_http_status_error(e)
except httpx.HTTPError as e:
raise self.handle_http_error(e)
async def get_user(self) -> User:
url = f'{self.BASE_URL}/user'
response, _ = await self._make_request(url)
# Use a default avatar URL if not provided
# In some self-hosted GitLab instances, the avatar_url field may be returned as None.
avatar_url = response.get('avatar_url') or ''
return User(
id=str(response.get('id', '')),
login=response.get('username'), # type: ignore[call-arg]
avatar_url=avatar_url,
name=response.get('name'),
email=response.get('email'),
company=response.get('organization'),
)
def _parse_repository(
self, repo: dict, link_header: str | None = None
) -> Repository:
"""Parse a GitLab API project response into a Repository object.
Args:
repo: Project data from GitLab API
link_header: Optional link header for pagination
Returns:
Repository object
"""
return Repository(
id=str(repo.get('id')), # type: ignore[arg-type]
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
owner_type=(
OwnerType.ORGANIZATION
if repo.get('namespace', {}).get('kind') == 'group'
else OwnerType.USER
),
link_header=link_header,
main_branch=repo.get('default_branch'),
)
def _parse_gitlab_url(self, url: str) -> str | None:
"""Parse a GitLab URL to extract the repository path.
Expected format: https://{domain}/{group}/{possibly_subgroup}/{repo}
Returns the full path from group onwards (e.g., 'group/subgroup/repo' or 'group/repo')
"""
try:
# Remove protocol and domain
if '://' in url:
url = url.split('://', 1)[1]
if '/' in url:
path = url.split('/', 1)[1]
else:
return None
# Clean up the path
path = path.strip('/')
if not path:
return None
# Split the path and remove empty parts
path_parts = [part for part in path.split('/') if part]
# We need at least 2 parts: group/repo
if len(path_parts) < 2:
return None
# Join all parts to form the full repository path
return '/'.join(path_parts)
except Exception:
return None
async def search_repositories(
self,
query: str,
per_page: int = 30,
sort: str = 'updated',
order: str = 'desc',
public: bool = False,
) -> list[Repository]:
if public:
# When public=True, query is a GitLab URL that we need to parse
repo_path = self._parse_gitlab_url(query)
if not repo_path:
return [] # Invalid URL format
repository = await self.get_repository_details_from_repo_name(repo_path)
return [repository]
return await self.get_paginated_repos(1, per_page, sort, None, query)
async def get_paginated_repos(
self,
page: int,
per_page: int,
sort: str,
installation_id: str | None,
query: str | None = None,
) -> list[Repository]:
url = f'{self.BASE_URL}/projects'
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name',
}.get(sort, 'last_activity_at')
params = {
'page': str(page),
'per_page': str(per_page),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'membership': True, # Include projects user is a member of
}
if query:
params['search'] = query
params['search_namespaces'] = True
response, headers = await self._make_request(url, params)
next_link: str = headers.get('Link', '')
repos = [
self._parse_repository(repo, link_header=next_link) for repo in response
]
return repos
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []
page = 1
url = f'{self.BASE_URL}/projects'
# Map GitHub's sort values to GitLab's order_by values
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name',
}.get(sort, 'last_activity_at')
while len(all_repos) < MAX_REPOS:
params = {
'page': str(page),
'per_page': str(PER_PAGE),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'membership': 1, # Use 1 instead of True
}
response, headers = await self._make_request(url, params)
if not response: # No more repositories
break
all_repos.extend(response)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
# Trim to MAX_REPOS if needed and convert to Repository objects
all_repos = all_repos[:MAX_REPOS]
return [self._parse_repository(repo) for repo in all_repos]
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories.
Returns:
- Merge requests authored by the user.
- Issues assigned to the user.
"""
# Get user info to use in queries
user = await self.get_user()
username = user.login
# GraphQL query to get merge requests
query = """
query GetUserTasks {
currentUser {
authoredMergeRequests(state: opened, sort: UPDATED_DESC, first: 100) {
nodes {
id
iid
title
project {
fullPath
}
conflicts
mergeStatus
pipelines(first: 1) {
nodes {
status
}
}
discussions(first: 100) {
nodes {
notes {
nodes {
resolvable
resolved
}
}
}
}
}
}
}
}
"""
try:
tasks: list[SuggestedTask] = []
# Get merge requests using GraphQL
response = await self.execute_graphql_query(query)
data = response.get('currentUser', {})
# Process merge requests
merge_requests = data.get('authoredMergeRequests', {}).get('nodes', [])
for mr in merge_requests:
repo_name = mr.get('project', {}).get('fullPath', '')
mr_number = mr.get('iid')
title = mr.get('title', '')
# Start with default task type
task_type = TaskType.OPEN_PR
# Check for specific states
if mr.get('conflicts'):
task_type = TaskType.MERGE_CONFLICTS
elif (
mr.get('pipelines', {}).get('nodes', [])
and mr.get('pipelines', {}).get('nodes', [])[0].get('status')
== 'FAILED'
):
task_type = TaskType.FAILING_CHECKS
else:
# Check for unresolved comments
has_unresolved_comments = False
for discussion in mr.get('discussions', {}).get('nodes', []):
for note in discussion.get('notes', {}).get('nodes', []):
if note.get('resolvable') and not note.get('resolved'):
has_unresolved_comments = True
break
if has_unresolved_comments:
break
if has_unresolved_comments:
task_type = TaskType.UNRESOLVED_COMMENTS
# Only add the task if it's not OPEN_PR
if task_type != TaskType.OPEN_PR:
tasks.append(
SuggestedTask(
git_provider=ProviderType.GITLAB,
task_type=task_type,
repo=repo_name,
issue_number=mr_number,
title=title,
)
)
# Get assigned issues using REST API
url = f'{self.BASE_URL}/issues'
params = {
'assignee_username': username,
'state': 'opened',
'scope': 'assigned_to_me',
}
issues_response, _ = await self._make_request(
method=RequestMethod.GET, url=url, params=params
)
# Process issues
for issue in issues_response:
repo_name = (
issue.get('references', {}).get('full', '').split('#')[0].strip()
)
issue_number = issue.get('iid')
title = issue.get('title', '')
tasks.append(
SuggestedTask(
git_provider=ProviderType.GITLAB,
task_type=TaskType.OPEN_ISSUE,
repo=repo_name,
issue_number=issue_number,
title=title,
)
)
return tasks
except Exception:
return []
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}'
repo, _ = await self._make_request(url)
return self._parse_repository(repo)
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository"""
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
# Set maximum branches to fetch (10 pages with 100 per page)
MAX_BRANCHES = 1000
PER_PAGE = 100
all_branches: list[Branch] = []
page = 1
# Fetch up to 10 pages of branches
while page <= 10 and len(all_branches) < MAX_BRANCHES:
params = {'per_page': str(PER_PAGE), 'page': str(page)}
response, headers = await self._make_request(url, params)
if not response: # No more branches
break
for branch_data in response:
branch = Branch(
name=branch_data.get('name'),
commit_sha=branch_data.get('commit', {}).get('id', ''),
protected=branch_data.get('protected', False),
last_push_date=branch_data.get('commit', {}).get('committed_date'),
)
all_branches.append(branch)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
return all_branches
async def get_paginated_branches(
self, repository: str, page: int = 1, per_page: int = 30
) -> PaginatedBranchesResponse:
"""Get branches for a repository with pagination"""
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
params = {'per_page': str(per_page), 'page': str(page)}
response, headers = await self._make_request(url, params)
branches: list[Branch] = []
for branch_data in response:
branch = Branch(
name=branch_data.get('name'),
commit_sha=branch_data.get('commit', {}).get('id', ''),
protected=branch_data.get('protected', False),
last_push_date=branch_data.get('commit', {}).get('committed_date'),
)
branches.append(branch)
has_next_page = False
total_count = None
if headers.get('Link', ''):
has_next_page = True
if 'X-Total' in headers:
try:
total_count = int(headers['X-Total'])
except (ValueError, TypeError):
pass
return PaginatedBranchesResponse(
branches=branches,
has_next_page=has_next_page,
current_page=page,
per_page=per_page,
total_count=total_count,
)
async def search_branches(
self, repository: str, query: str, per_page: int = 30
) -> list[Branch]:
"""Search branches using GitLab API which supports `search` param."""
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
params = {'per_page': str(per_page), 'search': query}
response, _ = await self._make_request(url, params)
branches: list[Branch] = []
for branch_data in response:
branches.append(
Branch(
name=branch_data.get('name'),
commit_sha=branch_data.get('commit', {}).get('id', ''),
protected=branch_data.get('protected', False),
last_push_date=branch_data.get('commit', {}).get('committed_date'),
)
)
return branches
async def create_mr(
self,
id: int | str,
source_branch: str,
target_branch: str,
title: str,
description: str | None = None,
labels: list[str] | None = None,
) -> str:
"""Creates a merge request in GitLab
Args:
id: The ID or URL-encoded path of the project
source_branch: The name of the branch where your changes are implemented
target_branch: The name of the branch you want the changes merged into
title: The title of the merge request (optional, defaults to a generic title)
description: The description of the merge request (optional)
labels: A list of labels to apply to the merge request (optional)
Returns:
- MR URL when successful
- Error message when unsuccessful
"""
# Convert string ID to URL-encoded path if needed
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
# Set default description if none provided
if not description:
description = f'Merging changes from {source_branch} into {target_branch}'
# Prepare the request payload
payload = {
'source_branch': source_branch,
'target_branch': target_branch,
'title': title,
'description': description,
}
# Add labels if provided
if labels and len(labels) > 0:
payload['labels'] = ','.join(labels)
# Make the POST request to create the MR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
return response['web_url']
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific merge request
Args:
repository: Repository name in format 'owner/repo'
pr_number: The merge request number (iid)
Returns:
Raw GitLab API response for the merge request
"""
project_id = self._extract_project_id(repository)
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{pr_number}'
mr_data, _ = await self._make_request(url)
return mr_data
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)
async def get_review_thread_comments(
self, project_id: str, issue_iid: int, discussion_id: str
) -> list[Comment]:
url = (
f'{self.BASE_URL}/projects/{project_id}'
f'/merge_requests/{issue_iid}/discussions/{discussion_id}'
)
# Single discussion fetch; notes are returned inline.
response, _ = await self._make_request(url)
notes = response.get('notes') or []
return self._process_raw_comments(notes)
async def get_issue_or_mr_title_and_body(
self, project_id: str, issue_number: int, is_mr: bool = False
) -> tuple[str, str]:
"""Get the title and body of an issue or merge request.
Args:
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
issue_number: The issue/MR IID within the project
is_mr: If True, treat as merge request; if False, treat as issue;
if None, try issue first then merge request (default behavior)
Returns:
A tuple of (title, body)
"""
if is_mr:
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{issue_number}'
response, _ = await self._make_request(url)
title = response.get('title') or ''
body = response.get('description') or ''
return title, body
url = f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}'
response, _ = await self._make_request(url)
title = response.get('title') or ''
body = response.get('description') or ''
return title, body
async def get_issue_or_mr_comments(
self,
project_id: str,
issue_number: int,
max_comments: int = 10,
is_mr: bool = False,
) -> list[Comment]:
"""Get comments for an issue or merge request.
Args:
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
issue_number: The issue/MR IID within the project
max_comments: Maximum number of comments to retrieve
is_pr: If True, treat as merge request; if False, treat as issue;
if None, try issue first then merge request (default behavior)
Returns:
List of Comment objects ordered by creation date
"""
all_comments: list[Comment] = []
page = 1
per_page = min(max_comments, 10)
url = (
f'{self.BASE_URL}/projects/{project_id}/merge_requests/{issue_number}/discussions'
if is_mr
else f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}/notes'
)
while len(all_comments) < max_comments:
params = {
'per_page': per_page,
'page': page,
'order_by': 'created_at',
'sort': 'asc',
}
response, headers = await self._make_request(url, params)
if not response:
break
if is_mr:
for discussions in response:
# Keep root level comments
all_comments.append(discussions['notes'][0])
else:
all_comments.extend(response)
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
page += 1
return self._process_raw_comments(all_comments)
def _process_raw_comments(
self, comments: list, max_comments: int = 10
) -> list[Comment]:
"""Helper method to fetch comments from a given URL with pagination."""
all_comments: list[Comment] = []
for comment_data in comments:
comment = Comment(
id=str(comment_data.get('id', 'unknown')),
body=self._truncate_comment(comment_data.get('body', '')),
author=comment_data.get('author', {}).get('username', 'unknown'),
created_at=datetime.fromisoformat(
comment_data.get('created_at', '').replace('Z', '+00:00')
)
if comment_data.get('created_at')
else datetime.fromtimestamp(0),
updated_at=datetime.fromisoformat(
comment_data.get('updated_at', '').replace('Z', '+00:00')
)
if comment_data.get('updated_at')
else datetime.fromtimestamp(0),
system=comment_data.get('system', False),
)
all_comments.append(comment)
# Sort comments by creation date and return the most recent ones
all_comments.sort(key=lambda c: c.created_at)
return all_comments[-max_comments:]
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a GitLab merge request is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The merge request number (iid)
Returns:
True if MR is active (opened), False if closed/merged
"""
try:
mr_details = await self.get_pr_details(repository, pr_number)
# GitLab API response structure
# https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr
if 'state' in mr_details:
return mr_details['state'] == 'opened'
elif 'merged_at' in mr_details and 'closed_at' in mr_details:
# Check if MR is merged or closed
return not (mr_details['merged_at'] or mr_details['closed_at'])
# If we can't determine the state, assume it's active (safer default)
logger.warning(
f'Could not determine GitLab MR status for {repository}#{pr_number}. '
f'Response keys: {list(mr_details.keys())}. Assuming MR is active.'
)
return True
except Exception as e:
logger.warning(
f'Could not determine GitLab MR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the MR status, include the conversation to be safe
return True
gitlab_service_cls = os.environ.get(
'OPENHANDS_GITLAB_SERVICE_CLS',
@@ -0,0 +1,17 @@
# openhands/integrations/gitlab/service/__init__.py
from .base import GitLabMixinBase
from .branches import GitLabBranchesMixin
from .features import GitLabFeaturesMixin
from .prs import GitLabPRsMixin
from .repos import GitLabReposMixin
from .resolver import GitLabResolverMixin
__all__ = [
'GitLabMixinBase',
'GitLabBranchesMixin',
'GitLabFeaturesMixin',
'GitLabPRsMixin',
'GitLabReposMixin',
'GitLabResolverMixin',
]
@@ -0,0 +1,177 @@
from typing import Any
import httpx
from pydantic import SecretStr
from openhands.integrations.protocols.http_client import HTTPClient
from openhands.integrations.service_types import (
BaseGitService,
RequestMethod,
UnknownException,
User,
)
class GitLabMixinBase(BaseGitService, HTTPClient):
"""
Declares common attributes and method signatures used across mixins.
"""
BASE_URL: str
GRAPHQL_URL: str
async def _get_headers(self) -> dict[str, Any]:
"""Retrieve the GitLab Token to construct the headers"""
if not self.token:
latest_token = await self.get_latest_token()
if latest_token:
self.token = latest_token
return {
'Authorization': f'Bearer {self.token.get_secret_value()}',
}
async def get_latest_token(self) -> SecretStr | None: # type: ignore[override]
return self.token
async def _make_request(
self,
url: str,
params: dict | None = None,
method: RequestMethod = RequestMethod.GET,
) -> tuple[Any, dict]: # type: ignore[override]
try:
async with httpx.AsyncClient() as client:
gitlab_headers = await self._get_headers()
# Make initial request
response = await self.execute_request(
client=client,
url=url,
headers=gitlab_headers,
params=params,
method=method,
)
# Handle token refresh if needed
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
gitlab_headers = await self._get_headers()
response = await self.execute_request(
client=client,
url=url,
headers=gitlab_headers,
params=params,
method=method,
)
response.raise_for_status()
headers = {}
if 'Link' in response.headers:
headers['Link'] = response.headers['Link']
if 'X-Total' in response.headers:
headers['X-Total'] = response.headers['X-Total']
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)
except httpx.HTTPError as e:
raise self.handle_http_error(e)
async def execute_graphql_query(
self, query: str, variables: dict[str, Any] | None = None
) -> Any:
"""Execute a GraphQL query against the GitLab GraphQL API
Args:
query: The GraphQL query string
variables: Optional variables for the GraphQL query
Returns:
The data portion of the GraphQL response
"""
if variables is None:
variables = {}
try:
async with httpx.AsyncClient() as client:
gitlab_headers = await self._get_headers()
# Add content type header for GraphQL
gitlab_headers['Content-Type'] = 'application/json'
payload = {
'query': query,
'variables': variables if variables is not None else {},
}
response = await client.post(
self.GRAPHQL_URL, headers=gitlab_headers, json=payload
)
if self.refresh and self._has_token_expired(response.status_code):
await self.get_latest_token()
gitlab_headers = await self._get_headers()
gitlab_headers['Content-Type'] = 'application/json'
response = await client.post(
self.GRAPHQL_URL, headers=gitlab_headers, json=payload
)
response.raise_for_status()
result = response.json()
# Check for GraphQL errors
if 'errors' in result:
error_message = result['errors'][0].get(
'message', 'Unknown GraphQL error'
)
raise UnknownException(f'GraphQL error: {error_message}')
return result.get('data')
except httpx.HTTPStatusError as e:
raise self.handle_http_status_error(e)
except httpx.HTTPError as e:
raise self.handle_http_error(e)
async def get_user(self) -> User:
url = f'{self.BASE_URL}/user'
response, _ = await self._make_request(url)
# Use a default avatar URL if not provided
# In some self-hosted GitLab instances, the avatar_url field may be returned as None.
avatar_url = response.get('avatar_url') or ''
return User(
id=str(response.get('id', '')),
login=response.get('username'), # type: ignore[call-arg]
avatar_url=avatar_url,
name=response.get('name'),
email=response.get('email'),
company=response.get('organization'),
)
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
@@ -0,0 +1,107 @@
from openhands.integrations.gitlab.service.base import GitLabMixinBase
from openhands.integrations.service_types import Branch, PaginatedBranchesResponse
class GitLabBranchesMixin(GitLabMixinBase):
"""
Methods for interacting with GitLab branches
"""
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository"""
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
# Set maximum branches to fetch (10 pages with 100 per page)
MAX_BRANCHES = 1000
PER_PAGE = 100
all_branches: list[Branch] = []
page = 1
# Fetch up to 10 pages of branches
while page <= 10 and len(all_branches) < MAX_BRANCHES:
params = {'per_page': str(PER_PAGE), 'page': str(page)}
response, headers = await self._make_request(url, params)
if not response: # No more branches
break
for branch_data in response:
branch = Branch(
name=branch_data.get('name'),
commit_sha=branch_data.get('commit', {}).get('id', ''),
protected=branch_data.get('protected', False),
last_push_date=branch_data.get('commit', {}).get('committed_date'),
)
all_branches.append(branch)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
return all_branches
async def get_paginated_branches(
self, repository: str, page: int = 1, per_page: int = 30
) -> PaginatedBranchesResponse:
"""Get branches for a repository with pagination"""
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
params = {'per_page': str(per_page), 'page': str(page)}
response, headers = await self._make_request(url, params)
branches: list[Branch] = []
for branch_data in response:
branch = Branch(
name=branch_data.get('name'),
commit_sha=branch_data.get('commit', {}).get('id', ''),
protected=branch_data.get('protected', False),
last_push_date=branch_data.get('commit', {}).get('committed_date'),
)
branches.append(branch)
has_next_page = False
total_count = None
if headers.get('Link', ''):
has_next_page = True
if 'X-Total' in headers:
try:
total_count = int(headers['X-Total'])
except (ValueError, TypeError):
pass
return PaginatedBranchesResponse(
branches=branches,
has_next_page=has_next_page,
current_page=page,
per_page=per_page,
total_count=total_count,
)
async def search_branches(
self, repository: str, query: str, per_page: int = 30
) -> list[Branch]:
"""Search branches using GitLab API which supports `search` param."""
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}/repository/branches'
params = {'per_page': str(per_page), 'search': query}
response, _ = await self._make_request(url, params)
branches: list[Branch] = []
for branch_data in response:
branches.append(
Branch(
name=branch_data.get('name'),
commit_sha=branch_data.get('commit', {}).get('id', ''),
protected=branch_data.get('protected', False),
last_push_date=branch_data.get('commit', {}).get('committed_date'),
)
)
return branches
@@ -0,0 +1,207 @@
from openhands.integrations.gitlab.service.base import GitLabMixinBase
from openhands.integrations.service_types import (
MicroagentContentResponse,
ProviderType,
RequestMethod,
SuggestedTask,
TaskType,
)
class GitLabFeaturesMixin(GitLabMixinBase):
"""
Methods used for custom features in UI driven via GitLab integration
"""
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 get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories.
Returns:
- Merge requests authored by the user.
- Issues assigned to the user.
"""
# Get user info to use in queries
user = await self.get_user()
username = user.login
# GraphQL query to get merge requests
query = """
query GetUserTasks {
currentUser {
authoredMergeRequests(state: opened, sort: UPDATED_DESC, first: 100) {
nodes {
id
iid
title
project {
fullPath
}
conflicts
mergeStatus
pipelines(first: 1) {
nodes {
status
}
}
discussions(first: 100) {
nodes {
notes {
nodes {
resolvable
resolved
}
}
}
}
}
}
}
}
"""
try:
tasks: list[SuggestedTask] = []
# Get merge requests using GraphQL
response = await self.execute_graphql_query(query)
data = response.get('currentUser', {})
# Process merge requests
merge_requests = data.get('authoredMergeRequests', {}).get('nodes', [])
for mr in merge_requests:
repo_name = mr.get('project', {}).get('fullPath', '')
mr_number = mr.get('iid')
title = mr.get('title', '')
# Start with default task type
task_type = TaskType.OPEN_PR
# Check for specific states
if mr.get('conflicts'):
task_type = TaskType.MERGE_CONFLICTS
elif (
mr.get('pipelines', {}).get('nodes', [])
and mr.get('pipelines', {}).get('nodes', [])[0].get('status')
== 'FAILED'
):
task_type = TaskType.FAILING_CHECKS
else:
# Check for unresolved comments
has_unresolved_comments = False
for discussion in mr.get('discussions', {}).get('nodes', []):
for note in discussion.get('notes', {}).get('nodes', []):
if note.get('resolvable') and not note.get('resolved'):
has_unresolved_comments = True
break
if has_unresolved_comments:
break
if has_unresolved_comments:
task_type = TaskType.UNRESOLVED_COMMENTS
# Only add the task if it's not OPEN_PR
if task_type != TaskType.OPEN_PR:
tasks.append(
SuggestedTask(
git_provider=ProviderType.GITLAB,
task_type=task_type,
repo=repo_name,
issue_number=mr_number,
title=title,
)
)
# Get assigned issues using REST API
url = f'{self.BASE_URL}/issues'
params = {
'assignee_username': username,
'state': 'opened',
'scope': 'assigned_to_me',
}
issues_response, _ = await self._make_request(
method=RequestMethod.GET, url=url, params=params
)
# Process issues
for issue in issues_response:
repo_name = (
issue.get('references', {}).get('full', '').split('#')[0].strip()
)
issue_number = issue.get('iid')
title = issue.get('title', '')
tasks.append(
SuggestedTask(
git_provider=ProviderType.GITLAB,
task_type=TaskType.OPEN_ISSUE,
repo=repo_name,
issue_number=issue_number,
title=title,
)
)
return tasks
except Exception:
return []
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)
@@ -0,0 +1,111 @@
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.service.base import GitLabMixinBase
from openhands.integrations.service_types import RequestMethod
class GitLabPRsMixin(GitLabMixinBase):
"""
Methods for interacting with GitLab merge requests (PRs)
"""
async def create_mr(
self,
id: int | str,
source_branch: str,
target_branch: str,
title: str,
description: str | None = None,
labels: list[str] | None = None,
) -> str:
"""Creates a merge request in GitLab
Args:
id: The ID or URL-encoded path of the project
source_branch: The name of the branch where your changes are implemented
target_branch: The name of the branch you want the changes merged into
title: The title of the merge request (optional, defaults to a generic title)
description: The description of the merge request (optional)
labels: A list of labels to apply to the merge request (optional)
Returns:
- MR URL when successful
- Error message when unsuccessful
"""
# Convert string ID to URL-encoded path if needed
project_id = str(id).replace('/', '%2F') if isinstance(id, str) else id
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests'
# Set default description if none provided
if not description:
description = f'Merging changes from {source_branch} into {target_branch}'
# Prepare the request payload
payload = {
'source_branch': source_branch,
'target_branch': target_branch,
'title': title,
'description': description,
}
# Add labels if provided
if labels and len(labels) > 0:
payload['labels'] = ','.join(labels)
# Make the POST request to create the MR
response, _ = await self._make_request(
url=url, params=payload, method=RequestMethod.POST
)
return response['web_url']
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific merge request
Args:
repository: Repository name in format 'owner/repo'
pr_number: The merge request number (iid)
Returns:
Raw GitLab API response for the merge request
"""
project_id = self._extract_project_id(repository)
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{pr_number}'
mr_data, _ = await self._make_request(url)
return mr_data
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a GitLab merge request is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The merge request number (iid)
Returns:
True if MR is active (opened), False if closed/merged
"""
try:
mr_details = await self.get_pr_details(repository, pr_number)
# GitLab API response structure
# https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr
if 'state' in mr_details:
return mr_details['state'] == 'opened'
elif 'merged_at' in mr_details and 'closed_at' in mr_details:
# Check if MR is merged or closed
return not (mr_details['merged_at'] or mr_details['closed_at'])
# If we can't determine the state, assume it's active (safer default)
logger.warning(
f'Could not determine GitLab MR status for {repository}#{pr_number}. '
f'Response keys: {list(mr_details.keys())}. Assuming MR is active.'
)
return True
except Exception as e:
logger.warning(
f'Could not determine GitLab MR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the MR status, include the conversation to be safe
return True
@@ -0,0 +1,176 @@
from openhands.integrations.gitlab.service.base import GitLabMixinBase
from openhands.integrations.service_types import OwnerType, ProviderType, Repository
from openhands.server.types import AppMode
class GitLabReposMixin(GitLabMixinBase):
"""
Methods for interacting with GitLab repositories
"""
def _parse_repository(
self, repo: dict, link_header: str | None = None
) -> Repository:
"""Parse a GitLab API project response into a Repository object.
Args:
repo: Project data from GitLab API
link_header: Optional link header for pagination
Returns:
Repository object
"""
return Repository(
id=str(repo.get('id')), # type: ignore[arg-type]
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
owner_type=(
OwnerType.ORGANIZATION
if repo.get('namespace', {}).get('kind') == 'group'
else OwnerType.USER
),
link_header=link_header,
main_branch=repo.get('default_branch'),
)
def _parse_gitlab_url(self, url: str) -> str | None:
"""Parse a GitLab URL to extract the repository path.
Expected format: https://{domain}/{group}/{possibly_subgroup}/{repo}
Returns the full path from group onwards (e.g., 'group/subgroup/repo' or 'group/repo')
"""
try:
# Remove protocol and domain
if '://' in url:
url = url.split('://', 1)[1]
if '/' in url:
path = url.split('/', 1)[1]
else:
return None
# Clean up the path
path = path.strip('/')
if not path:
return None
# Split the path and remove empty parts
path_parts = [part for part in path.split('/') if part]
# We need at least 2 parts: group/repo
if len(path_parts) < 2:
return None
# Join all parts to form the full repository path
return '/'.join(path_parts)
except Exception:
return None
async def search_repositories(
self,
query: str,
per_page: int = 30,
sort: str = 'updated',
order: str = 'desc',
public: bool = False,
) -> list[Repository]:
if public:
# When public=True, query is a GitLab URL that we need to parse
repo_path = self._parse_gitlab_url(query)
if not repo_path:
return [] # Invalid URL format
repository = await self.get_repository_details_from_repo_name(repo_path)
return [repository]
return await self.get_paginated_repos(1, per_page, sort, None, query)
async def get_paginated_repos(
self,
page: int,
per_page: int,
sort: str,
installation_id: str | None,
query: str | None = None,
) -> list[Repository]:
url = f'{self.BASE_URL}/projects'
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name',
}.get(sort, 'last_activity_at')
params = {
'page': str(page),
'per_page': str(per_page),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'membership': True, # Include projects user is a member of
}
if query:
params['search'] = query
params['search_namespaces'] = True
response, headers = await self._make_request(url, params)
next_link: str = headers.get('Link', '')
repos = [
self._parse_repository(repo, link_header=next_link) for repo in response
]
return repos
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []
page = 1
url = f'{self.BASE_URL}/projects'
# Map GitHub's sort values to GitLab's order_by values
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name',
}.get(sort, 'last_activity_at')
while len(all_repos) < MAX_REPOS:
params = {
'page': str(page),
'per_page': str(PER_PAGE),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'membership': 1, # Use 1 instead of True
}
response, headers = await self._make_request(url, params)
if not response: # No more repositories
break
all_repos.extend(response)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
# Trim to MAX_REPOS if needed and convert to Repository objects
all_repos = all_repos[:MAX_REPOS]
return [self._parse_repository(repo) for repo in all_repos]
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:
encoded_name = repository.replace('/', '%2F')
url = f'{self.BASE_URL}/projects/{encoded_name}'
repo, _ = await self._make_request(url)
return self._parse_repository(repo)
@@ -0,0 +1,134 @@
from datetime import datetime
from openhands.integrations.gitlab.service.base import GitLabMixinBase
from openhands.integrations.service_types import Comment
class GitLabResolverMixin(GitLabMixinBase):
"""
Helper methods used for the GitLab Resolver
"""
async def get_review_thread_comments(
self, project_id: str, issue_iid: int, discussion_id: str
) -> list[Comment]:
url = (
f'{self.BASE_URL}/projects/{project_id}'
f'/merge_requests/{issue_iid}/discussions/{discussion_id}'
)
# Single discussion fetch; notes are returned inline.
response, _ = await self._make_request(url)
notes = response.get('notes') or []
return self._process_raw_comments(notes)
async def get_issue_or_mr_title_and_body(
self, project_id: str, issue_number: int, is_mr: bool = False
) -> tuple[str, str]:
"""Get the title and body of an issue or merge request.
Args:
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
issue_number: The issue/MR IID within the project
is_mr: If True, treat as merge request; if False, treat as issue;
if None, try issue first then merge request (default behavior)
Returns:
A tuple of (title, body)
"""
if is_mr:
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{issue_number}'
response, _ = await self._make_request(url)
title = response.get('title') or ''
body = response.get('description') or ''
return title, body
url = f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}'
response, _ = await self._make_request(url)
title = response.get('title') or ''
body = response.get('description') or ''
return title, body
async def get_issue_or_mr_comments(
self,
project_id: str,
issue_number: int,
max_comments: int = 10,
is_mr: bool = False,
) -> list[Comment]:
"""Get comments for an issue or merge request.
Args:
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
issue_number: The issue/MR IID within the project
max_comments: Maximum number of comments to retrieve
is_pr: If True, treat as merge request; if False, treat as issue;
if None, try issue first then merge request (default behavior)
Returns:
List of Comment objects ordered by creation date
"""
all_comments: list[Comment] = []
page = 1
per_page = min(max_comments, 10)
url = (
f'{self.BASE_URL}/projects/{project_id}/merge_requests/{issue_number}/discussions'
if is_mr
else f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}/notes'
)
while len(all_comments) < max_comments:
params = {
'per_page': per_page,
'page': page,
'order_by': 'created_at',
'sort': 'asc',
}
response, headers = await self._make_request(url, params)
if not response:
break
if is_mr:
for discussions in response:
# Keep root level comments
all_comments.append(discussions['notes'][0])
else:
all_comments.extend(response)
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
page += 1
return self._process_raw_comments(all_comments)
def _process_raw_comments(
self, comments: list, max_comments: int = 10
) -> list[Comment]:
"""Helper method to fetch comments from a given URL with pagination."""
all_comments: list[Comment] = []
for comment_data in comments:
comment = Comment(
id=str(comment_data.get('id', 'unknown')),
body=self._truncate_comment(comment_data.get('body', '')),
author=comment_data.get('author', {}).get('username', 'unknown'),
created_at=datetime.fromisoformat(
comment_data.get('created_at', '').replace('Z', '+00:00')
)
if comment_data.get('created_at')
else datetime.fromtimestamp(0),
updated_at=datetime.fromisoformat(
comment_data.get('updated_at', '').replace('Z', '+00:00')
)
if comment_data.get('updated_at')
else datetime.fromtimestamp(0),
system=comment_data.get('system', False),
)
all_comments.append(comment)
# Sort comments by creation date and return the most recent ones
all_comments.sort(key=lambda c: c.created_at)
return all_comments[-max_comments:]
@@ -0,0 +1,99 @@
"""HTTP Client Protocol for Git Service Integrations."""
from abc import ABC, abstractmethod
from typing import Any
from httpx import AsyncClient, HTTPError, HTTPStatusError
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import (
AuthenticationError,
RateLimitError,
RequestMethod,
ResourceNotFoundError,
UnknownException,
)
class HTTPClient(ABC):
"""Abstract base class defining the HTTP client interface for Git service integrations.
This class abstracts the common HTTP client functionality needed by all
Git service providers (GitHub, GitLab, BitBucket) while keeping inheritance in place.
"""
# Default attributes (subclasses may override)
token: SecretStr = SecretStr('')
refresh: bool = False
external_auth_id: str | None = None
external_auth_token: SecretStr | None = None
external_token_manager: bool = False
base_domain: str | None = None
# Provider identification must be implemented by subclasses
@property
@abstractmethod
def provider(self) -> str: ...
# Abstract methods that concrete classes must implement
@abstractmethod
async def get_latest_token(self) -> SecretStr | None:
"""Get the latest working token for the service."""
...
@abstractmethod
async def _get_headers(self) -> dict[str, Any]:
"""Get HTTP headers for API requests."""
...
@abstractmethod
async def _make_request(
self,
url: str,
params: dict | None = None,
method: RequestMethod = RequestMethod.GET,
) -> tuple[Any, dict]:
"""Make an HTTP request to the Git service API."""
...
def _has_token_expired(self, status_code: int) -> bool:
"""Check if the token has expired based on HTTP status code."""
return status_code == 401
async def execute_request(
self,
client: AsyncClient,
url: str,
headers: dict,
params: dict | None,
method: RequestMethod = RequestMethod.GET,
):
"""Execute an HTTP request using the provided client."""
if method == RequestMethod.POST:
return await client.post(url, headers=headers, json=params)
return await client.get(url, headers=headers, params=params)
def handle_http_status_error(
self, e: HTTPStatusError
) -> (
AuthenticationError | RateLimitError | ResourceNotFoundError | UnknownException
):
"""Handle HTTP status errors and convert them to appropriate exceptions."""
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(f'{self.provider} API rate limit exceeded')
logger.warning(f'Status error on {self.provider} API: {e}')
return UnknownException(f'Unknown error: {e}')
def handle_http_error(self, e: HTTPError) -> UnknownException:
"""Handle general HTTP errors."""
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
-38
View File
@@ -4,7 +4,6 @@ from enum import Enum
from pathlib import Path
from typing import Any, Protocol
from httpx import AsyncClient, HTTPError, HTTPStatusError
from jinja2 import Environment, FileSystemLoader
from pydantic import BaseModel, SecretStr
@@ -242,40 +241,6 @@ class BaseGitService(ABC):
"""Extract file path from directory item."""
...
async def execute_request(
self,
client: AsyncClient,
url: str,
headers: dict,
params: dict | None,
method: RequestMethod = RequestMethod.GET,
):
if method == RequestMethod.POST:
return await client.post(url, headers=headers, json=params)
return await client.get(url, headers=headers, params=params)
def handle_http_status_error(
self, e: HTTPStatusError
) -> (
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')
logger.warning(f'Status error on {self.provider} API: {e}')
return UnknownException(f'Unknown error: {e}')
def handle_http_error(self, e: HTTPError) -> UnknownException:
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]
@@ -462,9 +427,6 @@ class BaseGitService(ABC):
return comment_body[:max_comment_length] + '...'
return comment_body
def _has_token_expired(self, status_code: int) -> bool:
return status_code == 401
class InstallationsService(Protocol):
async def get_installations(self) -> list[str]:
+69 -37
View File
@@ -2,7 +2,7 @@ import io
import re
from itertools import chain
from pathlib import Path
from typing import Union
from typing import ClassVar, Union
import frontmatter
from pydantic import BaseModel
@@ -23,6 +23,31 @@ class BaseMicroagent(BaseModel):
source: str # path to the file
type: MicroagentType
PATH_TO_THIRD_PARTY_MICROAGENT_NAME: ClassVar[dict[str, str]] = {
'.cursorrules': 'cursorrules',
'agents.md': 'agents',
'agent.md': 'agents',
}
@classmethod
def _handle_third_party(
cls, path: Path, file_content: str
) -> Union['RepoMicroagent', None]:
# Determine the agent name based on file type
microagent_name = cls.PATH_TO_THIRD_PARTY_MICROAGENT_NAME.get(path.name.lower())
# Create RepoMicroagent if we recognized the file type
if microagent_name is not None:
return RepoMicroagent(
name=microagent_name,
content=file_content,
metadata=MicroagentMetadata(name=microagent_name),
source=str(path),
type=MicroagentType.REPO_KNOWLEDGE,
)
return None
@classmethod
def load(
cls,
@@ -40,11 +65,10 @@ class BaseMicroagent(BaseModel):
# Otherwise, we will rely on the name from metadata later
derived_name = None
if microagent_dir is not None:
# Special handling for .cursorrules files which are not in microagent_dir
if path.name == '.cursorrules':
derived_name = 'cursorrules'
else:
derived_name = str(path.relative_to(microagent_dir).with_suffix(''))
# Special handling for files which are not in microagent_dir
derived_name = cls.PATH_TO_THIRD_PARTY_MICROAGENT_NAME.get(
path.name.lower()
) or str(path.relative_to(microagent_dir).with_suffix(''))
# Only load directly from path if file_content is not provided
if file_content is None:
@@ -61,15 +85,10 @@ class BaseMicroagent(BaseModel):
type=MicroagentType.REPO_KNOWLEDGE,
)
# Handle .cursorrules files
if path.name == '.cursorrules':
return RepoMicroagent(
name='cursorrules',
content=file_content,
metadata=MicroagentMetadata(name='cursorrules'),
source=str(path),
type=MicroagentType.REPO_KNOWLEDGE,
)
# Handle third-party agent instruction files
third_party_agent = cls._handle_third_party(path, file_content)
if third_party_agent is not None:
return third_party_agent
file_io = io.StringIO(file_content)
loaded = frontmatter.load(file_io)
@@ -276,31 +295,44 @@ def load_microagents_from_dir(
# Load all agents from microagents directory
logger.debug(f'Loading agents from {microagent_dir}')
if microagent_dir.exists():
# Collect .cursorrules file from repo root and .md files from microagents dir
cursorrules_files = []
if (microagent_dir.parent.parent / '.cursorrules').exists():
cursorrules_files = [microagent_dir.parent.parent / '.cursorrules']
# Always check for .cursorrules and AGENTS.md files in repo root, regardless of whether microagents_dir exists
special_files = []
repo_root = microagent_dir.parent.parent
# Check for .cursorrules
if (repo_root / '.cursorrules').exists():
special_files.append(repo_root / '.cursorrules')
# Check for AGENTS.md (case-insensitive)
for agents_filename in ['AGENTS.md', 'agents.md', 'AGENT.md', 'agent.md']:
agents_path = repo_root / agents_filename
if agents_path.exists():
special_files.append(agents_path)
break # Only add the first one found to avoid duplicates
# Collect .md files from microagents directory if it exists
md_files = []
if microagent_dir.exists():
md_files = [f for f in microagent_dir.rglob('*.md') if f.name != 'README.md']
# Process all files in one loop
for file in chain(cursorrules_files, md_files):
try:
agent = BaseMicroagent.load(file, microagent_dir)
if isinstance(agent, RepoMicroagent):
repo_agents[agent.name] = agent
elif isinstance(agent, KnowledgeMicroagent):
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
knowledge_agents[agent.name] = agent
except MicroagentValidationError as e:
# For validation errors, include the original exception
error_msg = f'Error loading microagent from {file}: {str(e)}'
raise MicroagentValidationError(error_msg) from e
except Exception as e:
# For other errors, wrap in a ValueError with detailed message
error_msg = f'Error loading microagent from {file}: {str(e)}'
raise ValueError(error_msg) from e
# Process all files in one loop
for file in chain(special_files, md_files):
try:
agent = BaseMicroagent.load(file, microagent_dir)
if isinstance(agent, RepoMicroagent):
repo_agents[agent.name] = agent
elif isinstance(agent, KnowledgeMicroagent):
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
knowledge_agents[agent.name] = agent
except MicroagentValidationError as e:
# For validation errors, include the original exception
error_msg = f'Error loading microagent from {file}: {str(e)}'
raise MicroagentValidationError(error_msg) from e
except Exception as e:
# For other errors, wrap in a ValueError with detailed message
error_msg = f'Error loading microagent from {file}: {str(e)}'
raise ValueError(error_msg) from e
logger.debug(
f'Loaded {len(repo_agents) + len(knowledge_agents)} microagents: '
+1 -1
View File
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik"
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik"
```
#### Additional Kubernetes Options
+8 -83
View File
@@ -1,86 +1,11 @@
import contextlib
import warnings
from contextlib import asynccontextmanager
from typing import AsyncIterator
"""OpenHands FastAPI application.
from fastapi.routing import Mount
This module provides the main FastAPI application for OpenHands.
For extensibility and custom configurations, use the factory pattern
from openhands.server.factory instead of importing this app directly.
"""
with warnings.catch_warnings():
warnings.simplefilter('ignore')
from openhands.server.factory import create_default_app
from fastapi import (
FastAPI,
Request,
)
from fastapi.responses import JSONResponse
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands import __version__
from openhands.integrations.service_types import AuthenticationError
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
from openhands.server.routes.files import app as files_api_router
from openhands.server.routes.git import app as git_api_router
from openhands.server.routes.health import add_health_endpoints
from openhands.server.routes.manage_conversations import (
app as manage_conversation_api_router,
)
from openhands.server.routes.mcp import mcp_server
from openhands.server.routes.public import app as public_api_router
from openhands.server.routes.secrets import app as secrets_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
from openhands.server.routes.trajectory import app as trajectory_router
from openhands.server.shared import conversation_manager, server_config
from openhands.server.types import AppMode
mcp_app = mcp_server.http_app(path='/mcp')
def combine_lifespans(*lifespans):
# Create a combined lifespan to manage multiple session managers
@contextlib.asynccontextmanager
async def combined_lifespan(app):
async with contextlib.AsyncExitStack() as stack:
for lifespan in lifespans:
await stack.enter_async_context(lifespan(app))
yield
return combined_lifespan
@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
async with conversation_manager:
yield
app = FastAPI(
title='OpenHands',
description='OpenHands: Code Less, Make More',
version=__version__,
lifespan=combine_lifespans(_lifespan, mcp_app.lifespan),
routes=[Mount(path='/mcp', app=mcp_app)],
)
@app.exception_handler(AuthenticationError)
async def authentication_error_handler(request: Request, exc: AuthenticationError):
return JSONResponse(
status_code=401,
content=str(exc),
)
app.include_router(public_api_router)
app.include_router(files_api_router)
app.include_router(security_api_router)
app.include_router(feedback_api_router)
app.include_router(conversation_api_router)
app.include_router(manage_conversation_api_router)
app.include_router(settings_router)
app.include_router(secrets_router)
if server_config.app_mode == AppMode.OSS:
app.include_router(git_api_router)
app.include_router(trajectory_router)
add_health_endpoints(app)
# Create the default OpenHands app using the factory
app = create_default_app()
-2
View File
@@ -13,7 +13,6 @@ class ServerConfig(ServerConfigInterface):
enable_billing = os.environ.get('ENABLE_BILLING', 'false') == 'true'
hide_llm_settings = os.environ.get('HIDE_LLM_SETTINGS', 'false') == 'true'
# This config is used to hide the microagent management page from the users for now. We will remove this once we release the new microagent management page.
hide_microagent_management = True
settings_store_class: str = (
'openhands.storage.settings.file_settings_store.FileSettingsStore'
)
@@ -44,7 +43,6 @@ class ServerConfig(ServerConfigInterface):
'FEATURE_FLAGS': {
'ENABLE_BILLING': self.enable_billing,
'HIDE_LLM_SETTINGS': self.hide_llm_settings,
'HIDE_MICROAGENT_MANAGEMENT': self.hide_microagent_management,
},
}
+38
View File
@@ -0,0 +1,38 @@
"""Server context system for dependency injection and extensibility.
This module provides a context-based approach to managing server dependencies,
replacing the global variables in shared.py. This enables:
- Dependency injection for better testability
- Easy extensibility for custom implementations
- Per-request contexts for multi-user scenarios
- No import-time dependencies on environment variables
Usage:
# In route handlers
from openhands.server.context import get_server_context
@app.get('/example')
async def example_route(
request: Request,
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
# ... use context instead of importing from shared
# For custom extensions
from openhands.server.factory import create_openhands_app
app = create_openhands_app(context_factory=MyServerContext)
"""
from .context_provider import (
create_server_context,
get_server_context,
)
from .server_context import ServerContext
__all__ = [
'ServerContext',
'get_server_context',
'create_server_context',
]
@@ -0,0 +1,90 @@
"""Context provider system for dependency injection.
This module provides the default context provider for OpenHands routes.
For custom context implementations, use the factory pattern from
openhands.server.factory instead of modifying global state.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastapi import Request
from .server_context import ServerContext
async def get_server_context(request: Request) -> ServerContext:
"""Get server context from request, with caching.
This function provides dependency injection for ServerContext. It:
1. Checks if a context is already cached on the request
2. If not, creates a new context using the configured context class
3. Caches the context on the request for subsequent use
This enables:
- Per-request context instances for multi-user scenarios
- Lazy initialization of dependencies
- Easy testing with mock contexts
Args:
request: FastAPI request object
Returns:
ServerContext: The server context instance for this request
Usage:
from fastapi import Depends, Request
from openhands.server.context import get_server_context, ServerContext
@app.get('/example')
async def example_route(
request: Request,
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
# ... use context
"""
# Check if context is already cached on the request
context = getattr(request.state, 'server_context', None)
if context:
return context
# Create default context instance
from .default_server_context import DefaultServerContext
context = DefaultServerContext()
# Cache on request for subsequent use
request.state.server_context = context
return context
def create_server_context(context_class: str | None = None) -> ServerContext:
"""Create a server context instance directly.
This is useful for testing, CLI applications, or other scenarios where
you need a context outside of a FastAPI request.
Args:
context_class: Optional context class name. If None, uses DefaultServerContext.
Returns:
ServerContext: New context instance
Example:
# For testing with custom context
from openhands.utils.import_utils import get_impl
context_cls = get_impl(ServerContext, 'tests.mocks.MockServerContext')
context = context_cls()
# Use default context
context = create_server_context()
"""
if context_class:
from openhands.utils.import_utils import get_impl
context_cls = get_impl(ServerContext, context_class)
return context_cls()
else:
from .default_server_context import DefaultServerContext
return DefaultServerContext()
@@ -0,0 +1,180 @@
"""Default ServerContext implementation that maintains current behavior."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from openhands.server.context.server_context import ServerContext
# Lazy imports to avoid import-time dependencies
if TYPE_CHECKING:
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.config.server_config import ServerConfig
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.server.monitoring import MonitoringListener
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.files import FileStore
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
class DefaultServerContext(ServerContext):
"""Default implementation that maintains current behavior.
This implementation replicates the exact behavior of the original shared.py
globals, ensuring backward compatibility while providing the extensibility
framework for SaaS implementations.
All dependencies are lazily initialized to avoid import-time side effects
and allow for proper testing and mocking.
"""
def __init__(self):
# Lazy initialization - only create instances when requested
self._config: OpenHandsConfig | None = None
self._server_config: ServerConfig | None = None
self._file_store: FileStore | None = None
self._socketio_server = None
self._conversation_manager: ConversationManager | None = None
self._monitoring_listener: MonitoringListener | None = None
self._settings_store_class: type[SettingsStore] | None = None
self._secrets_store_class: type[SecretsStore] | None = None
self._conversation_store_class: type[ConversationStore] | None = None
def get_config(self) -> OpenHandsConfig:
"""Get the OpenHands configuration."""
if self._config is None:
from openhands.core.config import load_openhands_config
self._config = load_openhands_config()
return self._config
def get_server_config(self) -> ServerConfig:
"""Get the server configuration."""
if self._server_config is None:
from openhands.server.config.server_config import load_server_config
self._server_config = load_server_config()
return self._server_config
def get_file_store(self) -> FileStore:
"""Get the file store implementation."""
if self._file_store is None:
from openhands.storage import get_file_store
config = self.get_config()
self._file_store = get_file_store(
file_store_type=config.file_store,
file_store_path=config.file_store_path,
file_store_web_hook_url=config.file_store_web_hook_url,
file_store_web_hook_headers=config.file_store_web_hook_headers,
file_store_web_hook_batch=config.file_store_web_hook_batch,
)
return self._file_store
def get_socketio_server(self):
"""Get the Socket.IO server instance."""
if self._socketio_server is None:
import socketio
# Replicate the original Redis client manager logic
client_manager = None
redis_host = os.environ.get('REDIS_HOST')
if redis_host:
client_manager = socketio.AsyncRedisManager(
f'redis://{redis_host}',
redis_options={'password': os.environ.get('REDIS_PASSWORD')},
)
self._socketio_server = socketio.AsyncServer(
async_mode='asgi',
cors_allowed_origins='*',
client_manager=client_manager,
# Increase buffer size to 4MB (to handle 3MB files with base64 overhead)
max_http_buffer_size=4 * 1024 * 1024,
)
return self._socketio_server
def get_conversation_manager(self) -> ConversationManager:
"""Get the conversation manager implementation."""
if self._conversation_manager is None:
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.utils.import_utils import get_impl
server_config = self.get_server_config()
config = self.get_config()
file_store = self.get_file_store()
sio = self.get_socketio_server()
monitoring_listener = self.get_monitoring_listener()
ConversationManagerImpl = get_impl(
ConversationManager,
server_config.conversation_manager_class,
)
self._conversation_manager = ConversationManagerImpl.get_instance(
sio, config, file_store, server_config, monitoring_listener
)
return self._conversation_manager
def get_monitoring_listener(self) -> MonitoringListener:
"""Get the monitoring listener implementation."""
if self._monitoring_listener is None:
from openhands.server.monitoring import MonitoringListener
from openhands.utils.import_utils import get_impl
server_config = self.get_server_config()
config = self.get_config()
MonitoringListenerImpl = get_impl(
MonitoringListener,
server_config.monitoring_listener_class,
)
self._monitoring_listener = MonitoringListenerImpl.get_instance(config)
return self._monitoring_listener
def get_settings_store_class(self) -> type[SettingsStore]:
"""Get the settings store class."""
if self._settings_store_class is None:
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.import_utils import get_impl
server_config = self.get_server_config()
self._settings_store_class = get_impl(
SettingsStore, server_config.settings_store_class
)
return self._settings_store_class
def get_secrets_store_class(self) -> type[SecretsStore]:
"""Get the secrets store class."""
if self._secrets_store_class is None:
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.utils.import_utils import get_impl
server_config = self.get_server_config()
self._secrets_store_class = get_impl(
SecretsStore, server_config.secret_store_class
)
return self._secrets_store_class
def get_conversation_store_class(self) -> type[ConversationStore]:
"""Get the conversation store class."""
if self._conversation_store_class is None:
from openhands.storage.conversation.conversation_store import (
ConversationStore,
)
from openhands.utils.import_utils import get_impl
server_config = self.get_server_config()
self._conversation_store_class = get_impl(
ConversationStore,
server_config.conversation_store_class,
)
return self._conversation_store_class
+134
View File
@@ -0,0 +1,134 @@
"""Base ServerContext class for dependency injection and extensibility."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import socketio
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.config.server_config import ServerConfig
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.server.monitoring import MonitoringListener
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.files import FileStore
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
class ServerContext(ABC):
"""Base class for server context that holds all server dependencies.
This replaces the global variables in shared.py and allows for:
- Dependency injection for better testability
- Easy extensibility for SaaS and enterprise features
- Per-request contexts for multi-user scenarios
- No import-time dependencies on environment variables
SaaS implementations can extend this class to provide:
- Custom server configurations with enterprise features
- Multi-tenant storage implementations
- Per-user/per-organization contexts
- Custom conversation managers and monitoring
Example SaaS extension:
class SaaSServerContext(ServerContext):
def __init__(self, user_id: str, org_id: str):
super().__init__()
self.user_id = user_id
self.org_id = org_id
def get_server_config(self) -> ServerConfig:
return SaaSServerConfig(org_id=self.org_id)
def get_file_store(self) -> FileStore:
return MultiTenantFileStore(self.user_id, self.org_id)
"""
@abstractmethod
def get_config(self) -> OpenHandsConfig:
"""Get the OpenHands configuration.
Returns:
OpenHandsConfig: The core application configuration
"""
raise NotImplementedError
@abstractmethod
def get_server_config(self) -> ServerConfig:
"""Get the server configuration.
Returns:
ServerConfig: Server-specific configuration including feature flags,
authentication settings, and component class names
"""
raise NotImplementedError
@abstractmethod
def get_file_store(self) -> FileStore:
"""Get the file store implementation.
Returns:
FileStore: File storage implementation for handling uploads,
downloads, and file management
"""
raise NotImplementedError
@abstractmethod
def get_socketio_server(self) -> socketio.AsyncServer:
"""Get the Socket.IO server instance.
Returns:
socketio.AsyncServer: The Socket.IO server for real-time communication
"""
raise NotImplementedError
@abstractmethod
def get_conversation_manager(self) -> ConversationManager:
"""Get the conversation manager implementation.
Returns:
ConversationManager: Manager for handling conversation lifecycle,
agent sessions, and conversation state
"""
raise NotImplementedError
@abstractmethod
def get_monitoring_listener(self) -> MonitoringListener:
"""Get the monitoring listener implementation.
Returns:
MonitoringListener: Listener for monitoring events and metrics
"""
raise NotImplementedError
@abstractmethod
def get_settings_store_class(self) -> type[SettingsStore]:
"""Get the settings store class.
Returns:
type[SettingsStore]: Class for storing and retrieving user settings
"""
raise NotImplementedError
@abstractmethod
def get_secrets_store_class(self) -> type[SecretsStore]:
"""Get the secrets store class.
Returns:
type[SecretsStore]: Class for storing and retrieving user secrets
"""
raise NotImplementedError
@abstractmethod
def get_conversation_store_class(self) -> type[ConversationStore]:
"""Get the conversation store class.
Returns:
type[ConversationStore]: Class for storing and retrieving conversations
"""
raise NotImplementedError
+227
View File
@@ -0,0 +1,227 @@
"""FastAPI app factory for OpenHands server.
This module provides a factory function to create OpenHands FastAPI applications
with configurable dependencies, enabling external repositories to extend OpenHands
without relying on global variables or environment variable configuration.
Example usage for external repositories:
# In your external repo
from openhands.server.factory import create_openhands_app
from my_custom_context import MyServerContext
# Create OpenHands app with your custom context
openhands_app = create_openhands_app(
context_factory=lambda: MyServerContext(),
include_oss_routes=False, # Skip OSS-specific routes
custom_lifespan=my_custom_lifespan
)
# Add your own routes
@openhands_app.get('/my-custom-route')
async def my_route():
return {'message': 'Hello from my extension!'}
# Or create your own app and include OpenHands routes
from fastapi import FastAPI
my_app = FastAPI()
my_app.mount('/openhands', openhands_app)
"""
import contextlib
import warnings
from contextlib import asynccontextmanager
from typing import AsyncIterator, Callable, Optional
from fastapi import FastAPI
from fastapi.routing import Mount
with warnings.catch_warnings():
warnings.simplefilter('ignore')
from fastapi import Request
from fastapi.responses import JSONResponse
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands import __version__
from openhands.integrations.service_types import AuthenticationError
from openhands.server.context.server_context import ServerContext
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
from openhands.server.routes.files import app as files_api_router
from openhands.server.routes.git import app as git_api_router
from openhands.server.routes.health import add_health_endpoints
from openhands.server.routes.manage_conversations import (
app as manage_conversation_api_router,
)
from openhands.server.routes.mcp import mcp_server
from openhands.server.routes.public import app as public_api_router
from openhands.server.routes.secrets import app as secrets_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
from openhands.server.routes.trajectory import app as trajectory_router
from openhands.server.types import AppMode
def combine_lifespans(*lifespans):
"""Combine multiple FastAPI lifespans into one."""
@contextlib.asynccontextmanager
async def combined_lifespan(app):
async with contextlib.AsyncExitStack() as stack:
for lifespan in lifespans:
await stack.enter_async_context(lifespan(app))
yield
return combined_lifespan
def create_openhands_app(
context_factory: Optional[Callable[[], ServerContext]] = None,
include_oss_routes: bool = True,
include_mcp: bool = True,
custom_lifespan: Optional[Callable] = None,
title: str = 'OpenHands',
description: str = 'OpenHands: Code Less, Make More',
) -> FastAPI:
"""Create a FastAPI application with OpenHands routes and configurable dependencies.
This factory function allows external repositories to create OpenHands applications
with their own context implementations and configuration, without relying on
global variables or environment variable configuration.
Args:
context_factory: Factory function to create ServerContext instances.
If None, uses DefaultServerContext.
include_oss_routes: Whether to include OSS-specific routes (like git).
include_mcp: Whether to include MCP (Model Context Protocol) routes.
custom_lifespan: Custom lifespan function for the FastAPI app.
title: Title for the FastAPI app.
description: Description for the FastAPI app.
Returns:
FastAPI: Configured FastAPI application with OpenHands routes.
Example:
# Basic usage with default context
app = create_openhands_app()
# Custom context for multi-tenant SaaS
def create_saas_context():
return SaaSServerContext(tenant_id='default')
app = create_openhands_app(
context_factory=create_saas_context,
include_oss_routes=False
)
# External repo extending OpenHands
from my_extension import MyServerContext, my_lifespan
app = create_openhands_app(
context_factory=lambda: MyServerContext(),
custom_lifespan=my_lifespan
)
"""
# Import default context here to avoid import-time dependencies
from openhands.server.context.default_server_context import DefaultServerContext
# Use provided context factory or default
if context_factory is None:
context_factory = DefaultServerContext
# Create a context instance to get configuration
context = context_factory()
server_config = context.get_server_config()
conversation_manager = context.get_conversation_manager()
# Build lifespan functions
lifespans = []
# Add conversation manager lifespan
@asynccontextmanager
async def conversation_lifespan(app: FastAPI) -> AsyncIterator[None]:
async with conversation_manager:
yield
lifespans.append(conversation_lifespan)
# Add MCP lifespan if requested
if include_mcp:
mcp_app = mcp_server.http_app(path='/mcp')
lifespans.append(mcp_app.lifespan)
# Add custom lifespan if provided
if custom_lifespan:
lifespans.append(custom_lifespan)
# Create routes list
routes = []
if include_mcp:
routes.append(Mount(path='/mcp', app=mcp_app))
# Create FastAPI app
app = FastAPI(
title=title,
description=description,
version=__version__,
lifespan=combine_lifespans(*lifespans) if lifespans else None,
routes=routes,
)
# Add exception handlers
@app.exception_handler(AuthenticationError)
async def authentication_error_handler(request: Request, exc: AuthenticationError):
return JSONResponse(
status_code=401,
content=str(exc),
)
# Override the context dependency for all routes
# This is the key: we inject our context factory into the dependency system
from openhands.server.context.context_provider import get_server_context
async def custom_get_server_context(request: Request) -> ServerContext:
"""Custom context provider that uses our factory."""
# Check if context is already cached on the request
context = getattr(request.state, 'server_context', None)
if context:
return context
# Create new context instance using our factory
context = context_factory()
# Cache on request for subsequent use
request.state.server_context = context
return context
# Override the dependency
app.dependency_overrides[get_server_context] = custom_get_server_context
# Include all the standard OpenHands routes
app.include_router(public_api_router)
app.include_router(files_api_router)
app.include_router(security_api_router)
app.include_router(feedback_api_router)
app.include_router(conversation_api_router)
app.include_router(manage_conversation_api_router)
app.include_router(settings_router)
app.include_router(secrets_router)
# Conditionally include OSS routes based on server config
if include_oss_routes and server_config.app_mode == AppMode.OSS:
app.include_router(git_api_router)
app.include_router(trajectory_router)
add_health_endpoints(app)
return app
# For backward compatibility, create the default app
def create_default_app() -> FastAPI:
"""Create the default OpenHands FastAPI app.
This is equivalent to the old app.py behavior but using the factory pattern.
Used for backward compatibility.
"""
return create_openhands_app()
+69 -70
View File
@@ -1,77 +1,76 @@
import os
"""Shared server dependencies - DEPRECATED.
import socketio
from dotenv import load_dotenv
This module is deprecated and maintained only for backward compatibility.
New code should use the context system from openhands.server.context instead.
from openhands.core.config import load_openhands_config
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.config.server_config import ServerConfig, load_server_config
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.server.monitoring import MonitoringListener
from openhands.server.types import ServerConfigInterface
from openhands.storage import get_file_store
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.files import FileStore
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.import_utils import get_impl
The context system provides:
- Better dependency injection
- Easier testing and mocking
- SaaS extensibility
- Per-request contexts
- No import-time side effects
load_dotenv()
Migration guide:
# Old way (deprecated)
from openhands.server.shared import config, server_config
config: OpenHandsConfig = load_openhands_config()
server_config_interface: ServerConfigInterface = load_server_config()
assert isinstance(server_config_interface, ServerConfig), (
'Loaded server config interface is not a ServerConfig, despite this being assumed'
)
server_config: ServerConfig = server_config_interface
file_store: FileStore = get_file_store(
file_store_type=config.file_store,
file_store_path=config.file_store_path,
file_store_web_hook_url=config.file_store_web_hook_url,
file_store_web_hook_headers=config.file_store_web_hook_headers,
file_store_web_hook_batch=config.file_store_web_hook_batch,
# New way (recommended)
from openhands.server.context import get_server_context
@app.get('/example')
async def example_route(
request: Request,
context: ServerContext = Depends(get_server_context)
):
config = context.get_config()
server_config = context.get_server_config()
"""
import warnings
from openhands.server.context.default_server_context import DefaultServerContext
# Load environment variables for backward compatibility
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
# dotenv is optional
pass
# Create default context for backward compatibility
_default_context = DefaultServerContext()
# Issue deprecation warning when this module is imported
warnings.warn(
'openhands.server.shared is deprecated. Use openhands.server.context instead. '
'See the module docstring for migration guidance.',
DeprecationWarning,
stacklevel=2,
)
client_manager = None
redis_host = os.environ.get('REDIS_HOST')
if redis_host:
client_manager = socketio.AsyncRedisManager(
f'redis://{redis_host}',
redis_options={'password': os.environ.get('REDIS_PASSWORD')},
)
sio = socketio.AsyncServer(
async_mode='asgi',
cors_allowed_origins='*',
client_manager=client_manager,
# Increase buffer size to 4MB (to handle 3MB files with base64 overhead)
max_http_buffer_size=4 * 1024 * 1024,
)
MonitoringListenerImpl = get_impl(
MonitoringListener,
server_config.monitoring_listener_class,
)
monitoring_listener = MonitoringListenerImpl.get_instance(config)
ConversationManagerImpl = get_impl(
ConversationManager,
server_config.conversation_manager_class,
)
conversation_manager = ConversationManagerImpl.get_instance(
sio, config, file_store, server_config, monitoring_listener
)
SettingsStoreImpl = get_impl(SettingsStore, server_config.settings_store_class)
SecretsStoreImpl = get_impl(SecretsStore, server_config.secret_store_class)
ConversationStoreImpl = get_impl(
ConversationStore,
server_config.conversation_store_class,
)
# Module-level lazy loading using __getattr__
def __getattr__(name: str):
"""Lazy loading for backward compatibility globals."""
if name == 'config':
return _default_context.get_config()
elif name == 'server_config':
return _default_context.get_server_config()
elif name == 'file_store':
return _default_context.get_file_store()
elif name == 'sio':
return _default_context.get_socketio_server()
elif name == 'conversation_manager':
return _default_context.get_conversation_manager()
elif name == 'monitoring_listener':
return _default_context.get_monitoring_listener()
elif name == 'SettingsStoreImpl':
return _default_context.get_settings_store_class()
elif name == 'SecretsStoreImpl':
return _default_context.get_secrets_store_class()
elif name == 'ConversationStoreImpl':
return _default_context.get_conversation_store_class()
else:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
+1 -1
View File
@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
version = "0.54.0"
version = "0.55.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -0,0 +1,113 @@
"""Tests for Bitbucket repository service URL parsing."""
from unittest.mock import patch
import pytest
from pydantic import SecretStr
from openhands.integrations.bitbucket.bitbucket_service import BitBucketService
from openhands.integrations.service_types import OwnerType, Repository
from openhands.integrations.service_types import ProviderType as ServiceProviderType
@pytest.fixture
def bitbucket_service():
"""Create a BitBucketService instance for testing."""
return BitBucketService(token=SecretStr('test-token'))
@pytest.mark.asyncio
async def test_search_repositories_url_parsing_standard_url(bitbucket_service):
"""Test URL parsing with standard Bitbucket URL and verify correct workspace/repo extraction."""
mock_repo = Repository(
id='1',
full_name='workspace/repo',
name='repo',
owner=OwnerType.USER,
git_provider=ServiceProviderType.BITBUCKET,
is_public=True,
clone_url='https://bitbucket.org/workspace/repo.git',
html_url='https://bitbucket.org/workspace/repo',
)
with patch.object(
bitbucket_service,
'get_repository_details_from_repo_name',
return_value=mock_repo,
) as mock_get_repo:
url = 'https://bitbucket.org/workspace/repo'
repositories = await bitbucket_service.search_repositories(
query=url, per_page=10, sort='updated', order='desc', public=True
)
# Verify the correct workspace/repo combination was extracted and passed
assert len(repositories) == 1
assert repositories[0].full_name == 'workspace/repo'
mock_get_repo.assert_called_once_with('workspace/repo')
@pytest.mark.asyncio
async def test_search_repositories_url_parsing_with_extra_path_segments(
bitbucket_service,
):
"""Test URL parsing with additional path segments and verify correct workspace/repo extraction."""
mock_repo = Repository(
id='1',
full_name='my-workspace/my-repo',
name='my-repo',
owner=OwnerType.USER,
git_provider=ServiceProviderType.BITBUCKET,
is_public=True,
clone_url='https://bitbucket.org/my-workspace/my-repo.git',
html_url='https://bitbucket.org/my-workspace/my-repo',
)
with patch.object(
bitbucket_service,
'get_repository_details_from_repo_name',
return_value=mock_repo,
) as mock_get_repo:
# Test complex URL with query params, fragments, and extra paths
url = 'https://bitbucket.org/my-workspace/my-repo/src/feature-branch/src/main.py?at=feature-branch&fileviewer=file-view-default#lines-25'
repositories = await bitbucket_service.search_repositories(
query=url, per_page=10, sort='updated', order='desc', public=True
)
# Verify the correct workspace/repo combination was extracted from complex URL
assert len(repositories) == 1
assert repositories[0].full_name == 'my-workspace/my-repo'
mock_get_repo.assert_called_once_with('my-workspace/my-repo')
@pytest.mark.asyncio
async def test_search_repositories_url_parsing_invalid_url(bitbucket_service):
"""Test URL parsing with invalid URL returns empty results."""
with patch.object(
bitbucket_service, 'get_repository_details_from_repo_name'
) as mock_get_repo:
url = 'not-a-valid-url'
repositories = await bitbucket_service.search_repositories(
query=url, per_page=10, sort='updated', order='desc', public=True
)
# Should return empty list for invalid URL and not call API
assert len(repositories) == 0
mock_get_repo.assert_not_called()
@pytest.mark.asyncio
async def test_search_repositories_url_parsing_insufficient_path_segments(
bitbucket_service,
):
"""Test URL parsing with insufficient path segments returns empty results."""
with patch.object(
bitbucket_service, 'get_repository_details_from_repo_name'
) as mock_get_repo:
url = 'https://bitbucket.org/workspace'
repositories = await bitbucket_service.search_repositories(
query=url, per_page=10, sort='updated', order='desc', public=True
)
# Should return empty list for insufficient path segments and not call API
assert len(repositories) == 0
mock_get_repo.assert_not_called()
@@ -1,32 +0,0 @@
import pytest
from pydantic import SecretStr
from openhands.integrations.github.service.api import GitHubAPI
@pytest.mark.asyncio
async def test_base_urls_github_com():
api = GitHubAPI(base_domain=None, token=SecretStr("t"))
assert api.rest_base == "https://api.github.com"
assert api.graphql_base == "https://api.github.com/graphql"
@pytest.mark.asyncio
async def test_base_urls_enterprise():
api = GitHubAPI(base_domain="gh.example.com", token=SecretStr("t"))
assert api.rest_base == "https://gh.example.com/api/v3"
assert api.graphql_base == "https://gh.example.com/api/graphql"
@pytest.mark.asyncio
async def test_headers_include_standard_and_auth():
api = GitHubAPI(base_domain=None, token=SecretStr("t"))
h = api.headers
assert h["Accept"].startswith("application/vnd.github+")
assert h["User-Agent"].startswith("OpenHands-GitHubService")
assert h["X-GitHub-Api-Version"]
assert h["Authorization"] == "Bearer t"
api.set_token(None)
h2 = api.headers
assert "Authorization" not in h2
@@ -24,7 +24,7 @@ async def test_github_service_token_handling():
assert service.token.get_secret_value() == 'test-token'
# Test headers contain the token correctly
headers = await service._get_github_headers()
headers = await service._get_headers()
assert headers['Authorization'] == 'Bearer test-token'
assert headers['Accept'] == 'application/vnd.github.v3+json'
@@ -0,0 +1,309 @@
"""Unit tests for HTTPClient abstract base class (ABC)."""
from typing import Any
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from pydantic import SecretStr
from openhands.integrations.protocols.http_client import HTTPClient
from openhands.integrations.service_types import (
AuthenticationError,
RateLimitError,
RequestMethod,
ResourceNotFoundError,
UnknownException,
)
class TestableHTTPClient(HTTPClient):
"""Testable concrete implementation of HTTPClient for unit testing."""
def __init__(self, provider_name: str = 'test-provider'):
self.token = SecretStr('test-token')
self.refresh = False
self.external_auth_id = None
self.external_auth_token = None
self.external_token_manager = False
self.base_domain = None
self._provider_name = provider_name
@property
def provider(self) -> str:
return self._provider_name
@provider.setter
def provider(self, value: str) -> None:
self._provider_name = value
async def get_latest_token(self) -> SecretStr | None:
return self.token
async def _get_headers(self) -> dict[str, Any]:
return {'Authorization': f'Bearer {self.token.get_secret_value()}'}
async def _make_request(
self,
url: str,
params: dict | None = None,
method: RequestMethod = RequestMethod.GET,
):
# Mock implementation for testing
return {'test': 'data'}, {}
@pytest.mark.asyncio
class TestHTTPClient:
"""Test cases for HTTPClient ABC."""
def setup_method(self):
"""Set up test fixtures."""
self.client = TestableHTTPClient()
def test_default_attributes(self):
"""Test default attribute values."""
assert isinstance(self.client.token, SecretStr)
assert self.client.refresh is False
assert self.client.external_auth_id is None
assert self.client.external_auth_token is None
assert self.client.external_token_manager is False
assert self.client.base_domain is None
def test_provider_property(self):
"""Test provider property."""
assert self.client.provider == 'test-provider'
def test_has_token_expired_default_implementation(self):
"""Test default _has_token_expired implementation."""
# The TestableHTTPClient inherits the default implementation from the protocol
client = TestableHTTPClient()
assert client._has_token_expired(401) is True
assert client._has_token_expired(200) is False
assert client._has_token_expired(404) is False
assert client._has_token_expired(500) is False
async def test_execute_request_get(self):
"""Test execute_request with GET method."""
client = TestableHTTPClient()
mock_client = AsyncMock()
mock_response = AsyncMock()
mock_client.get.return_value = mock_response
url = 'https://api.example.com/user'
headers = {'Authorization': 'Bearer token'}
params = {'per_page': 10}
result = await client.execute_request(
mock_client, url, headers, params, RequestMethod.GET
)
assert result == mock_response
mock_client.get.assert_called_once_with(url, headers=headers, params=params)
async def test_execute_request_post(self):
"""Test execute_request with POST method."""
client = TestableHTTPClient()
mock_client = AsyncMock()
mock_response = AsyncMock()
mock_client.post.return_value = mock_response
url = 'https://api.example.com/issues'
headers = {'Authorization': 'Bearer token'}
params = {'title': 'Test Issue'}
result = await client.execute_request(
mock_client, url, headers, params, RequestMethod.POST
)
assert result == mock_response
mock_client.post.assert_called_once_with(url, headers=headers, json=params)
def test_handle_http_status_error_401(self):
"""Test handling of 401 HTTP status error."""
client = TestableHTTPClient('github')
mock_response = Mock()
mock_response.status_code = 401
error = httpx.HTTPStatusError(
message='401 Unauthorized', request=Mock(), response=mock_response
)
result = client.handle_http_status_error(error)
assert isinstance(result, AuthenticationError)
assert 'Invalid github token' in str(result)
def test_handle_http_status_error_404(self):
"""Test handling of 404 HTTP status error."""
client = TestableHTTPClient()
client.provider = 'gitlab'
mock_response = Mock()
mock_response.status_code = 404
error = httpx.HTTPStatusError(
message='404 Not Found', request=Mock(), response=mock_response
)
result = client.handle_http_status_error(error)
assert isinstance(result, ResourceNotFoundError)
assert 'Resource not found on gitlab API' in str(result)
def test_handle_http_status_error_429(self):
"""Test handling of 429 HTTP status error."""
client = TestableHTTPClient()
client.provider = 'bitbucket'
mock_response = Mock()
mock_response.status_code = 429
error = httpx.HTTPStatusError(
message='429 Too Many Requests', request=Mock(), response=mock_response
)
result = client.handle_http_status_error(error)
assert isinstance(result, RateLimitError)
assert 'bitbucket API rate limit exceeded' in str(result)
def test_handle_http_status_error_other(self):
"""Test handling of other HTTP status errors."""
client = TestableHTTPClient()
client.provider = 'test-provider'
mock_response = Mock()
mock_response.status_code = 500
error = httpx.HTTPStatusError(
message='500 Internal Server Error', request=Mock(), response=mock_response
)
result = client.handle_http_status_error(error)
assert isinstance(result, UnknownException)
assert 'Unknown error' in str(result)
def test_handle_http_error(self):
"""Test handling of general HTTP errors."""
client = TestableHTTPClient()
client.provider = 'test-provider'
error = httpx.ConnectError('Connection failed')
result = client.handle_http_error(error)
assert isinstance(result, UnknownException)
assert 'HTTP error ConnectError' in str(result)
def test_handle_http_error_with_different_error_types(self):
"""Test handling of different HTTP error types."""
client = TestableHTTPClient()
client.provider = 'test-provider'
# Test with different error types
errors = [
httpx.ConnectError('Connection failed'),
httpx.TimeoutException('Request timed out'),
httpx.ReadTimeout('Read timeout'),
httpx.WriteTimeout('Write timeout'),
]
for error in errors:
result = client.handle_http_error(error)
assert isinstance(result, UnknownException)
assert f'HTTP error {type(error).__name__}' in str(result)
def test_runtime_checkable(self):
"""Test that HTTPClient is runtime checkable."""
from openhands.integrations.protocols.http_client import HTTPClient
# Test that our testable client implements the protocol
assert isinstance(self.client, HTTPClient)
# Test that a class without the required methods doesn't implement the protocol
class IncompleteClient:
pass
incomplete = IncompleteClient()
assert not isinstance(incomplete, HTTPClient)
def test_protocol_attributes_exist(self):
"""Test that protocol defines expected attributes."""
client = TestableHTTPClient()
# Test default attribute values from protocol
assert hasattr(client, 'token')
assert hasattr(client, 'refresh')
assert hasattr(client, 'external_auth_id')
assert hasattr(client, 'external_auth_token')
assert hasattr(client, 'external_token_manager')
assert hasattr(client, 'base_domain')
# Test TestableHTTPClient values
assert client.token == SecretStr('test-token')
assert client.refresh is False
assert client.external_auth_id is None
assert client.external_auth_token is None
assert client.external_token_manager is False
assert client.base_domain is None
def test_protocol_methods_exist(self):
"""Test that protocol defines expected methods."""
client = TestableHTTPClient()
# Test that methods exist
assert hasattr(client, 'get_latest_token')
assert hasattr(client, '_get_headers')
assert hasattr(client, '_make_request')
assert hasattr(client, '_has_token_expired')
assert hasattr(client, 'execute_request')
assert hasattr(client, 'handle_http_status_error')
assert hasattr(client, 'handle_http_error')
assert hasattr(client, 'provider')
def test_protocol_concrete_methods_work(self):
"""Test that concrete protocol methods work correctly."""
client = TestableHTTPClient()
# These methods should work since TestableHTTPClient implements them
assert client.provider == 'test-provider'
# Test that the default implementations from the protocol are available
assert hasattr(client, '_has_token_expired')
assert hasattr(client, 'execute_request')
assert hasattr(client, 'handle_http_status_error')
assert hasattr(client, 'handle_http_error')
def test_provider_specific_error_messages(self):
"""Test that error messages are provider-specific."""
providers = ['github', 'gitlab', 'bitbucket']
for provider in providers:
client = TestableHTTPClient()
client.provider = provider
# Test 401 error
mock_response = Mock()
mock_response.status_code = 401
error = httpx.HTTPStatusError(
message='401 Unauthorized', request=Mock(), response=mock_response
)
result = client.handle_http_status_error(error)
assert f'Invalid {provider} token' in str(result)
# Test 404 error
mock_response.status_code = 404
error = httpx.HTTPStatusError(
message='404 Not Found', request=Mock(), response=mock_response
)
result = client.handle_http_status_error(error)
assert f'Resource not found on {provider} API' in str(result)
# Test 429 error
mock_response.status_code = 429
error = httpx.HTTPStatusError(
message='429 Too Many Requests', request=Mock(), response=mock_response
)
result = client.handle_http_status_error(error)
assert f'{provider} API rate limit exceeded' in str(result)
@@ -364,3 +364,184 @@ def test_load_microagents_with_cursorrules(temp_microagents_dir_with_cursorrules
assert cursorrules_agent.name == 'cursorrules'
assert 'Always use TypeScript for new files' in cursorrules_agent.content
assert cursorrules_agent.type == MicroagentType.REPO_KNOWLEDGE
@pytest.fixture
def temp_dir_with_cursorrules_only():
"""Create a temporary directory with only .cursorrules file (no .openhands/microagents directory)."""
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
# Create .cursorrules file in repository root
cursorrules_content = """Always use Python for new files.
Follow PEP 8 style guidelines."""
(root / '.cursorrules').write_text(cursorrules_content)
# Note: We intentionally do NOT create .openhands/microagents directory
yield root
def test_load_cursorrules_without_microagents_dir(temp_dir_with_cursorrules_only):
"""Test loading .cursorrules file when .openhands/microagents directory doesn't exist.
This test reproduces the bug where .cursorrules is only loaded when
.openhands/microagents directory exists.
"""
# Try to load from non-existent microagents directory
microagents_dir = temp_dir_with_cursorrules_only / '.openhands' / 'microagents'
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
# This should find the .cursorrules file even though microagents_dir doesn't exist
assert len(repo_agents) == 1 # Only .cursorrules
assert 'cursorrules' in repo_agents
assert len(knowledge_agents) == 0
# Check .cursorrules agent
cursorrules_agent = repo_agents['cursorrules']
assert isinstance(cursorrules_agent, RepoMicroagent)
assert cursorrules_agent.name == 'cursorrules'
assert 'Always use Python for new files' in cursorrules_agent.content
assert cursorrules_agent.type == MicroagentType.REPO_KNOWLEDGE
def test_agents_md_file_load():
"""Test loading AGENTS.md file as a RepoMicroagent."""
agents_content = """# Project Setup
## Setup commands
- Install deps: `npm install`
- Start dev server: `npm run dev`
- Run tests: `npm test`
## Code style
- TypeScript strict mode
- Single quotes, no semicolons
- Use functional patterns where possible"""
agents_path = Path('AGENTS.md')
# Test loading AGENTS.md file directly
agent = BaseMicroagent.load(agents_path, file_content=agents_content)
# Verify it's loaded as a RepoMicroagent
assert isinstance(agent, RepoMicroagent)
assert agent.name == 'agents'
assert agent.content == agents_content
assert agent.type == MicroagentType.REPO_KNOWLEDGE
assert agent.metadata.name == 'agents'
assert agent.source == str(agents_path)
def test_agents_md_case_insensitive():
"""Test that AGENTS.md loading is case-insensitive."""
agents_content = """# Development Guide
Use TypeScript for all new files."""
test_cases = ['AGENTS.md', 'agents.md', 'AGENT.md', 'agent.md']
for filename in test_cases:
agents_path = Path(filename)
agent = BaseMicroagent.load(agents_path, file_content=agents_content)
assert isinstance(agent, RepoMicroagent)
assert agent.name == 'agents'
assert agent.content == agents_content
assert agent.type == MicroagentType.REPO_KNOWLEDGE
@pytest.fixture
def temp_dir_with_agents_md_only():
"""Create a temporary directory with only AGENTS.md file (no .openhands/microagents directory)."""
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
# Create AGENTS.md file in repository root
agents_content = """# Development Guide
## Setup commands
- Install deps: `poetry install`
- Start dev server: `poetry run python app.py`
- Run tests: `poetry run pytest`
## Code style
- Python 3.12+
- Follow PEP 8 guidelines
- Use type hints everywhere"""
(root / 'AGENTS.md').write_text(agents_content)
# Note: We intentionally do NOT create .openhands/microagents directory
yield root
def test_load_agents_md_without_microagents_dir(temp_dir_with_agents_md_only):
"""Test loading AGENTS.md file when .openhands/microagents directory doesn't exist."""
# Try to load from non-existent microagents directory
microagents_dir = temp_dir_with_agents_md_only / '.openhands' / 'microagents'
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
# This should find the AGENTS.md file even though microagents_dir doesn't exist
assert len(repo_agents) == 1 # Only AGENTS.md
assert 'agents' in repo_agents
assert len(knowledge_agents) == 0
# Check AGENTS.md agent
agents_agent = repo_agents['agents']
assert isinstance(agents_agent, RepoMicroagent)
assert agents_agent.name == 'agents'
assert 'Install deps: `poetry install`' in agents_agent.content
assert agents_agent.type == MicroagentType.REPO_KNOWLEDGE
@pytest.fixture
def temp_dir_with_both_cursorrules_and_agents():
"""Create a temporary directory with both .cursorrules and AGENTS.md files."""
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
# Create .cursorrules file
cursorrules_content = """Always use Python for new files.
Follow PEP 8 style guidelines."""
(root / '.cursorrules').write_text(cursorrules_content)
# Create AGENTS.md file
agents_content = """# Development Guide
## Setup commands
- Install deps: `poetry install`
- Run tests: `poetry run pytest`"""
(root / 'AGENTS.md').write_text(agents_content)
yield root
def test_load_both_cursorrules_and_agents_md(temp_dir_with_both_cursorrules_and_agents):
"""Test loading both .cursorrules and AGENTS.md files when .openhands/microagents doesn't exist."""
# Try to load from non-existent microagents directory
microagents_dir = (
temp_dir_with_both_cursorrules_and_agents / '.openhands' / 'microagents'
)
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
# This should find both files
assert len(repo_agents) == 2 # .cursorrules + AGENTS.md
assert 'cursorrules' in repo_agents
assert 'agents' in repo_agents
assert len(knowledge_agents) == 0
# Check both agents
cursorrules_agent = repo_agents['cursorrules']
assert isinstance(cursorrules_agent, RepoMicroagent)
assert 'Always use Python for new files' in cursorrules_agent.content
agents_agent = repo_agents['agents']
assert isinstance(agents_agent, RepoMicroagent)
assert 'Install deps: `poetry install`' in agents_agent.content