mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9a8e3e461 | |||
| 0599f75713 | |||
| 16cc0433e3 | |||
| 98fb3ab979 | |||
| 41c4000d48 | |||
| 08c63b248d | |||
| 1989f1c106 | |||
| dc5de98e8d | |||
| dba8b28824 |
@@ -225,7 +225,7 @@ jobs:
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=false \
|
||||
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=true \
|
||||
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ 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
@@ -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.55-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.54-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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.
|
||||
|
||||
The MIT License (MIT)
|
||||
=====================
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2025
|
||||
Copyright © 2023
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
# 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
|
||||
@@ -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.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-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.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -130,6 +130,7 @@ If you want to modify the OpenHands source code, check out [Development.md](http
|
||||
Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/usage/troubleshooting) can help.
|
||||
|
||||
## 📖 Documentation
|
||||
<a href="https://deepwiki.com/All-Hands-AI/OpenHands"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" title="Autogenerated Documentation by DeepWiki"></a>
|
||||
|
||||
To learn more about the project, and for tips on using OpenHands,
|
||||
check out our [documentation](https://docs.all-hands.dev/usage/getting-started).
|
||||
|
||||
+3
-3
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-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.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
+3
-3
@@ -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.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-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.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,206 +0,0 @@
|
||||
# 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.
|
||||
@@ -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.55-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.54-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -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.55-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.54-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:
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
# 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.
|
||||
@@ -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.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-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.55 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -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.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-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.55 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -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.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-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.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
```
|
||||
|
||||
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.55
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
@@ -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.55-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-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.55
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -38,23 +38,6 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
|
||||
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
|
||||
OpenHands.
|
||||
|
||||
### On Linux, Getting ConnectTimeout Error
|
||||
|
||||
**Description**
|
||||
|
||||
When running on Linux, you might run into the error `ERROR:root:<class 'httpx.ConnectTimeout'>: timed out`.
|
||||
|
||||
**Resolution**
|
||||
|
||||
If you installed Docker from your distribution’s 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
|
||||
|
||||
**Description**
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,217 +0,0 @@
|
||||
"""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()
|
||||
@@ -54,14 +54,12 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
full_name: "rbren/polaris",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "All-Hands-AI/OpenHands",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -101,15 +99,16 @@ describe("RepoConnector", () => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then interact with the repository dropdown
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -135,23 +134,23 @@ describe("RepoConnector", () => {
|
||||
expect(launchButton).toBeDisabled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
]);
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -162,8 +161,7 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(launchButton).toBeEnabled();
|
||||
@@ -226,19 +224,6 @@ describe("RepoConnector", () => {
|
||||
|
||||
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockResolvedValue({
|
||||
conversation_id: "mock-conversation-id",
|
||||
title: "Test Conversation",
|
||||
selected_repository: "user/repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2023-01-01T00:00:00Z",
|
||||
created_at: "2023-01-01T00:00:00Z",
|
||||
status: "STARTING",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
@@ -259,23 +244,23 @@ describe("RepoConnector", () => {
|
||||
expect(createConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
]);
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoInput = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("git-repo-dropdown"),
|
||||
const repoDropdown = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("repo-dropdown"),
|
||||
);
|
||||
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -286,8 +271,7 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
@@ -304,8 +288,6 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
@@ -316,10 +298,10 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
]);
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
@@ -327,16 +309,16 @@ describe("RepoConnector", () => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -347,8 +329,7 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
@@ -377,7 +358,7 @@ describe("RepoConnector", () => {
|
||||
const goToSettingsButton = await screen.findByTestId(
|
||||
"navigate-to-settings-button",
|
||||
);
|
||||
const dropdown = screen.queryByTestId("git-repo-dropdown");
|
||||
const dropdown = screen.queryByTestId("repo-dropdown");
|
||||
const launchButton = screen.queryByTestId("repo-launch-button");
|
||||
const providerLinks = screen.queryAllByText(/add git(hub|lab) repos/i);
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("RepositorySelectionForm", () => {
|
||||
});
|
||||
|
||||
renderForm();
|
||||
expect(await screen.findByTestId("git-repo-dropdown")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when repository fetch fails", async () => {
|
||||
@@ -168,10 +168,10 @@ describe("RepositorySelectionForm", () => {
|
||||
renderForm();
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("dropdown-error"),
|
||||
await screen.findByTestId("repo-dropdown-error"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Failed to load data"),
|
||||
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -231,7 +231,11 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
@@ -266,7 +270,11 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
|
||||
+69
-45
@@ -18,7 +18,6 @@ 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(),
|
||||
@@ -41,10 +40,6 @@ vi.mock("#/hooks/query/use-microagent-management-conversations", () => ({
|
||||
mockUseMicroagentManagementConversations(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-search-repositories", () => ({
|
||||
useSearchRepositories: () => mockUseSearchRepositories(),
|
||||
}));
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
@@ -224,12 +219,6 @@ describe("MicroagentManagement", () => {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseSearchRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
|
||||
data: [...mockRepositories],
|
||||
@@ -754,24 +743,17 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
await user.type(searchInput, "nonexistent");
|
||||
|
||||
// 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();
|
||||
});
|
||||
// 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();
|
||||
});
|
||||
|
||||
it("should handle special characters in search", async () => {
|
||||
@@ -1290,14 +1272,9 @@ 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([
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should render add microagent button", async () => {
|
||||
@@ -1983,14 +1960,9 @@ 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([
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should render update microagent modal when updateMicroagentModalVisible is true", async () => {
|
||||
@@ -2545,6 +2517,58 @@ 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([
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
{ name: "develop", commit_sha: "def456", protected: false },
|
||||
]);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -37,27 +37,34 @@ const selectRepository = async (repoName: string) => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoInput = within(repoConnector).getByTestId("git-repo-dropdown");
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
const repoInput = within(dropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
const dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
|
||||
expect(within(dropdownMenu).getByText(repoName)).toBeInTheDocument();
|
||||
const options = screen.getAllByText(repoName);
|
||||
// Find the option in the dropdown (it will have role="option")
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
expect(dropdownOption).toBeInTheDocument();
|
||||
});
|
||||
const dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
|
||||
await userEvent.click(within(dropdownMenu).getByText(repoName));
|
||||
const options = screen.getAllByText(repoName);
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
await userEvent.click(dropdownOption!);
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -78,14 +85,12 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
full_name: "octocat/hello-world",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "octocat/earth",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -135,10 +140,10 @@ describe("HomeScreen", () => {
|
||||
await screen.findAllByTestId("task-launch-button");
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
]);
|
||||
|
||||
// Select a repository to enable the repo launch button
|
||||
await selectRepository("octocat/hello-world");
|
||||
|
||||
Generated
+865
-1133
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.55.0",
|
||||
"version": "0.54.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -21,23 +21,22 @@
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@vitejs/plugin-react": "^5.0.1",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "^9.0.10",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.30",
|
||||
"jose": "^6.1.0",
|
||||
"jose": "^6.0.13",
|
||||
"lucide-react": "^0.542.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.261.0",
|
||||
"posthog-js": "^1.260.3",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -47,6 +46,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.8.2",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-breaks": "^4.0.0",
|
||||
@@ -97,8 +97,8 @@
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react": "^19.1.11",
|
||||
"@types/react-dom": "^19.1.8",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
@@ -123,7 +123,7 @@
|
||||
"lint-staged": "^16.1.4",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.6.2",
|
||||
"stripe": "^18.5.0",
|
||||
"stripe": "^18.4.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.9.2",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
|
||||
@@ -21,17 +21,11 @@ import {
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import {
|
||||
GitUser,
|
||||
GitRepository,
|
||||
PaginatedBranchesResponse,
|
||||
Branch,
|
||||
} from "#/types/git";
|
||||
import { GitUser, GitRepository, Branch } from "#/types/git";
|
||||
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;
|
||||
@@ -434,13 +428,6 @@ 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");
|
||||
|
||||
@@ -580,35 +567,11 @@ class OpenHands {
|
||||
};
|
||||
}
|
||||
|
||||
static async getRepositoryBranches(
|
||||
repository: string,
|
||||
page: number = 1,
|
||||
perPage: number = 30,
|
||||
): Promise<PaginatedBranchesResponse> {
|
||||
const { data } = await openHands.get<PaginatedBranchesResponse>(
|
||||
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}&page=${page}&per_page=${perPage}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchRepositoryBranches(
|
||||
repository: string,
|
||||
query: string,
|
||||
perPage: number = 30,
|
||||
selectedProvider?: Provider,
|
||||
): Promise<Branch[]> {
|
||||
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
|
||||
const { data } = await openHands.get<Branch[]>(
|
||||
`/api/user/search/branches`,
|
||||
{
|
||||
params: {
|
||||
repository,
|
||||
query,
|
||||
per_page: perPage,
|
||||
selected_provider: selectedProvider,
|
||||
},
|
||||
},
|
||||
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,11 +49,13 @@ 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;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRepositoryBranches } from "../../hooks/query/use-repository-branches";
|
||||
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
|
||||
|
||||
export interface GitBranchDropdownProps {
|
||||
repositoryName?: string | null;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (branchName: string | null) => void;
|
||||
}
|
||||
|
||||
export function GitBranchDropdown({
|
||||
repositoryName,
|
||||
value,
|
||||
placeholder = "Select branch...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitBranchDropdownProps) {
|
||||
const { data: branches, isLoading } = useRepositoryBranches(
|
||||
repositoryName || null,
|
||||
);
|
||||
|
||||
const options: SelectOption[] = useMemo(
|
||||
() =>
|
||||
branches?.map((branch) => ({
|
||||
value: branch.name,
|
||||
label: branch.name,
|
||||
})) || [],
|
||||
[branches],
|
||||
);
|
||||
|
||||
const hasNoBranches = !isLoading && branches && branches.length === 0;
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === value) || null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const handleChange = (option: SelectOption | null) => {
|
||||
onChange?.(option?.value || null);
|
||||
};
|
||||
|
||||
const isDisabled = disabled || !repositoryName || isLoading || hasNoBranches;
|
||||
|
||||
const displayPlaceholder = hasNoBranches ? "No branches found" : placeholder;
|
||||
const displayErrorMessage = hasNoBranches
|
||||
? "This repository has no branches"
|
||||
: errorMessage;
|
||||
|
||||
return (
|
||||
<ReactSelectDropdown
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
placeholder={displayPlaceholder}
|
||||
className={className}
|
||||
errorMessage={displayErrorMessage}
|
||||
disabled={isDisabled}
|
||||
isClearable={false}
|
||||
isSearchable
|
||||
isLoading={isLoading}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from "react";
|
||||
import { StylesConfig } from "react-select";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
|
||||
|
||||
export interface GitProviderDropdownProps {
|
||||
providers: Provider[];
|
||||
value?: Provider | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
onChange?: (provider: Provider | null) => void;
|
||||
classNamePrefix?: string;
|
||||
styles?: StylesConfig<SelectOption, false>;
|
||||
}
|
||||
|
||||
export function GitProviderDropdown({
|
||||
providers,
|
||||
value,
|
||||
placeholder = "Select Provider",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
classNamePrefix,
|
||||
styles,
|
||||
}: GitProviderDropdownProps) {
|
||||
const options: SelectOption[] = useMemo(
|
||||
() =>
|
||||
providers.map((provider) => ({
|
||||
value: provider,
|
||||
label: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||
})),
|
||||
[providers],
|
||||
);
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === value) || null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const handleChange = (option: SelectOption | null) => {
|
||||
onChange?.(option?.value as Provider | null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactSelectDropdown
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
errorMessage={errorMessage}
|
||||
disabled={disabled}
|
||||
isClearable={false}
|
||||
isSearchable={false}
|
||||
isLoading={isLoading}
|
||||
onChange={handleChange}
|
||||
classNamePrefix={classNamePrefix}
|
||||
styles={styles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { useGitRepositories } from "../../hooks/query/use-git-repositories";
|
||||
import { useSearchRepositories } from "../../hooks/query/use-search-repositories";
|
||||
import { useDebounce } from "../../hooks/use-debounce";
|
||||
import OpenHands from "../../api/open-hands";
|
||||
import { GitRepository } from "../../types/git";
|
||||
import {
|
||||
ReactSelectAsyncDropdown,
|
||||
AsyncSelectOption,
|
||||
} from "./react-select-async-dropdown";
|
||||
|
||||
export interface GitRepositoryDropdownProps {
|
||||
provider: Provider;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (repository?: GitRepository) => void;
|
||||
}
|
||||
|
||||
export function GitRepositoryDropdown({
|
||||
provider,
|
||||
value,
|
||||
placeholder = "Search repositories...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitRepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const debouncedSearchInput = useDebounce(searchInput, 300);
|
||||
|
||||
// Process search input to handle URLs
|
||||
const processedSearchInput = useMemo(() => {
|
||||
if (debouncedSearchInput.startsWith("https://")) {
|
||||
const match = debouncedSearchInput.match(
|
||||
/https:\/\/[^/]+\/([^/]+\/[^/]+)/,
|
||||
);
|
||||
return match ? match[1] : debouncedSearchInput;
|
||||
}
|
||||
return debouncedSearchInput;
|
||||
}, [debouncedSearchInput]);
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useGitRepositories({
|
||||
provider,
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
// Search query for processed input (handles URLs)
|
||||
const { data: searchData, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(processedSearchInput, provider);
|
||||
|
||||
const allOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
data?.pages
|
||||
? data.pages.flatMap((page) =>
|
||||
page.data.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
})),
|
||||
)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const searchOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
searchData
|
||||
? searchData.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}))
|
||||
: [],
|
||||
[searchData],
|
||||
);
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
// First check in loaded pages
|
||||
const option = allOptions.find((opt) => opt.value === value);
|
||||
if (option) return option;
|
||||
|
||||
// If not found, check in search results
|
||||
const searchOption = searchOptions.find((opt) => opt.value === value);
|
||||
if (searchOption) return searchOption;
|
||||
|
||||
return null;
|
||||
}, [allOptions, searchOptions, value]);
|
||||
|
||||
const loadOptions = useCallback(
|
||||
async (inputValue: string): Promise<AsyncSelectOption[]> => {
|
||||
// Update search input to trigger debounced search
|
||||
setSearchInput(inputValue);
|
||||
|
||||
// If empty input, show all loaded options
|
||||
if (!inputValue.trim()) {
|
||||
return allOptions;
|
||||
}
|
||||
|
||||
// For very short inputs, do local filtering
|
||||
if (inputValue.length < 2) {
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle URL inputs by performing direct search
|
||||
if (inputValue.startsWith("https://")) {
|
||||
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
const repoName = match[1];
|
||||
try {
|
||||
// Perform direct search for URL-based inputs
|
||||
const repositories = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
provider,
|
||||
);
|
||||
return repositories.map((repo) => ({
|
||||
value: repo.full_name,
|
||||
label: repo.full_name,
|
||||
data: repo,
|
||||
}));
|
||||
} catch (error) {
|
||||
// Fall back to local filtering if search fails
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(repoName.toLowerCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For regular text inputs, use hook-based search results if available
|
||||
if (searchOptions.length > 0 && processedSearchInput === inputValue) {
|
||||
return searchOptions;
|
||||
}
|
||||
|
||||
// Fallback to local filtering while search is loading
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
},
|
||||
[allOptions, searchOptions, processedSearchInput, provider],
|
||||
);
|
||||
|
||||
const handleChange = (option: AsyncSelectOption | null) => {
|
||||
if (!option) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// First check in loaded pages
|
||||
let repo = data?.pages
|
||||
?.flatMap((p) => p.data)
|
||||
.find((r) => r.id === option.value);
|
||||
|
||||
// If not found, check in search results
|
||||
if (!repo) {
|
||||
repo = searchData?.find((r) => r.id === option.value);
|
||||
}
|
||||
|
||||
onChange?.(repo);
|
||||
};
|
||||
|
||||
const handleMenuScrollToBottom = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage && !isLoading) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactSelectAsyncDropdown
|
||||
testId="repo-dropdown"
|
||||
loadOptions={loadOptions}
|
||||
value={selectedOption}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
errorMessage={errorMessage}
|
||||
disabled={disabled}
|
||||
isClearable={false}
|
||||
isLoading={isLoading || isFetchingNextPage || isSearchLoading}
|
||||
cacheOptions
|
||||
defaultOptions={allOptions}
|
||||
onChange={handleChange}
|
||||
onMenuScrollToBottom={handleMenuScrollToBottom}
|
||||
/>
|
||||
{isError && (
|
||||
<div
|
||||
data-testid="repo-dropdown-error"
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import AsyncSelect from "react-select/async";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
|
||||
|
||||
export type AsyncSelectOption = SelectOptionBase;
|
||||
|
||||
export interface ReactSelectAsyncDropdownProps {
|
||||
loadOptions: (inputValue: string) => Promise<AsyncSelectOption[]>;
|
||||
testId?: string;
|
||||
placeholder?: string;
|
||||
value?: AsyncSelectOption | null;
|
||||
defaultValue?: AsyncSelectOption | null;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isClearable?: boolean;
|
||||
isLoading?: boolean;
|
||||
cacheOptions?: boolean;
|
||||
defaultOptions?: boolean | AsyncSelectOption[];
|
||||
onChange?: (option: AsyncSelectOption | null) => void;
|
||||
onMenuScrollToBottom?: () => void;
|
||||
}
|
||||
|
||||
export function ReactSelectAsyncDropdown({
|
||||
loadOptions,
|
||||
testId,
|
||||
placeholder = "Search...",
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isLoading = false,
|
||||
cacheOptions = true,
|
||||
defaultOptions = true,
|
||||
onChange,
|
||||
onMenuScrollToBottom,
|
||||
}: ReactSelectAsyncDropdownProps) {
|
||||
const customStyles = useMemo(() => getCustomStyles<AsyncSelectOption>(), []);
|
||||
|
||||
const handleLoadOptions = useCallback(
|
||||
(inputValue: string, callback: (options: AsyncSelectOption[]) => void) => {
|
||||
loadOptions(inputValue)
|
||||
.then((options) => callback(options))
|
||||
.catch(() => callback([]));
|
||||
},
|
||||
[loadOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid={testId} className={cn("w-full", className)}>
|
||||
<AsyncSelect
|
||||
loadOptions={handleLoadOptions}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
isLoading={isLoading}
|
||||
cacheOptions={cacheOptions}
|
||||
defaultOptions={defaultOptions}
|
||||
onChange={onChange}
|
||||
onMenuScrollToBottom={onMenuScrollToBottom}
|
||||
styles={customStyles}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p
|
||||
data-testid="repo-dropdown-error"
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from "react";
|
||||
import Select, { StylesConfig } from "react-select";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
|
||||
|
||||
export type SelectOption = SelectOptionBase;
|
||||
|
||||
export interface ReactSelectDropdownProps {
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
value?: SelectOption | null;
|
||||
defaultValue?: SelectOption | null;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isClearable?: boolean;
|
||||
isSearchable?: boolean;
|
||||
isLoading?: boolean;
|
||||
onChange?: (option: SelectOption | null) => void;
|
||||
classNamePrefix?: string;
|
||||
styles?: StylesConfig<SelectOption, false>;
|
||||
}
|
||||
|
||||
export function ReactSelectDropdown({
|
||||
options,
|
||||
placeholder = "Select option...",
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isSearchable = true,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
classNamePrefix,
|
||||
styles,
|
||||
}: ReactSelectDropdownProps) {
|
||||
const customStyles = useMemo(() => getCustomStyles<SelectOption>(), []);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<Select
|
||||
options={options}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
isSearchable={isSearchable}
|
||||
isLoading={isLoading}
|
||||
onChange={onChange}
|
||||
styles={styles || customStyles}
|
||||
className="w-full"
|
||||
classNamePrefix={classNamePrefix}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-red-500 text-sm mt-1">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { StylesConfig } from "react-select";
|
||||
|
||||
export interface SelectOptionBase {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const getCustomStyles = <T extends SelectOptionBase>(): StylesConfig<
|
||||
T,
|
||||
false
|
||||
> => ({
|
||||
control: (provided, state) => ({
|
||||
...provided,
|
||||
backgroundColor: state.isDisabled ? "#363636" : "#454545", // darker tertiary when disabled
|
||||
border: "1px solid #717888",
|
||||
borderRadius: "0.125rem",
|
||||
minHeight: "2.5rem",
|
||||
padding: "0 0.5rem",
|
||||
boxShadow: state.isFocused ? "0 0 0 1px #717888" : "none",
|
||||
opacity: state.isDisabled ? 0.6 : 1,
|
||||
cursor: state.isDisabled ? "not-allowed" : "pointer",
|
||||
"&:hover": {
|
||||
borderColor: "#717888",
|
||||
},
|
||||
}),
|
||||
input: (provided) => ({
|
||||
...provided,
|
||||
color: "#ECEDEE", // content
|
||||
}),
|
||||
placeholder: (provided) => ({
|
||||
...provided,
|
||||
fontStyle: "italic",
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
}),
|
||||
singleValue: (provided, state) => ({
|
||||
...provided,
|
||||
color: state.isDisabled ? "#B7BDC2" : "#ECEDEE", // tertiary-light when disabled, content otherwise
|
||||
}),
|
||||
menu: (provided) => ({
|
||||
...provided,
|
||||
backgroundColor: "#454545", // tertiary
|
||||
border: "1px solid #717888",
|
||||
borderRadius: "0.75rem",
|
||||
overflow: "hidden", // ensure menu items don't overflow rounded corners
|
||||
}),
|
||||
menuList: (provided) => ({
|
||||
...provided,
|
||||
padding: "0.25rem", // add some padding around menu items
|
||||
}),
|
||||
option: (provided, state) => {
|
||||
let backgroundColor = "transparent";
|
||||
if (state.isSelected) {
|
||||
backgroundColor = "#C9B974"; // primary for selected
|
||||
} else if (state.isFocused) {
|
||||
backgroundColor = "#24272E"; // base-secondary for hover/focus
|
||||
}
|
||||
|
||||
return {
|
||||
...provided,
|
||||
backgroundColor,
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE", // black text on yellow, white on gray
|
||||
borderRadius: "0.5rem", // rounded menu items
|
||||
margin: "0.125rem 0", // small gap between items
|
||||
"&:hover": {
|
||||
backgroundColor: state.isSelected ? "#C9B974" : "#24272E", // keep yellow if selected, else gray
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE", // maintain text color on hover
|
||||
},
|
||||
"&:active": {
|
||||
backgroundColor: state.isSelected ? "#C9B974" : "#24272E",
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE",
|
||||
},
|
||||
};
|
||||
},
|
||||
clearIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
"&:hover": {
|
||||
color: "#ECEDEE", // content
|
||||
},
|
||||
}),
|
||||
dropdownIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
"&:hover": {
|
||||
color: "#ECEDEE", // content
|
||||
},
|
||||
}),
|
||||
loadingIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
}),
|
||||
});
|
||||
|
||||
export const getGitProviderMicroagentManagementCustomStyles = <
|
||||
T extends SelectOptionBase,
|
||||
>(): StylesConfig<T, false> => ({
|
||||
...getCustomStyles<T>(),
|
||||
control: (provided, state) => ({
|
||||
...provided,
|
||||
backgroundColor: state.isDisabled ? "#363636" : "#454545", // darker tertiary when disabled
|
||||
border: "1px solid #717888",
|
||||
borderRadius: "0.125rem",
|
||||
minHeight: "2.5rem",
|
||||
padding: "0 0.5rem",
|
||||
boxShadow: "none",
|
||||
opacity: state.isDisabled ? 0.6 : 1,
|
||||
cursor: state.isDisabled ? "not-allowed" : "pointer",
|
||||
"&:hover": {
|
||||
borderColor: "#717888",
|
||||
},
|
||||
"& .git-provider-dropdown__value-container": {
|
||||
padding: "2px 0",
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
UseComboboxGetMenuPropsOptions,
|
||||
UseComboboxGetItemPropsOptions,
|
||||
} from "downshift";
|
||||
import { Branch } from "#/types/git";
|
||||
import { DropdownItem } from "../shared/dropdown-item";
|
||||
import { GenericDropdownMenu, EmptyState } from "../shared";
|
||||
|
||||
export interface BranchDropdownMenuProps {
|
||||
isOpen: boolean;
|
||||
filteredBranches: Branch[];
|
||||
inputValue: string;
|
||||
highlightedIndex: number;
|
||||
selectedItem: Branch | null;
|
||||
getMenuProps: <Options>(
|
||||
options?: UseComboboxGetMenuPropsOptions & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<Branch> & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
onScroll: (event: React.UIEvent<HTMLUListElement>) => void;
|
||||
menuRef: React.RefObject<HTMLUListElement | null>;
|
||||
}
|
||||
|
||||
export function BranchDropdownMenu({
|
||||
isOpen,
|
||||
filteredBranches,
|
||||
inputValue,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
onScroll,
|
||||
menuRef,
|
||||
}: BranchDropdownMenuProps) {
|
||||
const renderItem = (
|
||||
branch: Branch,
|
||||
index: number,
|
||||
currentHighlightedIndex: number,
|
||||
currentSelectedItem: Branch | null,
|
||||
currentGetItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<Branch> & Options,
|
||||
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => (
|
||||
<DropdownItem
|
||||
key={branch.name}
|
||||
item={branch}
|
||||
index={index}
|
||||
isHighlighted={currentHighlightedIndex === index}
|
||||
isSelected={currentSelectedItem?.name === branch.name}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={(branchItem) => branchItem.name}
|
||||
getItemKey={(branchItem) => branchItem.name}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderEmptyState = (currentInputValue: string) => (
|
||||
<li className="px-3 py-2">
|
||||
<EmptyState
|
||||
inputValue={currentInputValue}
|
||||
searchMessage="No branches found"
|
||||
emptyMessage="No branches available"
|
||||
testId="git-branch-dropdown-empty"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="git-branch-dropdown-menu">
|
||||
<GenericDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredItems={filteredBranches}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={onScroll}
|
||||
menuRef={menuRef}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useBranchData } from "#/hooks/query/use-branch-data";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
import { ClearButton } from "../shared/clear-button";
|
||||
import { ToggleButton } from "../shared/toggle-button";
|
||||
import { ErrorMessage } from "../shared/error-message";
|
||||
import { BranchDropdownMenu } from "./branch-dropdown-menu";
|
||||
|
||||
export interface GitBranchDropdownProps {
|
||||
repository: string | null;
|
||||
provider: Provider;
|
||||
selectedBranch: Branch | null;
|
||||
onBranchSelect: (branch: Branch | null) => void;
|
||||
defaultBranch?: string | null;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GitBranchDropdown({
|
||||
repository,
|
||||
provider,
|
||||
selectedBranch,
|
||||
onBranchSelect,
|
||||
defaultBranch,
|
||||
placeholder = "Select branch...",
|
||||
disabled = false,
|
||||
className,
|
||||
}: GitBranchDropdownProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [userManuallyCleared, setUserManuallyCleared] = useState(false);
|
||||
const debouncedInputValue = useDebounce(inputValue, 300);
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Process search input (debounced and filtered)
|
||||
const processedSearchInput = useMemo(
|
||||
() =>
|
||||
debouncedInputValue.trim().length > 0 ? debouncedInputValue.trim() : "",
|
||||
[debouncedInputValue],
|
||||
);
|
||||
|
||||
// Use the new branch data hook with default branch prioritization
|
||||
const {
|
||||
branches: filteredBranches,
|
||||
isLoading,
|
||||
isError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isSearchLoading,
|
||||
} = useBranchData(
|
||||
repository,
|
||||
provider,
|
||||
defaultBranch || null,
|
||||
processedSearchInput,
|
||||
inputValue,
|
||||
selectedBranch,
|
||||
);
|
||||
|
||||
const error = isError ? new Error("Failed to load branches") : null;
|
||||
|
||||
// Handle clear
|
||||
const handleClear = useCallback(() => {
|
||||
setInputValue("");
|
||||
onBranchSelect(null);
|
||||
setUserManuallyCleared(true); // Mark that user manually cleared the branch
|
||||
}, [onBranchSelect]);
|
||||
|
||||
// Handle branch selection
|
||||
const handleBranchSelect = useCallback(
|
||||
(branch: Branch | null) => {
|
||||
onBranchSelect(branch);
|
||||
setInputValue("");
|
||||
},
|
||||
[onBranchSelect],
|
||||
);
|
||||
|
||||
// Handle input value change
|
||||
const handleInputValueChange = useCallback(
|
||||
({ inputValue: newInputValue }: { inputValue?: string }) => {
|
||||
if (newInputValue !== undefined) {
|
||||
setInputValue(newInputValue);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle menu scroll for infinite loading
|
||||
const handleMenuScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLUListElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
if (
|
||||
scrollHeight - scrollTop <= clientHeight * 1.5 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
[hasNextPage, isFetchingNextPage, fetchNextPage],
|
||||
);
|
||||
|
||||
// Downshift configuration
|
||||
const {
|
||||
isOpen,
|
||||
selectedItem,
|
||||
highlightedIndex,
|
||||
getInputProps,
|
||||
getItemProps,
|
||||
getMenuProps,
|
||||
getToggleButtonProps,
|
||||
} = useCombobox({
|
||||
items: filteredBranches,
|
||||
selectedItem: selectedBranch,
|
||||
itemToString: (item) => item?.name || "",
|
||||
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||
handleBranchSelect(newSelectedItem || null);
|
||||
},
|
||||
onInputValueChange: handleInputValueChange,
|
||||
inputValue,
|
||||
});
|
||||
|
||||
// Reset branch selection when repository changes
|
||||
useEffect(() => {
|
||||
if (repository) {
|
||||
onBranchSelect(null);
|
||||
setUserManuallyCleared(false); // Reset the manual clear flag when repository changes
|
||||
}
|
||||
}, [repository, onBranchSelect]);
|
||||
|
||||
// Auto-select default branch when branches are loaded and no branch is selected
|
||||
// But only if the user hasn't manually cleared the branch
|
||||
useEffect(() => {
|
||||
if (
|
||||
repository &&
|
||||
defaultBranch &&
|
||||
!selectedBranch &&
|
||||
!userManuallyCleared && // Don't auto-select if user manually cleared
|
||||
filteredBranches.length > 0 &&
|
||||
!isLoading
|
||||
) {
|
||||
const defaultBranchObj = filteredBranches.find(
|
||||
(branch) => branch.name === defaultBranch,
|
||||
);
|
||||
|
||||
if (defaultBranchObj) {
|
||||
onBranchSelect(defaultBranchObj);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
repository,
|
||||
defaultBranch,
|
||||
selectedBranch,
|
||||
userManuallyCleared,
|
||||
filteredBranches,
|
||||
onBranchSelect,
|
||||
isLoading,
|
||||
]);
|
||||
|
||||
// Reset input when repository changes
|
||||
useEffect(() => {
|
||||
setInputValue("");
|
||||
}, [repository]);
|
||||
|
||||
// Initialize input value when selectedBranch changes (but not when user is typing)
|
||||
useEffect(() => {
|
||||
if (selectedBranch && !isOpen && inputValue !== selectedBranch.name) {
|
||||
setInputValue(selectedBranch.name);
|
||||
} else if (!selectedBranch && !isOpen && inputValue) {
|
||||
setInputValue("");
|
||||
}
|
||||
}, [selectedBranch, isOpen, inputValue]);
|
||||
|
||||
const isLoadingState = isLoading || isSearchLoading || isFetchingNextPage;
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="relative">
|
||||
<input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getInputProps({
|
||||
disabled: disabled || !repository,
|
||||
placeholder,
|
||||
className: cn(
|
||||
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
|
||||
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
|
||||
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
|
||||
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"pr-10", // Space for toggle button
|
||||
),
|
||||
})}
|
||||
data-testid="git-branch-dropdown-input"
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
|
||||
{selectedBranch && (
|
||||
<ClearButton disabled={disabled} onClear={handleClear} />
|
||||
)}
|
||||
|
||||
<ToggleButton
|
||||
isOpen={isOpen}
|
||||
disabled={disabled || !repository}
|
||||
getToggleButtonProps={getToggleButtonProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoadingState && <LoadingSpinner hasSelection={!!selectedBranch} />}
|
||||
</div>
|
||||
|
||||
<BranchDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredBranches={filteredBranches}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={handleMenuScroll}
|
||||
menuRef={menuRef}
|
||||
/>
|
||||
|
||||
<ErrorMessage isError={!!error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { GitBranchDropdown } from "./git-branch-dropdown";
|
||||
export { BranchDropdownMenu } from "./branch-dropdown-menu";
|
||||
export type { GitBranchDropdownProps } from "./git-branch-dropdown";
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { DropdownItem } from "../shared/dropdown-item";
|
||||
import { GenericDropdownMenu } from "../shared/generic-dropdown-menu";
|
||||
import { ToggleButton } from "../shared/toggle-button";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
import { ErrorMessage } from "../shared/error-message";
|
||||
import { EmptyState } from "../shared/empty-state";
|
||||
|
||||
export interface GitProviderDropdownProps {
|
||||
providers: Provider[];
|
||||
value?: Provider | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
onChange?: (provider: Provider | null) => void;
|
||||
}
|
||||
|
||||
export function GitProviderDropdown({
|
||||
providers,
|
||||
value,
|
||||
placeholder = "Select Provider",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
}: GitProviderDropdownProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [localSelectedItem, setLocalSelectedItem] = useState<Provider | null>(
|
||||
value || null,
|
||||
);
|
||||
|
||||
// Format provider names for display
|
||||
const formatProviderName = (provider: Provider): string => {
|
||||
switch (provider) {
|
||||
case "github":
|
||||
return "GitHub";
|
||||
case "gitlab":
|
||||
return "GitLab";
|
||||
case "bitbucket":
|
||||
return "Bitbucket";
|
||||
case "enterprise_sso":
|
||||
return "Enterprise SSO";
|
||||
default:
|
||||
// Fallback for any future provider types
|
||||
return (
|
||||
(provider as string).charAt(0).toUpperCase() +
|
||||
(provider as string).slice(1)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter providers based on input value
|
||||
const filteredProviders = useMemo(() => {
|
||||
// If we have a selected provider and the input matches it exactly, show all providers
|
||||
if (
|
||||
localSelectedItem &&
|
||||
inputValue === formatProviderName(localSelectedItem)
|
||||
) {
|
||||
return providers;
|
||||
}
|
||||
|
||||
// If no input value, show all providers
|
||||
if (!inputValue || !inputValue.trim()) {
|
||||
return providers;
|
||||
}
|
||||
|
||||
// Filter providers based on input
|
||||
return providers.filter((provider) =>
|
||||
formatProviderName(provider)
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()),
|
||||
);
|
||||
}, [providers, inputValue, localSelectedItem]);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
highlightedIndex,
|
||||
getItemProps,
|
||||
selectedItem,
|
||||
} = useCombobox({
|
||||
items: filteredProviders,
|
||||
itemToString: (item) => (item ? formatProviderName(item) : ""),
|
||||
selectedItem: localSelectedItem,
|
||||
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||
setLocalSelectedItem(newSelectedItem || null);
|
||||
onChange?.(newSelectedItem || null);
|
||||
},
|
||||
onInputValueChange: ({ inputValue: newInputValue }) => {
|
||||
setInputValue(newInputValue || "");
|
||||
},
|
||||
inputValue,
|
||||
});
|
||||
|
||||
// Sync with external value prop
|
||||
useEffect(() => {
|
||||
if (value !== localSelectedItem) {
|
||||
setLocalSelectedItem(value || null);
|
||||
}
|
||||
}, [value, localSelectedItem]);
|
||||
|
||||
// Update input value when selection changes (but not when user is typing)
|
||||
useEffect(() => {
|
||||
if (selectedItem && !isOpen) {
|
||||
setInputValue(formatProviderName(selectedItem));
|
||||
} else if (!selectedItem) {
|
||||
setInputValue("");
|
||||
}
|
||||
}, [selectedItem, isOpen]);
|
||||
|
||||
const renderItem = (
|
||||
item: Provider,
|
||||
index: number,
|
||||
currentHighlightedIndex: number,
|
||||
currentSelectedItem: Provider | null,
|
||||
currentGetItemProps: any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => (
|
||||
<DropdownItem
|
||||
key={item}
|
||||
item={item}
|
||||
index={index}
|
||||
isHighlighted={index === currentHighlightedIndex}
|
||||
isSelected={item === currentSelectedItem}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={formatProviderName}
|
||||
getItemKey={(provider) => provider}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderEmptyState = (currentInputValue: string) => (
|
||||
<EmptyState
|
||||
inputValue={currentInputValue}
|
||||
searchMessage="No providers found"
|
||||
emptyMessage="No providers available"
|
||||
testId="git-provider-dropdown-empty"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="relative">
|
||||
<input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getInputProps({
|
||||
disabled,
|
||||
placeholder,
|
||||
readOnly: true, // Make it non-searchable like the original
|
||||
className: cn(
|
||||
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
|
||||
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
|
||||
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
|
||||
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"pr-10 cursor-pointer", // Space for toggle button and pointer cursor
|
||||
),
|
||||
})}
|
||||
data-testid="git-provider-dropdown"
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
|
||||
<ToggleButton
|
||||
isOpen={isOpen}
|
||||
disabled={disabled}
|
||||
getToggleButtonProps={getToggleButtonProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading && <LoadingSpinner hasSelection={!!selectedItem} />}
|
||||
</div>
|
||||
|
||||
<GenericDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredItems={filteredProviders}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
/>
|
||||
|
||||
<ErrorMessage isError={!!errorMessage} message={errorMessage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { GitProviderDropdown } from "./git-provider-dropdown";
|
||||
export type { GitProviderDropdownProps } from "./git-provider-dropdown";
|
||||
@@ -1,79 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
UseComboboxGetMenuPropsOptions,
|
||||
UseComboboxGetItemPropsOptions,
|
||||
} from "downshift";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { DropdownItem } from "../shared/dropdown-item";
|
||||
import { GenericDropdownMenu, EmptyState } from "../shared";
|
||||
|
||||
interface DropdownMenuProps {
|
||||
isOpen: boolean;
|
||||
filteredRepositories: GitRepository[];
|
||||
inputValue: string;
|
||||
highlightedIndex: number;
|
||||
selectedItem: GitRepository | null;
|
||||
getMenuProps: <Options>(
|
||||
options?: UseComboboxGetMenuPropsOptions & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<GitRepository> & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
onScroll: (event: React.UIEvent<HTMLUListElement>) => void;
|
||||
menuRef: React.RefObject<HTMLUListElement | null>;
|
||||
}
|
||||
|
||||
export function DropdownMenu({
|
||||
isOpen,
|
||||
filteredRepositories,
|
||||
inputValue,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
onScroll,
|
||||
menuRef,
|
||||
}: DropdownMenuProps) {
|
||||
const renderItem = (
|
||||
repository: GitRepository,
|
||||
index: number,
|
||||
currentHighlightedIndex: number,
|
||||
currentSelectedItem: GitRepository | null,
|
||||
currentGetItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<GitRepository> & Options,
|
||||
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => (
|
||||
<DropdownItem
|
||||
key={repository.id}
|
||||
item={repository}
|
||||
index={index}
|
||||
isHighlighted={currentHighlightedIndex === index}
|
||||
isSelected={currentSelectedItem?.id === repository.id}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={(repo) => repo.full_name}
|
||||
getItemKey={(repo) => repo.id.toString()}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderEmptyState = (currentInputValue: string) => (
|
||||
<EmptyState inputValue={currentInputValue} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="git-repo-dropdown-menu">
|
||||
<GenericDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredItems={filteredRepositories}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={onScroll}
|
||||
menuRef={menuRef}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
import { ClearButton } from "../shared/clear-button";
|
||||
import { ToggleButton } from "../shared/toggle-button";
|
||||
import { ErrorMessage } from "../shared/error-message";
|
||||
import { useUrlSearch } from "./use-url-search";
|
||||
import { useRepositoryData } from "./use-repository-data";
|
||||
import { DropdownMenu } from "./dropdown-menu";
|
||||
|
||||
export interface GitRepoDropdownProps {
|
||||
provider: Provider;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (repository?: GitRepository) => void;
|
||||
}
|
||||
|
||||
export function GitRepoDropdown({
|
||||
provider,
|
||||
value,
|
||||
placeholder = "Search repositories...",
|
||||
className,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitRepoDropdownProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [localSelectedItem, setLocalSelectedItem] =
|
||||
useState<GitRepository | null>(null);
|
||||
const debouncedInputValue = useDebounce(inputValue, 300);
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Process search input to handle URLs
|
||||
const processedSearchInput = useMemo(() => {
|
||||
if (debouncedInputValue.startsWith("https://")) {
|
||||
const match = debouncedInputValue.match(
|
||||
/https:\/\/[^/]+\/([^/]+\/[^/]+)/,
|
||||
);
|
||||
return match ? match[1] : debouncedInputValue;
|
||||
}
|
||||
return debouncedInputValue;
|
||||
}, [debouncedInputValue]);
|
||||
|
||||
// URL search functionality
|
||||
const { urlSearchResults, isUrlSearchLoading } = useUrlSearch(
|
||||
inputValue,
|
||||
provider,
|
||||
);
|
||||
|
||||
// Repository data management
|
||||
const {
|
||||
repositories,
|
||||
selectedRepository,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
isSearchLoading,
|
||||
} = useRepositoryData(
|
||||
provider,
|
||||
disabled,
|
||||
processedSearchInput,
|
||||
urlSearchResults,
|
||||
inputValue,
|
||||
value,
|
||||
);
|
||||
|
||||
// Filter repositories based on input value
|
||||
const filteredRepositories = useMemo(() => {
|
||||
// If we have URL search results, show them directly (no filtering needed)
|
||||
if (urlSearchResults.length > 0) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
// If we have a selected repository and the input matches it exactly, show all repositories
|
||||
if (selectedRepository && inputValue === selectedRepository.full_name) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
// If no input value, show all repositories
|
||||
if (!inputValue || !inputValue.trim()) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
// For URL inputs, use the processed search input for filtering
|
||||
const filterText = inputValue.startsWith("https://")
|
||||
? processedSearchInput
|
||||
: inputValue;
|
||||
|
||||
return repositories.filter((repo) =>
|
||||
repo.full_name.toLowerCase().includes(filterText.toLowerCase()),
|
||||
);
|
||||
}, [
|
||||
repositories,
|
||||
inputValue,
|
||||
selectedRepository,
|
||||
urlSearchResults,
|
||||
processedSearchInput,
|
||||
]);
|
||||
|
||||
// Handle selection
|
||||
const handleSelectionChange = useCallback(
|
||||
(selectedItem: GitRepository | null) => {
|
||||
setLocalSelectedItem(selectedItem);
|
||||
onChange?.(selectedItem || undefined);
|
||||
// Update input value to show selected item
|
||||
if (selectedItem) {
|
||||
setInputValue(selectedItem.full_name);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Handle clear selection
|
||||
const handleClear = useCallback(() => {
|
||||
setLocalSelectedItem(null);
|
||||
handleSelectionChange(null);
|
||||
setInputValue("");
|
||||
}, [handleSelectionChange]);
|
||||
|
||||
// Handle input value change
|
||||
const handleInputValueChange = useCallback(
|
||||
({ inputValue: newInputValue }: { inputValue?: string }) => {
|
||||
setInputValue(newInputValue || "");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle scroll to bottom for pagination
|
||||
const handleMenuScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLUListElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10;
|
||||
|
||||
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
[hasNextPage, isFetchingNextPage, fetchNextPage],
|
||||
);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
highlightedIndex,
|
||||
getItemProps,
|
||||
selectedItem,
|
||||
} = useCombobox({
|
||||
items: filteredRepositories,
|
||||
itemToString: (item) => item?.full_name || "",
|
||||
selectedItem: localSelectedItem,
|
||||
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||
handleSelectionChange(newSelectedItem);
|
||||
},
|
||||
onInputValueChange: handleInputValueChange,
|
||||
inputValue,
|
||||
});
|
||||
|
||||
// Sync localSelectedItem with external value prop
|
||||
useEffect(() => {
|
||||
if (selectedRepository) {
|
||||
setLocalSelectedItem(selectedRepository);
|
||||
} else if (value === null) {
|
||||
setLocalSelectedItem(null);
|
||||
}
|
||||
}, [selectedRepository, value]);
|
||||
|
||||
// Initialize input value when selectedRepository changes (but not when user is typing)
|
||||
useEffect(() => {
|
||||
if (selectedRepository && !isOpen) {
|
||||
setInputValue(selectedRepository.full_name);
|
||||
}
|
||||
}, [selectedRepository, isOpen]);
|
||||
|
||||
const isLoadingState =
|
||||
isLoading || isSearchLoading || isFetchingNextPage || isUrlSearchLoading;
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="relative">
|
||||
<input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getInputProps({
|
||||
disabled,
|
||||
placeholder,
|
||||
className: cn(
|
||||
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
|
||||
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
|
||||
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
|
||||
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"pr-10", // Space for toggle button
|
||||
),
|
||||
})}
|
||||
data-testid="git-repo-dropdown"
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
|
||||
{selectedRepository && (
|
||||
<ClearButton disabled={disabled} onClear={handleClear} />
|
||||
)}
|
||||
|
||||
<ToggleButton
|
||||
isOpen={isOpen}
|
||||
disabled={disabled}
|
||||
getToggleButtonProps={getToggleButtonProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoadingState && (
|
||||
<LoadingSpinner hasSelection={!!selectedRepository} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredRepositories={filteredRepositories}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={handleMenuScroll}
|
||||
menuRef={menuRef}
|
||||
/>
|
||||
|
||||
<ErrorMessage isError={isError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Main component
|
||||
export { GitRepoDropdown } from "./git-repo-dropdown";
|
||||
export type { GitRepoDropdownProps } from "./git-repo-dropdown";
|
||||
|
||||
// Repository-specific UI Components
|
||||
export { DropdownMenu } from "./dropdown-menu";
|
||||
|
||||
// Repository-specific Custom Hooks
|
||||
export { useUrlSearch } from "./use-url-search";
|
||||
export { useRepositoryData } from "./use-repository-data";
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useMemo, useEffect } from "react";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { useGitRepositories } from "#/hooks/query/use-git-repositories";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
|
||||
export function useRepositoryData(
|
||||
provider: Provider,
|
||||
disabled: boolean,
|
||||
processedSearchInput: string,
|
||||
urlSearchResults: GitRepository[],
|
||||
inputValue: string,
|
||||
value?: string | null,
|
||||
) {
|
||||
// Fetch user repositories with pagination
|
||||
const {
|
||||
data: repoData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useGitRepositories({
|
||||
provider,
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
// Search repositories when user types
|
||||
const { data: searchData, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(processedSearchInput, provider);
|
||||
|
||||
// Combine all repositories from paginated data
|
||||
const allRepositories = useMemo(
|
||||
() => repoData?.pages?.flatMap((page) => page.data) || [],
|
||||
[repoData],
|
||||
);
|
||||
|
||||
// Find selected repository from all possible sources
|
||||
const selectedRepository = useMemo(() => {
|
||||
if (!value) return null;
|
||||
|
||||
// Search in all possible repository sources
|
||||
const allPossibleRepos = [
|
||||
...allRepositories,
|
||||
...urlSearchResults,
|
||||
...(searchData || []),
|
||||
];
|
||||
|
||||
return allPossibleRepos.find((repo) => repo.id === value) || null;
|
||||
}, [allRepositories, urlSearchResults, searchData, value]);
|
||||
|
||||
// Get repositories to display (URL search, regular search, or all repos)
|
||||
const repositories = useMemo(() => {
|
||||
// Prioritize URL search results when available
|
||||
if (urlSearchResults.length > 0) {
|
||||
return urlSearchResults;
|
||||
}
|
||||
|
||||
// Don't use search results if input exactly matches selected repository
|
||||
const shouldUseSearch =
|
||||
processedSearchInput &&
|
||||
searchData &&
|
||||
!(selectedRepository && inputValue === selectedRepository.full_name);
|
||||
|
||||
if (shouldUseSearch) {
|
||||
return searchData;
|
||||
}
|
||||
return allRepositories;
|
||||
}, [
|
||||
urlSearchResults,
|
||||
processedSearchInput,
|
||||
searchData,
|
||||
allRepositories,
|
||||
selectedRepository,
|
||||
inputValue,
|
||||
]);
|
||||
|
||||
// Auto-load more repositories when there aren't enough items to create a scrollable dropdown
|
||||
// This is particularly important for SaaS mode with installations that might have very few repos
|
||||
useEffect(() => {
|
||||
const shouldAutoLoad =
|
||||
!disabled &&
|
||||
!isLoading &&
|
||||
!isFetchingNextPage &&
|
||||
!isSearchLoading &&
|
||||
hasNextPage &&
|
||||
!processedSearchInput && // Not during search (use all repos, not search results)
|
||||
urlSearchResults.length === 0 &&
|
||||
repositories.length > 0 && // Have some repositories loaded
|
||||
repositories.length < 10; // But not enough to create a scrollable dropdown
|
||||
|
||||
if (shouldAutoLoad) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
disabled,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isSearchLoading,
|
||||
hasNextPage,
|
||||
processedSearchInput,
|
||||
urlSearchResults.length,
|
||||
repositories.length,
|
||||
fetchNextPage,
|
||||
]);
|
||||
|
||||
return {
|
||||
repositories,
|
||||
allRepositories,
|
||||
selectedRepository,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
isSearchLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export function useUrlSearch(inputValue: string, provider: Provider) {
|
||||
const [urlSearchResults, setUrlSearchResults] = useState<GitRepository[]>([]);
|
||||
const [isUrlSearchLoading, setIsUrlSearchLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleUrlSearch = async () => {
|
||||
if (inputValue.startsWith("https://")) {
|
||||
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
const repoName = match[1];
|
||||
|
||||
setIsUrlSearchLoading(true);
|
||||
try {
|
||||
const repositories = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
provider,
|
||||
);
|
||||
|
||||
setUrlSearchResults(repositories);
|
||||
} catch (error) {
|
||||
setUrlSearchResults([]);
|
||||
} finally {
|
||||
setIsUrlSearchLoading(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setUrlSearchResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
handleUrlSearch();
|
||||
}, [inputValue, provider]);
|
||||
|
||||
return { urlSearchResults, isUrlSearchLoading };
|
||||
}
|
||||
@@ -2,15 +2,15 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
// Removed useRepositoryBranches import - GitBranchDropdown manages its own data
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { Branch, GitRepository } from "#/types/git";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitProviderDropdown } from "./git-provider-dropdown";
|
||||
import { GitBranchDropdown } from "./git-branch-dropdown";
|
||||
import { GitRepoDropdown } from "./git-repo-dropdown";
|
||||
import { GitProviderDropdown } from "../../common/git-provider-dropdown";
|
||||
import { GitRepositoryDropdown } from "../../common/git-repository-dropdown";
|
||||
import { GitBranchDropdown } from "../../common/git-branch-dropdown";
|
||||
|
||||
interface RepositorySelectionFormProps {
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
@@ -28,6 +28,8 @@ export function RepositorySelectionForm({
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
React.useState<Provider | null>(null);
|
||||
const { providers } = useUserProviders();
|
||||
const { data: branches, isLoading: isLoadingBranches } =
|
||||
useRepositoryBranches(selectedRepository?.full_name || null);
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
@@ -48,7 +50,8 @@ export function RepositorySelectionForm({
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
// Branch selection is now handled by GitBranchDropdown component
|
||||
// Check if repository has no branches (empty array after loading completes)
|
||||
const hasNoBranches = !isLoadingBranches && branches && branches.length === 0;
|
||||
|
||||
const handleProviderSelection = (provider: Provider | null) => {
|
||||
setSelectedProvider(provider);
|
||||
@@ -57,9 +60,14 @@ export function RepositorySelectionForm({
|
||||
onRepoSelection(null); // Reset parent component's selected repo
|
||||
};
|
||||
|
||||
const handleBranchSelection = React.useCallback((branch: Branch | null) => {
|
||||
setSelectedBranch(branch);
|
||||
}, []);
|
||||
const handleBranchSelection = (branchName: string | null) => {
|
||||
const selectedBranchObj = branches?.find(
|
||||
(branch) => branch.name === branchName,
|
||||
);
|
||||
if (selectedBranchObj) {
|
||||
setSelectedBranch(selectedBranchObj);
|
||||
}
|
||||
};
|
||||
|
||||
// Render the provider dropdown
|
||||
const renderProviderSelector = () => {
|
||||
@@ -79,6 +87,19 @@ export function RepositorySelectionForm({
|
||||
);
|
||||
};
|
||||
|
||||
// Effect to auto-select main/master branch when branches are loaded
|
||||
React.useEffect(() => {
|
||||
if (branches?.length) {
|
||||
// Look for main or master branch
|
||||
const defaultBranch = branches.find(
|
||||
(branch) => branch.name === "main" || branch.name === "master",
|
||||
);
|
||||
|
||||
// If found, select it, otherwise select the first branch
|
||||
setSelectedBranch(defaultBranch || branches[0]);
|
||||
}
|
||||
}, [branches]);
|
||||
|
||||
// Render the repository selector using our new component
|
||||
const renderRepositorySelector = () => {
|
||||
const handleRepoSelection = (repository?: GitRepository) => {
|
||||
@@ -86,14 +107,13 @@ export function RepositorySelectionForm({
|
||||
onRepoSelection(repository);
|
||||
setSelectedRepository(repository);
|
||||
} else {
|
||||
onRepoSelection(null); // Notify parent component that repo was cleared
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitRepoDropdown
|
||||
<GitRepositoryDropdown
|
||||
provider={selectedProvider || providers[0]}
|
||||
value={selectedRepository?.id || null}
|
||||
placeholder="Search repositories..."
|
||||
@@ -105,21 +125,16 @@ export function RepositorySelectionForm({
|
||||
};
|
||||
|
||||
// Render the branch selector
|
||||
const renderBranchSelector = () => {
|
||||
const defaultBranch = selectedRepository?.main_branch || null;
|
||||
return (
|
||||
<GitBranchDropdown
|
||||
repository={selectedRepository?.full_name || null}
|
||||
provider={selectedProvider || providers[0]}
|
||||
selectedBranch={selectedBranch}
|
||||
onBranchSelect={handleBranchSelection}
|
||||
defaultBranch={defaultBranch}
|
||||
placeholder="Select branch..."
|
||||
className="max-w-[500px]"
|
||||
disabled={!selectedRepository}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const renderBranchSelector = () => (
|
||||
<GitBranchDropdown
|
||||
repositoryName={selectedRepository?.full_name}
|
||||
value={selectedBranch?.name || null}
|
||||
placeholder="Select branch..."
|
||||
className="max-w-[500px]"
|
||||
disabled={!selectedRepository}
|
||||
onChange={handleBranchSelection}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -133,7 +148,8 @@ export function RepositorySelectionForm({
|
||||
type="button"
|
||||
isDisabled={
|
||||
!selectedRepository ||
|
||||
!selectedBranch ||
|
||||
(!selectedBranch && !hasNoBranches) ||
|
||||
isLoadingBranches ||
|
||||
isCreatingConversation ||
|
||||
(providers.length > 1 && !selectedProvider)
|
||||
}
|
||||
@@ -143,7 +159,7 @@ export function RepositorySelectionForm({
|
||||
repository: {
|
||||
name: selectedRepository?.full_name || "",
|
||||
gitProvider: selectedRepository?.git_provider || "github",
|
||||
branch: selectedBranch?.name || "main",
|
||||
branch: selectedBranch?.name || (hasNoBranches ? "" : "main"),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ClearButtonProps {
|
||||
disabled: boolean;
|
||||
onClear: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ClearButton({
|
||||
disabled,
|
||||
onClear,
|
||||
testId = "dropdown-clear",
|
||||
}: ClearButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"p-1 text-[#B7BDC2] hover:text-[#ECEDEE]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
data-testid={testId}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface DropdownItemProps<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
isHighlighted: boolean;
|
||||
isSelected: boolean;
|
||||
getItemProps: <Options>(options: any & Options) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getDisplayText: (item: T) => string;
|
||||
getItemKey: (item: T) => string;
|
||||
}
|
||||
|
||||
export function DropdownItem<T>({
|
||||
item,
|
||||
index,
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
getItemProps,
|
||||
getDisplayText,
|
||||
getItemKey,
|
||||
}: DropdownItemProps<T>) {
|
||||
const itemProps = getItemProps({
|
||||
index,
|
||||
item,
|
||||
className: cn(
|
||||
"px-3 py-2 cursor-pointer text-sm rounded-lg mx-0.5 my-0.5",
|
||||
"text-[#ECEDEE] focus:outline-none",
|
||||
{
|
||||
"bg-[#24272E]": isHighlighted && !isSelected,
|
||||
"bg-[#C9B974] text-black": isSelected,
|
||||
"hover:bg-[#24272E]": !isSelected,
|
||||
"hover:bg-[#C9B974] hover:text-black": isSelected,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<li key={getItemKey(item)} {...itemProps}>
|
||||
<span className="font-medium">{getDisplayText(item)}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
inputValue: string;
|
||||
searchMessage?: string;
|
||||
emptyMessage?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
inputValue,
|
||||
searchMessage = "No items found",
|
||||
emptyMessage = "No items available",
|
||||
testId = "dropdown-empty",
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<li
|
||||
className="px-3 py-2 text-[#B7BDC2] text-sm rounded-lg mx-0.5 my-0.5"
|
||||
data-testid={testId}
|
||||
>
|
||||
{inputValue ? searchMessage : emptyMessage}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface ErrorMessageProps {
|
||||
isError: boolean;
|
||||
message?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ErrorMessage({
|
||||
isError,
|
||||
message = "Failed to load data",
|
||||
testId = "dropdown-error",
|
||||
}: ErrorMessageProps) {
|
||||
if (!isError) return null;
|
||||
|
||||
return (
|
||||
<div className="text-red-500 text-sm mt-1" data-testid={testId}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
UseComboboxGetMenuPropsOptions,
|
||||
UseComboboxGetItemPropsOptions,
|
||||
} from "downshift";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export interface GenericDropdownMenuProps<T> {
|
||||
isOpen: boolean;
|
||||
filteredItems: T[];
|
||||
inputValue: string;
|
||||
highlightedIndex: number;
|
||||
selectedItem: T | null;
|
||||
getMenuProps: <Options>(
|
||||
options?: UseComboboxGetMenuPropsOptions & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<T> & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
onScroll?: (event: React.UIEvent<HTMLUListElement>) => void;
|
||||
menuRef?: React.RefObject<HTMLUListElement | null>;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
highlightedIndex: number,
|
||||
selectedItem: T | null,
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<T> & Options,
|
||||
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => React.ReactNode;
|
||||
renderEmptyState: (inputValue: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function GenericDropdownMenu<T>({
|
||||
isOpen,
|
||||
filteredItems,
|
||||
inputValue,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
onScroll,
|
||||
menuRef,
|
||||
renderItem,
|
||||
renderEmptyState,
|
||||
}: GenericDropdownMenuProps<T>) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<ul
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getMenuProps({
|
||||
ref: menuRef,
|
||||
className: cn(
|
||||
"absolute z-10 w-full bg-[#454545] border border-[#717888] rounded-xl shadow-lg max-h-60 overflow-auto",
|
||||
"focus:outline-none p-1 gap-2 flex flex-col",
|
||||
),
|
||||
onScroll,
|
||||
})}
|
||||
>
|
||||
{filteredItems.length === 0
|
||||
? renderEmptyState(inputValue)
|
||||
: filteredItems.map((item, index) =>
|
||||
renderItem(
|
||||
item,
|
||||
index,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getItemProps,
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { GenericDropdownMenu } from "./generic-dropdown-menu";
|
||||
export { EmptyState } from "./empty-state";
|
||||
export { ErrorMessage } from "./error-message";
|
||||
export { LoadingSpinner } from "./loading-spinner";
|
||||
export { ClearButton } from "./clear-button";
|
||||
export { ToggleButton } from "./toggle-button";
|
||||
export type { GenericDropdownMenuProps } from "./generic-dropdown-menu";
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
hasSelection: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({
|
||||
hasSelection,
|
||||
testId = "dropdown-loading",
|
||||
}: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 transform -translate-y-1/2",
|
||||
hasSelection ? "right-16" : "right-12",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"
|
||||
data-testid={testId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ToggleButtonProps {
|
||||
isOpen: boolean;
|
||||
disabled: boolean;
|
||||
getToggleButtonProps: (
|
||||
props?: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ToggleButton({
|
||||
isOpen,
|
||||
disabled,
|
||||
getToggleButtonProps,
|
||||
}: ToggleButtonProps) {
|
||||
return (
|
||||
<button
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getToggleButtonProps({
|
||||
disabled,
|
||||
className: cn(
|
||||
"p-1 text-[#B7BDC2] hover:text-[#ECEDEE]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
),
|
||||
})}
|
||||
type="button"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg
|
||||
className={cn("w-4 h-4 transition-transform", isOpen && "rotate-180")}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+2
@@ -240,6 +240,7 @@ export function MicroagentManagementContent() {
|
||||
conversationInstructions,
|
||||
repository: {
|
||||
name: repositoryName,
|
||||
branch: formData.selectedBranch,
|
||||
gitProvider,
|
||||
},
|
||||
createMicroagent,
|
||||
@@ -288,6 +289,7 @@ export function MicroagentManagementContent() {
|
||||
conversationInstructions: formData.query,
|
||||
repository: {
|
||||
name: repositoryName,
|
||||
branch: formData.selectedBranch,
|
||||
gitProvider,
|
||||
},
|
||||
createMicroagent,
|
||||
|
||||
+114
-3
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
@@ -10,6 +10,13 @@ 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;
|
||||
@@ -25,11 +32,47 @@ 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();
|
||||
|
||||
@@ -40,6 +83,7 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
|
||||
onConfirm({
|
||||
query: finalQuery,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -51,9 +95,66 @@ 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
|
||||
@@ -99,6 +200,9 @@ 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"
|
||||
@@ -141,9 +245,16 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
testId="confirm-button"
|
||||
isDisabled={isLoading}
|
||||
isDisabled={
|
||||
isLoading ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isBranchesError
|
||||
}
|
||||
>
|
||||
{isLoading ? t(I18nKey.HOME$LOADING) : t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
{isLoading || isLoadingBranches
|
||||
? t(I18nKey.HOME$LOADING)
|
||||
: t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
+1
-1
@@ -137,7 +137,7 @@ export function MicroagentManagementRepoMicroagents({
|
||||
{hasConversations && (
|
||||
<div className={cn("flex flex-col", hasMicroagents && "mt-4")}>
|
||||
<span className="text-md text-white font-medium leading-5 mb-4">
|
||||
{t(I18nKey.COMMON$IN_PROGRESS)}
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$OPEN_MICROAGENT_PULL_REQUESTS)}
|
||||
</span>
|
||||
{conversations?.map((conversation) => (
|
||||
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
|
||||
|
||||
+1
-15
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Accordion, AccordionItem, Spinner } from "@heroui/react";
|
||||
import { Accordion, AccordionItem } from "@heroui/react";
|
||||
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { TabType } from "#/types/microagent-management";
|
||||
@@ -11,30 +11,16 @@ 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 (
|
||||
|
||||
+1
-10
@@ -5,13 +5,7 @@ import { MicroagentManagementRepositories } from "./microagent-management-reposi
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
interface MicroagentManagementSidebarTabsProps {
|
||||
isSearchLoading?: boolean;
|
||||
}
|
||||
|
||||
export function MicroagentManagementSidebarTabs({
|
||||
isSearchLoading = false,
|
||||
}: MicroagentManagementSidebarTabsProps) {
|
||||
export function MicroagentManagementSidebarTabs() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { repositories, personalRepositories, organizationRepositories } =
|
||||
@@ -35,21 +29,18 @@ 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>
|
||||
|
||||
+23
-78
@@ -5,8 +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 { GitProviderDropdown } from "#/components/common/git-provider-dropdown";
|
||||
import {
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
@@ -17,7 +16,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";
|
||||
import { getGitProviderMicroagentManagementCustomStyles } from "#/components/common/react-select-styles";
|
||||
|
||||
interface MicroagentManagementSidebarProps {
|
||||
isSmallerScreen?: boolean;
|
||||
@@ -33,29 +32,17 @@ export function MicroagentManagementSidebar({
|
||||
);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Use Git repositories hook with pagination for infinite scrolling
|
||||
const {
|
||||
data: repositories,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
} = useGitRepositories({
|
||||
const { data: repositories, isLoading } = useGitRepositories({
|
||||
provider: selectedProvider,
|
||||
pageSize: 30, // Load 30 repositories per page
|
||||
pageSize: 200,
|
||||
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) {
|
||||
@@ -68,31 +55,23 @@ export function MicroagentManagementSidebar({
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
// Filter repositories based on search query and available data
|
||||
// Filter repositories based on search query
|
||||
const filteredRepositories = useMemo(() => {
|
||||
// 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 [];
|
||||
if (!repositories?.pages) return null;
|
||||
|
||||
// Flatten all pages to get all repositories
|
||||
const allRepositories = repositories.pages.flatMap((page) => page.data);
|
||||
|
||||
// If no search query, return all repositories
|
||||
if (!debouncedSearchQuery.trim()) {
|
||||
if (!searchQuery.trim()) {
|
||||
return allRepositories;
|
||||
}
|
||||
|
||||
// Fallback to client-side filtering if search didn't return results
|
||||
const sanitizedQuery = sanitizeQuery(debouncedSearchQuery);
|
||||
const sanitizedQuery = sanitizeQuery(searchQuery);
|
||||
return allRepositories.filter((repository: GitRepository) => {
|
||||
const sanitizedRepoName = sanitizeQuery(repository.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
});
|
||||
}, [repositories, debouncedSearchQuery, searchResults]);
|
||||
}, [repositories, searchQuery, selectedProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filteredRepositories?.length) {
|
||||
@@ -126,28 +105,12 @@ 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 />
|
||||
|
||||
@@ -160,6 +123,8 @@ export function MicroagentManagementSidebar({
|
||||
placeholder="Select Provider"
|
||||
onChange={handleProviderChange}
|
||||
className="w-full"
|
||||
classNamePrefix="git-provider-dropdown"
|
||||
styles={getGitProviderMicroagentManagementCustomStyles()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -169,26 +134,18 @@ export function MicroagentManagementSidebar({
|
||||
<label htmlFor="repository-search" className="sr-only">
|
||||
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
|
||||
</label>
|
||||
<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>
|
||||
<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>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -199,19 +156,7 @@ export function MicroagentManagementSidebar({
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
<MicroagentManagementSidebarTabs />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+110
-4
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
@@ -11,8 +11,14 @@ import XIcon from "#/icons/x.svg?react";
|
||||
import { cn, extractRepositoryInfo } from "#/utils/utils";
|
||||
import { BadgeInput } from "#/components/shared/inputs/badge-input";
|
||||
import { MicroagentFormData } from "#/types/microagent-management";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { Branch, GitRepository } from "#/types/git";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content";
|
||||
import {
|
||||
BranchDropdown,
|
||||
BranchLoadingState,
|
||||
BranchErrorState,
|
||||
} from "../home/repository-selection";
|
||||
|
||||
interface MicroagentManagementUpsertMicroagentModalProps {
|
||||
onConfirm: (formData: MicroagentFormData) => void;
|
||||
@@ -31,6 +37,7 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
|
||||
const [triggers, setTriggers] = useState<string[]>([]);
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
@@ -42,6 +49,9 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
|
||||
const { microagent } = selectedMicroagentItem ?? {};
|
||||
|
||||
// Add a ref to track if the branch was manually cleared by the user
|
||||
const branchManuallyClearedRef = useRef<boolean>(false);
|
||||
|
||||
// Extract owner and repo from full_name for content API
|
||||
const { owner, repo, filePath } = extractRepositoryInfo(
|
||||
selectedRepository,
|
||||
@@ -60,6 +70,38 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
}
|
||||
}, [isUpdate, microagentContentData]);
|
||||
|
||||
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 modalTitle = useMemo(() => {
|
||||
if (isUpdate) {
|
||||
return t(I18nKey.MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT);
|
||||
@@ -92,6 +134,7 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
onConfirm({
|
||||
query: query.trim(),
|
||||
triggers,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
microagentPath: microagent?.path || "",
|
||||
});
|
||||
};
|
||||
@@ -104,10 +147,67 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
onConfirm({
|
||||
query: query.trim(),
|
||||
triggers,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
microagentPath: microagent?.path || "",
|
||||
});
|
||||
};
|
||||
|
||||
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 className="items-start rounded-[12px] p-6 min-w-[611px]">
|
||||
@@ -136,6 +236,7 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
{renderBranchSelector()}
|
||||
<label
|
||||
htmlFor="query-input"
|
||||
className="flex flex-col gap-2 w-full text-sm font-normal"
|
||||
@@ -200,10 +301,15 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
onClick={handleConfirm}
|
||||
testId="confirm-button"
|
||||
isDisabled={
|
||||
!query.trim() || isLoading || (isUpdate && isLoadingContent) // Disable while loading content for updates
|
||||
!query.trim() ||
|
||||
isLoading ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isBranchesError ||
|
||||
(isUpdate && isLoadingContent) // Disable while loading content for updates
|
||||
}
|
||||
>
|
||||
{isLoading || (isUpdate && isLoadingContent)
|
||||
{isLoading || isLoadingBranches || (isUpdate && isLoadingContent)
|
||||
? t(I18nKey.HOME$LOADING)
|
||||
: t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
</BrandButton>
|
||||
|
||||
@@ -37,6 +37,9 @@ 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;
|
||||
|
||||
@@ -80,9 +83,11 @@ export function Sidebar() {
|
||||
}
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
<MicroagentManagementButton
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
{!shouldHideMicroagentManagement && (
|
||||
<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 RobotIcon from "#/icons/robot.svg?react";
|
||||
import UnionIcon from "#/icons/union.svg?react";
|
||||
|
||||
interface MicroagentManagementButtonProps {
|
||||
disabled?: boolean;
|
||||
@@ -22,7 +22,7 @@ export function MicroagentManagementButton({
|
||||
testId="microagent-management-button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<RobotIcon width={28} height={28} />
|
||||
<UnionIcon />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRepositoryBranchesPaginated } from "./use-repository-branches";
|
||||
import { useSearchBranches } from "./use-search-branches";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export function useBranchData(
|
||||
repository: string | null,
|
||||
provider: Provider,
|
||||
defaultBranch: string | null,
|
||||
processedSearchInput: string,
|
||||
inputValue: string,
|
||||
selectedBranch?: Branch | null,
|
||||
) {
|
||||
// Fetch branches with pagination
|
||||
const {
|
||||
data: branchData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useRepositoryBranchesPaginated(repository);
|
||||
|
||||
// Search branches when user types
|
||||
const { data: searchData, isLoading: isSearchLoading } = useSearchBranches(
|
||||
repository,
|
||||
processedSearchInput,
|
||||
30,
|
||||
provider,
|
||||
);
|
||||
|
||||
// Combine all branches from paginated data
|
||||
const allBranches = useMemo(
|
||||
() => branchData?.pages?.flatMap((page) => page.branches) || [],
|
||||
[branchData],
|
||||
);
|
||||
|
||||
// Check if default branch is in the loaded branches
|
||||
const defaultBranchInLoaded = useMemo(
|
||||
() =>
|
||||
defaultBranch
|
||||
? allBranches.find((branch) => branch.name === defaultBranch)
|
||||
: null,
|
||||
[allBranches, defaultBranch],
|
||||
);
|
||||
|
||||
// Only search for default branch if it's not already in the loaded branches
|
||||
// and we have loaded some branches (to avoid searching immediately on mount)
|
||||
const shouldSearchDefaultBranch =
|
||||
defaultBranch &&
|
||||
!defaultBranchInLoaded &&
|
||||
allBranches.length > 0 &&
|
||||
!processedSearchInput; // Don't search for default branch when user is searching
|
||||
|
||||
const { data: defaultBranchData, isLoading: isDefaultBranchLoading } =
|
||||
useSearchBranches(
|
||||
repository,
|
||||
shouldSearchDefaultBranch ? defaultBranch : "",
|
||||
30,
|
||||
provider,
|
||||
);
|
||||
|
||||
// Get branches to display with default branch prioritized
|
||||
const branches = useMemo(() => {
|
||||
// Don't use search results if input exactly matches selected branch
|
||||
const shouldUseSearch =
|
||||
processedSearchInput &&
|
||||
searchData &&
|
||||
!(selectedBranch && inputValue === selectedBranch.name);
|
||||
|
||||
let branchesToUse = shouldUseSearch ? searchData : allBranches;
|
||||
|
||||
// If we have a default branch, ensure it's at the top of the list
|
||||
if (defaultBranch) {
|
||||
// Use the already computed defaultBranchInLoaded or check in current branches
|
||||
let defaultBranchObj = shouldUseSearch
|
||||
? branchesToUse.find((branch) => branch.name === defaultBranch)
|
||||
: defaultBranchInLoaded;
|
||||
|
||||
// If not found in current branches, check if we have it from the default branch search
|
||||
if (
|
||||
!defaultBranchObj &&
|
||||
defaultBranchData &&
|
||||
defaultBranchData.length > 0
|
||||
) {
|
||||
defaultBranchObj = defaultBranchData.find(
|
||||
(branch) => branch.name === defaultBranch,
|
||||
);
|
||||
|
||||
// Add the default branch to the beginning of the list
|
||||
if (defaultBranchObj) {
|
||||
branchesToUse = [defaultBranchObj, ...branchesToUse];
|
||||
}
|
||||
} else if (defaultBranchObj) {
|
||||
// If found in current branches, move it to the front
|
||||
const otherBranches = branchesToUse.filter(
|
||||
(branch) => branch.name !== defaultBranch,
|
||||
);
|
||||
branchesToUse = [defaultBranchObj, ...otherBranches];
|
||||
}
|
||||
}
|
||||
|
||||
return branchesToUse;
|
||||
}, [
|
||||
processedSearchInput,
|
||||
searchData,
|
||||
allBranches,
|
||||
selectedBranch,
|
||||
inputValue,
|
||||
defaultBranch,
|
||||
defaultBranchInLoaded,
|
||||
defaultBranchData,
|
||||
]);
|
||||
|
||||
return {
|
||||
branches,
|
||||
allBranches,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading: isLoading || isDefaultBranchLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
isSearchLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,46 +1,14 @@
|
||||
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Branch, PaginatedBranchesResponse } from "#/types/git";
|
||||
import { Branch } from "#/types/git";
|
||||
|
||||
export const useRepositoryBranches = (repository: string | null) =>
|
||||
useQuery<Branch[]>({
|
||||
queryKey: ["repository", repository, "branches"],
|
||||
queryFn: async () => {
|
||||
if (!repository) return [];
|
||||
const response = await OpenHands.getRepositoryBranches(repository);
|
||||
// Ensure we return an array even if the response is malformed
|
||||
return Array.isArray(response.branches) ? response.branches : [];
|
||||
return OpenHands.getRepositoryBranches(repository);
|
||||
},
|
||||
enabled: !!repository,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
export const useRepositoryBranchesPaginated = (
|
||||
repository: string | null,
|
||||
perPage: number = 30,
|
||||
) =>
|
||||
useInfiniteQuery<PaginatedBranchesResponse, Error>({
|
||||
queryKey: ["repository", repository, "branches", "paginated", perPage],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
if (!repository) {
|
||||
return {
|
||||
branches: [],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: perPage,
|
||||
total_count: 0,
|
||||
};
|
||||
}
|
||||
return OpenHands.getRepositoryBranches(
|
||||
repository,
|
||||
pageParam as number,
|
||||
perPage,
|
||||
);
|
||||
},
|
||||
enabled: !!repository,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
getNextPageParam: (lastPage) =>
|
||||
// Use the has_next_page flag from the API response
|
||||
lastPage.has_next_page ? lastPage.current_page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export function useSearchBranches(
|
||||
repository: string | null,
|
||||
query: string,
|
||||
perPage: number = 30,
|
||||
selectedProvider?: Provider,
|
||||
) {
|
||||
return useQuery<Branch[]>({
|
||||
queryKey: [
|
||||
"repository",
|
||||
repository,
|
||||
"branches",
|
||||
"search",
|
||||
query,
|
||||
perPage,
|
||||
selectedProvider,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!repository || !query) return [];
|
||||
return OpenHands.searchRepositoryBranches(
|
||||
repository,
|
||||
query,
|
||||
perPage,
|
||||
selectedProvider,
|
||||
);
|
||||
},
|
||||
enabled: !!repository && !!query,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 15,
|
||||
});
|
||||
}
|
||||
@@ -5,16 +5,11 @@ import { Provider } from "#/types/settings";
|
||||
export function useSearchRepositories(
|
||||
query: string,
|
||||
selectedProvider?: Provider | null,
|
||||
pageSize: number = 3,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["repositories", "search", query, selectedProvider, pageSize],
|
||||
queryKey: ["repositories", "search", query, selectedProvider],
|
||||
queryFn: () =>
|
||||
OpenHands.searchGitRepositories(
|
||||
query,
|
||||
pageSize,
|
||||
selectedProvider || undefined,
|
||||
),
|
||||
OpenHands.searchGitRepositories(query, 3, selectedProvider || undefined),
|
||||
enabled: !!query && !!selectedProvider,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -128,7 +128,7 @@ export const useCreateConversationAndSubscribeMultiple = () => {
|
||||
conversationInstructions: string;
|
||||
repository: {
|
||||
name: string;
|
||||
branch?: string;
|
||||
branch: string;
|
||||
gitProvider: Provider;
|
||||
};
|
||||
createMicroagent?: CreateMicroagent;
|
||||
|
||||
@@ -85,8 +85,6 @@ 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",
|
||||
@@ -820,11 +818,11 @@ export enum I18nKey {
|
||||
MICROAGENT$UNKNOWN_ERROR = "MICROAGENT$UNKNOWN_ERROR",
|
||||
MICROAGENT$CONVERSATION_STARTING = "MICROAGENT$CONVERSATION_STARTING",
|
||||
MICROAGENT_MANAGEMENT$EXISTING_MICROAGENTS = "MICROAGENT_MANAGEMENT$EXISTING_MICROAGENTS",
|
||||
MICROAGENT_MANAGEMENT$OPEN_MICROAGENT_PULL_REQUESTS = "MICROAGENT_MANAGEMENT$OPEN_MICROAGENT_PULL_REQUESTS",
|
||||
SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT = "SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT",
|
||||
SETTINGS$SECURITY_ANALYZER_NONE = "SETTINGS$SECURITY_ANALYZER_NONE",
|
||||
SETTINGS$SECURITY_ANALYZER_INVARIANT = "SETTINGS$SECURITY_ANALYZER_INVARIANT",
|
||||
COMMON$HIGH_RISK = "COMMON$HIGH_RISK",
|
||||
MICROAGENT$DEFINITION = "MICROAGENT$DEFINITION",
|
||||
MICROAGENT$ADD_TO_MEMORY = "MICROAGENT$ADD_TO_MEMORY",
|
||||
COMMON$IN_PROGRESS = "COMMON$IN_PROGRESS",
|
||||
}
|
||||
|
||||
@@ -1359,38 +1359,6 @@
|
||||
"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": "リポジトリの読み込みに失敗しました",
|
||||
@@ -11984,20 +11952,20 @@
|
||||
"uk": "Бажаєте, щоб OpenHands розпочав нову розмову, щоб допомогти вам зрозуміти цей репозиторій?"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO": {
|
||||
"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": "Що ви хотіли б дізнатися про цей репозиторій? (необов'язково)"
|
||||
"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": "Що ви хотіли б дізнатися про цей репозиторій?"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO": {
|
||||
"en": "Describe what you would like to know about this repository.",
|
||||
@@ -13119,6 +13087,22 @@
|
||||
"de": "Vorhandene Mikroagenten",
|
||||
"uk": "Існуючі мікроагенти"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$OPEN_MICROAGENT_PULL_REQUESTS": {
|
||||
"en": "Open Microagent Pull Requests",
|
||||
"ja": "未解決のマイクロエージェントのプルリクエスト",
|
||||
"zh-CN": "未合并的微代理拉取请求",
|
||||
"zh-TW": "未合併的微代理拉取請求",
|
||||
"ko-KR": "오픈된 마이크로에이전트 풀 리퀘스트",
|
||||
"no": "Åpne mikroagent-pull requests",
|
||||
"it": "Pull request di microagent aperte",
|
||||
"pt": "Pull requests de microagentes abertas",
|
||||
"es": "Pull requests de microagentes abiertas",
|
||||
"ar": "طلبات السحب المفتوحة للوكلاء الدقيقين",
|
||||
"fr": "Pull requests de microagents ouvertes",
|
||||
"tr": "Açık Mikroajan Pull İstekleri",
|
||||
"de": "Offene Microagent-Pull-Requests",
|
||||
"uk": "Відкриті pull-запити мікроагентів"
|
||||
},
|
||||
"SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT": {
|
||||
"en": "LLM Analyzer (Default)",
|
||||
"ja": "LLMアナライザー(デフォルト)",
|
||||
@@ -13214,21 +13198,5 @@
|
||||
"tr": "Mikroajan Hafızasına Ekle",
|
||||
"de": "Zur Microagent-Speicher hinzufügen",
|
||||
"uk": "Додати до пам'яті мікроагента"
|
||||
},
|
||||
"COMMON$IN_PROGRESS": {
|
||||
"en": "In Progress",
|
||||
"ja": "進行中",
|
||||
"zh-CN": "进行中",
|
||||
"zh-TW": "進行中",
|
||||
"ko-KR": "진행 중",
|
||||
"no": "Pågår",
|
||||
"it": "In corso",
|
||||
"pt": "Em andamento",
|
||||
"es": "En progreso",
|
||||
"ar": "قيد التنفيذ",
|
||||
"fr": "En cours",
|
||||
"tr": "Devam Ediyor",
|
||||
"de": "In Bearbeitung",
|
||||
"uk": "В процесі"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -1,8 +1,6 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import { GitRepository, Branch, PaginatedBranchesResponse } from "#/types/git";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { MicroagentContentResponse } from "#/api/open-hands.types";
|
||||
|
||||
// Generate a list of mock repositories with realistic data
|
||||
const generateMockRepositories = (
|
||||
@@ -21,32 +19,6 @@ const generateMockRepositories = (
|
||||
owner_type: Math.random() > 0.7 ? "organization" : "user", // 30% chance of being organization
|
||||
}));
|
||||
|
||||
// Generate mock branches for a repository
|
||||
const generateMockBranches = (count: number): Branch[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
name: (() => {
|
||||
if (i === 0) return "main";
|
||||
if (i === 1) return "develop";
|
||||
return `feature/branch-${i}`;
|
||||
})(),
|
||||
commit_sha: `abc123${i.toString().padStart(3, "0")}`,
|
||||
protected: i === 0, // main branch is protected
|
||||
last_push_date: new Date(
|
||||
Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
}));
|
||||
|
||||
// Generate mock microagents for a repository
|
||||
const generateMockMicroagents = (count: number): RepositoryMicroagent[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
name: `microagent-${i + 1}`,
|
||||
path: `.openhands/microagents/microagent-${i + 1}.md`,
|
||||
created_at: new Date(
|
||||
Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
git_provider: "github",
|
||||
}));
|
||||
|
||||
// Mock repositories for each provider
|
||||
const MOCK_REPOSITORIES = {
|
||||
github: generateMockRepositories(120, "github"),
|
||||
@@ -54,12 +26,6 @@ const MOCK_REPOSITORIES = {
|
||||
bitbucket: generateMockRepositories(120, "bitbucket"),
|
||||
};
|
||||
|
||||
// Mock branches (same for all repos for simplicity)
|
||||
const MOCK_BRANCHES = generateMockBranches(25);
|
||||
|
||||
// Mock microagents (same for all repos for simplicity)
|
||||
const MOCK_MICROAGENTS = generateMockMicroagents(5);
|
||||
|
||||
export const GIT_REPOSITORY_HANDLERS = [
|
||||
http.get("/api/user/repositories", async ({ request }) => {
|
||||
await delay(500); // Simulate network delay
|
||||
@@ -188,138 +154,4 @@ export const GIT_REPOSITORY_HANDLERS = [
|
||||
|
||||
return HttpResponse.json(limitedRepos);
|
||||
}),
|
||||
|
||||
// Repository branches endpoint
|
||||
http.get("/api/user/repository/branches", async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const repository = url.searchParams.get("repository");
|
||||
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
||||
const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
|
||||
|
||||
if (!repository) {
|
||||
return HttpResponse.json("Repository parameter is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const endIndex = startIndex + perPage;
|
||||
const paginatedBranches = MOCK_BRANCHES.slice(startIndex, endIndex);
|
||||
const hasNextPage = endIndex < MOCK_BRANCHES.length;
|
||||
|
||||
const response: PaginatedBranchesResponse = {
|
||||
branches: paginatedBranches,
|
||||
has_next_page: hasNextPage,
|
||||
current_page: page,
|
||||
per_page: perPage,
|
||||
total_count: MOCK_BRANCHES.length,
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// Search repository branches endpoint
|
||||
http.get("/api/user/search/branches", async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const repository = url.searchParams.get("repository");
|
||||
const query = url.searchParams.get("query") || "";
|
||||
const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
|
||||
|
||||
if (!repository) {
|
||||
return HttpResponse.json("Repository parameter is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter branches by search query
|
||||
const filteredBranches = MOCK_BRANCHES.filter((branch) =>
|
||||
branch.name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
// Limit results
|
||||
const limitedBranches = filteredBranches.slice(0, perPage);
|
||||
|
||||
return HttpResponse.json(limitedBranches);
|
||||
}),
|
||||
|
||||
// Repository microagents endpoint
|
||||
http.get(
|
||||
"/api/user/repository/:owner/:repo/microagents",
|
||||
async ({ params }) => {
|
||||
await delay(400);
|
||||
|
||||
const { owner, repo } = params;
|
||||
|
||||
if (!owner || !repo) {
|
||||
return HttpResponse.json("Owner and repo parameters are required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(MOCK_MICROAGENTS);
|
||||
},
|
||||
),
|
||||
|
||||
// Repository microagent content endpoint
|
||||
http.get(
|
||||
"/api/user/repository/:owner/:repo/microagents/content",
|
||||
async ({ request, params }) => {
|
||||
await delay(300);
|
||||
|
||||
const { owner, repo } = params;
|
||||
const url = new URL(request.url);
|
||||
const filePath = url.searchParams.get("file_path");
|
||||
|
||||
if (!owner || !repo || !filePath) {
|
||||
return HttpResponse.json(
|
||||
"Owner, repo, and file_path parameters are required",
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find the microagent by path
|
||||
const microagent = MOCK_MICROAGENTS.find((m) => m.path === filePath);
|
||||
|
||||
if (!microagent) {
|
||||
return HttpResponse.json("Microagent not found", { status: 404 });
|
||||
}
|
||||
|
||||
const response: MicroagentContentResponse = {
|
||||
content: `# ${microagent.name}
|
||||
|
||||
A helpful microagent for repository tasks.
|
||||
|
||||
## Instructions
|
||||
|
||||
This microagent helps with specific tasks related to the repository.
|
||||
|
||||
### Usage
|
||||
|
||||
1. Describe your task clearly
|
||||
2. The microagent will analyze the context
|
||||
3. Follow the provided recommendations
|
||||
|
||||
### Capabilities
|
||||
|
||||
- Code analysis
|
||||
- Task automation
|
||||
- Best practice recommendations
|
||||
- Error detection and resolution
|
||||
|
||||
---
|
||||
|
||||
*Generated mock content for ${microagent.name}*`,
|
||||
path: microagent.path,
|
||||
git_provider: "github",
|
||||
triggers: ["code review", "bug fix", "feature development"],
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
@@ -169,6 +169,7 @@ 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,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from "react";
|
||||
import { NavLink, Outlet, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
@@ -9,7 +8,6 @@ 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",
|
||||
@@ -64,22 +62,10 @@ 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 = 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]);
|
||||
const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
|
||||
|
||||
return (
|
||||
<main
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export type SubscriptionAccess = {
|
||||
status: "ACTIVE" | "DISABLED";
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
Vendored
-9
@@ -22,14 +22,6 @@ interface Branch {
|
||||
last_push_date?: string;
|
||||
}
|
||||
|
||||
interface PaginatedBranchesResponse {
|
||||
branches: Branch[];
|
||||
has_next_page: boolean;
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total_count?: number;
|
||||
}
|
||||
|
||||
interface GitRepository {
|
||||
id: string;
|
||||
full_name: string;
|
||||
@@ -39,7 +31,6 @@ interface GitRepository {
|
||||
link_header?: string;
|
||||
pushed_at?: string;
|
||||
owner_type?: "user" | "organization";
|
||||
main_branch?: string;
|
||||
}
|
||||
|
||||
interface GitHubCommit {
|
||||
|
||||
@@ -17,9 +17,11 @@ export interface IMicroagentItem {
|
||||
export interface MicroagentFormData {
|
||||
query: string;
|
||||
triggers: string[];
|
||||
selectedBranch: string;
|
||||
microagentPath: string;
|
||||
}
|
||||
|
||||
export interface LearnThisRepoFormData {
|
||||
query: string;
|
||||
selectedBranch: string;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import "@testing-library/jest-dom/vitest";
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn();
|
||||
HTMLElement.prototype.scrollTo = vi.fn();
|
||||
window.scrollTo = vi.fn();
|
||||
|
||||
// Mock the i18n provider
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
|
||||
@@ -217,7 +217,7 @@ class BrowsingAgent(Agent):
|
||||
messages.append(Message(role='user', content=[TextContent(text=prompt)]))
|
||||
|
||||
response = self.llm.completion(
|
||||
messages=messages,
|
||||
messages=self.llm.format_messages_for_llm(messages),
|
||||
stop=[')```', ')\n```'],
|
||||
)
|
||||
return self.response_parser.parse(response)
|
||||
|
||||
@@ -204,7 +204,7 @@ class CodeActAgent(Agent):
|
||||
initial_user_message = self._get_initial_user_message(state.history)
|
||||
messages = self._get_messages(condensed_history, initial_user_message)
|
||||
params: dict = {
|
||||
'messages': messages,
|
||||
'messages': self.llm.format_messages_for_llm(messages),
|
||||
}
|
||||
params['tools'] = check_tools(self.tools, self.llm.config)
|
||||
params['extra_body'] = {
|
||||
|
||||
@@ -263,36 +263,9 @@ def response_to_actions(
|
||||
f'Missing required argument "task_list" for "plan" command in tool call {tool_call.function.name}'
|
||||
)
|
||||
|
||||
raw_task_list = arguments.get('task_list', [])
|
||||
if not isinstance(raw_task_list, list):
|
||||
raise FunctionCallValidationError(
|
||||
f'Invalid format for "task_list". Expected a list but got {type(raw_task_list)}.'
|
||||
)
|
||||
|
||||
# Normalize task_list to ensure it's always a list of dictionaries
|
||||
normalized_task_list = []
|
||||
for i, task in enumerate(raw_task_list):
|
||||
if isinstance(task, dict):
|
||||
# Task is already in correct format, ensure required fields exist
|
||||
normalized_task = {
|
||||
'id': task.get('id', f'task-{i + 1}'),
|
||||
'title': task.get('title', 'Untitled task'),
|
||||
'status': task.get('status', 'todo'),
|
||||
'notes': task.get('notes', ''),
|
||||
}
|
||||
else:
|
||||
# Unexpected format, raise validation error
|
||||
logger.warning(
|
||||
f'Unexpected task format in task_list: {type(task)} - {task}'
|
||||
)
|
||||
raise FunctionCallValidationError(
|
||||
f'Unexpected task format in task_list: {type(task)}. Each task shoud be a dictionary.'
|
||||
)
|
||||
normalized_task_list.append(normalized_task)
|
||||
|
||||
action = TaskTrackingAction(
|
||||
command=arguments['command'],
|
||||
task_list=normalized_task_list,
|
||||
task_list=arguments.get('task_list', []),
|
||||
)
|
||||
|
||||
# ================================================
|
||||
|
||||
@@ -38,9 +38,3 @@
|
||||
[Assistant proceeds with implementation step by step, updating tasks to in_progress and done as work progresses]
|
||||
```
|
||||
</TASK_MANAGEMENT>
|
||||
|
||||
<TASK_TRACKING_PERSISTENCE>
|
||||
* IMPORTANT: If you were using the task_tracker tool before a condensation event, continue using it after condensation
|
||||
* Check condensation summaries for TASK_TRACKING sections to maintain continuity
|
||||
* If you see a condensation event with TASK_TRACKING, immediately use task_tracker to view and continue managing them
|
||||
</TASK_TRACKING_PERSISTENCE>
|
||||
|
||||
@@ -301,8 +301,10 @@ You are an agent trying to solve a web task based on the content of the page and
|
||||
messages.append(Message(role='system', content=[TextContent(text=system_msg)]))
|
||||
messages.append(Message(role='user', content=human_prompt))
|
||||
|
||||
flat_messages = self.llm.format_messages_for_llm(messages)
|
||||
|
||||
response = self.llm.completion(
|
||||
messages=messages,
|
||||
messages=flat_messages,
|
||||
temperature=0.0,
|
||||
stop=[')```', ')\n```'],
|
||||
)
|
||||
|
||||
@@ -522,7 +522,7 @@ def display_task_tracking_action(event: TaskTrackingAction) -> None:
|
||||
"""Display a TaskTracking action in the CLI."""
|
||||
# Display thought first if present
|
||||
if hasattr(event, 'thought') and event.thought:
|
||||
display_thought_if_new(event.thought)
|
||||
display_message(event.thought)
|
||||
|
||||
# Format the command and task list for display
|
||||
display_text = f'Command: {event.command}'
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.bitbucket.service import (
|
||||
BitBucketBranchesMixin,
|
||||
BitBucketFeaturesMixin,
|
||||
BitBucketPRsMixin,
|
||||
BitBucketReposMixin,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
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(
|
||||
BitBucketReposMixin,
|
||||
BitBucketBranchesMixin,
|
||||
BitBucketPRsMixin,
|
||||
BitBucketFeaturesMixin,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
):
|
||||
class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
"""Default implementation of GitService for Bitbucket integration.
|
||||
|
||||
This is an extension point in OpenHands that allows applications to customize Bitbucket
|
||||
@@ -35,6 +37,10 @@ class BitBucketService(
|
||||
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,
|
||||
@@ -43,7 +49,7 @@ class BitBucketService(
|
||||
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
|
||||
@@ -59,6 +65,618 @@ class BitBucketService(
|
||||
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 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',
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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',
|
||||
]
|
||||
@@ -1,247 +0,0 @@
|
||||
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']
|
||||
@@ -1,116 +0,0 @@
|
||||
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
|
||||
@@ -1,45 +0,0 @@
|
||||
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)
|
||||
@@ -1,100 +0,0 @@
|
||||
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
|
||||
@@ -1,256 +0,0 @@
|
||||
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 []
|
||||
@@ -1,35 +1,42 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.github.service import (
|
||||
GitHubBranchesMixin,
|
||||
GitHubFeaturesMixin,
|
||||
GitHubPRsMixin,
|
||||
GitHubReposMixin,
|
||||
GitHubResolverMixin,
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.queries import (
|
||||
get_review_threads_graphql_query,
|
||||
get_thread_comments_graphql_query,
|
||||
get_thread_from_comment_graphql_query,
|
||||
suggested_task_issue_graphql_query,
|
||||
suggested_task_pr_graphql_query,
|
||||
)
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
Comment,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
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 GitHubService(
|
||||
GitHubBranchesMixin,
|
||||
GitHubFeaturesMixin,
|
||||
GitHubPRsMixin,
|
||||
GitHubReposMixin,
|
||||
GitHubResolverMixin,
|
||||
BaseGitService,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
):
|
||||
"""
|
||||
Assembled GitHub service class combining mixins by feature area.
|
||||
class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
"""Default implementation of GitService for GitHub integration.
|
||||
|
||||
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 GitHub
|
||||
@@ -42,7 +49,8 @@ class GitHubService(
|
||||
"""
|
||||
|
||||
BASE_URL = 'https://api.github.com'
|
||||
GRAPHQL_URL = 'https://api.github.com/graphql'
|
||||
token: SecretStr = SecretStr('')
|
||||
refresh = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -52,7 +60,7 @@ class GitHubService(
|
||||
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
|
||||
|
||||
@@ -61,7 +69,6 @@ class GitHubService(
|
||||
|
||||
if base_domain and base_domain != 'github.com':
|
||||
self.BASE_URL = f'https://{base_domain}/api/v3'
|
||||
self.GRAPHQL_URL = f'https://{base_domain}/api/graphql'
|
||||
|
||||
self.external_auth_id = external_auth_id
|
||||
self.external_auth_token = external_auth_token
|
||||
@@ -70,6 +77,895 @@ class GitHubService(
|
||||
def provider(self) -> str:
|
||||
return ProviderType.GITHUB.value
|
||||
|
||||
async def _get_github_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()
|
||||
if latest_token:
|
||||
self.token = latest_token
|
||||
|
||||
return {
|
||||
'Authorization': f'Bearer {self.token.get_secret_value() if self.token else ""}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
|
||||
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."""
|
||||
return f'{self.BASE_URL}/repos/{repository}/contents/.cursorrules'
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
return f'{self.BASE_URL}/repos/{repository}/contents/{microagents_path}'
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'file'
|
||||
and item['name'].endswith('.md')
|
||||
and item['name'] != 'README.md'
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['name']
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return f'{microagents_path}/{item["name"]}'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
return None
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
params: dict | None = None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
) -> tuple[Any, dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
github_headers = await self._get_github_headers()
|
||||
|
||||
# Make initial request
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=github_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()
|
||||
github_headers = await self._get_github_headers()
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=github_headers,
|
||||
params=params,
|
||||
method=method,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
headers = {}
|
||||
if 'Link' in response.headers:
|
||||
headers['Link'] = response.headers['Link']
|
||||
|
||||
return response.json(), 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:
|
||||
url = f'{self.BASE_URL}/user'
|
||||
response, _ = await self._make_request(url)
|
||||
|
||||
return User(
|
||||
id=str(response.get('id', '')),
|
||||
login=response.get('login'),
|
||||
avatar_url=response.get('avatar_url'),
|
||||
company=response.get('company'),
|
||||
name=response.get('name'),
|
||||
email=response.get('email'),
|
||||
)
|
||||
|
||||
async def verify_access(self) -> bool:
|
||||
"""Verify if the token is valid by making a simple request."""
|
||||
url = f'{self.BASE_URL}'
|
||||
await self._make_request(url)
|
||||
return True
|
||||
|
||||
async def _fetch_paginated_repos(
|
||||
self, url: str, params: dict, max_repos: int, extract_key: str | None = None
|
||||
) -> list[dict]:
|
||||
"""Fetch repositories with pagination support.
|
||||
|
||||
Args:
|
||||
url: The API endpoint URL
|
||||
params: Query parameters for the request
|
||||
max_repos: Maximum number of repositories to fetch
|
||||
extract_key: If provided, extract repositories from this key in the response
|
||||
|
||||
Returns:
|
||||
List of repository dictionaries
|
||||
"""
|
||||
repos: list[dict] = []
|
||||
page = 1
|
||||
|
||||
while len(repos) < max_repos:
|
||||
page_params = {**params, 'page': str(page)}
|
||||
response, headers = await self._make_request(url, page_params)
|
||||
|
||||
# Extract repositories from response
|
||||
page_repos = response.get(extract_key, []) if extract_key else response
|
||||
|
||||
if not page_repos: # No more repositories
|
||||
break
|
||||
|
||||
repos.extend(page_repos)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
return repos[:max_repos] # Trim to max_repos if needed
|
||||
|
||||
def parse_pushed_at_date(self, repo):
|
||||
ts = repo.get('pushed_at')
|
||||
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ') if ts else datetime.min
|
||||
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""Parse a GitHub API repository response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Repository data from GitHub 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('full_name'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=not repo.get('private', True),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=link_header,
|
||||
)
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
):
|
||||
params = {'page': str(page), 'per_page': str(per_page)}
|
||||
if installation_id:
|
||||
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
|
||||
response, headers = await self._make_request(url, params)
|
||||
response = response.get('repositories', [])
|
||||
else:
|
||||
url = f'{self.BASE_URL}/user/repos'
|
||||
params['sort'] = sort
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
return [
|
||||
self._parse_repository(repo, link_header=next_link) for repo in response
|
||||
]
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitHub API
|
||||
all_repos: list[dict] = []
|
||||
|
||||
if app_mode == AppMode.SAAS:
|
||||
# Get all installation IDs and fetch repos for each one
|
||||
installation_ids = await self.get_installations()
|
||||
|
||||
# Iterate through each installation ID
|
||||
for installation_id in installation_ids:
|
||||
params = {'per_page': str(PER_PAGE)}
|
||||
url = (
|
||||
f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
|
||||
)
|
||||
|
||||
# Fetch repositories for this installation
|
||||
installation_repos = await self._fetch_paginated_repos(
|
||||
url, params, MAX_REPOS - len(all_repos), extract_key='repositories'
|
||||
)
|
||||
|
||||
all_repos.extend(installation_repos)
|
||||
|
||||
# If we've already reached MAX_REPOS, no need to check other installations
|
||||
if len(all_repos) >= MAX_REPOS:
|
||||
break
|
||||
|
||||
if sort == 'pushed':
|
||||
all_repos.sort(key=self.parse_pushed_at_date, reverse=True)
|
||||
else:
|
||||
# Original behavior for non-SaaS mode
|
||||
params = {'per_page': str(PER_PAGE), 'sort': sort}
|
||||
url = f'{self.BASE_URL}/user/repos'
|
||||
|
||||
# Fetch user repositories
|
||||
all_repos = await self._fetch_paginated_repos(url, params, MAX_REPOS)
|
||||
|
||||
# Convert to Repository objects
|
||||
return [self._parse_repository(repo) for repo in all_repos]
|
||||
|
||||
async def get_installations(self) -> list[str]:
|
||||
url = f'{self.BASE_URL}/user/installations'
|
||||
response, _ = await self._make_request(url)
|
||||
installations = response.get('installations', [])
|
||||
return [str(i['id']) for i in installations]
|
||||
|
||||
async def get_user_organizations(self) -> list[str]:
|
||||
"""Get list of organization logins that the user is a member of."""
|
||||
url = f'{self.BASE_URL}/user/orgs'
|
||||
try:
|
||||
response, _ = await self._make_request(url)
|
||||
orgs = [org['login'] for org in response]
|
||||
return orgs
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get user organizations: {e}')
|
||||
return []
|
||||
|
||||
def _fuzzy_match_org_name(self, query: str, org_name: str) -> bool:
|
||||
"""Check if query fuzzy matches organization name."""
|
||||
query_lower = query.lower().replace('-', '').replace('_', '').replace(' ', '')
|
||||
org_lower = org_name.lower().replace('-', '').replace('_', '').replace(' ', '')
|
||||
|
||||
# Exact match after normalization
|
||||
if query_lower == org_lower:
|
||||
return True
|
||||
|
||||
# Query is a substring of org name
|
||||
if query_lower in org_lower:
|
||||
return True
|
||||
|
||||
# Org name is a substring of query (less common but possible)
|
||||
if org_lower in query_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def search_repositories(
|
||||
self, query: str, per_page: int, sort: str, order: str, public: bool
|
||||
) -> list[Repository]:
|
||||
url = f'{self.BASE_URL}/search/repositories'
|
||||
params = {
|
||||
'per_page': per_page,
|
||||
'sort': sort,
|
||||
'order': order,
|
||||
}
|
||||
|
||||
if public:
|
||||
url_parts = query.split('/')
|
||||
if len(url_parts) < 4:
|
||||
return []
|
||||
|
||||
org = url_parts[3]
|
||||
repo_name = url_parts[4]
|
||||
# Add is:public to the query to ensure we only search for public repositories
|
||||
params['q'] = f'in:name {org}/{repo_name} is:public'
|
||||
|
||||
# Handle private repository searches
|
||||
if not public and '/' in query:
|
||||
org, repo_query = query.split('/', 1)
|
||||
query_with_user = f'org:{org} in:name {repo_query}'
|
||||
params['q'] = query_with_user
|
||||
elif not public:
|
||||
# Expand search scope to include user's repositories and organizations they're a member of
|
||||
user = await self.get_user()
|
||||
user_orgs = await self.get_user_organizations()
|
||||
|
||||
# Search in user repos and org repos separately
|
||||
all_repos = []
|
||||
|
||||
# Search in user repositories
|
||||
user_query = f'{query} user:{user.login}'
|
||||
user_params = params.copy()
|
||||
user_params['q'] = user_query
|
||||
|
||||
try:
|
||||
user_response, _ = await self._make_request(url, user_params)
|
||||
user_items = user_response.get('items', [])
|
||||
all_repos.extend(user_items)
|
||||
except Exception as e:
|
||||
logger.warning(f'User search failed: {e}')
|
||||
|
||||
# Search for repos named "query" in each organization
|
||||
for org in user_orgs:
|
||||
org_query = f'{query} org:{org}'
|
||||
org_params = params.copy()
|
||||
org_params['q'] = org_query
|
||||
|
||||
try:
|
||||
org_response, _ = await self._make_request(url, org_params)
|
||||
org_items = org_response.get('items', [])
|
||||
all_repos.extend(org_items)
|
||||
except Exception as e:
|
||||
logger.warning(f'Org {org} search failed: {e}')
|
||||
|
||||
# Also search for top repos from orgs that match the query name
|
||||
for org in user_orgs:
|
||||
if self._fuzzy_match_org_name(query, org):
|
||||
org_repos_query = f'org:{org}'
|
||||
org_repos_params = params.copy()
|
||||
org_repos_params['q'] = org_repos_query
|
||||
org_repos_params['sort'] = 'stars'
|
||||
org_repos_params['per_page'] = 2 # Limit to first 2 repos
|
||||
|
||||
try:
|
||||
org_repos_response, _ = await self._make_request(
|
||||
url, org_repos_params
|
||||
)
|
||||
org_repo_items = org_repos_response.get('items', [])
|
||||
all_repos.extend(org_repo_items)
|
||||
except Exception as e:
|
||||
logger.warning(f'Org repos search for {org} failed: {e}')
|
||||
|
||||
return [self._parse_repository(repo) for repo in all_repos]
|
||||
|
||||
# Default case (public search or slash query)
|
||||
response, _ = await self._make_request(url, params)
|
||||
repo_items = response.get('items', [])
|
||||
return [self._parse_repository(repo) for repo in repo_items]
|
||||
|
||||
async def execute_graphql_query(
|
||||
self, query: str, variables: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a GraphQL query against the GitHub API."""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
github_headers = await self._get_github_headers()
|
||||
response = await client.post(
|
||||
f'{self.BASE_URL}/graphql',
|
||||
headers=github_headers,
|
||||
json={'query': query, 'variables': variables},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
if 'errors' in result:
|
||||
raise UnknownException(
|
||||
f'GraphQL query error: {json.dumps(result["errors"])}'
|
||||
)
|
||||
|
||||
return dict(result)
|
||||
|
||||
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_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories.
|
||||
|
||||
Returns:
|
||||
- PRs authored by the user.
|
||||
- Issues assigned to the user.
|
||||
|
||||
Note: Queries are split to avoid timeout issues.
|
||||
"""
|
||||
# Get user info to use in queries
|
||||
user = await self.get_user()
|
||||
login = user.login
|
||||
tasks: list[SuggestedTask] = []
|
||||
variables = {'login': login}
|
||||
|
||||
try:
|
||||
pr_response = await self.execute_graphql_query(
|
||||
suggested_task_pr_graphql_query, variables
|
||||
)
|
||||
pr_data = pr_response['data']['user']
|
||||
|
||||
# Process pull requests
|
||||
for pr in pr_data['pullRequests']['nodes']:
|
||||
repo_name = pr['repository']['nameWithOwner']
|
||||
|
||||
# Start with default task type
|
||||
task_type = TaskType.OPEN_PR
|
||||
|
||||
# Check for specific states
|
||||
if pr['mergeable'] == 'CONFLICTING':
|
||||
task_type = TaskType.MERGE_CONFLICTS
|
||||
elif (
|
||||
pr['commits']['nodes']
|
||||
and pr['commits']['nodes'][0]['commit']['statusCheckRollup']
|
||||
and pr['commits']['nodes'][0]['commit']['statusCheckRollup'][
|
||||
'state'
|
||||
]
|
||||
== 'FAILURE'
|
||||
):
|
||||
task_type = TaskType.FAILING_CHECKS
|
||||
elif any(
|
||||
review['state'] in ['CHANGES_REQUESTED', 'COMMENTED']
|
||||
for review in pr['reviews']['nodes']
|
||||
):
|
||||
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.GITHUB,
|
||||
task_type=task_type,
|
||||
repo=repo_name,
|
||||
issue_number=pr['number'],
|
||||
title=pr['title'],
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
f'Error fetching suggested task for PRs: {e}',
|
||||
extra={
|
||||
'signal': 'github_suggested_tasks',
|
||||
'user_id': self.external_auth_id,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Execute issue query
|
||||
issue_response = await self.execute_graphql_query(
|
||||
suggested_task_issue_graphql_query, variables
|
||||
)
|
||||
issue_data = issue_response['data']['user']
|
||||
|
||||
# Process issues
|
||||
for issue in issue_data['issues']['nodes']:
|
||||
repo_name = issue['repository']['nameWithOwner']
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITHUB,
|
||||
task_type=TaskType.OPEN_ISSUE,
|
||||
repo=repo_name,
|
||||
issue_number=issue['number'],
|
||||
title=issue['title'],
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
f'Error fetching suggested task for issues: {e}',
|
||||
extra={
|
||||
'signal': 'github_suggested_tasks',
|
||||
'user_id': self.external_auth_id,
|
||||
},
|
||||
)
|
||||
|
||||
return tasks
|
||||
|
||||
async def get_repository_details_from_repo_name(
|
||||
self, repository: str
|
||||
) -> Repository:
|
||||
url = f'{self.BASE_URL}/repos/{repository}'
|
||||
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"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/branches'
|
||||
|
||||
# Set maximum branches to fetch (100 per page)
|
||||
MAX_BRANCHES = 5_000
|
||||
PER_PAGE = 100
|
||||
|
||||
all_branches: list[Branch] = []
|
||||
page = 1
|
||||
|
||||
# Fetch up to 10 pages of branches
|
||||
while 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:
|
||||
# Extract the last commit date if available
|
||||
last_push_date = None
|
||||
if branch_data.get('commit') and branch_data['commit'].get('commit'):
|
||||
commit_info = branch_data['commit']['commit']
|
||||
if commit_info.get('committer') and commit_info['committer'].get(
|
||||
'date'
|
||||
):
|
||||
last_push_date = commit_info['committer']['date']
|
||||
|
||||
branch = Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('sha', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=last_push_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 create_pr(
|
||||
self,
|
||||
repo_name: str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
draft: bool = True,
|
||||
labels: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Creates a PR using user credentials
|
||||
|
||||
Args:
|
||||
repo_name: The full name of the repository (owner/repo)
|
||||
source_branch: The name of the branch where your changes are implemented
|
||||
target_branch: The name of the branch you want the changes pulled into
|
||||
title: The title of the pull request (optional, defaults to a generic title)
|
||||
body: The body/description of the pull request (optional)
|
||||
draft: Whether to create the PR as a draft (optional, defaults to False)
|
||||
labels: A list of labels to apply to the pull request (optional)
|
||||
|
||||
Returns:
|
||||
- PR URL when successful
|
||||
- Error message when unsuccessful
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repos/{repo_name}/pulls'
|
||||
|
||||
# Set default body if none provided
|
||||
if not body:
|
||||
body = f'Merging changes from {source_branch} into {target_branch}'
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'title': title,
|
||||
'head': source_branch,
|
||||
'base': target_branch,
|
||||
'body': body,
|
||||
'draft': draft,
|
||||
}
|
||||
|
||||
# Make the POST request to create the PR
|
||||
response, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
# Add labels if provided (PRs are a type of issue in GitHub's API)
|
||||
if labels and len(labels) > 0:
|
||||
pr_number = response['number']
|
||||
labels_url = f'{self.BASE_URL}/repos/{repo_name}/issues/{pr_number}/labels'
|
||||
labels_payload = {'labels': labels}
|
||||
await self._make_request(
|
||||
url=labels_url, params=labels_payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
# Return the HTML URL of the created PR
|
||||
return response['html_url']
|
||||
|
||||
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 GitHub API response for the pull request
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/pulls/{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 GitHub repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
file_url = f'{self.BASE_URL}/repos/{repository}/contents/{file_path}'
|
||||
|
||||
file_data, _ = await self._make_request(file_url)
|
||||
file_content = base64.b64decode(file_data['content']).decode('utf-8')
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(file_content, file_path)
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a GitHub PR 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)
|
||||
|
||||
# GitHub API response structure
|
||||
# https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
|
||||
if 'state' in pr_details:
|
||||
return pr_details['state'] == 'open'
|
||||
elif 'merged' in pr_details and 'closed_at' in pr_details:
|
||||
# Check if PR is merged or closed
|
||||
return not (pr_details['merged'] or pr_details['closed_at'])
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine GitHub 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 GitHub 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
|
||||
|
||||
async def get_issue_or_pr_comments(
|
||||
self, repository: str, issue_number: int, max_comments: int = 10
|
||||
) -> list[Comment]:
|
||||
"""Get comments for an issue.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
issue_number: The issue number
|
||||
discussion_id: Not used for GitHub (kept for compatibility with GitLab)
|
||||
|
||||
Returns:
|
||||
List of Comment objects ordered by creation date
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/issues/{issue_number}/comments'
|
||||
page = 1
|
||||
all_comments: list[dict] = []
|
||||
|
||||
while len(all_comments) < max_comments:
|
||||
params = {
|
||||
'per_page': 10,
|
||||
'sort': 'created',
|
||||
'direction': 'asc',
|
||||
'page': page,
|
||||
}
|
||||
response, headers = await self._make_request(url, params=params)
|
||||
all_comments.extend(response or [])
|
||||
|
||||
# Parse the Link header for rel="next"
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
return self._process_raw_comments(all_comments)
|
||||
|
||||
async def get_issue_or_pr_title_and_body(
|
||||
self, repository: str, issue_number: int
|
||||
) -> tuple[str, str]:
|
||||
"""Get the title and body of an issue.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
issue_number: The issue number
|
||||
|
||||
Returns:
|
||||
A tuple of (title, body)
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/issues/{issue_number}'
|
||||
response, _ = await self._make_request(url)
|
||||
title = response.get('title') or ''
|
||||
body = response.get('body') or ''
|
||||
return title, body
|
||||
|
||||
async def get_review_thread_comments(
|
||||
self,
|
||||
comment_id: str,
|
||||
repository: str,
|
||||
pr_number: int,
|
||||
) -> list[Comment]:
|
||||
"""Get all comments in a review thread starting from a specific comment.
|
||||
|
||||
Uses GraphQL to traverse the reply chain from the given comment up to the root
|
||||
comment, then finds the review thread and returns all comments in the thread.
|
||||
|
||||
Args:
|
||||
comment_id: The GraphQL node ID of any comment in the thread
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
|
||||
Returns:
|
||||
List of Comment objects representing the entire thread
|
||||
"""
|
||||
|
||||
# Step 1: Use existing GraphQL query to get the comment and check for replyTo
|
||||
variables = {'commentId': comment_id}
|
||||
data = await self.execute_graphql_query(
|
||||
get_thread_from_comment_graphql_query, variables
|
||||
)
|
||||
|
||||
comment_node = data.get('data', {}).get('node')
|
||||
if not comment_node:
|
||||
return []
|
||||
|
||||
# Step 2: If replyTo exists, traverse to the root comment
|
||||
root_comment_id = comment_id
|
||||
reply_to = comment_node.get('replyTo')
|
||||
if reply_to:
|
||||
root_comment_id = reply_to['id']
|
||||
|
||||
# Step 3: Get all review threads and find the one containing our root comment
|
||||
owner, repo = repository.split('/')
|
||||
thread_id = None
|
||||
after_cursor = None
|
||||
has_next_page = True
|
||||
|
||||
while has_next_page and not thread_id:
|
||||
threads_variables: dict[str, Any] = {
|
||||
'owner': owner,
|
||||
'repo': repo,
|
||||
'number': pr_number,
|
||||
'first': 50,
|
||||
}
|
||||
if after_cursor:
|
||||
threads_variables['after'] = after_cursor
|
||||
|
||||
threads_data = await self.execute_graphql_query(
|
||||
get_review_threads_graphql_query, threads_variables
|
||||
)
|
||||
|
||||
review_threads_data = (
|
||||
threads_data.get('data', {})
|
||||
.get('repository', {})
|
||||
.get('pullRequest', {})
|
||||
.get('reviewThreads', {})
|
||||
)
|
||||
|
||||
review_threads = review_threads_data.get('nodes', [])
|
||||
page_info = review_threads_data.get('pageInfo', {})
|
||||
|
||||
# Search for the thread containing our root comment
|
||||
for thread in review_threads:
|
||||
first_comments = thread.get('comments', {}).get('nodes', [])
|
||||
for first_comment in first_comments:
|
||||
if first_comment.get('id') == root_comment_id:
|
||||
thread_id = thread.get('id')
|
||||
break
|
||||
if thread_id:
|
||||
break
|
||||
|
||||
# Update pagination variables
|
||||
has_next_page = page_info.get('hasNextPage', False)
|
||||
after_cursor = page_info.get('endCursor')
|
||||
|
||||
if not thread_id:
|
||||
# Fallback: return just the comments we found during traversal
|
||||
logger.warning(
|
||||
f'Could not find review thread for comment {comment_id}, returning traversed comments'
|
||||
)
|
||||
return []
|
||||
|
||||
# Step 4: Get all comments from the review thread using the thread ID
|
||||
all_thread_comments = []
|
||||
after_cursor = None
|
||||
has_next_page = True
|
||||
|
||||
while has_next_page:
|
||||
comments_variables: dict[str, Any] = {}
|
||||
comments_variables['threadId'] = thread_id
|
||||
comments_variables['page'] = 50
|
||||
if after_cursor:
|
||||
comments_variables['after'] = after_cursor
|
||||
|
||||
thread_comments_data = await self.execute_graphql_query(
|
||||
get_thread_comments_graphql_query, comments_variables
|
||||
)
|
||||
|
||||
thread_node = thread_comments_data.get('data', {}).get('node')
|
||||
if not thread_node:
|
||||
break
|
||||
|
||||
comments_data = thread_node.get('comments', {})
|
||||
comments_nodes = comments_data.get('nodes', [])
|
||||
page_info = comments_data.get('pageInfo', {})
|
||||
|
||||
all_thread_comments.extend(comments_nodes)
|
||||
|
||||
has_next_page = page_info.get('hasNextPage', False)
|
||||
after_cursor = page_info.get('endCursor')
|
||||
|
||||
return self._process_raw_comments(all_thread_comments)
|
||||
|
||||
def _truncate_comment(
|
||||
self, comment_body: str, max_comment_length: int = 500
|
||||
) -> str:
|
||||
"""Truncate comment body to a maximum length."""
|
||||
if len(comment_body) > max_comment_length:
|
||||
return comment_body[:max_comment_length] + '...'
|
||||
return comment_body
|
||||
|
||||
def _process_raw_comments(
|
||||
self, comments_data: list, max_comments: int = 10
|
||||
) -> list[Comment]:
|
||||
"""Convert raw comment data to Comment objects."""
|
||||
comments: list[Comment] = []
|
||||
for comment in comments_data:
|
||||
author = 'unknown'
|
||||
|
||||
if comment.get('author'):
|
||||
author = comment.get('author', {}).get('login', 'unknown')
|
||||
elif comment.get('user'):
|
||||
author = comment.get('user', {}).get('login', 'unknown')
|
||||
|
||||
comments.append(
|
||||
Comment(
|
||||
id=str(comment.get('id', 'unknown')),
|
||||
body=self._truncate_comment(comment.get('body', '')),
|
||||
author=author,
|
||||
created_at=datetime.fromisoformat(
|
||||
comment.get('createdAt', '').replace('Z', '+00:00')
|
||||
)
|
||||
if comment.get('createdAt')
|
||||
else datetime.fromtimestamp(0),
|
||||
updated_at=datetime.fromisoformat(
|
||||
comment.get('updatedAt', '').replace('Z', '+00:00')
|
||||
)
|
||||
if comment.get('updatedAt')
|
||||
else datetime.fromtimestamp(0),
|
||||
system=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort comments by creation date to maintain chronological order
|
||||
comments.sort(key=lambda c: c.created_at)
|
||||
return comments[-max_comments:]
|
||||
|
||||
|
||||
github_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITHUB_SERVICE_CLS',
|
||||
|
||||
@@ -122,32 +122,3 @@ query ($threadId: ID!, $page: Int = 50, $after: String) {
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# Search branches in a repository by partial name using GitHub GraphQL.
|
||||
# This leverages the `refs` connection with:
|
||||
# - refPrefix: "refs/heads/" to restrict to branches
|
||||
# - query: partial branch name provided by the user
|
||||
# - first: pagination size (clamped by caller to GitHub limits)
|
||||
search_branches_graphql_query = """
|
||||
query SearchBranches($owner: String!, $name: String!, $query: String!, $perPage: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
refs(
|
||||
refPrefix: "refs/heads/",
|
||||
query: $query,
|
||||
first: $perPage,
|
||||
orderBy: { field: ALPHABETICAL, direction: ASC }
|
||||
) {
|
||||
nodes {
|
||||
name
|
||||
target {
|
||||
__typename
|
||||
... on Commit {
|
||||
oid
|
||||
committedDate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# openhands/integrations/github/service/__init__.py
|
||||
|
||||
from .base import GitHubMixinBase
|
||||
from .branches_prs import GitHubBranchesMixin
|
||||
from .features import GitHubFeaturesMixin
|
||||
from .prs import GitHubPRsMixin
|
||||
from .repos import GitHubReposMixin
|
||||
from .resolver import GitHubResolverMixin
|
||||
|
||||
__all__ = [
|
||||
'GitHubMixinBase',
|
||||
'GitHubBranchesMixin',
|
||||
'GitHubFeaturesMixin',
|
||||
'GitHubPRsMixin',
|
||||
'GitHubReposMixin',
|
||||
'GitHubResolverMixin',
|
||||
]
|
||||
@@ -1,125 +0,0 @@
|
||||
import json
|
||||
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,
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class GitHubMixinBase(BaseGitService, HTTPClient):
|
||||
"""
|
||||
Declares common attributes and method signatures used across mixins.
|
||||
"""
|
||||
|
||||
BASE_URL: str
|
||||
GRAPHQL_URL: str
|
||||
|
||||
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()
|
||||
if latest_token:
|
||||
self.token = latest_token
|
||||
|
||||
return {
|
||||
'Authorization': f'Bearer {self.token.get_secret_value() if self.token else ""}',
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
|
||||
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:
|
||||
github_headers = await self._get_headers()
|
||||
|
||||
# Make initial request
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=github_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()
|
||||
github_headers = await self._get_headers()
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=github_headers,
|
||||
params=params,
|
||||
method=method,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
headers: dict = {}
|
||||
if 'Link' in response.headers:
|
||||
headers['Link'] = response.headers['Link']
|
||||
|
||||
return response.json(), 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]
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
github_headers = await self._get_headers()
|
||||
|
||||
response = await client.post(
|
||||
self.GRAPHQL_URL,
|
||||
headers=github_headers,
|
||||
json={'query': query, 'variables': variables},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
if 'errors' in result:
|
||||
raise UnknownException(
|
||||
f'GraphQL query error: {json.dumps(result["errors"])}'
|
||||
)
|
||||
|
||||
return dict(result)
|
||||
|
||||
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 verify_access(self) -> bool:
|
||||
url = f'{self.BASE_URL}'
|
||||
await self._make_request(url)
|
||||
return True
|
||||
|
||||
async def get_user(self):
|
||||
url = f'{self.BASE_URL}/user'
|
||||
response, _ = await self._make_request(url)
|
||||
|
||||
return User(
|
||||
id=str(response.get('id', '')),
|
||||
login=cast(str, response.get('login') or ''),
|
||||
avatar_url=cast(str, response.get('avatar_url') or ''),
|
||||
company=response.get('company'),
|
||||
name=response.get('name'),
|
||||
email=response.get('email'),
|
||||
)
|
||||
@@ -1,161 +0,0 @@
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.queries import (
|
||||
search_branches_graphql_query,
|
||||
)
|
||||
from openhands.integrations.github.service.base import GitHubMixinBase
|
||||
from openhands.integrations.service_types import Branch, PaginatedBranchesResponse
|
||||
|
||||
|
||||
class GitHubBranchesMixin(GitHubMixinBase):
|
||||
"""
|
||||
Methods for interacting with branches for a repo
|
||||
"""
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/branches'
|
||||
|
||||
# Set maximum branches to fetch (100 per page)
|
||||
MAX_BRANCHES = 5_000
|
||||
PER_PAGE = 100
|
||||
|
||||
all_branches: list[Branch] = []
|
||||
page = 1
|
||||
|
||||
# Fetch up to 10 pages of branches
|
||||
while 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:
|
||||
# Extract the last commit date if available
|
||||
last_push_date = None
|
||||
if branch_data.get('commit') and branch_data['commit'].get('commit'):
|
||||
commit_info = branch_data['commit']['commit']
|
||||
if commit_info.get('committer') and commit_info['committer'].get(
|
||||
'date'
|
||||
):
|
||||
last_push_date = commit_info['committer']['date']
|
||||
|
||||
branch = Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('sha', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=last_push_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"""
|
||||
url = f'{self.BASE_URL}/repos/{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:
|
||||
# Extract the last commit date if available
|
||||
last_push_date = None
|
||||
if branch_data.get('commit') and branch_data['commit'].get('commit'):
|
||||
commit_info = branch_data['commit']['commit']
|
||||
if commit_info.get('committer') and commit_info['committer'].get(
|
||||
'date'
|
||||
):
|
||||
last_push_date = commit_info['committer']['date']
|
||||
|
||||
branch = Branch(
|
||||
name=branch_data.get('name'),
|
||||
commit_sha=branch_data.get('commit', {}).get('sha', ''),
|
||||
protected=branch_data.get('protected', False),
|
||||
last_push_date=last_push_date,
|
||||
)
|
||||
branches.append(branch)
|
||||
|
||||
# Parse Link header to determine if there's a next page
|
||||
has_next_page = False
|
||||
if 'Link' in headers:
|
||||
link_header = headers['Link']
|
||||
has_next_page = 'rel="next"' in link_header
|
||||
|
||||
return PaginatedBranchesResponse(
|
||||
branches=branches,
|
||||
has_next_page=has_next_page,
|
||||
current_page=page,
|
||||
per_page=per_page,
|
||||
total_count=None, # GitHub doesn't provide total count in branch API
|
||||
)
|
||||
|
||||
async def search_branches(
|
||||
self, repository: str, query: str, per_page: int = 30
|
||||
) -> list[Branch]:
|
||||
"""Search branches by name using GitHub GraphQL with a partial query."""
|
||||
# Require a non-empty query
|
||||
if not query:
|
||||
return []
|
||||
|
||||
# Clamp per_page to GitHub GraphQL limits
|
||||
per_page = min(max(per_page, 1), 100)
|
||||
|
||||
# Extract owner and repo name from the repository string
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
return []
|
||||
owner, name = parts[-2], parts[-1]
|
||||
|
||||
variables = {
|
||||
'owner': owner,
|
||||
'name': name,
|
||||
'query': query or '',
|
||||
'perPage': per_page,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await self.execute_graphql_query(
|
||||
search_branches_graphql_query, variables
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to search for branches: {e}')
|
||||
# Fallback to empty result on any GraphQL error
|
||||
return []
|
||||
|
||||
repo = result.get('data', {}).get('repository')
|
||||
if not repo or not repo.get('refs'):
|
||||
return []
|
||||
|
||||
branches: list[Branch] = []
|
||||
for node in repo['refs'].get('nodes', []):
|
||||
bname = node.get('name') or ''
|
||||
target = node.get('target') or {}
|
||||
typename = target.get('__typename')
|
||||
commit_sha = ''
|
||||
last_push_date = None
|
||||
if typename == 'Commit':
|
||||
commit_sha = target.get('oid', '') or ''
|
||||
last_push_date = target.get('committedDate')
|
||||
|
||||
protected = node.get('branchProtectionRule') is not None
|
||||
|
||||
branches.append(
|
||||
Branch(
|
||||
name=bname,
|
||||
commit_sha=commit_sha,
|
||||
protected=protected,
|
||||
last_push_date=last_push_date,
|
||||
)
|
||||
)
|
||||
|
||||
return branches
|
||||
@@ -1,177 +0,0 @@
|
||||
import base64
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.queries import (
|
||||
suggested_task_issue_graphql_query,
|
||||
suggested_task_pr_graphql_query,
|
||||
)
|
||||
from openhands.integrations.github.service.base import GitHubMixinBase
|
||||
from openhands.integrations.service_types import (
|
||||
MicroagentContentResponse,
|
||||
ProviderType,
|
||||
SuggestedTask,
|
||||
TaskType,
|
||||
)
|
||||
|
||||
|
||||
class GitHubFeaturesMixin(GitHubMixinBase):
|
||||
"""
|
||||
Methods used for custom features in UI driven via GitHub integration
|
||||
"""
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories.
|
||||
|
||||
Returns:
|
||||
- PRs authored by the user.
|
||||
- Issues assigned to the user.
|
||||
|
||||
Note: Queries are split to avoid timeout issues.
|
||||
"""
|
||||
# Get user info to use in queries
|
||||
user = await self.get_user()
|
||||
login = user.login
|
||||
tasks: list[SuggestedTask] = []
|
||||
variables = {'login': login}
|
||||
|
||||
try:
|
||||
pr_response = await self.execute_graphql_query(
|
||||
suggested_task_pr_graphql_query, variables
|
||||
)
|
||||
pr_data = pr_response['data']['user']
|
||||
|
||||
# Process pull requests
|
||||
for pr in pr_data['pullRequests']['nodes']:
|
||||
repo_name = pr['repository']['nameWithOwner']
|
||||
|
||||
# Start with default task type
|
||||
task_type = TaskType.OPEN_PR
|
||||
|
||||
# Check for specific states
|
||||
if pr['mergeable'] == 'CONFLICTING':
|
||||
task_type = TaskType.MERGE_CONFLICTS
|
||||
elif (
|
||||
pr['commits']['nodes']
|
||||
and pr['commits']['nodes'][0]['commit']['statusCheckRollup']
|
||||
and pr['commits']['nodes'][0]['commit']['statusCheckRollup'][
|
||||
'state'
|
||||
]
|
||||
== 'FAILURE'
|
||||
):
|
||||
task_type = TaskType.FAILING_CHECKS
|
||||
elif any(
|
||||
review['state'] in ['CHANGES_REQUESTED', 'COMMENTED']
|
||||
for review in pr['reviews']['nodes']
|
||||
):
|
||||
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.GITHUB,
|
||||
task_type=task_type,
|
||||
repo=repo_name,
|
||||
issue_number=pr['number'],
|
||||
title=pr['title'],
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
f'Error fetching suggested task for PRs: {e}',
|
||||
extra={
|
||||
'signal': 'github_suggested_tasks',
|
||||
'user_id': self.external_auth_id,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Execute issue query
|
||||
issue_response = await self.execute_graphql_query(
|
||||
suggested_task_issue_graphql_query, variables
|
||||
)
|
||||
issue_data = issue_response['data']['user']
|
||||
|
||||
# Process issues
|
||||
for issue in issue_data['issues']['nodes']:
|
||||
repo_name = issue['repository']['nameWithOwner']
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITHUB,
|
||||
task_type=TaskType.OPEN_ISSUE,
|
||||
repo=repo_name,
|
||||
issue_number=issue['number'],
|
||||
title=issue['title'],
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
|
||||
except Exception as e:
|
||||
logger.info(
|
||||
f'Error fetching suggested task for issues: {e}',
|
||||
extra={
|
||||
'signal': 'github_suggested_tasks',
|
||||
'user_id': self.external_auth_id,
|
||||
},
|
||||
)
|
||||
|
||||
return tasks
|
||||
|
||||
"""
|
||||
Methods specifically for microagent management page
|
||||
"""
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
return f'{self.BASE_URL}/repos/{repository}/contents/.cursorrules'
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
return f'{self.BASE_URL}/repos/{repository}/contents/{microagents_path}'
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'file'
|
||||
and item['name'].endswith('.md')
|
||||
and item['name'] != 'README.md'
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['name']
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return f'{microagents_path}/{item["name"]}'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
return None
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from GitHub repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
file_url = f'{self.BASE_URL}/repos/{repository}/contents/{file_path}'
|
||||
|
||||
file_data, _ = await self._make_request(file_url)
|
||||
file_content = base64.b64decode(file_data['content']).decode('utf-8')
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(file_content, file_path)
|
||||
@@ -1,117 +0,0 @@
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.service.base import GitHubMixinBase
|
||||
from openhands.integrations.service_types import RequestMethod
|
||||
|
||||
|
||||
class GitHubPRsMixin(GitHubMixinBase):
|
||||
"""
|
||||
Methods for interacting with GitHub PRs
|
||||
"""
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
repo_name: str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
draft: bool = True,
|
||||
labels: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Creates a PR using user credentials
|
||||
|
||||
Args:
|
||||
repo_name: The full name of the repository (owner/repo)
|
||||
source_branch: The name of the branch where your changes are implemented
|
||||
target_branch: The name of the branch you want the changes pulled into
|
||||
title: The title of the pull request (optional, defaults to a generic title)
|
||||
body: The body/description of the pull request (optional)
|
||||
draft: Whether to create the PR as a draft (optional, defaults to False)
|
||||
labels: A list of labels to apply to the pull request (optional)
|
||||
|
||||
Returns:
|
||||
- PR URL when successful
|
||||
- Error message when unsuccessful
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repos/{repo_name}/pulls'
|
||||
|
||||
# Set default body if none provided
|
||||
if not body:
|
||||
body = f'Merging changes from {source_branch} into {target_branch}'
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'title': title,
|
||||
'head': source_branch,
|
||||
'base': target_branch,
|
||||
'body': body,
|
||||
'draft': draft,
|
||||
}
|
||||
|
||||
# Make the POST request to create the PR
|
||||
response, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
# Add labels if provided (PRs are a type of issue in GitHub's API)
|
||||
if labels and len(labels) > 0:
|
||||
pr_number = response['number']
|
||||
labels_url = f'{self.BASE_URL}/repos/{repo_name}/issues/{pr_number}/labels'
|
||||
labels_payload = {'labels': labels}
|
||||
await self._make_request(
|
||||
url=labels_url, params=labels_payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
# Return the HTML URL of the created PR
|
||||
return response['html_url']
|
||||
|
||||
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 GitHub API response for the pull request
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/pulls/{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 GitHub PR 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)
|
||||
|
||||
# GitHub API response structure
|
||||
# https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
|
||||
if 'state' in pr_details:
|
||||
return pr_details['state'] == 'open'
|
||||
elif 'merged' in pr_details and 'closed_at' in pr_details:
|
||||
# Check if PR is merged or closed
|
||||
return not (pr_details['merged'] or pr_details['closed_at'])
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine GitHub 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 GitHub 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
|
||||
@@ -1,272 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.service.base import GitHubMixinBase
|
||||
from openhands.integrations.service_types import OwnerType, ProviderType, Repository
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class GitHubReposMixin(GitHubMixinBase):
|
||||
"""
|
||||
Methods for interacting with GitHub repositories (from both personal and app installations)
|
||||
"""
|
||||
|
||||
async def get_installations(self) -> list[str]:
|
||||
url = f'{self.BASE_URL}/user/installations'
|
||||
response, _ = await self._make_request(url)
|
||||
installations = response.get('installations', [])
|
||||
return [str(i['id']) for i in installations]
|
||||
|
||||
async def _fetch_paginated_repos(
|
||||
self, url: str, params: dict, max_repos: int, extract_key: str | None = None
|
||||
) -> list[dict]:
|
||||
"""Fetch repositories with pagination support.
|
||||
|
||||
Args:
|
||||
url: The API endpoint URL
|
||||
params: Query parameters for the request
|
||||
max_repos: Maximum number of repositories to fetch
|
||||
extract_key: If provided, extract repositories from this key in the response
|
||||
|
||||
Returns:
|
||||
List of repository dictionaries
|
||||
"""
|
||||
repos: list[dict] = []
|
||||
page = 1
|
||||
|
||||
while len(repos) < max_repos:
|
||||
page_params = {**params, 'page': str(page)}
|
||||
response, headers = await self._make_request(url, page_params)
|
||||
|
||||
# Extract repositories from response
|
||||
page_repos = response.get(extract_key, []) if extract_key else response
|
||||
|
||||
if not page_repos: # No more repositories
|
||||
break
|
||||
|
||||
repos.extend(page_repos)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
return repos[:max_repos] # Trim to max_repos if needed
|
||||
|
||||
def parse_pushed_at_date(self, repo):
|
||||
ts = repo.get('pushed_at')
|
||||
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ') if ts else datetime.min
|
||||
|
||||
def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
) -> Repository:
|
||||
"""Parse a GitHub API repository response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Repository data from GitHub 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('full_name'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=not repo.get('private', True),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=link_header,
|
||||
main_branch=repo.get('default_branch'),
|
||||
)
|
||||
|
||||
async def get_paginated_repos(
|
||||
self,
|
||||
page: int,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
installation_id: str | None,
|
||||
query: str | None = None,
|
||||
):
|
||||
params = {'page': str(page), 'per_page': str(per_page)}
|
||||
if installation_id:
|
||||
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
|
||||
response, headers = await self._make_request(url, params)
|
||||
response = response.get('repositories', [])
|
||||
else:
|
||||
url = f'{self.BASE_URL}/user/repos'
|
||||
params['sort'] = sort
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
return [
|
||||
self._parse_repository(repo, link_header=next_link) for repo in response
|
||||
]
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitHub API
|
||||
all_repos: list[dict] = []
|
||||
|
||||
if app_mode == AppMode.SAAS:
|
||||
# Get all installation IDs and fetch repos for each one
|
||||
installation_ids = await self.get_installations()
|
||||
|
||||
# Iterate through each installation ID
|
||||
for installation_id in installation_ids:
|
||||
params = {'per_page': str(PER_PAGE)}
|
||||
url = (
|
||||
f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
|
||||
)
|
||||
|
||||
# Fetch repositories for this installation
|
||||
installation_repos = await self._fetch_paginated_repos(
|
||||
url, params, MAX_REPOS - len(all_repos), extract_key='repositories'
|
||||
)
|
||||
|
||||
all_repos.extend(installation_repos)
|
||||
|
||||
# If we've already reached MAX_REPOS, no need to check other installations
|
||||
if len(all_repos) >= MAX_REPOS:
|
||||
break
|
||||
|
||||
if sort == 'pushed':
|
||||
all_repos.sort(key=self.parse_pushed_at_date, reverse=True)
|
||||
else:
|
||||
# Original behavior for non-SaaS mode
|
||||
params = {'per_page': str(PER_PAGE), 'sort': sort}
|
||||
url = f'{self.BASE_URL}/user/repos'
|
||||
|
||||
# Fetch user repositories
|
||||
all_repos = await self._fetch_paginated_repos(url, params, MAX_REPOS)
|
||||
|
||||
# Convert to Repository objects
|
||||
return [self._parse_repository(repo) for repo in all_repos]
|
||||
|
||||
async def get_user_organizations(self) -> list[str]:
|
||||
"""Get list of organization logins that the user is a member of."""
|
||||
url = f'{self.BASE_URL}/user/orgs'
|
||||
try:
|
||||
response, _ = await self._make_request(url)
|
||||
orgs = [org['login'] for org in response]
|
||||
return orgs
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get user organizations: {e}')
|
||||
return []
|
||||
|
||||
def _fuzzy_match_org_name(self, query: str, org_name: str) -> bool:
|
||||
"""Check if query fuzzy matches organization name."""
|
||||
query_lower = query.lower().replace('-', '').replace('_', '').replace(' ', '')
|
||||
org_lower = org_name.lower().replace('-', '').replace('_', '').replace(' ', '')
|
||||
|
||||
# Exact match after normalization
|
||||
if query_lower == org_lower:
|
||||
return True
|
||||
|
||||
# Query is a substring of org name
|
||||
if query_lower in org_lower:
|
||||
return True
|
||||
|
||||
# Org name is a substring of query (less common but possible)
|
||||
if org_lower in query_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def search_repositories(
|
||||
self, query: str, per_page: int, sort: str, order: str, public: bool
|
||||
) -> list[Repository]:
|
||||
url = f'{self.BASE_URL}/search/repositories'
|
||||
params = {
|
||||
'per_page': per_page,
|
||||
'sort': sort,
|
||||
'order': order,
|
||||
}
|
||||
|
||||
if public:
|
||||
url_parts = query.split('/')
|
||||
if len(url_parts) < 4:
|
||||
return []
|
||||
|
||||
org = url_parts[3]
|
||||
repo_name = url_parts[4]
|
||||
# Add is:public to the query to ensure we only search for public repositories
|
||||
params['q'] = f'in:name {org}/{repo_name} is:public'
|
||||
|
||||
# Handle private repository searches
|
||||
if not public and '/' in query:
|
||||
org, repo_query = query.split('/', 1)
|
||||
query_with_user = f'org:{org} in:name {repo_query}'
|
||||
params['q'] = query_with_user
|
||||
elif not public:
|
||||
# Expand search scope to include user's repositories and organizations they're a member of
|
||||
user = await self.get_user()
|
||||
user_orgs = await self.get_user_organizations()
|
||||
|
||||
# Search in user repos and org repos separately
|
||||
all_repos = []
|
||||
|
||||
# Search in user repositories
|
||||
user_query = f'{query} user:{user.login}'
|
||||
user_params = params.copy()
|
||||
user_params['q'] = user_query
|
||||
|
||||
try:
|
||||
user_response, _ = await self._make_request(url, user_params)
|
||||
user_items = user_response.get('items', [])
|
||||
all_repos.extend(user_items)
|
||||
except Exception as e:
|
||||
logger.warning(f'User search failed: {e}')
|
||||
|
||||
# Search for repos named "query" in each organization
|
||||
for org in user_orgs:
|
||||
org_query = f'{query} org:{org}'
|
||||
org_params = params.copy()
|
||||
org_params['q'] = org_query
|
||||
|
||||
try:
|
||||
org_response, _ = await self._make_request(url, org_params)
|
||||
org_items = org_response.get('items', [])
|
||||
all_repos.extend(org_items)
|
||||
except Exception as e:
|
||||
logger.warning(f'Org {org} search failed: {e}')
|
||||
|
||||
# Also search for top repos from orgs that match the query name
|
||||
for org in user_orgs:
|
||||
if self._fuzzy_match_org_name(query, org):
|
||||
org_repos_query = f'org:{org}'
|
||||
org_repos_params = params.copy()
|
||||
org_repos_params['q'] = org_repos_query
|
||||
org_repos_params['sort'] = 'stars'
|
||||
org_repos_params['per_page'] = 2 # Limit to first 2 repos
|
||||
|
||||
try:
|
||||
org_repos_response, _ = await self._make_request(
|
||||
url, org_repos_params
|
||||
)
|
||||
org_repo_items = org_repos_response.get('items', [])
|
||||
all_repos.extend(org_repo_items)
|
||||
except Exception as e:
|
||||
logger.warning(f'Org repos search for {org} failed: {e}')
|
||||
|
||||
return [self._parse_repository(repo) for repo in all_repos]
|
||||
|
||||
# Default case (public search or slash query)
|
||||
response, _ = await self._make_request(url, params)
|
||||
repo_items = response.get('items', [])
|
||||
return [self._parse_repository(repo) for repo in repo_items]
|
||||
|
||||
async def get_repository_details_from_repo_name(
|
||||
self, repository: str
|
||||
) -> Repository:
|
||||
url = f'{self.BASE_URL}/repos/{repository}'
|
||||
repo, _ = await self._make_request(url)
|
||||
|
||||
return self._parse_repository(repo)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user