mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8316ec9eb3 | |||
| 059e5b7af6 | |||
| d858882c3d | |||
| 49d37119a9 | |||
| cfd416c29f | |||
| c052dd7da5 | |||
| 3f77b8229a | |||
| 8d13c9f328 | |||
| f46b112f17 | |||
| 44dc7f9e9b | |||
| 00eaa7a6e1 | |||
| 9f1d6963b8 | |||
| f61fa93596 | |||
| 3e87c08631 | |||
| 21f3ef540f | |||
| 61a93d010c | |||
| 9d6afa09b6 | |||
| c648b6f74f | |||
| c0fa41da65 | |||
| 6eb32e9ae4 | |||
| 6a544d4274 | |||
| 4aada82b75 | |||
| ab2da611f5 | |||
| e47bcf31e4 | |||
| 83b9262379 | |||
| edc95141f7 | |||
| 5b35203253 | |||
| 7e3eabe777 | |||
| 23713bfe8c | |||
| 81829289ab | |||
| 9709431874 | |||
| 0e9906f41e | |||
| 9ac9a47207 | |||
| 75653e805a | |||
| 9630b536cd | |||
| 6f5c8186b8 | |||
| 36e0d8d3da | |||
| e68abf8d75 | |||
| 93ef1b0cda | |||
| 77b5c6b161 | |||
| 57aa7d5c12 | |||
| 50391ecdf3 | |||
| 672650d3d9 | |||
| 9afedea170 | |||
| c0bb84dfa2 | |||
| 18b5139237 | |||
| 4849369ede | |||
| b082ccc0fb | |||
| b0007076c0 |
@@ -225,7 +225,7 @@ jobs:
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=false \
|
||||
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
poetry run pytest -n 0 -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 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ jobs:
|
||||
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
|
||||
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
|
||||
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
|
||||
"Feel free to join our developer community on [Slack](dub.sh/openhands). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"🙌 Happy hacking! 🙌\n\n" +
|
||||
"<!-- auto-comment:good-first-issue -->"
|
||||
});
|
||||
|
||||
+1
-1
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.54-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.55-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
The MIT License (MIT)
|
||||
Portions of this software are licensed as follows:
|
||||
* All content that resides under the enterprise/ directory is licensed under the license defined in "enterprise/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below.
|
||||
|
||||
=====================
|
||||
|
||||
Copyright © 2023
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2025
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
|
||||
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -130,7 +130,6 @@ 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.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
+3
-3
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -54,7 +54,7 @@ else
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
usermod -aG app enduser
|
||||
usermod -aG openhands enduser
|
||||
# get the user group of /var/run/docker.sock and set openhands to that group
|
||||
DOCKER_SOCKET_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||
echo "Docker socket group id: $DOCKER_SOCKET_GID"
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.54-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.55-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+3
-1
@@ -153,7 +153,9 @@
|
||||
"group": "Architecture",
|
||||
"pages": [
|
||||
"usage/architecture/backend",
|
||||
"usage/architecture/runtime"
|
||||
"usage/architecture/runtime",
|
||||
"usage/architecture/auth-system-summary",
|
||||
"usage/architecture/auth-system-design"
|
||||
]
|
||||
},
|
||||
"usage/how-to/debugging",
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
# OpenHands AuthSystem Design
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document proposes a comprehensive AuthSystem design for OpenHands that supports three authentication strategies: **None** (current behavior), **Single User (SU)** with GitHub OAuth, and **Multi User (MU)** (for custom builds). The design introduces clean abstraction boundaries, eliminates scattered `user_id` threading, and provides a foundation for future authentication enhancements.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Current Issues
|
||||
|
||||
1. **No Auth Strategy Abstraction**: OpenHands currently has a monolithic `DefaultUserAuth` that always returns `None` for `user_id`, with no clear path to support different authentication modes.
|
||||
|
||||
2. **Scattered user_id Threading**: 339+ occurrences of `user_id` across 68 files, with complex threading through:
|
||||
- Storage partitioning (`users/{user_id}/` paths)
|
||||
- Conversation/session scoping
|
||||
- API route dependencies
|
||||
- Provider token resolution
|
||||
- Data model fields
|
||||
|
||||
3. **Provider Token Pollution**: Routes accept `provider_tokens` parameters and thread them through `ProviderHandler`, creating security risks and complex signatures.
|
||||
|
||||
4. **No Single User Support**: No way to enable GitHub OAuth for personal/single-user deployments while maintaining the simplicity of the current "None" mode.
|
||||
|
||||
5. **Boundary Violations**: Auth concerns are mixed with business logic throughout the codebase, making it difficult to switch between authentication modes.
|
||||
|
||||
### Requirements from GitHub Issues
|
||||
|
||||
From **Issue #10751** (user_id audit):
|
||||
- Support None, SU, and MU modes
|
||||
- Introduce `UserContext` and `StorageNamespace` abstractions
|
||||
- Remove redundant `if user_id` guards (7 identified)
|
||||
- Clean up storage path helpers
|
||||
|
||||
From **Issue #10730** (token provider):
|
||||
- Remove `provider_tokens` dependency injection
|
||||
- Introduce `TokenProvider` boundary abstraction
|
||||
- Support backend-only credential resolution
|
||||
- Enable custom builds with token refresh/rotation patterns
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. AuthStrategy Interface
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/base.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
|
||||
class AuthStrategy(ABC):
|
||||
"""Base class for authentication strategies"""
|
||||
|
||||
@abstractmethod
|
||||
def get_name(self) -> str:
|
||||
"""Return strategy name for logging/debugging"""
|
||||
|
||||
@abstractmethod
|
||||
def requires_auth(self) -> bool:
|
||||
"""Whether this strategy requires user authentication"""
|
||||
|
||||
@abstractmethod
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
"""Authenticate request and return UserContext or None"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
"""Get token provider for this request"""
|
||||
|
||||
@abstractmethod
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
"""Get login URL for frontend, None if no auth required"""
|
||||
```
|
||||
|
||||
#### 2. UserContext
|
||||
|
||||
```python
|
||||
# openhands/auth/user_context.py
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserContext:
|
||||
"""Immutable user context for authenticated requests"""
|
||||
|
||||
user_id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
github_id: Optional[int] = None
|
||||
github_username: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def storage_namespace(self) -> str:
|
||||
"""Get storage namespace for this user"""
|
||||
return self.user_id
|
||||
```
|
||||
|
||||
#### 3. TokenProvider Interface
|
||||
|
||||
```python
|
||||
# openhands/auth/token_provider.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Mapping
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
|
||||
class TokenProvider(ABC):
|
||||
"""Abstract token provider for git integrations"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
|
||||
"""Get token for specific provider"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_all_tokens(self) -> Mapping[ProviderType, ProviderToken]:
|
||||
"""Get all available provider tokens"""
|
||||
```
|
||||
|
||||
#### 4. StorageNamespace
|
||||
|
||||
```python
|
||||
# openhands/auth/storage_namespace.py
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
"""Encapsulates storage path logic for user data"""
|
||||
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
|
||||
def get_conversation_events_dir(self, sid: str) -> str:
|
||||
return f'{self.get_conversation_dir(sid)}events/'
|
||||
|
||||
def get_conversation_metadata_filename(self, sid: str) -> str:
|
||||
return f'{self.get_conversation_dir(sid)}metadata.json'
|
||||
|
||||
# ... other path methods
|
||||
```
|
||||
|
||||
### Authentication Strategies
|
||||
|
||||
#### 1. None Strategy (Current Behavior)
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/none_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider, DefaultTokenProvider
|
||||
|
||||
class NoneStrategy(AuthStrategy):
|
||||
"""No authentication - current OpenHands behavior"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "none"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return False
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
return None # No user context
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
return DefaultTokenProvider() # Uses secrets.json
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
return None
|
||||
```
|
||||
|
||||
#### 2. Single User Strategy
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/single_user_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request, HTTPException
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider, SingleUserTokenProvider
|
||||
from openhands.server.shared import server_config
|
||||
|
||||
class SingleUserStrategy(AuthStrategy):
|
||||
"""Single user with GitHub OAuth"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "single_user"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return server_config.enable_su_auth
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
if not self.requires_auth():
|
||||
# SU mode without auth - create virtual user
|
||||
return UserContext(
|
||||
user_id="local",
|
||||
username="local_user",
|
||||
is_admin=True
|
||||
)
|
||||
|
||||
# Extract JWT token from cookie/header
|
||||
token = self._extract_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# Validate JWT and extract user info
|
||||
user_data = self._validate_jwt(token)
|
||||
if not user_data:
|
||||
return None
|
||||
|
||||
# Verify user is allowed (if configured)
|
||||
if (server_config.su_github_username and
|
||||
user_data.get('github_username') != server_config.su_github_username):
|
||||
raise HTTPException(403, "Access denied")
|
||||
|
||||
return UserContext(
|
||||
user_id=user_data['github_username'],
|
||||
email=user_data.get('email'),
|
||||
username=user_data['github_username'],
|
||||
github_id=user_data.get('github_id'),
|
||||
github_username=user_data['github_username'],
|
||||
is_admin=True # SU user is always admin
|
||||
)
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
user_context = await self.authenticate(request)
|
||||
return SingleUserTokenProvider(user_context)
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
if not self.requires_auth():
|
||||
return None
|
||||
return f"/api/auth/github/login"
|
||||
```
|
||||
|
||||
#### 3. Multi User Strategy (Custom Build Extension Point)
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/multi_user_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
|
||||
class MultiUserStrategy(AuthStrategy):
|
||||
"""Multi-user strategy - extension point for custom builds"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "multi_user"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
# This would be implemented by custom builds/applications built on OH
|
||||
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
return "/api/auth/login"
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
#### 1. Updated UserAuth
|
||||
|
||||
```python
|
||||
# openhands/server/user_auth/strategy_user_auth.py
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.storage_namespace import StorageNamespace
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
|
||||
class StrategyUserAuth(UserAuth):
|
||||
"""UserAuth implementation using AuthStrategy pattern"""
|
||||
|
||||
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
|
||||
self.strategy = strategy
|
||||
self.user_context = user_context
|
||||
self._storage_namespace = StorageNamespace(
|
||||
user_context.storage_namespace if user_context else None
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_context.user_id if self.user_context else None
|
||||
|
||||
async def get_user_email(self) -> str | None:
|
||||
return self.user_context.email if self.user_context else None
|
||||
|
||||
# ... other methods using storage_namespace
|
||||
```
|
||||
|
||||
#### 2. FastAPI Dependencies
|
||||
|
||||
```python
|
||||
# openhands/server/dependencies/auth.py
|
||||
from fastapi import Depends, Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
from openhands.server.shared import get_auth_strategy
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
strategy: AuthStrategy = Depends(get_auth_strategy)
|
||||
) -> Optional[UserContext]:
|
||||
"""Get current user context"""
|
||||
return await strategy.authenticate(request)
|
||||
|
||||
async def get_token_provider(
|
||||
request: Request,
|
||||
strategy: AuthStrategy = Depends(get_auth_strategy)
|
||||
) -> TokenProvider:
|
||||
"""Get token provider for current request"""
|
||||
return await strategy.get_token_provider(request)
|
||||
|
||||
async def require_auth(
|
||||
user: Optional[UserContext] = Depends(get_current_user)
|
||||
) -> UserContext:
|
||||
"""Require authentication"""
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
```
|
||||
|
||||
#### 3. Updated Routes
|
||||
|
||||
```python
|
||||
# openhands/server/routes/git.py (AFTER)
|
||||
from fastapi import APIRouter, Depends
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.server.dependencies.auth import get_token_provider, get_current_user
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
|
||||
app = APIRouter(prefix='/api/user')
|
||||
|
||||
@app.get('/repositories')
|
||||
async def get_user_repositories(
|
||||
sort: str = "pushed",
|
||||
selected_provider: ProviderType | None = None,
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user)
|
||||
):
|
||||
"""Get user repositories - no provider_tokens parameter!"""
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
return await client.get_repositories(sort, selected_provider)
|
||||
```
|
||||
|
||||
## Before/After Code Comparison
|
||||
|
||||
### Before: Current Implementation
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/server/routes/git.py
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = Query(default='pushed'),
|
||||
selected_provider: ProviderType | None = Query(default=None),
|
||||
page: int | None = Query(default=None),
|
||||
per_page: int | None = Query(default=None),
|
||||
installation_id: str | None = Query(default=None),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
# ... complex logic
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/storage/locations.py
|
||||
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
|
||||
if user_id:
|
||||
return f'users/{user_id}/conversations/{sid}/'
|
||||
else:
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/server/user_auth/default_user_auth.py
|
||||
class DefaultUserAuth(UserAuth):
|
||||
async def get_user_id(self) -> str | None:
|
||||
return None # Always None - no multi-tenancy support
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
user_secrets = await self.get_user_secrets()
|
||||
if user_secrets is None:
|
||||
return None
|
||||
return user_secrets.provider_tokens
|
||||
```
|
||||
|
||||
### After: Proposed Implementation
|
||||
|
||||
```python
|
||||
# AFTER: openhands/server/routes/git.py
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = Query(default='pushed'),
|
||||
selected_provider: ProviderType | None = Query(default=None),
|
||||
page: int | None = Query(default=None),
|
||||
per_page: int | None = Query(default=None),
|
||||
installation_id: str | None = Query(default=None),
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user),
|
||||
):
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
return await client.get_repositories(
|
||||
sort, server_config.app_mode, selected_provider, page, per_page, installation_id
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# AFTER: openhands/auth/storage_namespace.py
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
```python
|
||||
# AFTER: openhands/server/user_auth/strategy_user_auth.py
|
||||
class StrategyUserAuth(UserAuth):
|
||||
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
|
||||
self.strategy = strategy
|
||||
self.user_context = user_context
|
||||
self.storage_namespace = StorageNamespace(
|
||||
user_context.storage_namespace if user_context else None
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_context.user_id if self.user_context else None
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Authentication Strategy
|
||||
OH_AUTH_STRATEGY=none # Options: none, single_user, multi_user
|
||||
|
||||
# Single User Mode Settings
|
||||
OH_ENABLE_SU_AUTH=false # Enable GitHub OAuth in SU mode
|
||||
OH_SU_GITHUB_USERNAME=your_username # Restrict access to specific user
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# Multi User Mode (custom build extension point)
|
||||
OH_MU_ADMIN_USERNAME=admin_user
|
||||
```
|
||||
|
||||
### Configuration Modes
|
||||
|
||||
#### 1. None Mode (Current Default)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=none
|
||||
# No additional config needed
|
||||
```
|
||||
|
||||
#### 2. Single User - No Auth
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
```
|
||||
|
||||
#### 3. Single User - GitHub Auth
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=your_username
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## Implementation Benefits
|
||||
|
||||
### 1. Clean Separation of Concerns
|
||||
- Auth logic isolated in strategy classes
|
||||
- Business logic doesn't need to know about user_id
|
||||
- Clear boundaries between auth and core functionality
|
||||
|
||||
### 2. Reduced Complexity
|
||||
- Eliminates 7 redundant `if user_id` guards
|
||||
- Removes provider_tokens dependency injection
|
||||
- Simplifies method signatures throughout codebase
|
||||
|
||||
### 3. Forward Compatibility
|
||||
- custom builds can extend with custom strategies
|
||||
- Token refresh/rotation support built-in
|
||||
- Multi-tenancy ready without core changes
|
||||
|
||||
### 4. Security Improvements
|
||||
- Tokens never exposed in route parameters
|
||||
- Centralized token management
|
||||
- Immutable user context prevents tampering
|
||||
|
||||
### 5. Developer Experience
|
||||
- Clear configuration options
|
||||
- Easy mode switching
|
||||
- Consistent patterns across codebase
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Foundation
|
||||
1. Introduce auth strategy interfaces
|
||||
2. Add UserContext and StorageNamespace
|
||||
3. Create TokenProvider abstraction
|
||||
4. Update core dependencies
|
||||
|
||||
### Phase 2: Strategy Implementation
|
||||
1. Implement NoneStrategy (backward compatible)
|
||||
2. Implement SingleUserStrategy
|
||||
3. Add configuration support
|
||||
4. Update UserAuth integration
|
||||
|
||||
### Phase 3: Route Migration
|
||||
1. Update FastAPI dependencies
|
||||
2. Remove provider_tokens dependency injection
|
||||
3. Update ProviderHandler integration
|
||||
4. Clean up redundant if-guards
|
||||
|
||||
### Phase 4: Storage Migration
|
||||
1. Replace storage path helpers
|
||||
2. Update conversation managers
|
||||
3. Migrate event stores
|
||||
4. Clean up legacy code
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Strategy implementations
|
||||
- UserContext immutability
|
||||
- StorageNamespace path generation
|
||||
- TokenProvider implementations
|
||||
|
||||
### Integration Tests
|
||||
- End-to-end auth flows
|
||||
- Route authentication
|
||||
- Storage partitioning
|
||||
- Configuration switching
|
||||
|
||||
### Migration Tests
|
||||
- Backward compatibility
|
||||
- Data migration paths
|
||||
- Configuration validation
|
||||
|
||||
## Future Extensions
|
||||
|
||||
### custom builds Integration Points
|
||||
```python
|
||||
# custom builds can provide their own strategies
|
||||
class custom buildsMultiUserStrategy(AuthStrategy):
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
# Custom custom builds authentication logic
|
||||
pass
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
# custom builds token refresh/rotation
|
||||
return CustomBuildTokenProvider(request)
|
||||
```
|
||||
|
||||
### Additional Auth Methods
|
||||
- SAML/OIDC strategies
|
||||
- API key authentication
|
||||
- Custom JWT providers
|
||||
- Enterprise SSO integration
|
||||
|
||||
## Architecture Diagrams
|
||||
|
||||
### 1. Overall Auth System Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "FastAPI Application"
|
||||
Routes[API Routes]
|
||||
Deps[FastAPI Dependencies]
|
||||
end
|
||||
|
||||
subgraph "Auth Layer"
|
||||
AuthStrategy[AuthStrategy Interface]
|
||||
NoneStrategy[NoneStrategy]
|
||||
SUStrategy[SingleUserStrategy]
|
||||
MUStrategy[MultiUserStrategy]
|
||||
end
|
||||
|
||||
subgraph "Core Abstractions"
|
||||
UserContext[UserContext]
|
||||
TokenProvider[TokenProvider Interface]
|
||||
StorageNamespace[StorageNamespace]
|
||||
end
|
||||
|
||||
subgraph "Token Providers"
|
||||
DefaultTP[DefaultTokenProvider]
|
||||
SingleUserTP[SingleUserTokenProvider]
|
||||
custom buildsTP[CustomBuildTokenProvider]
|
||||
end
|
||||
|
||||
subgraph "Storage Layer"
|
||||
SecretsStore[SecretsStore]
|
||||
SettingsStore[SettingsStore]
|
||||
ConversationStore[ConversationStore]
|
||||
end
|
||||
|
||||
Routes --> Deps
|
||||
Deps --> AuthStrategy
|
||||
AuthStrategy --> UserContext
|
||||
AuthStrategy --> TokenProvider
|
||||
UserContext --> StorageNamespace
|
||||
|
||||
NoneStrategy --> DefaultTP
|
||||
SUStrategy --> SingleUserTP
|
||||
MUStrategy --> custom buildsTP
|
||||
|
||||
TokenProvider --> SecretsStore
|
||||
StorageNamespace --> ConversationStore
|
||||
StorageNamespace --> SettingsStore
|
||||
```
|
||||
|
||||
### 2. Authentication Flow - None Strategy
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant NoneStrategy
|
||||
participant DefaultTP
|
||||
participant SecretsStore
|
||||
|
||||
Client->>Route: API Request
|
||||
Route->>NoneStrategy: authenticate(request)
|
||||
NoneStrategy->>Route: None (no user context)
|
||||
Route->>NoneStrategy: get_token_provider(request)
|
||||
NoneStrategy->>DefaultTP: create()
|
||||
DefaultTP->>SecretsStore: load secrets.json
|
||||
SecretsStore->>DefaultTP: provider tokens
|
||||
DefaultTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 3. Authentication Flow - Single User Strategy (No Auth)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant SUStrategy
|
||||
participant UserContext
|
||||
participant SingleUserTP
|
||||
participant SecretsStore
|
||||
|
||||
Client->>Route: API Request
|
||||
Route->>SUStrategy: authenticate(request)
|
||||
SUStrategy->>UserContext: create virtual user (local)
|
||||
UserContext->>SUStrategy: user context
|
||||
SUStrategy->>Route: UserContext(user_id="local")
|
||||
Route->>SUStrategy: get_token_provider(request)
|
||||
SUStrategy->>SingleUserTP: create(user_context)
|
||||
SingleUserTP->>SecretsStore: load user secrets
|
||||
SecretsStore->>SingleUserTP: provider tokens
|
||||
SingleUserTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 4. Authentication Flow - Single User Strategy (GitHub Auth)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant SUStrategy
|
||||
participant GitHub
|
||||
participant UserContext
|
||||
participant SingleUserTP
|
||||
participant Database
|
||||
|
||||
Client->>Route: API Request with JWT cookie
|
||||
Route->>SUStrategy: authenticate(request)
|
||||
SUStrategy->>SUStrategy: extract JWT token
|
||||
SUStrategy->>SUStrategy: validate JWT
|
||||
SUStrategy->>SUStrategy: check allowed user
|
||||
SUStrategy->>UserContext: create from JWT data
|
||||
UserContext->>SUStrategy: user context
|
||||
SUStrategy->>Route: UserContext
|
||||
Route->>SUStrategy: get_token_provider(request)
|
||||
SUStrategy->>SingleUserTP: create(user_context)
|
||||
SingleUserTP->>Database: load encrypted tokens
|
||||
Database->>SingleUserTP: encrypted provider tokens
|
||||
SingleUserTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 5. GitHub OAuth Flow - Single User Strategy
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant AuthRoute
|
||||
participant GitHub
|
||||
participant SUStrategy
|
||||
participant Database
|
||||
participant UserContext
|
||||
|
||||
Client->>AuthRoute: GET /auth/login
|
||||
AuthRoute->>Client: GitHub OAuth URL
|
||||
Client->>GitHub: OAuth authorization
|
||||
GitHub->>AuthRoute: GET /auth/callback?code=xxx
|
||||
AuthRoute->>GitHub: exchange code for token
|
||||
GitHub->>AuthRoute: access token + user info
|
||||
AuthRoute->>SUStrategy: validate user allowed
|
||||
SUStrategy->>AuthRoute: user authorized
|
||||
AuthRoute->>Database: create/update user record
|
||||
Database->>AuthRoute: user saved
|
||||
AuthRoute->>AuthRoute: create JWT token
|
||||
AuthRoute->>Client: Set JWT cookie + redirect
|
||||
```
|
||||
|
||||
### 6. Storage Namespace Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "User Context"
|
||||
UC[UserContext]
|
||||
UC --> SN[StorageNamespace]
|
||||
end
|
||||
|
||||
subgraph "Storage Paths"
|
||||
SN --> ConvDir[get_conversation_dir]
|
||||
SN --> EventsDir[get_events_dir]
|
||||
SN --> MetaFile[get_metadata_file]
|
||||
SN --> StateFile[get_state_file]
|
||||
end
|
||||
|
||||
subgraph "Path Examples"
|
||||
ConvDir --> NonePath[sessions/sid/]
|
||||
ConvDir --> UserPath[users/user_id/conversations/sid/]
|
||||
|
||||
EventsDir --> NoneEvents[sessions/sid/events/]
|
||||
EventsDir --> UserEvents[users/user_id/conversations/sid/events/]
|
||||
end
|
||||
|
||||
subgraph "Strategy Impact"
|
||||
NoneStrategy2[NoneStrategy] --> NonePath
|
||||
SUStrategy2[SingleUserStrategy] --> UserPath
|
||||
MUStrategy2[MultiUserStrategy] --> UserPath
|
||||
end
|
||||
```
|
||||
|
||||
### 7. Token Provider Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Token Provider Interface"
|
||||
TP[TokenProvider]
|
||||
TP --> GetToken[get_token(provider)]
|
||||
TP --> GetAllTokens[get_all_tokens()]
|
||||
end
|
||||
|
||||
subgraph "Implementations"
|
||||
DefaultTP2[DefaultTokenProvider]
|
||||
SingleUserTP2[SingleUserTokenProvider]
|
||||
custom buildsTP2[CustomBuildTokenProvider]
|
||||
end
|
||||
|
||||
subgraph "Token Sources"
|
||||
SecretsJSON[secrets.json]
|
||||
UserDB[User Database]
|
||||
Custom Build API[custom builds Token API]
|
||||
end
|
||||
|
||||
subgraph "Provider Integration"
|
||||
ProviderHandler[ProviderHandler]
|
||||
GitHubService[GitHubService]
|
||||
GitLabService[GitLabService]
|
||||
BitBucketService[BitBucketService]
|
||||
end
|
||||
|
||||
DefaultTP2 --> SecretsJSON
|
||||
SingleUserTP2 --> UserDB
|
||||
custom buildsTP2 --> Custom Build API
|
||||
|
||||
TP --> ProviderHandler
|
||||
ProviderHandler --> GitHubService
|
||||
ProviderHandler --> GitLabService
|
||||
ProviderHandler --> BitBucketService
|
||||
```
|
||||
|
||||
### 8. Configuration-Driven Strategy Selection
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Configuration"
|
||||
Config[OH_AUTH_STRATEGY]
|
||||
Config --> None[none]
|
||||
Config --> SU[single_user]
|
||||
Config --> MU[multi_user]
|
||||
end
|
||||
|
||||
subgraph "Strategy Factory"
|
||||
Factory[AuthStrategyFactory]
|
||||
Factory --> CreateNone[create NoneStrategy]
|
||||
Factory --> CreateSU[create SingleUserStrategy]
|
||||
Factory --> CreateMU[create MultiUserStrategy]
|
||||
end
|
||||
|
||||
subgraph "Additional Config"
|
||||
SUConfig[OH_ENABLE_SU_AUTH<br/>OH_SU_GITHUB_USERNAME<br/>OH_GITHUB_CLIENT_ID]
|
||||
MUConfig[OH_MU_ADMIN_USERNAME<br/>Database Config]
|
||||
end
|
||||
|
||||
None --> CreateNone
|
||||
SU --> CreateSU
|
||||
MU --> CreateMU
|
||||
|
||||
CreateSU --> SUConfig
|
||||
CreateMU --> MUConfig
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
This AuthSystem design provides OpenHands with a robust, extensible authentication foundation that:
|
||||
|
||||
1. **Maintains backward compatibility** with the current "None" mode
|
||||
2. **Enables Single User mode** with optional GitHub OAuth
|
||||
3. **Provides extension points** for custom builds with multi-user implementations
|
||||
4. **Cleans up the codebase** by removing scattered user_id threading
|
||||
5. **Improves security** by centralizing token management
|
||||
6. **Simplifies development** with clear abstractions and patterns
|
||||
|
||||
The design is ready for implementation and will significantly improve OpenHands' authentication capabilities while maintaining its current simplicity for users who don't need authentication.
|
||||
@@ -0,0 +1,860 @@
|
||||
---
|
||||
title: AuthSystem Design - Complete Specification
|
||||
---
|
||||
|
||||
# OpenHands AuthSystem Design
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document proposes a comprehensive AuthSystem design for OpenHands that supports three authentication strategies: **None** (current behavior), **Single User (SU)** with GitHub OAuth, and **Multi User (MU)** (for custom builds). The design introduces clean abstraction boundaries, eliminates scattered `user_id` threading, and provides a foundation for future authentication enhancements.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Current Issues
|
||||
|
||||
1. **No Auth Strategy Abstraction**: OpenHands currently has a monolithic `DefaultUserAuth` that always returns `None` for `user_id`, with no clear path to support different authentication modes.
|
||||
|
||||
2. **Scattered user_id Threading**: 339+ occurrences of `user_id` across 68 files, with complex threading through:
|
||||
- Storage partitioning (`users/{user_id}/` paths)
|
||||
- Conversation/session scoping
|
||||
- API route dependencies
|
||||
- Provider token resolution
|
||||
- Data model fields
|
||||
|
||||
3. **Provider Token Pollution**: Routes accept `provider_tokens` parameters and thread them through `ProviderHandler`, creating security risks and complex signatures.
|
||||
|
||||
4. **No Single User Support**: No way to enable GitHub OAuth for personal/single-user deployments while maintaining the simplicity of the current "None" mode.
|
||||
|
||||
5. **Boundary Violations**: Auth concerns are mixed with business logic throughout the codebase, making it difficult to switch between authentication modes.
|
||||
|
||||
### Requirements from GitHub Issues
|
||||
|
||||
From **Issue #10751** (user_id audit):
|
||||
- Support None, SU, and MU modes
|
||||
- Introduce `UserContext` and `StorageNamespace` abstractions
|
||||
- Remove redundant `if user_id` guards (7 identified)
|
||||
- Clean up storage path helpers
|
||||
|
||||
From **Issue #10730** (token provider):
|
||||
- Remove `provider_tokens` dependency injection
|
||||
- Introduce `TokenProvider` boundary abstraction
|
||||
- Support backend-only credential resolution
|
||||
- Enable custom builds with token refresh/rotation patterns
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. AuthStrategy Interface
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/base.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
|
||||
class AuthStrategy(ABC):
|
||||
"""Base class for authentication strategies"""
|
||||
|
||||
@abstractmethod
|
||||
def get_name(self) -> str:
|
||||
"""Return strategy name for logging/debugging"""
|
||||
|
||||
@abstractmethod
|
||||
def requires_auth(self) -> bool:
|
||||
"""Whether this strategy requires user authentication"""
|
||||
|
||||
@abstractmethod
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
"""Authenticate request and return UserContext or None"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
"""Get token provider for this request"""
|
||||
|
||||
@abstractmethod
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
"""Get login URL for frontend, None if no auth required"""
|
||||
```
|
||||
|
||||
#### 2. UserContext
|
||||
|
||||
```python
|
||||
# openhands/auth/user_context.py
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserContext:
|
||||
"""Immutable user context for authenticated requests"""
|
||||
|
||||
user_id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
github_id: Optional[int] = None
|
||||
github_username: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def storage_namespace(self) -> str:
|
||||
"""Get storage namespace for this user"""
|
||||
return self.user_id
|
||||
```
|
||||
|
||||
#### 3. TokenProvider Interface
|
||||
|
||||
```python
|
||||
# openhands/auth/token_provider.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Mapping
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
|
||||
class TokenProvider(ABC):
|
||||
"""Abstract token provider for git integrations"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
|
||||
"""Get token for specific provider"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_all_tokens(self) -> Mapping[ProviderType, ProviderToken]:
|
||||
"""Get all available provider tokens"""
|
||||
```
|
||||
|
||||
#### 4. StorageNamespace
|
||||
|
||||
```python
|
||||
# openhands/auth/storage_namespace.py
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
"""Encapsulates storage path logic for user data"""
|
||||
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
|
||||
def get_conversation_events_dir(self, sid: str) -> str:
|
||||
return f'{self.get_conversation_dir(sid)}events/'
|
||||
|
||||
def get_conversation_metadata_filename(self, sid: str) -> str:
|
||||
return f'{self.get_conversation_dir(sid)}metadata.json'
|
||||
|
||||
# ... other path methods
|
||||
```
|
||||
|
||||
### Authentication Strategies
|
||||
|
||||
#### 1. None Strategy (Current Behavior)
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/none_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider, DefaultTokenProvider
|
||||
|
||||
class NoneStrategy(AuthStrategy):
|
||||
"""No authentication - current OpenHands behavior"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "none"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return False
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
return None # No user context
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
return DefaultTokenProvider() # Uses secrets.json
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
return None
|
||||
```
|
||||
|
||||
#### 2. Single User Strategy
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/single_user_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request, HTTPException
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider, SingleUserTokenProvider
|
||||
from openhands.server.shared import server_config
|
||||
|
||||
class SingleUserStrategy(AuthStrategy):
|
||||
"""Single user with GitHub OAuth"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "single_user"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return server_config.enable_su_auth
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
if not self.requires_auth():
|
||||
# SU mode without auth - create virtual user
|
||||
return UserContext(
|
||||
user_id="local",
|
||||
username="local_user",
|
||||
is_admin=True
|
||||
)
|
||||
|
||||
# Extract JWT token from cookie/header
|
||||
token = self._extract_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# Validate JWT and extract user info
|
||||
user_data = self._validate_jwt(token)
|
||||
if not user_data:
|
||||
return None
|
||||
|
||||
# Verify user is allowed (if configured)
|
||||
if (server_config.su_github_username and
|
||||
user_data.get('github_username') != server_config.su_github_username):
|
||||
raise HTTPException(403, "Access denied")
|
||||
|
||||
return UserContext(
|
||||
user_id=user_data['github_username'],
|
||||
email=user_data.get('email'),
|
||||
username=user_data['github_username'],
|
||||
github_id=user_data.get('github_id'),
|
||||
github_username=user_data['github_username'],
|
||||
is_admin=True # SU user is always admin
|
||||
)
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
user_context = await self.authenticate(request)
|
||||
return SingleUserTokenProvider(user_context)
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
if not self.requires_auth():
|
||||
return None
|
||||
return f"/api/auth/github/login"
|
||||
```
|
||||
|
||||
#### 3. Multi User Strategy (Custom Build Extension Point)
|
||||
|
||||
```python
|
||||
# openhands/auth/strategies/multi_user_strategy.py
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
|
||||
class MultiUserStrategy(AuthStrategy):
|
||||
"""Multi-user strategy - extension point for custom builds"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "multi_user"
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
# This would be implemented by custom builds/applications built on OH
|
||||
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
raise NotImplementedError("Multi-user strategy not available in base OpenHands")
|
||||
|
||||
def get_login_url(self) -> Optional[str]:
|
||||
return "/api/auth/login"
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
|
||||
#### 1. Updated UserAuth
|
||||
|
||||
```python
|
||||
# openhands/server/user_auth/strategy_user_auth.py
|
||||
from fastapi import Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.storage_namespace import StorageNamespace
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
|
||||
class StrategyUserAuth(UserAuth):
|
||||
"""UserAuth implementation using AuthStrategy pattern"""
|
||||
|
||||
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
|
||||
self.strategy = strategy
|
||||
self.user_context = user_context
|
||||
self._storage_namespace = StorageNamespace(
|
||||
user_context.storage_namespace if user_context else None
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_context.user_id if self.user_context else None
|
||||
|
||||
async def get_user_email(self) -> str | None:
|
||||
return self.user_context.email if self.user_context else None
|
||||
|
||||
# ... other methods using storage_namespace
|
||||
```
|
||||
|
||||
#### 2. FastAPI Dependencies
|
||||
|
||||
```python
|
||||
# openhands/server/dependencies/auth.py
|
||||
from fastapi import Depends, Request
|
||||
from openhands.auth.strategies.base import AuthStrategy
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
from openhands.server.shared import get_auth_strategy
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
strategy: AuthStrategy = Depends(get_auth_strategy)
|
||||
) -> Optional[UserContext]:
|
||||
"""Get current user context"""
|
||||
return await strategy.authenticate(request)
|
||||
|
||||
async def get_token_provider(
|
||||
request: Request,
|
||||
strategy: AuthStrategy = Depends(get_auth_strategy)
|
||||
) -> TokenProvider:
|
||||
"""Get token provider for current request"""
|
||||
return await strategy.get_token_provider(request)
|
||||
|
||||
async def require_auth(
|
||||
user: Optional[UserContext] = Depends(get_current_user)
|
||||
) -> UserContext:
|
||||
"""Require authentication"""
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
```
|
||||
|
||||
#### 3. Updated Routes
|
||||
|
||||
```python
|
||||
# openhands/server/routes/git.py (AFTER)
|
||||
from fastapi import APIRouter, Depends
|
||||
from openhands.auth.token_provider import TokenProvider
|
||||
from openhands.auth.user_context import UserContext
|
||||
from openhands.server.dependencies.auth import get_token_provider, get_current_user
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
|
||||
app = APIRouter(prefix='/api/user')
|
||||
|
||||
@app.get('/repositories')
|
||||
async def get_user_repositories(
|
||||
sort: str = "pushed",
|
||||
selected_provider: ProviderType | None = None,
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user)
|
||||
):
|
||||
"""Get user repositories - no provider_tokens parameter!"""
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
return await client.get_repositories(sort, selected_provider)
|
||||
```
|
||||
|
||||
## Before/After Code Comparison
|
||||
|
||||
### Before: Current Implementation
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/server/routes/git.py
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = Query(default='pushed'),
|
||||
selected_provider: ProviderType | None = Query(default=None),
|
||||
page: int | None = Query(default=None),
|
||||
per_page: int | None = Query(default=None),
|
||||
installation_id: str | None = Query(default=None),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
# ... complex logic
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/storage/locations.py
|
||||
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
|
||||
if user_id:
|
||||
return f'users/{user_id}/conversations/{sid}/'
|
||||
else:
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE: openhands/server/user_auth/default_user_auth.py
|
||||
class DefaultUserAuth(UserAuth):
|
||||
async def get_user_id(self) -> str | None:
|
||||
return None # Always None - no multi-tenancy support
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
user_secrets = await self.get_user_secrets()
|
||||
if user_secrets is None:
|
||||
return None
|
||||
return user_secrets.provider_tokens
|
||||
```
|
||||
|
||||
### After: Proposed Implementation
|
||||
|
||||
```python
|
||||
# AFTER: openhands/server/routes/git.py
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = Query(default='pushed'),
|
||||
selected_provider: ProviderType | None = Query(default=None),
|
||||
page: int | None = Query(default=None),
|
||||
per_page: int | None = Query(default=None),
|
||||
installation_id: str | None = Query(default=None),
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user),
|
||||
):
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
return await client.get_repositories(
|
||||
sort, server_config.app_mode, selected_provider, page, per_page, installation_id
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# AFTER: openhands/auth/storage_namespace.py
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
```python
|
||||
# AFTER: openhands/server/user_auth/strategy_user_auth.py
|
||||
class StrategyUserAuth(UserAuth):
|
||||
def __init__(self, strategy: AuthStrategy, user_context: Optional[UserContext]):
|
||||
self.strategy = strategy
|
||||
self.user_context = user_context
|
||||
self.storage_namespace = StorageNamespace(
|
||||
user_context.storage_namespace if user_context else None
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_context.user_id if self.user_context else None
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Authentication Strategy
|
||||
OH_AUTH_STRATEGY=none # Options: none, single_user, multi_user
|
||||
|
||||
# Single User Mode Settings
|
||||
OH_ENABLE_SU_AUTH=false # Enable GitHub OAuth in SU mode
|
||||
OH_SU_GITHUB_USERNAME=your_username # Restrict access to specific user
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# Multi User Mode (custom build extension point)
|
||||
OH_MU_ADMIN_USERNAME=admin_user
|
||||
```
|
||||
|
||||
### Configuration Modes
|
||||
|
||||
#### 1. None Mode (Current Default)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=none
|
||||
# No additional config needed
|
||||
```
|
||||
|
||||
#### 2. Single User - No Auth
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
```
|
||||
|
||||
#### 3. Single User - GitHub Auth
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=your_username
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## Implementation Benefits
|
||||
|
||||
### 1. Clean Separation of Concerns
|
||||
- Auth logic isolated in strategy classes
|
||||
- Business logic doesn't need to know about user_id
|
||||
- Clear boundaries between auth and core functionality
|
||||
|
||||
### 2. Reduced Complexity
|
||||
- Eliminates 7 redundant `if user_id` guards
|
||||
- Removes provider_tokens dependency injection
|
||||
- Simplifies method signatures throughout codebase
|
||||
|
||||
### 3. Forward Compatibility
|
||||
- custom builds can extend with custom strategies
|
||||
- Token refresh/rotation support built-in
|
||||
- Multi-tenancy ready without core changes
|
||||
|
||||
### 4. Security Improvements
|
||||
- Tokens never exposed in route parameters
|
||||
- Centralized token management
|
||||
- Immutable user context prevents tampering
|
||||
|
||||
### 5. Developer Experience
|
||||
- Clear configuration options
|
||||
- Easy mode switching
|
||||
- Consistent patterns across codebase
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Foundation
|
||||
1. Introduce auth strategy interfaces
|
||||
2. Add UserContext and StorageNamespace
|
||||
3. Create TokenProvider abstraction
|
||||
4. Update core dependencies
|
||||
|
||||
### Phase 2: Strategy Implementation
|
||||
1. Implement NoneStrategy (backward compatible)
|
||||
2. Implement SingleUserStrategy
|
||||
3. Add configuration support
|
||||
4. Update UserAuth integration
|
||||
|
||||
### Phase 3: Route Migration
|
||||
1. Update FastAPI dependencies
|
||||
2. Remove provider_tokens dependency injection
|
||||
3. Update ProviderHandler integration
|
||||
4. Clean up redundant if-guards
|
||||
|
||||
### Phase 4: Storage Migration
|
||||
1. Replace storage path helpers
|
||||
2. Update conversation managers
|
||||
3. Migrate event stores
|
||||
4. Clean up legacy code
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Strategy implementations
|
||||
- UserContext immutability
|
||||
- StorageNamespace path generation
|
||||
- TokenProvider implementations
|
||||
|
||||
### Integration Tests
|
||||
- End-to-end auth flows
|
||||
- Route authentication
|
||||
- Storage partitioning
|
||||
- Configuration switching
|
||||
|
||||
### Migration Tests
|
||||
- Backward compatibility
|
||||
- Data migration paths
|
||||
- Configuration validation
|
||||
|
||||
## Future Extensions
|
||||
|
||||
### custom builds Integration Points
|
||||
```python
|
||||
# custom builds can provide their own strategies
|
||||
class custom buildsMultiUserStrategy(AuthStrategy):
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
# Custom custom builds authentication logic
|
||||
pass
|
||||
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
# custom builds token refresh/rotation
|
||||
return CustomBuildTokenProvider(request)
|
||||
```
|
||||
|
||||
### Additional Auth Methods
|
||||
- SAML/OIDC strategies
|
||||
- API key authentication
|
||||
- Custom JWT providers
|
||||
- Enterprise SSO integration
|
||||
|
||||
## Architecture Diagrams
|
||||
|
||||
### 1. Overall Auth System Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "FastAPI Application"
|
||||
Routes[API Routes]
|
||||
Deps[FastAPI Dependencies]
|
||||
end
|
||||
|
||||
subgraph "Auth Layer"
|
||||
AuthStrategy[AuthStrategy Interface]
|
||||
NoneStrategy[NoneStrategy]
|
||||
SUStrategy[SingleUserStrategy]
|
||||
MUStrategy[MultiUserStrategy]
|
||||
end
|
||||
|
||||
subgraph "Core Abstractions"
|
||||
UserContext[UserContext]
|
||||
TokenProvider[TokenProvider Interface]
|
||||
StorageNamespace[StorageNamespace]
|
||||
end
|
||||
|
||||
subgraph "Token Providers"
|
||||
DefaultTP[DefaultTokenProvider]
|
||||
SingleUserTP[SingleUserTokenProvider]
|
||||
custom buildsTP[CustomBuildTokenProvider]
|
||||
end
|
||||
|
||||
subgraph "Storage Layer"
|
||||
SecretsStore[SecretsStore]
|
||||
SettingsStore[SettingsStore]
|
||||
ConversationStore[ConversationStore]
|
||||
end
|
||||
|
||||
Routes --> Deps
|
||||
Deps --> AuthStrategy
|
||||
AuthStrategy --> UserContext
|
||||
AuthStrategy --> TokenProvider
|
||||
UserContext --> StorageNamespace
|
||||
|
||||
NoneStrategy --> DefaultTP
|
||||
SUStrategy --> SingleUserTP
|
||||
MUStrategy --> custom buildsTP
|
||||
|
||||
TokenProvider --> SecretsStore
|
||||
StorageNamespace --> ConversationStore
|
||||
StorageNamespace --> SettingsStore
|
||||
```
|
||||
|
||||
### 2. Authentication Flow - None Strategy
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant NoneStrategy
|
||||
participant DefaultTP
|
||||
participant SecretsStore
|
||||
|
||||
Client->>Route: API Request
|
||||
Route->>NoneStrategy: authenticate(request)
|
||||
NoneStrategy->>Route: None (no user context)
|
||||
Route->>NoneStrategy: get_token_provider(request)
|
||||
NoneStrategy->>DefaultTP: create()
|
||||
DefaultTP->>SecretsStore: load secrets.json
|
||||
SecretsStore->>DefaultTP: provider tokens
|
||||
DefaultTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 3. Authentication Flow - Single User Strategy (No Auth)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant SUStrategy
|
||||
participant UserContext
|
||||
participant SingleUserTP
|
||||
participant SecretsStore
|
||||
|
||||
Client->>Route: API Request
|
||||
Route->>SUStrategy: authenticate(request)
|
||||
SUStrategy->>UserContext: create virtual user (local)
|
||||
UserContext->>SUStrategy: user context
|
||||
SUStrategy->>Route: UserContext(user_id="local")
|
||||
Route->>SUStrategy: get_token_provider(request)
|
||||
SUStrategy->>SingleUserTP: create(user_context)
|
||||
SingleUserTP->>SecretsStore: load user secrets
|
||||
SecretsStore->>SingleUserTP: provider tokens
|
||||
SingleUserTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 4. Authentication Flow - Single User Strategy (GitHub Auth)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Route
|
||||
participant SUStrategy
|
||||
participant GitHub
|
||||
participant UserContext
|
||||
participant SingleUserTP
|
||||
participant Database
|
||||
|
||||
Client->>Route: API Request with JWT cookie
|
||||
Route->>SUStrategy: authenticate(request)
|
||||
SUStrategy->>SUStrategy: extract JWT token
|
||||
SUStrategy->>SUStrategy: validate JWT
|
||||
SUStrategy->>SUStrategy: check allowed user
|
||||
SUStrategy->>UserContext: create from JWT data
|
||||
UserContext->>SUStrategy: user context
|
||||
SUStrategy->>Route: UserContext
|
||||
Route->>SUStrategy: get_token_provider(request)
|
||||
SUStrategy->>SingleUserTP: create(user_context)
|
||||
SingleUserTP->>Database: load encrypted tokens
|
||||
Database->>SingleUserTP: encrypted provider tokens
|
||||
SingleUserTP->>Route: token provider
|
||||
Route->>Client: API Response
|
||||
```
|
||||
|
||||
### 5. GitHub OAuth Flow - Single User Strategy
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant AuthRoute
|
||||
participant GitHub
|
||||
participant SUStrategy
|
||||
participant Database
|
||||
participant UserContext
|
||||
|
||||
Client->>AuthRoute: GET /auth/login
|
||||
AuthRoute->>Client: GitHub OAuth URL
|
||||
Client->>GitHub: OAuth authorization
|
||||
GitHub->>AuthRoute: GET /auth/callback?code=xxx
|
||||
AuthRoute->>GitHub: exchange code for token
|
||||
GitHub->>AuthRoute: access token + user info
|
||||
AuthRoute->>SUStrategy: validate user allowed
|
||||
SUStrategy->>AuthRoute: user authorized
|
||||
AuthRoute->>Database: create/update user record
|
||||
Database->>AuthRoute: user saved
|
||||
AuthRoute->>AuthRoute: create JWT token
|
||||
AuthRoute->>Client: Set JWT cookie + redirect
|
||||
```
|
||||
|
||||
### 6. Storage Namespace Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "User Context"
|
||||
UC[UserContext]
|
||||
UC --> SN[StorageNamespace]
|
||||
end
|
||||
|
||||
subgraph "Storage Paths"
|
||||
SN --> ConvDir[get_conversation_dir]
|
||||
SN --> EventsDir[get_events_dir]
|
||||
SN --> MetaFile[get_metadata_file]
|
||||
SN --> StateFile[get_state_file]
|
||||
end
|
||||
|
||||
subgraph "Path Examples"
|
||||
ConvDir --> NonePath[sessions/sid/]
|
||||
ConvDir --> UserPath[users/user_id/conversations/sid/]
|
||||
|
||||
EventsDir --> NoneEvents[sessions/sid/events/]
|
||||
EventsDir --> UserEvents[users/user_id/conversations/sid/events/]
|
||||
end
|
||||
|
||||
subgraph "Strategy Impact"
|
||||
NoneStrategy2[NoneStrategy] --> NonePath
|
||||
SUStrategy2[SingleUserStrategy] --> UserPath
|
||||
MUStrategy2[MultiUserStrategy] --> UserPath
|
||||
end
|
||||
```
|
||||
|
||||
### 7. Token Provider Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Token Provider Interface"
|
||||
TP[TokenProvider]
|
||||
TP --> GetToken[get_token(provider)]
|
||||
TP --> GetAllTokens[get_all_tokens()]
|
||||
end
|
||||
|
||||
subgraph "Implementations"
|
||||
DefaultTP2[DefaultTokenProvider]
|
||||
SingleUserTP2[SingleUserTokenProvider]
|
||||
custom buildsTP2[CustomBuildTokenProvider]
|
||||
end
|
||||
|
||||
subgraph "Token Sources"
|
||||
SecretsJSON[secrets.json]
|
||||
UserDB[User Database]
|
||||
Custom Build API[custom builds Token API]
|
||||
end
|
||||
|
||||
subgraph "Provider Integration"
|
||||
ProviderHandler[ProviderHandler]
|
||||
GitHubService[GitHubService]
|
||||
GitLabService[GitLabService]
|
||||
BitBucketService[BitBucketService]
|
||||
end
|
||||
|
||||
DefaultTP2 --> SecretsJSON
|
||||
SingleUserTP2 --> UserDB
|
||||
custom buildsTP2 --> Custom Build API
|
||||
|
||||
TP --> ProviderHandler
|
||||
ProviderHandler --> GitHubService
|
||||
ProviderHandler --> GitLabService
|
||||
ProviderHandler --> BitBucketService
|
||||
```
|
||||
|
||||
### 8. Configuration-Driven Strategy Selection
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Configuration"
|
||||
Config[OH_AUTH_STRATEGY]
|
||||
Config --> None[none]
|
||||
Config --> SU[single_user]
|
||||
Config --> MU[multi_user]
|
||||
end
|
||||
|
||||
subgraph "Strategy Factory"
|
||||
Factory[AuthStrategyFactory]
|
||||
Factory --> CreateNone[create NoneStrategy]
|
||||
Factory --> CreateSU[create SingleUserStrategy]
|
||||
Factory --> CreateMU[create MultiUserStrategy]
|
||||
end
|
||||
|
||||
subgraph "Additional Config"
|
||||
SUConfig[OH_ENABLE_SU_AUTH<br/>OH_SU_GITHUB_USERNAME<br/>OH_GITHUB_CLIENT_ID]
|
||||
MUConfig[OH_MU_ADMIN_USERNAME<br/>Database Config]
|
||||
end
|
||||
|
||||
None --> CreateNone
|
||||
SU --> CreateSU
|
||||
MU --> CreateMU
|
||||
|
||||
CreateSU --> SUConfig
|
||||
CreateMU --> MUConfig
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
This AuthSystem design provides OpenHands with a robust, extensible authentication foundation that:
|
||||
|
||||
1. **Maintains backward compatibility** with the current "None" mode
|
||||
2. **Enables Single User mode** with optional GitHub OAuth
|
||||
3. **Provides extension points** for custom builds with multi-user implementations
|
||||
4. **Cleans up the codebase** by removing scattered user_id threading
|
||||
5. **Improves security** by centralizing token management
|
||||
6. **Simplifies development** with clear abstractions and patterns
|
||||
|
||||
The design is ready for implementation and will significantly improve OpenHands' authentication capabilities while maintaining its current simplicity for users who don't need authentication.
|
||||
@@ -0,0 +1,210 @@
|
||||
# OpenHands AuthSystem Design - Executive Summary
|
||||
|
||||
## Goal
|
||||
Design a flexible authentication system for OpenHands that supports three strategies:
|
||||
- **None**: Current behavior (no auth, optional GitHub token)
|
||||
- **SU (Single User)**: GitHub OAuth for personal use
|
||||
- **MU (Multi User)**: Extension point for custom builds (not in base OH)
|
||||
|
||||
## Current Problems
|
||||
- 339+ `user_id` occurrences scattered across 68 files
|
||||
- No auth strategy abstraction
|
||||
- `provider_tokens` dependency injection complexity
|
||||
- No single-user GitHub OAuth support
|
||||
- Mixed auth/business logic concerns
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### Core Components
|
||||
1. **AuthStrategy Interface** - Pluggable auth strategies
|
||||
2. **UserContext** - Immutable user data container
|
||||
3. **TokenProvider** - Centralized token management
|
||||
4. **StorageNamespace** - Clean storage path abstraction
|
||||
|
||||
### Auth Strategies
|
||||
```python
|
||||
# None Strategy (current behavior)
|
||||
OH_AUTH_STRATEGY=none
|
||||
|
||||
# Single User - No Auth (virtual user)
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
|
||||
# Single User - GitHub OAuth
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=your_username
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## 🔄 Key Changes
|
||||
|
||||
### Before (Current)
|
||||
```python
|
||||
# Route with complex dependencies
|
||||
async def get_repositories(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
# Scattered path logic
|
||||
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
|
||||
if user_id:
|
||||
return f'users/{user_id}/conversations/{sid}/'
|
||||
else:
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
### After (Proposed)
|
||||
```python
|
||||
# Clean route signature
|
||||
async def get_repositories(
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user),
|
||||
):
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
|
||||
# Encapsulated storage logic
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
## Architectural Benefits
|
||||
|
||||
### Codebase Cleanup
|
||||
- Removes 7 redundant `if user_id` guards across the codebase
|
||||
- Eliminates `provider_tokens` dependency injection complexity
|
||||
- Reduces method signature complexity throughout the system
|
||||
- Centralizes storage path logic in dedicated abstractions
|
||||
|
||||
### Extensibility
|
||||
- Strategy pattern enables custom build extension points
|
||||
- Token refresh/rotation patterns built-in
|
||||
- Multi-tenancy ready without core changes
|
||||
- Additional auth methods can be added without refactoring
|
||||
|
||||
### Code Organization
|
||||
- Clear separation of auth and business logic
|
||||
- Consistent patterns across all authentication modes
|
||||
- Centralized token and credential management
|
||||
- Immutable user context prevents state corruption
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [ ] Auth strategy interfaces
|
||||
- [ ] UserContext & StorageNamespace
|
||||
- [ ] TokenProvider abstraction
|
||||
- [ ] Core dependencies
|
||||
|
||||
### Phase 2: Strategies
|
||||
- [ ] NoneStrategy (backward compatible)
|
||||
- [ ] SingleUserStrategy
|
||||
- [ ] Configuration support
|
||||
- [ ] UserAuth integration
|
||||
|
||||
### Phase 3: Routes
|
||||
- [ ] Update FastAPI dependencies
|
||||
- [ ] Remove provider_tokens
|
||||
- [ ] Update ProviderHandler
|
||||
- [ ] Clean redundant guards
|
||||
|
||||
### Phase 4: Storage
|
||||
- [ ] Replace path helpers
|
||||
- [ ] Update conversation managers
|
||||
- [ ] Migrate event stores
|
||||
- [ ] Legacy cleanup
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
### Strategy Pattern
|
||||
```python
|
||||
class AuthStrategy(ABC):
|
||||
@abstractmethod
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
pass
|
||||
```
|
||||
|
||||
### Immutable User Context
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class UserContext:
|
||||
user_id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
```
|
||||
|
||||
### Token Provider Interface
|
||||
```python
|
||||
class TokenProvider(ABC):
|
||||
@abstractmethod
|
||||
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
|
||||
pass
|
||||
```
|
||||
|
||||
## 🔧 Configuration Examples
|
||||
|
||||
### Current Default (None)
|
||||
```bash
|
||||
# No configuration needed - maintains current behavior
|
||||
```
|
||||
|
||||
### Personal Use (SU without auth)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
# Creates virtual "local" user, uses secrets.json
|
||||
```
|
||||
|
||||
### Personal Use (SU with GitHub)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=myusername
|
||||
OH_GITHUB_CLIENT_ID=abc123
|
||||
OH_GITHUB_CLIENT_SECRET=secret456
|
||||
# Requires GitHub OAuth, restricts to specific user
|
||||
```
|
||||
|
||||
## Implementation Readiness
|
||||
|
||||
### Backward Compatibility
|
||||
- None strategy maintains exact current behavior
|
||||
- No breaking changes for existing users
|
||||
- Gradual migration path available
|
||||
|
||||
### Code Quality Improvements
|
||||
- Reduces complexity from 339 to ~50 user_id references
|
||||
- Introduces clear abstractions and boundaries
|
||||
- Enables better testing and maintainability
|
||||
|
||||
### Extensibility Foundation
|
||||
- Custom builds can add authentication strategies
|
||||
- Token refresh/rotation patterns built-in
|
||||
- Multi-tenancy foundation without core changes
|
||||
|
||||
## Summary
|
||||
|
||||
This design provides a clean authentication architecture for OpenHands with three key outcomes:
|
||||
|
||||
1. **Maintains simplicity** - Current users see no changes
|
||||
2. **Enables extension** - Custom builds can add authentication features
|
||||
3. **Improves codebase** - Reduces scattered auth logic and complexity
|
||||
|
||||
The architecture is well-defined with a clear migration path.
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
title: AuthSystem Design - Executive Summary
|
||||
---
|
||||
|
||||
# OpenHands AuthSystem Design - Executive Summary
|
||||
|
||||
## Goal
|
||||
Design a flexible authentication system for OpenHands that supports three strategies:
|
||||
- **None**: Current behavior (no auth, optional GitHub token)
|
||||
- **SU (Single User)**: GitHub OAuth for personal use
|
||||
- **MU (Multi User)**: Extension point for custom builds (not in base OH)
|
||||
|
||||
## Current Problems
|
||||
- 339+ `user_id` occurrences scattered across 68 files
|
||||
- No auth strategy abstraction
|
||||
- `provider_tokens` dependency injection complexity
|
||||
- No single-user GitHub OAuth support
|
||||
- Mixed auth/business logic concerns
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### Core Components
|
||||
1. **AuthStrategy Interface** - Pluggable auth strategies
|
||||
2. **UserContext** - Immutable user data container
|
||||
3. **TokenProvider** - Centralized token management
|
||||
4. **StorageNamespace** - Clean storage path abstraction
|
||||
|
||||
### Auth Strategies
|
||||
```python
|
||||
# None Strategy (current behavior)
|
||||
OH_AUTH_STRATEGY=none
|
||||
|
||||
# Single User - No Auth (virtual user)
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
|
||||
# Single User - GitHub OAuth
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=your_username
|
||||
OH_GITHUB_CLIENT_ID=your_client_id
|
||||
OH_GITHUB_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## 🔄 Key Changes
|
||||
|
||||
### Before (Current)
|
||||
```python
|
||||
# Route with complex dependencies
|
||||
async def get_repositories(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
# Scattered path logic
|
||||
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
|
||||
if user_id:
|
||||
return f'users/{user_id}/conversations/{sid}/'
|
||||
else:
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
### After (Proposed)
|
||||
```python
|
||||
# Clean route signature
|
||||
async def get_repositories(
|
||||
token_provider: TokenProvider = Depends(get_token_provider),
|
||||
user: Optional[UserContext] = Depends(get_current_user),
|
||||
):
|
||||
client = ProviderHandler(token_provider=token_provider)
|
||||
|
||||
# Encapsulated storage logic
|
||||
@dataclass(frozen=True)
|
||||
class StorageNamespace:
|
||||
namespace: Optional[str]
|
||||
|
||||
def get_conversation_dir(self, sid: str) -> str:
|
||||
if self.namespace:
|
||||
return f'users/{self.namespace}/conversations/{sid}/'
|
||||
return f'sessions/{sid}/'
|
||||
```
|
||||
|
||||
## Architectural Benefits
|
||||
|
||||
### Codebase Cleanup
|
||||
- Removes 7 redundant `if user_id` guards across the codebase
|
||||
- Eliminates `provider_tokens` dependency injection complexity
|
||||
- Reduces method signature complexity throughout the system
|
||||
- Centralizes storage path logic in dedicated abstractions
|
||||
|
||||
### Extensibility
|
||||
- Strategy pattern enables custom build extension points
|
||||
- Token refresh/rotation patterns built-in
|
||||
- Multi-tenancy ready without core changes
|
||||
- Additional auth methods can be added without refactoring
|
||||
|
||||
### Code Organization
|
||||
- Clear separation of auth and business logic
|
||||
- Consistent patterns across all authentication modes
|
||||
- Centralized token and credential management
|
||||
- Immutable user context prevents state corruption
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [ ] Auth strategy interfaces
|
||||
- [ ] UserContext & StorageNamespace
|
||||
- [ ] TokenProvider abstraction
|
||||
- [ ] Core dependencies
|
||||
|
||||
### Phase 2: Strategies
|
||||
- [ ] NoneStrategy (backward compatible)
|
||||
- [ ] SingleUserStrategy
|
||||
- [ ] Configuration support
|
||||
- [ ] UserAuth integration
|
||||
|
||||
### Phase 3: Routes
|
||||
- [ ] Update FastAPI dependencies
|
||||
- [ ] Remove provider_tokens
|
||||
- [ ] Update ProviderHandler
|
||||
- [ ] Clean redundant guards
|
||||
|
||||
### Phase 4: Storage
|
||||
- [ ] Replace path helpers
|
||||
- [ ] Update conversation managers
|
||||
- [ ] Migrate event stores
|
||||
- [ ] Legacy cleanup
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
### Strategy Pattern
|
||||
```python
|
||||
class AuthStrategy(ABC):
|
||||
@abstractmethod
|
||||
async def authenticate(self, request: Request) -> Optional[UserContext]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_token_provider(self, request: Request) -> TokenProvider:
|
||||
pass
|
||||
```
|
||||
|
||||
### Immutable User Context
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class UserContext:
|
||||
user_id: str
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
```
|
||||
|
||||
### Token Provider Interface
|
||||
```python
|
||||
class TokenProvider(ABC):
|
||||
@abstractmethod
|
||||
async def get_token(self, provider: ProviderType) -> Optional[ProviderToken]:
|
||||
pass
|
||||
```
|
||||
|
||||
## 🔧 Configuration Examples
|
||||
|
||||
### Current Default (None)
|
||||
```bash
|
||||
# No configuration needed - maintains current behavior
|
||||
```
|
||||
|
||||
### Personal Use (SU without auth)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=false
|
||||
# Creates virtual "local" user, uses secrets.json
|
||||
```
|
||||
|
||||
### Personal Use (SU with GitHub)
|
||||
```bash
|
||||
OH_AUTH_STRATEGY=single_user
|
||||
OH_ENABLE_SU_AUTH=true
|
||||
OH_SU_GITHUB_USERNAME=myusername
|
||||
OH_GITHUB_CLIENT_ID=abc123
|
||||
OH_GITHUB_CLIENT_SECRET=secret456
|
||||
# Requires GitHub OAuth, restricts to specific user
|
||||
```
|
||||
|
||||
## Implementation Readiness
|
||||
|
||||
### Backward Compatibility
|
||||
- None strategy maintains exact current behavior
|
||||
- No breaking changes for existing users
|
||||
- Gradual migration path available
|
||||
|
||||
### Code Quality Improvements
|
||||
- Reduces complexity from 339 to ~50 user_id references
|
||||
- Introduces clear abstractions and boundaries
|
||||
- Enables better testing and maintainability
|
||||
|
||||
### Extensibility Foundation
|
||||
- Custom builds can add authentication strategies
|
||||
- Token refresh/rotation patterns built-in
|
||||
- Multi-tenancy foundation without core changes
|
||||
|
||||
## Summary
|
||||
|
||||
This design provides a clean authentication architecture for OpenHands with three key outcomes:
|
||||
|
||||
1. **Maintains simplicity** - Current users see no changes
|
||||
2. **Enables extension** - Custom builds can add authentication features
|
||||
3. **Improves codebase** - Reduces scattered auth logic and complexity
|
||||
|
||||
The architecture is well-defined with a clear migration path.
|
||||
@@ -87,19 +87,13 @@ source ~/.bashrc # or source ~/.zshrc
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
3. Launch an interactive OpenHands conversation from the command line:
|
||||
```bash
|
||||
# If using uvx (recommended)
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
```
|
||||
|
||||
<Note>
|
||||
If you have cloned the repository, you can also run the CLI directly using Poetry:
|
||||
|
||||
poetry run openhands
|
||||
</Note>
|
||||
|
||||
4. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
|
||||
3. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
|
||||
|
||||
This command opens an interactive prompt where you can type tasks or commands and get responses from OpenHands.
|
||||
The first time you run the CLI, it will take you through configuring the required LLM
|
||||
@@ -119,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -128,7 +122,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
|
||||
<Accordion title="Docker Command (Click to expand)">
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.54
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -22,7 +22,7 @@ SDK to spawn and control these sandboxes.
|
||||
|
||||
You can use the E2B CLI to create a custom sandbox with a Dockerfile. Read the full guide
|
||||
[here](https://e2b.dev/docs/guide/custom-sandbox). The premade OpenHands sandbox for E2B is set up in the `containers`
|
||||
directory. and it's called `openhands`.
|
||||
directory, and it's called `openhands`.
|
||||
|
||||
## Debugging
|
||||
|
||||
|
||||
@@ -38,6 +38,23 @@ 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**
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# PolyForm Free Trial License 1.0.0
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the software
|
||||
to do everything you might do with the software that would
|
||||
otherwise infringe the licensor's copyright in it for any
|
||||
permitted purpose. However, you may only make changes or
|
||||
new works based on the software according to [Changes and New
|
||||
Works License](#changes-and-new-works-license), and you may
|
||||
not distribute copies of the software.
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to
|
||||
make changes and new works based on the software for any
|
||||
permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## Free Trial
|
||||
|
||||
Use of the software for more than 30 days per calendar year is not allowed without a commercial license.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
If you violate any of these terms, or do anything with the
|
||||
software not covered by your licenses, all your licenses
|
||||
end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control of,
|
||||
or are under common control with that organization. **Control**
|
||||
means ownership of substantially all the assets of an entity,
|
||||
or the power to direct its management and policies by vote,
|
||||
contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
@@ -13,6 +13,7 @@ N_RUNS=${4:-1}
|
||||
export EXP_NAME=$EXP_NAME
|
||||
# use 2x resources for rollout since some codebases are pretty resource-intensive
|
||||
export DEFAULT_RUNTIME_RESOURCE_FACTOR=2
|
||||
export ITERATIVE_EVAL_MODE=false
|
||||
echo "MODEL: $MODEL"
|
||||
echo "EXP_NAME: $EXP_NAME"
|
||||
DATASET="SWE-Gym/SWE-Gym" # change this to the "/SWE-Gym-Lite" if you want to rollout the lite subset
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to aggregate token usage metrics from LLM completion files.
|
||||
|
||||
Usage:
|
||||
python aggregate_token_usage.py <directory_path> [--input-cost <cost>] [--output-cost <cost>] [--cached-cost <cost>]
|
||||
|
||||
Arguments:
|
||||
directory_path: Path to the directory containing completion files
|
||||
--input-cost: Cost per input token (default: 0.0)
|
||||
--output-cost: Cost per output token (default: 0.0)
|
||||
--cached-cost: Cost per cached token (default: 0.0)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def aggregate_token_usage(
|
||||
directory_path, input_cost=0.0, output_cost=0.0, cached_cost=0.0
|
||||
):
|
||||
"""
|
||||
Aggregate token usage metrics from all JSON completion files in the directory.
|
||||
|
||||
Args:
|
||||
directory_path (str): Path to directory containing completion files
|
||||
input_cost (float): Cost per input token
|
||||
output_cost (float): Cost per output token
|
||||
cached_cost (float): Cost per cached token
|
||||
"""
|
||||
|
||||
# Initialize counters
|
||||
totals = {
|
||||
'input_tokens': 0,
|
||||
'output_tokens': 0,
|
||||
'cached_tokens': 0,
|
||||
'total_tokens': 0,
|
||||
'files_processed': 0,
|
||||
'files_with_errors': 0,
|
||||
'cost': 0,
|
||||
}
|
||||
|
||||
# Find all JSON files recursively
|
||||
json_files = list(Path(directory_path).rglob('*.json'))
|
||||
|
||||
print(f'Found {len(json_files)} JSON files to process...')
|
||||
|
||||
for json_file in json_files:
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Look for usage data in response or fncall_response
|
||||
usage_data = None
|
||||
if (
|
||||
'response' in data
|
||||
and isinstance(data['response'], dict)
|
||||
and 'usage' in data['response']
|
||||
):
|
||||
usage_data = data['response']['usage']
|
||||
elif (
|
||||
'fncall_response' in data
|
||||
and isinstance(data['fncall_response'], dict)
|
||||
and 'usage' in data['fncall_response']
|
||||
):
|
||||
usage_data = data['fncall_response']['usage']
|
||||
|
||||
if usage_data:
|
||||
# Extract token counts
|
||||
completion_tokens = usage_data.get('completion_tokens', 0)
|
||||
prompt_tokens = usage_data.get('prompt_tokens', 0)
|
||||
cached_tokens = usage_data.get('cached_tokens', 0)
|
||||
|
||||
# Handle cases where cached_tokens might be in prompt_tokens_details
|
||||
if cached_tokens == 0 and 'prompt_tokens_details' in usage_data:
|
||||
details = usage_data['prompt_tokens_details']
|
||||
if isinstance(details, dict) and 'cached_tokens' in details:
|
||||
cached_tokens = details.get('cached_tokens', 0) or 0
|
||||
|
||||
# Calculate non-cached input tokens
|
||||
non_cached_input = prompt_tokens - cached_tokens
|
||||
|
||||
# Update totals
|
||||
totals['input_tokens'] += non_cached_input
|
||||
totals['output_tokens'] += completion_tokens
|
||||
totals['cached_tokens'] += cached_tokens
|
||||
totals['total_tokens'] += prompt_tokens + completion_tokens
|
||||
|
||||
if 'cost' in data:
|
||||
totals['cost'] += data['cost']
|
||||
totals['files_processed'] += 1
|
||||
|
||||
# Progress indicator
|
||||
if totals['files_processed'] % 1000 == 0:
|
||||
print(f'Processed {totals["files_processed"]} files...')
|
||||
|
||||
except Exception as e:
|
||||
totals['files_with_errors'] += 1
|
||||
if totals['files_with_errors'] <= 5: # Only show first 5 errors
|
||||
print(f'Error processing {json_file}: {e}')
|
||||
|
||||
# Calculate costs
|
||||
input_cost_total = totals['input_tokens'] * input_cost
|
||||
output_cost_total = totals['output_tokens'] * output_cost
|
||||
cached_cost_total = totals['cached_tokens'] * cached_cost
|
||||
total_cost = input_cost_total + output_cost_total + cached_cost_total
|
||||
|
||||
# Print results
|
||||
print('\n' + '=' * 60)
|
||||
print('TOKEN USAGE AGGREGATION RESULTS')
|
||||
print('=' * 60)
|
||||
print(f'Files processed: {totals["files_processed"]:,}')
|
||||
print(f'Files with errors: {totals["files_with_errors"]:,}')
|
||||
print()
|
||||
print('TOKEN COUNTS:')
|
||||
print(f' Input tokens (non-cached): {totals["input_tokens"]:,}')
|
||||
print(f' Output tokens: {totals["output_tokens"]:,}')
|
||||
print(f' Cached tokens: {totals["cached_tokens"]:,}')
|
||||
print(f' Total tokens: {totals["total_tokens"]:,}')
|
||||
print(f' Total costs (based on returned value): ${totals["cost"]:.6f}')
|
||||
print()
|
||||
|
||||
if input_cost > 0 or output_cost > 0 or cached_cost > 0:
|
||||
print('COST CALCULATED BASED ON PROVIDED RATE:')
|
||||
print(
|
||||
f' Input cost: ${input_cost_total:.6f} ({totals["input_tokens"]:,} × ${input_cost:.6f})'
|
||||
)
|
||||
print(
|
||||
f' Output cost: ${output_cost_total:.6f} ({totals["output_tokens"]:,} × ${output_cost:.6f})'
|
||||
)
|
||||
print(
|
||||
f' Cached cost: ${cached_cost_total:.6f} ({totals["cached_tokens"]:,} × ${cached_cost:.6f})'
|
||||
)
|
||||
print(f' Total cost: ${total_cost:.6f}')
|
||||
print()
|
||||
|
||||
print('SUMMARY:')
|
||||
print(
|
||||
f' Total input tokens: {totals["input_tokens"] + totals["cached_tokens"]:,}'
|
||||
)
|
||||
print(f' Total output tokens: {totals["output_tokens"]:,}')
|
||||
print(f' Grand total tokens: {totals["total_tokens"]:,}')
|
||||
|
||||
return totals
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Aggregate token usage metrics from LLM completion files',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python aggregate_token_usage.py /path/to/completions
|
||||
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002
|
||||
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002 --cached-cost 0.0000005
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'directory_path', help='Path to directory containing completion files'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--input-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per input token (default: 0.0)',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--output-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per output token (default: 0.0)',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--cached-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per cached token (default: 0.0)',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate directory path
|
||||
if not os.path.exists(args.directory_path):
|
||||
print(f"Error: Directory '{args.directory_path}' does not exist.")
|
||||
return 1
|
||||
|
||||
if not os.path.isdir(args.directory_path):
|
||||
print(f"Error: '{args.directory_path}' is not a directory.")
|
||||
return 1
|
||||
|
||||
# Run aggregation
|
||||
try:
|
||||
aggregate_token_usage(
|
||||
args.directory_path, args.input_cost, args.output_cost, args.cached_cost
|
||||
)
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f'Error during aggregation: {e}')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
@@ -54,12 +54,14 @@ 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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -99,16 +101,15 @@ describe("RepoConnector", () => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then interact with the repository dropdown
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -134,23 +135,23 @@ describe("RepoConnector", () => {
|
||||
expect(launchButton).toBeDisabled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ 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.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -161,7 +162,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
expect(launchButton).toBeEnabled();
|
||||
@@ -224,6 +226,19 @@ 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",
|
||||
@@ -244,23 +259,23 @@ describe("RepoConnector", () => {
|
||||
expect(createConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ 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.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -271,7 +286,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
@@ -288,6 +304,8 @@ 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",
|
||||
@@ -298,10 +316,10 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ 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();
|
||||
|
||||
@@ -309,16 +327,16 @@ describe("RepoConnector", () => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -329,7 +347,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
@@ -358,7 +377,7 @@ describe("RepoConnector", () => {
|
||||
const goToSettingsButton = await screen.findByTestId(
|
||||
"navigate-to-settings-button",
|
||||
);
|
||||
const dropdown = screen.queryByTestId("repo-dropdown");
|
||||
const dropdown = screen.queryByTestId("git-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("repo-dropdown")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("git-repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when repository fetch fails", async () => {
|
||||
@@ -168,10 +168,10 @@ describe("RepositorySelectionForm", () => {
|
||||
renderForm();
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("repo-dropdown-error"),
|
||||
await screen.findByTestId("dropdown-error"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
|
||||
screen.getByText("Failed to load data"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -231,11 +231,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
@@ -270,11 +266,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
|
||||
+75
-98
@@ -17,7 +17,8 @@ const mockUseUserProviders = vi.fn();
|
||||
const mockUseGitRepositories = vi.fn();
|
||||
const mockUseConfig = vi.fn();
|
||||
const mockUseRepositoryMicroagents = vi.fn();
|
||||
const mockUseSearchConversations = vi.fn();
|
||||
const mockUseMicroagentManagementConversations = vi.fn();
|
||||
const mockUseSearchRepositories = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
@@ -35,8 +36,13 @@ vi.mock("#/hooks/query/use-repository-microagents", () => ({
|
||||
useRepositoryMicroagents: () => mockUseRepositoryMicroagents(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-search-conversations", () => ({
|
||||
useSearchConversations: () => mockUseSearchConversations(),
|
||||
vi.mock("#/hooks/query/use-microagent-management-conversations", () => ({
|
||||
useMicroagentManagementConversations: () =>
|
||||
mockUseMicroagentManagementConversations(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-search-repositories", () => ({
|
||||
useSearchRepositories: () => mockUseSearchRepositories(),
|
||||
}));
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
@@ -212,12 +218,18 @@ describe("MicroagentManagement", () => {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: mockConversations,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseSearchRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
|
||||
data: [...mockRepositories],
|
||||
@@ -742,17 +754,24 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
await user.type(searchInput, "nonexistent");
|
||||
|
||||
// No repositories should be visible
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("user/repo2/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
// Wait for debounced search to complete (300ms debounce + buffer)
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
|
||||
// Wait for the search to complete and check that no repositories are visible
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("user/repo2/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("user/TestRepository"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle special characters in search", async () => {
|
||||
@@ -859,8 +878,8 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
// Search conversations functionality tests
|
||||
describe("Search conversations functionality", () => {
|
||||
it("should call searchConversations API when repository is expanded", async () => {
|
||||
describe("Microagent management conversations functionality", () => {
|
||||
it("should call useMicroagentManagementConversations API when repository is expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
@@ -876,7 +895,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both microagents and conversations to be fetched
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -896,7 +915,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that microagents are displayed
|
||||
@@ -921,7 +940,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
@@ -958,7 +977,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that loading spinner is not displayed
|
||||
@@ -983,7 +1002,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that microagent file paths are displayed for microagents
|
||||
@@ -1013,7 +1032,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -1033,7 +1052,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that the learn this repo component is displayed
|
||||
@@ -1050,7 +1069,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [...mockConversations],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -1070,7 +1089,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that conversations are displayed
|
||||
@@ -1093,7 +1112,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -1113,7 +1132,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that microagents are displayed
|
||||
@@ -1131,7 +1150,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
it("should handle error when fetching conversations", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
@@ -1150,7 +1169,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for the error to be handled
|
||||
await waitFor(() => {
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that the learn this repo component is displayed (since conversations failed)
|
||||
@@ -1195,7 +1214,7 @@ describe("MicroagentManagement", () => {
|
||||
expect(learnThisRepo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call searchConversations with correct parameters", async () => {
|
||||
it("should call useMicroagentManagementConversations with correct parameters", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
@@ -1208,9 +1227,9 @@ describe("MicroagentManagement", () => {
|
||||
const repoAccordion = screen.getByTestId("repository-name-tooltip");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for searchConversations to be called
|
||||
// Wait for useMicroagentManagementConversations to be called
|
||||
await waitFor(() => {
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1230,7 +1249,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that conversations display correct information
|
||||
@@ -1257,7 +1276,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to be called for first repo
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that both microagents and conversations are displayed
|
||||
@@ -1271,9 +1290,14 @@ describe("MicroagentManagement", () => {
|
||||
// Add microagent integration tests
|
||||
describe("Add microagent functionality", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
]);
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: [{ name: "main", commit_sha: "abc123", protected: false }]
|
||||
.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render add microagent button", async () => {
|
||||
@@ -1959,9 +1983,14 @@ describe("MicroagentManagement", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
]);
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: [{ name: "main", commit_sha: "abc123", protected: false }]
|
||||
.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render update microagent modal when updateMicroagentModalVisible is true", async () => {
|
||||
@@ -2391,7 +2420,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -2411,7 +2440,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for microagents and conversations to be fetched
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify the learn this repo trigger is displayed when no microagents exist
|
||||
@@ -2436,7 +2465,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -2491,7 +2520,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -2508,7 +2537,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT show the learn this repo trigger when microagents exist
|
||||
@@ -2516,58 +2545,6 @@ describe("MicroagentManagement", () => {
|
||||
screen.queryByTestId("learn-this-repo-trigger"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle API call for branches when learn this repo modal opens", async () => {
|
||||
// Mock branch API
|
||||
const branchesSpy = vi
|
||||
.spyOn(OpenHands, "getRepositoryBranches")
|
||||
.mockResolvedValue([
|
||||
{ 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,34 +37,27 @@ const selectRepository = async (repoName: string) => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
const repoInput = within(dropdown).getByRole("combobox");
|
||||
const repoInput = within(repoConnector).getByTestId("git-repo-dropdown");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
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");
|
||||
expect(within(dropdownMenu).getByText(repoName)).toBeInTheDocument();
|
||||
});
|
||||
const options = screen.getAllByText(repoName);
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
await userEvent.click(dropdownOption!);
|
||||
const dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
|
||||
await userEvent.click(within(dropdownMenu).getByText(repoName));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -85,12 +78,14 @@ 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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -140,10 +135,10 @@ describe("HomeScreen", () => {
|
||||
await screen.findAllByTestId("task-launch-button");
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ 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
+1346
-883
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.54.0",
|
||||
"version": "0.55.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -13,30 +13,31 @@
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.8.2",
|
||||
"@react-router/serve": "^7.8.2",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.1",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@stripe/react-stripe-js": "^3.9.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.1",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@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.0.13",
|
||||
"lucide-react": "^0.541.0",
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.260.2",
|
||||
"posthog-js": "^1.261.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -46,8 +47,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.5",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -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.11",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@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.4.0",
|
||||
"stripe": "^18.5.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.9.2",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
|
||||
@@ -21,11 +21,17 @@ import {
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { GitUser, GitRepository, Branch } from "#/types/git";
|
||||
import {
|
||||
GitUser,
|
||||
GitRepository,
|
||||
PaginatedBranchesResponse,
|
||||
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;
|
||||
@@ -428,6 +434,13 @@ class OpenHands {
|
||||
return data.credits;
|
||||
}
|
||||
|
||||
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
|
||||
const { data } = await openHands.get<SubscriptionAccess | null>(
|
||||
"/api/billing/subscription-access",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getGitUser(): Promise<GitUser> {
|
||||
const response = await openHands.get<GitUser>("/api/user/info");
|
||||
|
||||
@@ -567,14 +580,38 @@ class OpenHands {
|
||||
};
|
||||
}
|
||||
|
||||
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
|
||||
const { data } = await openHands.get<Branch[]>(
|
||||
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}`,
|
||||
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[]> {
|
||||
const { data } = await openHands.get<Branch[]>(
|
||||
`/api/user/search/branches`,
|
||||
{
|
||||
params: {
|
||||
repository,
|
||||
query,
|
||||
per_page: perPage,
|
||||
selected_provider: selectedProvider,
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available microagents associated with a conversation
|
||||
* @param conversationId The ID of the conversation
|
||||
@@ -726,6 +763,27 @@ class OpenHands {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getMicroagentManagementConversations(
|
||||
selectedRepository: string,
|
||||
pageId?: string,
|
||||
limit: number = 100,
|
||||
): Promise<Conversation[]> {
|
||||
const params: Record<string, string | number> = {
|
||||
limit,
|
||||
selected_repository: selectedRepository,
|
||||
};
|
||||
|
||||
if (pageId) {
|
||||
params.page_id = pageId;
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/microagent-management/conversations",
|
||||
{ params },
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -49,13 +49,11 @@ export interface GetConfigResponse {
|
||||
APP_SLUG?: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
STRIPE_PUBLISHABLE_KEY?: string;
|
||||
PROVIDERS_CONFIGURED?: Provider[];
|
||||
AUTH_URL?: string;
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: boolean;
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
HIDE_MICROAGENT_MANAGEMENT?: boolean;
|
||||
ENABLE_JIRA: boolean;
|
||||
ENABLE_JIRA_DC: boolean;
|
||||
ENABLE_LINEAR: boolean;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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",
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipb
|
||||
import { anchor } from "../markdown/anchor";
|
||||
import { OpenHandsSourceType } from "#/types/core/base";
|
||||
import { paragraph } from "../markdown/paragraph";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
|
||||
interface ChatMessageProps {
|
||||
type: OpenHandsSourceType;
|
||||
@@ -16,6 +17,7 @@ interface ChatMessageProps {
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -66,17 +68,35 @@ export function ChatMessage({
|
||||
"items-center gap-1",
|
||||
)}
|
||||
>
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
{actions?.map((action, index) =>
|
||||
action.tooltip ? (
|
||||
<TooltipButton
|
||||
key={index}
|
||||
tooltip={action.tooltip}
|
||||
ariaLabel={action.tooltip}
|
||||
placement="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
</TooltipButton>
|
||||
) : (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
|
||||
@@ -72,6 +72,9 @@ const getRecallObservationContent = (event: RecallObservation): string => {
|
||||
if (event.extras.repo_instructions) {
|
||||
content += `\n\n**Repository Instructions:**\n\n${event.extras.repo_instructions}`;
|
||||
}
|
||||
if (event.extras.conversation_instructions) {
|
||||
content += `\n\n**Conversation Instructions:**\n\n${event.extras.conversation_instructions}`;
|
||||
}
|
||||
if (event.extras.additional_agent_instructions) {
|
||||
content += `\n\n**Additional Instructions:**\n\n${event.extras.additional_agent_instructions}`;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ interface EventMessageProps {
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
isInLast10Actions: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
@@ -62,6 +63,8 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
EventMicroagentStatus[]
|
||||
>([]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsAction | OpenHandsObservation): boolean => {
|
||||
if (isOpenHandsAction(event)) {
|
||||
@@ -243,6 +246,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
setSelectedEventId(message.id);
|
||||
setShowLaunchMicroagentModal(true);
|
||||
},
|
||||
tooltip: t("MICROAGENT$ADD_TO_MEMORY"),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
|
||||
@@ -76,6 +76,10 @@ export function LaunchMicroagentModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-[#A3A3A3] font-normal leading-5">
|
||||
{t("MICROAGENT$DEFINITION")}
|
||||
</span>
|
||||
|
||||
<form
|
||||
data-testid="launch-microagent-modal"
|
||||
onSubmit={onSubmit}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { GitBranchDropdown } from "./git-branch-dropdown";
|
||||
export { BranchDropdownMenu } from "./branch-dropdown-menu";
|
||||
export type { GitBranchDropdownProps } from "./git-branch-dropdown";
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GitProviderDropdown } from "./git-provider-dropdown";
|
||||
export type { GitProviderDropdownProps } from "./git-provider-dropdown";
|
||||
@@ -0,0 +1,79 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// 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";
|
||||
@@ -0,0 +1,118 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
// Removed useRepositoryBranches import - GitBranchDropdown manages its own data
|
||||
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 "../../common/git-provider-dropdown";
|
||||
import { GitRepositoryDropdown } from "../../common/git-repository-dropdown";
|
||||
import { GitBranchDropdown } from "../../common/git-branch-dropdown";
|
||||
import { GitProviderDropdown } from "./git-provider-dropdown";
|
||||
import { GitBranchDropdown } from "./git-branch-dropdown";
|
||||
import { GitRepoDropdown } from "./git-repo-dropdown";
|
||||
|
||||
interface RepositorySelectionFormProps {
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
@@ -28,8 +28,6 @@ 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,
|
||||
@@ -50,8 +48,7 @@ export function RepositorySelectionForm({
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
// Check if repository has no branches (empty array after loading completes)
|
||||
const hasNoBranches = !isLoadingBranches && branches && branches.length === 0;
|
||||
// Branch selection is now handled by GitBranchDropdown component
|
||||
|
||||
const handleProviderSelection = (provider: Provider | null) => {
|
||||
setSelectedProvider(provider);
|
||||
@@ -60,14 +57,9 @@ export function RepositorySelectionForm({
|
||||
onRepoSelection(null); // Reset parent component's selected repo
|
||||
};
|
||||
|
||||
const handleBranchSelection = (branchName: string | null) => {
|
||||
const selectedBranchObj = branches?.find(
|
||||
(branch) => branch.name === branchName,
|
||||
);
|
||||
if (selectedBranchObj) {
|
||||
setSelectedBranch(selectedBranchObj);
|
||||
}
|
||||
};
|
||||
const handleBranchSelection = React.useCallback((branch: Branch | null) => {
|
||||
setSelectedBranch(branch);
|
||||
}, []);
|
||||
|
||||
// Render the provider dropdown
|
||||
const renderProviderSelector = () => {
|
||||
@@ -87,19 +79,6 @@ 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) => {
|
||||
@@ -107,13 +86,14 @@ export function RepositorySelectionForm({
|
||||
onRepoSelection(repository);
|
||||
setSelectedRepository(repository);
|
||||
} else {
|
||||
onRepoSelection(null); // Notify parent component that repo was cleared
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitRepositoryDropdown
|
||||
<GitRepoDropdown
|
||||
provider={selectedProvider || providers[0]}
|
||||
value={selectedRepository?.id || null}
|
||||
placeholder="Search repositories..."
|
||||
@@ -125,16 +105,21 @@ export function RepositorySelectionForm({
|
||||
};
|
||||
|
||||
// Render the branch selector
|
||||
const renderBranchSelector = () => (
|
||||
<GitBranchDropdown
|
||||
repositoryName={selectedRepository?.full_name}
|
||||
value={selectedBranch?.name || null}
|
||||
placeholder="Select branch..."
|
||||
className="max-w-[500px]"
|
||||
disabled={!selectedRepository}
|
||||
onChange={handleBranchSelection}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -148,8 +133,7 @@ export function RepositorySelectionForm({
|
||||
type="button"
|
||||
isDisabled={
|
||||
!selectedRepository ||
|
||||
(!selectedBranch && !hasNoBranches) ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isCreatingConversation ||
|
||||
(providers.length > 1 && !selectedProvider)
|
||||
}
|
||||
@@ -159,7 +143,7 @@ export function RepositorySelectionForm({
|
||||
repository: {
|
||||
name: selectedRepository?.full_name || "",
|
||||
gitProvider: selectedRepository?.git_provider || "github",
|
||||
branch: selectedBranch?.name || (hasNoBranches ? "" : "main"),
|
||||
branch: selectedBranch?.name || "main",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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";
|
||||
@@ -0,0 +1,26 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+7
-2
@@ -240,7 +240,6 @@ export function MicroagentManagementContent() {
|
||||
conversationInstructions,
|
||||
repository: {
|
||||
name: repositoryName,
|
||||
branch: formData.selectedBranch,
|
||||
gitProvider,
|
||||
},
|
||||
createMicroagent,
|
||||
@@ -277,15 +276,21 @@ export function MicroagentManagementContent() {
|
||||
const repositoryName = repository.full_name;
|
||||
const gitProvider = repository.git_provider;
|
||||
|
||||
const createMicroagent = {
|
||||
repo: repositoryName,
|
||||
git_provider: gitProvider,
|
||||
title: formData.query,
|
||||
};
|
||||
|
||||
// Launch a new conversation to help the user understand the repo
|
||||
createConversationAndSubscribe({
|
||||
query: formData.query,
|
||||
conversationInstructions: formData.query,
|
||||
repository: {
|
||||
name: repositoryName,
|
||||
branch: formData.selectedBranch,
|
||||
gitProvider,
|
||||
},
|
||||
createMicroagent,
|
||||
onSuccessCallback: () => {
|
||||
hideLearnThisRepoModal();
|
||||
},
|
||||
|
||||
+14
-124
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
@@ -8,15 +8,8 @@ import { BrandButton } from "../settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
import XIcon from "#/icons/x.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { cn, getRepoMdCreatePrompt } from "#/utils/utils";
|
||||
import { LearnThisRepoFormData } from "#/types/microagent-management";
|
||||
import { Branch } from "#/types/git";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
import {
|
||||
BranchDropdown,
|
||||
BranchLoadingState,
|
||||
BranchErrorState,
|
||||
} from "../home/repository-selection";
|
||||
|
||||
interface MicroagentManagementLearnThisRepoModalProps {
|
||||
onConfirm: (formData: LearnThisRepoFormData) => void;
|
||||
@@ -32,127 +25,35 @@ 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();
|
||||
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
const finalQuery = getRepoMdCreatePrompt(
|
||||
selectedRepository?.git_provider || "github",
|
||||
query.trim(),
|
||||
);
|
||||
|
||||
onConfirm({
|
||||
query: query.trim(),
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
query: finalQuery,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
const finalQuery = getRepoMdCreatePrompt(
|
||||
selectedRepository?.git_provider || "github",
|
||||
query.trim(),
|
||||
);
|
||||
|
||||
onConfirm({
|
||||
query: query.trim(),
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
query: finalQuery,
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
@@ -198,9 +99,6 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<div data-testid="branch-selector-container">
|
||||
{renderBranchSelector()}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="query-input"
|
||||
className="flex flex-col gap-2 w-full text-sm font-normal"
|
||||
@@ -243,17 +141,9 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
testId="confirm-button"
|
||||
isDisabled={
|
||||
!query.trim() ||
|
||||
isLoading ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isBranchesError
|
||||
}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{isLoading || isLoadingBranches
|
||||
? t(I18nKey.HOME$LOADING)
|
||||
: t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
{isLoading ? t(I18nKey.HOME$LOADING) : t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
+4
-4
@@ -5,7 +5,7 @@ import { Spinner } from "@heroui/react";
|
||||
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
|
||||
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
|
||||
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
|
||||
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
|
||||
import { useMicroagentManagementConversations } from "#/hooks/query/use-microagent-management-conversations";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { RootState } from "#/store";
|
||||
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
|
||||
@@ -42,9 +42,9 @@ export function MicroagentManagementRepoMicroagents({
|
||||
data: conversations,
|
||||
isLoading: isLoadingConversations,
|
||||
isError: isErrorConversations,
|
||||
} = useSearchConversations(
|
||||
} = useMicroagentManagementConversations(
|
||||
repositoryName,
|
||||
"microagent_management",
|
||||
undefined,
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
@@ -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.MICROAGENT_MANAGEMENT$OPEN_MICROAGENT_PULL_REQUESTS)}
|
||||
{t(I18nKey.COMMON$IN_PROGRESS)}
|
||||
</span>
|
||||
{conversations?.map((conversation) => (
|
||||
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
|
||||
|
||||
+15
-1
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Accordion, AccordionItem } from "@heroui/react";
|
||||
import { Accordion, AccordionItem, Spinner } from "@heroui/react";
|
||||
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { TabType } from "#/types/microagent-management";
|
||||
@@ -11,16 +11,30 @@ import { MicroagentManagementAccordionTitle } from "./microagent-management-acco
|
||||
type MicroagentManagementRepositoriesProps = {
|
||||
repositories: GitRepository[];
|
||||
tabType: TabType;
|
||||
isSearchLoading?: boolean;
|
||||
};
|
||||
|
||||
export function MicroagentManagementRepositories({
|
||||
repositories,
|
||||
tabType,
|
||||
isSearchLoading = false,
|
||||
}: MicroagentManagementRepositoriesProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const numberOfRepoMicroagents = repositories.length;
|
||||
|
||||
// Show spinner when search is in progress, regardless of repository count
|
||||
if (isSearchLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-white">
|
||||
{t("HOME$SEARCHING_REPOSITORIES")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (numberOfRepoMicroagents === 0) {
|
||||
if (tabType === "personal") {
|
||||
return (
|
||||
|
||||
+10
-1
@@ -5,7 +5,13 @@ import { MicroagentManagementRepositories } from "./microagent-management-reposi
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
export function MicroagentManagementSidebarTabs() {
|
||||
interface MicroagentManagementSidebarTabsProps {
|
||||
isSearchLoading?: boolean;
|
||||
}
|
||||
|
||||
export function MicroagentManagementSidebarTabs({
|
||||
isSearchLoading = false,
|
||||
}: MicroagentManagementSidebarTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { repositories, personalRepositories, organizationRepositories } =
|
||||
@@ -29,18 +35,21 @@ export function MicroagentManagementSidebarTabs() {
|
||||
<MicroagentManagementRepositories
|
||||
repositories={personalRepositories}
|
||||
tabType="personal"
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="repositories" title={t(I18nKey.COMMON$REPOSITORIES)}>
|
||||
<MicroagentManagementRepositories
|
||||
repositories={repositories}
|
||||
tabType="repositories"
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="organizations" title={t(I18nKey.COMMON$ORGANIZATIONS)}>
|
||||
<MicroagentManagementRepositories
|
||||
repositories={organizationRepositories}
|
||||
tabType="organizations"
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
+78
-23
@@ -5,7 +5,8 @@ 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 { GitProviderDropdown } from "#/components/common/git-provider-dropdown";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown";
|
||||
import {
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
@@ -16,7 +17,7 @@ import { Provider } from "#/types/settings";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { getGitProviderMicroagentManagementCustomStyles } from "#/components/common/react-select-styles";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
|
||||
interface MicroagentManagementSidebarProps {
|
||||
isSmallerScreen?: boolean;
|
||||
@@ -32,17 +33,29 @@ export function MicroagentManagementSidebar({
|
||||
);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: repositories, isLoading } = useGitRepositories({
|
||||
// Use Git repositories hook with pagination for infinite scrolling
|
||||
const {
|
||||
data: repositories,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
} = useGitRepositories({
|
||||
provider: selectedProvider,
|
||||
pageSize: 200,
|
||||
pageSize: 30, // Load 30 repositories per page
|
||||
enabled: !!selectedProvider,
|
||||
});
|
||||
|
||||
// Server-side search functionality
|
||||
const { data: searchResults, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(debouncedSearchQuery, selectedProvider, 500); // Increase page size to 500 to to retrieve all search results. This should be optimized in the future.
|
||||
|
||||
// Auto-select provider if there's only one
|
||||
useEffect(() => {
|
||||
if (providers.length > 0 && !selectedProvider) {
|
||||
@@ -55,23 +68,31 @@ export function MicroagentManagementSidebar({
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
// Filter repositories based on search query
|
||||
// Filter repositories based on search query and available data
|
||||
const filteredRepositories = useMemo(() => {
|
||||
if (!repositories?.pages) return null;
|
||||
// If we have search results, use them directly (no filtering needed)
|
||||
if (debouncedSearchQuery && searchResults && searchResults.length > 0) {
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
// If no search query or no search results, use paginated repositories
|
||||
if (!repositories?.pages) return [];
|
||||
|
||||
// Flatten all pages to get all repositories
|
||||
const allRepositories = repositories.pages.flatMap((page) => page.data);
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
// If no search query, return all repositories
|
||||
if (!debouncedSearchQuery.trim()) {
|
||||
return allRepositories;
|
||||
}
|
||||
|
||||
const sanitizedQuery = sanitizeQuery(searchQuery);
|
||||
// Fallback to client-side filtering if search didn't return results
|
||||
const sanitizedQuery = sanitizeQuery(debouncedSearchQuery);
|
||||
return allRepositories.filter((repository: GitRepository) => {
|
||||
const sanitizedRepoName = sanitizeQuery(repository.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
});
|
||||
}, [repositories, searchQuery, selectedProvider]);
|
||||
}, [repositories, debouncedSearchQuery, searchResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filteredRepositories?.length) {
|
||||
@@ -105,12 +126,28 @@ export function MicroagentManagementSidebar({
|
||||
dispatch(setRepositories(otherRepos));
|
||||
}, [filteredRepositories, selectedProvider, dispatch]);
|
||||
|
||||
// Handle scroll to bottom for pagination
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
// Only enable pagination when not searching
|
||||
if (debouncedSearchQuery && searchResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10;
|
||||
|
||||
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col",
|
||||
isSmallerScreen && "w-full border-none",
|
||||
)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<MicroagentManagementSidebarHeader />
|
||||
|
||||
@@ -123,8 +160,6 @@ export function MicroagentManagementSidebar({
|
||||
placeholder="Select Provider"
|
||||
onChange={handleProviderChange}
|
||||
className="w-full"
|
||||
classNamePrefix="git-provider-dropdown"
|
||||
styles={getGitProviderMicroagentManagementCustomStyles()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -134,18 +169,26 @@ export function MicroagentManagementSidebar({
|
||||
<label htmlFor="repository-search" className="sr-only">
|
||||
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
|
||||
</label>
|
||||
<input
|
||||
id="repository-search"
|
||||
name="repository-search"
|
||||
type="text"
|
||||
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed h-10 box-shadow-none outline-none",
|
||||
<div className="relative">
|
||||
<input
|
||||
id="repository-search"
|
||||
name="repository-search"
|
||||
type="text"
|
||||
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed h-10 box-shadow-none outline-none",
|
||||
"pr-10", // Space for spinner
|
||||
)}
|
||||
/>
|
||||
{isSearchLoading && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -156,7 +199,19 @@ export function MicroagentManagementSidebar({
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<MicroagentManagementSidebarTabs />
|
||||
<>
|
||||
<MicroagentManagementSidebarTabs isSearchLoading={isSearchLoading} />
|
||||
|
||||
{/* Show loading indicator for pagination (only when not searching) */}
|
||||
{isFetchingNextPage && !debouncedSearchQuery && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-white ml-2">
|
||||
{t("HOME$LOADING_MORE_REPOSITORIES")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+4
-110
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
@@ -11,14 +11,8 @@ 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 { Branch, GitRepository } from "#/types/git";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content";
|
||||
import {
|
||||
BranchDropdown,
|
||||
BranchLoadingState,
|
||||
BranchErrorState,
|
||||
} from "../home/repository-selection";
|
||||
|
||||
interface MicroagentManagementUpsertMicroagentModalProps {
|
||||
onConfirm: (formData: MicroagentFormData) => void;
|
||||
@@ -37,7 +31,6 @@ 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,
|
||||
@@ -49,9 +42,6 @@ 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,
|
||||
@@ -70,38 +60,6 @@ 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);
|
||||
@@ -134,7 +92,6 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
onConfirm({
|
||||
query: query.trim(),
|
||||
triggers,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
microagentPath: microagent?.path || "",
|
||||
});
|
||||
};
|
||||
@@ -147,67 +104,10 @@ 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]">
|
||||
@@ -236,7 +136,6 @@ 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"
|
||||
@@ -301,15 +200,10 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
onClick={handleConfirm}
|
||||
testId="confirm-button"
|
||||
isDisabled={
|
||||
!query.trim() ||
|
||||
isLoading ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isBranchesError ||
|
||||
(isUpdate && isLoadingContent) // Disable while loading content for updates
|
||||
!query.trim() || isLoading || (isUpdate && isLoadingContent) // Disable while loading content for updates
|
||||
}
|
||||
>
|
||||
{isLoading || isLoadingBranches || (isUpdate && isLoadingContent)
|
||||
{isLoading || (isUpdate && isLoadingContent)
|
||||
? t(I18nKey.HOME$LOADING)
|
||||
: t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
</BrandButton>
|
||||
|
||||
@@ -37,9 +37,6 @@ export function Sidebar() {
|
||||
const shouldHideLlmSettings =
|
||||
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && config?.APP_MODE === "saas";
|
||||
|
||||
const shouldHideMicroagentManagement =
|
||||
config?.FEATURE_FLAGS.HIDE_MICROAGENT_MANAGEMENT;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shouldHideLlmSettings) return;
|
||||
|
||||
@@ -83,11 +80,9 @@ export function Sidebar() {
|
||||
}
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
{!shouldHideMicroagentManagement && (
|
||||
<MicroagentManagementButton
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
)}
|
||||
<MicroagentManagementButton
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
import UnionIcon from "#/icons/union.svg?react";
|
||||
import RobotIcon from "#/icons/robot.svg?react";
|
||||
|
||||
interface MicroagentManagementButtonProps {
|
||||
disabled?: boolean;
|
||||
@@ -22,7 +22,7 @@ export function MicroagentManagementButton({
|
||||
testId="microagent-management-button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<UnionIcon />
|
||||
<RobotIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useMicroagentManagementConversations = (
|
||||
selectedRepository: string,
|
||||
pageId?: string,
|
||||
limit: number = 100,
|
||||
cacheDisabled: boolean = false,
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: [
|
||||
"conversations",
|
||||
"microagent-management",
|
||||
pageId,
|
||||
limit,
|
||||
selectedRepository,
|
||||
],
|
||||
queryFn: () =>
|
||||
OpenHands.getMicroagentManagementConversations(
|
||||
selectedRepository,
|
||||
pageId,
|
||||
limit,
|
||||
),
|
||||
enabled: !!selectedRepository,
|
||||
staleTime: cacheDisabled ? 0 : 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: cacheDisabled ? 0 : 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
@@ -1,14 +1,46 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Branch, PaginatedBranchesResponse } from "#/types/git";
|
||||
|
||||
export const useRepositoryBranches = (repository: string | null) =>
|
||||
useQuery<Branch[]>({
|
||||
queryKey: ["repository", repository, "branches"],
|
||||
queryFn: async () => {
|
||||
if (!repository) return [];
|
||||
return OpenHands.getRepositoryBranches(repository);
|
||||
const response = await OpenHands.getRepositoryBranches(repository);
|
||||
// Ensure we return an array even if the response is malformed
|
||||
return Array.isArray(response.branches) ? response.branches : [];
|
||||
},
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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,11 +5,16 @@ import { Provider } from "#/types/settings";
|
||||
export function useSearchRepositories(
|
||||
query: string,
|
||||
selectedProvider?: Provider | null,
|
||||
pageSize: number = 3,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["repositories", "search", query, selectedProvider],
|
||||
queryKey: ["repositories", "search", query, selectedProvider, pageSize],
|
||||
queryFn: () =>
|
||||
OpenHands.searchGitRepositories(query, 3, selectedProvider || undefined),
|
||||
OpenHands.searchGitRepositories(
|
||||
query,
|
||||
pageSize,
|
||||
selectedProvider || undefined,
|
||||
),
|
||||
enabled: !!query && !!selectedProvider,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export const useSubscriptionAccess = () => {
|
||||
const { data: config } = useConfig();
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "subscription_access"],
|
||||
queryFn: OpenHands.getSubscriptionAccess,
|
||||
enabled:
|
||||
!isOnTosPage &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
config?.FEATURE_FLAGS?.ENABLE_BILLING,
|
||||
});
|
||||
};
|
||||
@@ -128,7 +128,7 @@ export const useCreateConversationAndSubscribeMultiple = () => {
|
||||
conversationInstructions: string;
|
||||
repository: {
|
||||
name: string;
|
||||
branch: string;
|
||||
branch?: string;
|
||||
gitProvider: Provider;
|
||||
};
|
||||
createMicroagent?: CreateMicroagent;
|
||||
|
||||
@@ -85,6 +85,8 @@ export enum I18nKey {
|
||||
HOME$CONNECT_TO_REPOSITORY_TOOLTIP = "HOME$CONNECT_TO_REPOSITORY_TOOLTIP",
|
||||
HOME$LOADING = "HOME$LOADING",
|
||||
HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES",
|
||||
HOME$SEARCHING_REPOSITORIES = "HOME$SEARCHING_REPOSITORIES",
|
||||
HOME$LOADING_MORE_REPOSITORIES = "HOME$LOADING_MORE_REPOSITORIES",
|
||||
HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES",
|
||||
HOME$LOADING_BRANCHES = "HOME$LOADING_BRANCHES",
|
||||
HOME$FAILED_TO_LOAD_BRANCHES = "HOME$FAILED_TO_LOAD_BRANCHES",
|
||||
@@ -131,7 +133,6 @@ export enum I18nKey {
|
||||
CONVERSATION$REPOSITORY = "CONVERSATION$REPOSITORY",
|
||||
CONVERSATION$BRANCH = "CONVERSATION$BRANCH",
|
||||
CONVERSATION$GIT_PROVIDER = "CONVERSATION$GIT_PROVIDER",
|
||||
ACCOUNT_SETTINGS$TITLE = "ACCOUNT_SETTINGS$TITLE",
|
||||
WORKSPACE$TERMINAL_TAB_LABEL = "WORKSPACE$TERMINAL_TAB_LABEL",
|
||||
WORKSPACE$BROWSER_TAB_LABEL = "WORKSPACE$BROWSER_TAB_LABEL",
|
||||
WORKSPACE$JUPYTER_TAB_LABEL = "WORKSPACE$JUPYTER_TAB_LABEL",
|
||||
@@ -479,7 +480,6 @@ export enum I18nKey {
|
||||
PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL",
|
||||
PROJECT_MENU_CARD$OPEN = "PROJECT_MENU_CARD$OPEN",
|
||||
ACTION_BUTTON$RESUME = "ACTION_BUTTON$RESUME",
|
||||
ACTION_BUTTON$PAUSE = "ACTION_BUTTON$PAUSE",
|
||||
BROWSER$SCREENSHOT_ALT = "BROWSER$SCREENSHOT_ALT",
|
||||
ERROR_TOAST$CLOSE_BUTTON_LABEL = "ERROR_TOAST$CLOSE_BUTTON_LABEL",
|
||||
FILE_EXPLORER$UPLOAD = "FILE_EXPLORER$UPLOAD",
|
||||
@@ -518,7 +518,6 @@ export enum I18nKey {
|
||||
STATUS$CONNECTED = "STATUS$CONNECTED",
|
||||
BROWSER$NO_PAGE_LOADED = "BROWSER$NO_PAGE_LOADED",
|
||||
USER$AVATAR_PLACEHOLDER = "USER$AVATAR_PLACEHOLDER",
|
||||
ACCOUNT_SETTINGS$SETTINGS = "ACCOUNT_SETTINGS$SETTINGS",
|
||||
ACCOUNT_SETTINGS$LOGOUT = "ACCOUNT_SETTINGS$LOGOUT",
|
||||
SETTINGS_FORM$ADVANCED_OPTIONS_LABEL = "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL",
|
||||
CONVERSATION$NO_CONVERSATIONS = "CONVERSATION$NO_CONVERSATIONS",
|
||||
@@ -578,8 +577,6 @@ export enum I18nKey {
|
||||
ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO = "ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO",
|
||||
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
|
||||
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
|
||||
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
|
||||
ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB = "ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB",
|
||||
CONVERSATION$DELETE_WARNING = "CONVERSATION$DELETE_WARNING",
|
||||
FEEDBACK$TITLE = "FEEDBACK$TITLE",
|
||||
FEEDBACK$DESCRIPTION = "FEEDBACK$DESCRIPTION",
|
||||
@@ -823,9 +820,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,6 +1359,38 @@
|
||||
"de": "Repositories werden geladen...",
|
||||
"uk": "Завантаження репозиторіїв..."
|
||||
},
|
||||
"HOME$SEARCHING_REPOSITORIES": {
|
||||
"en": "Searching repositories...",
|
||||
"ja": "リポジトリを検索中...",
|
||||
"zh-CN": "搜索仓库中...",
|
||||
"zh-TW": "搜尋儲存庫中...",
|
||||
"ko-KR": "저장소 검색 중...",
|
||||
"no": "Søker i repositories...",
|
||||
"it": "Ricerca repository in corso...",
|
||||
"pt": "Pesquisando repositórios...",
|
||||
"es": "Buscando repositorios...",
|
||||
"ar": "جار البحث في المستودعات...",
|
||||
"fr": "Recherche de dépôts...",
|
||||
"tr": "Depolar aranıyor...",
|
||||
"de": "Repositories werden durchsucht...",
|
||||
"uk": "Пошук репозиторіїв..."
|
||||
},
|
||||
"HOME$LOADING_MORE_REPOSITORIES": {
|
||||
"en": "Loading more repositories...",
|
||||
"ja": "さらに多くのリポジトリを読み込み中...",
|
||||
"zh-CN": "加载更多仓库中...",
|
||||
"zh-TW": "載入更多儲存庫中...",
|
||||
"ko-KR": "더 많은 저장소 로딩 중...",
|
||||
"no": "Laster flere repositories...",
|
||||
"it": "Caricamento di altri repository...",
|
||||
"pt": "Carregando mais repositórios...",
|
||||
"es": "Cargando más repositorios...",
|
||||
"ar": "جار تحميل المزيد من المستودعات...",
|
||||
"fr": "Chargement de plus de dépôts...",
|
||||
"tr": "Daha fazla depolar yükleniyor...",
|
||||
"de": "Weitere Repositories werden geladen...",
|
||||
"uk": "Завантаження більше репозиторіїв..."
|
||||
},
|
||||
"HOME$FAILED_TO_LOAD_REPOSITORIES": {
|
||||
"en": "Failed to load repositories",
|
||||
"ja": "リポジトリの読み込みに失敗しました",
|
||||
@@ -1568,20 +1600,20 @@
|
||||
"uk": "Максимальний розмір історії конденсатора пам'яті"
|
||||
},
|
||||
"SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP": {
|
||||
"en": "After this many events, the condenser will summarize history. Minimum 10.",
|
||||
"ja": "このイベント数を超えると、凝縮器が履歴を要約します。最小 10。",
|
||||
"zh-CN": "达到此事件数量后,凝缩器将汇总历史。最小 10。",
|
||||
"zh-TW": "超過此事件數後,凝縮器會摘要歷史。最小 10。",
|
||||
"ko-KR": "이 이벤트 수 이후 응축기가 기록을 요약합니다. 최소 10.",
|
||||
"no": "Etter så mange hendelser vil kondenseren oppsummere historikken. Minimum 10.",
|
||||
"it": "Dopo questo numero di eventi, il condensatore riassumerà la cronologia. Minimo 10.",
|
||||
"pt": "Após esse número de eventos, o condensador irá resumir o histórico. Mínimo 10.",
|
||||
"es": "Después de este número de eventos, el condensador resumirá el historial. Mínimo 10.",
|
||||
"ar": "بعد هذا العدد من الأحداث، سيقوم المكثف بتلخيص السجل. الحد الأدنى 10.",
|
||||
"fr": "Après ce nombre d'événements, le condenseur résumera l'historique. Minimum 10.",
|
||||
"tr": "Bu kadar olaydan sonra yoğunlaştırıcı geçmişi özetler. En az 10.",
|
||||
"de": "Nach so vielen Ereignissen fasst der Kondensator die Historie zusammen. Minimum 10.",
|
||||
"uk": "Після цієї кількості подій конденсатор узагальнить історію. Мінімум 10."
|
||||
"en": "After this many events, the condenser will summarize history. Minimum 20.",
|
||||
"ja": "このイベント数を超えると、凝縮器が履歴を要約します。最小 20。",
|
||||
"zh-CN": "达到此事件数量后,凝缩器将汇总历史。最小 20。",
|
||||
"zh-TW": "超過此事件數後,凝縮器會摘要歷史。最小 20。",
|
||||
"ko-KR": "이 이벤트 수 이후 응축기가 기록을 요약합니다. 최소 20.",
|
||||
"no": "Etter så mange hendelser vil kondenseren oppsummere historikken. Minimum 20.",
|
||||
"it": "Dopo questo numero di eventi, il condensatore riassumerà la cronologia. Minimo 20.",
|
||||
"pt": "Após esse número de eventos, o condensador irá resumir o histórico. Mínimo 20.",
|
||||
"es": "Después de este número de eventos, el condensador resumirá el historial. Mínimo 20.",
|
||||
"ar": "بعد هذا العدد من الأحداث، سيقوم المكثف بتلخيص السجل. الحد الأدنى 20.",
|
||||
"fr": "Après ce nombre d'événements, le condenseur résumera l'historique. Minimum 20.",
|
||||
"tr": "Bu kadar olaydan sonra yoğunlaştırıcı geçmişi özetler. En az 20.",
|
||||
"de": "Nach so vielen Ereignissen fasst der Kondensator die Historie zusammen. Minimum 20.",
|
||||
"uk": "Після цієї кількості подій конденсатор узагальнить історію. Мінімум 20."
|
||||
},
|
||||
"SETTINGS$LANGUAGE": {
|
||||
"en": "Language",
|
||||
@@ -11952,20 +11984,20 @@
|
||||
"uk": "Бажаєте, щоб OpenHands розпочав нову розмову, щоб допомогти вам зрозуміти цей репозиторій?"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO": {
|
||||
"en": "What would you like to know about this repository?",
|
||||
"ja": "このリポジトリについて何を知りたいですか?",
|
||||
"zh-CN": "您想了解此存储库的哪些内容?",
|
||||
"zh-TW": "您想了解此存儲庫的哪些內容?",
|
||||
"ko-KR": "이 저장소에 대해 무엇을 알고 싶으신가요?",
|
||||
"no": "Hva vil du vite om dette depotet?",
|
||||
"it": "Cosa vorresti sapere su questo repository?",
|
||||
"pt": "O que você gostaria de saber sobre este repositório?",
|
||||
"es": "¿Qué te gustaría saber sobre este repositorio?",
|
||||
"ar": "ماذا تريد أن تعرف عن هذا المستودع؟",
|
||||
"fr": "Que souhaitez-vous savoir sur ce dépôt ?",
|
||||
"tr": "Bu depo hakkında ne bilmek istersiniz?",
|
||||
"de": "Was möchten Sie über dieses Repository wissen?",
|
||||
"uk": "Що ви хотіли б дізнатися про цей репозиторій?"
|
||||
"en": "What would you like to know about this repository? (optional)",
|
||||
"ja": "このリポジトリについて知りたいことは何ですか?(任意)",
|
||||
"zh-CN": "您想了解此存储库的哪些内容?(可选)",
|
||||
"zh-TW": "您想了解此存儲庫的哪些內容?(選填)",
|
||||
"ko-KR": "이 저장소에 대해 무엇을 알고 싶으신가요? (선택 사항)",
|
||||
"no": "Hva vil du vite om dette depotet? (valgfritt)",
|
||||
"it": "Cosa vorresti sapere su questo repository? (opzionale)",
|
||||
"pt": "O que você gostaria de saber sobre este repositório? (opcional)",
|
||||
"es": "¿Qué te gustaría saber sobre este repositorio? (opcional)",
|
||||
"ar": "ماذا ترغب في معرفته عن هذا المستودع؟ (اختياري)",
|
||||
"fr": "Que souhaitez-vous savoir sur ce dépôt ? (facultatif)",
|
||||
"tr": "Bu depo hakkında ne bilmek istersiniz? (isteğe bağlı)",
|
||||
"de": "Was möchten Sie über dieses Repository wissen? (optional)",
|
||||
"uk": "Що ви хотіли б дізнатися про цей репозиторій? (необов'язково)"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO": {
|
||||
"en": "Describe what you would like to know about this repository.",
|
||||
@@ -13087,22 +13119,6 @@
|
||||
"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アナライザー(デフォルト)",
|
||||
@@ -13166,5 +13182,53 @@
|
||||
"tr": "Yüksek Risk",
|
||||
"de": "Hohes Risiko",
|
||||
"uk": "Високий ризик"
|
||||
},
|
||||
"MICROAGENT$DEFINITION": {
|
||||
"en": "Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge. They provide expert guidance, automate common tasks, and ensure consistent practices across projects.",
|
||||
"ja": "マイクロエージェントは、OpenHandsにドメイン固有の知識を追加するための専門的なプロンプトです。専門的なガイダンスを提供し、一般的なタスクを自動化し、プロジェクト全体で一貫した実践を保証します。",
|
||||
"zh-CN": "微代理是增强 OpenHands 领域知识的专用提示。它们提供专家指导,自动化常见任务,并确保项目中的一致实践。",
|
||||
"zh-TW": "微代理是增強 OpenHands 領域知識的專用提示。它們提供專家指導,自動化常見任務,並確保專案中的一致實踐。",
|
||||
"ko-KR": "마이크로에이전트는 OpenHands에 도메인별 지식을 추가하는 특화된 프롬프트입니다. 전문가의 안내를 제공하고, 일반적인 작업을 자동화하며, 프로젝트 전반에 걸쳐 일관된 관행을 보장합니다.",
|
||||
"no": "Mikroagenter er spesialiserte prompt som forbedrer OpenHands med domenespesifikk kunnskap. De gir ekspertråd, automatiserer vanlige oppgaver og sikrer konsistente praksiser på tvers av prosjekter.",
|
||||
"it": "I microagenti sono prompt specializzati che arricchiscono OpenHands con conoscenze specifiche di dominio. Forniscono guida esperta, automatizzano attività comuni e garantiscono pratiche coerenti tra i progetti.",
|
||||
"pt": "Microagentes são prompts especializados que aprimoram o OpenHands com conhecimento específico de domínio. Eles fornecem orientação especializada, automatizam tarefas comuns e garantem práticas consistentes em todos os projetos.",
|
||||
"es": "Los microagentes son prompts especializados que mejoran OpenHands con conocimientos específicos de dominio. Proporcionan orientación experta, automatizan tareas comunes y aseguran prácticas consistentes en los proyectos.",
|
||||
"ar": "الميكرووكلاء هم مطالبات متخصصة تعزز OpenHands بمعرفة متخصصة في المجال. يقدمون إرشادات خبراء، ويؤتمتون المهام الشائعة، ويضمنون ممارسات متسقة عبر المشاريع.",
|
||||
"fr": "Les microagents sont des invites spécialisées qui enrichissent OpenHands avec des connaissances spécifiques au domaine. Ils fournissent des conseils d'experts, automatisent les tâches courantes et garantissent des pratiques cohérentes dans les projets.",
|
||||
"tr": "Mikro ajanlar, OpenHands'i alanına özgü bilgilerle geliştiren özel istemlerdir. Uzman rehberliği sağlar, yaygın görevleri otomatikleştirir ve projeler arasında tutarlı uygulamalar sunar.",
|
||||
"de": "Microagents sind spezialisierte Prompts, die OpenHands mit domänenspezifischem Wissen erweitern. Sie bieten fachkundige Anleitung, automatisieren gängige Aufgaben und sorgen für konsistente Praktiken in Projekten.",
|
||||
"uk": "Мікроагенти — це спеціалізовані підказки, які розширюють OpenHands галузевими знаннями. Вони надають експертні поради, автоматизують типові завдання та забезпечують послідовні практики у проєктах."
|
||||
},
|
||||
"MICROAGENT$ADD_TO_MEMORY": {
|
||||
"en": "Add to Microagent Memory",
|
||||
"ja": "マイクロエージェントメモリに追加",
|
||||
"zh-CN": "添加到微代理记忆",
|
||||
"zh-TW": "加入微代理記憶體",
|
||||
"ko-KR": "마이크로에이전트 메모리에 추가",
|
||||
"no": "Legg til i mikroagentminne",
|
||||
"it": "Aggiungi alla memoria del microagente",
|
||||
"pt": "Adicionar à Memória do Microagente",
|
||||
"es": "Agregar a la memoria del microagente",
|
||||
"ar": "أضف إلى ذاكرة الميكرووكيل",
|
||||
"fr": "Ajouter à la mémoire du microagent",
|
||||
"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": "В процесі"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="47" height="42" viewBox="0 0 47 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.58409 21.782C2.01747 21.782 1.48418 22.0153 1.11755 22.3819C0.750916 22.7486 0.517578 23.3152 0.517578 23.8485V28.0148C0.517578 28.5814 0.750916 29.1147 1.11755 29.4813C1.51751 29.8813 2.05081 30.0813 2.58409 30.0813C3.11737 30.0813 3.684 29.8479 4.05063 29.4813C4.45059 29.0814 4.65055 28.5481 4.65055 28.0148V23.8485C4.65055 23.2819 4.41726 22.7486 4.05063 22.3819C3.684 22.0153 3.11737 21.782 2.58409 21.782ZM44.2802 21.782C43.7136 21.782 43.1803 22.0153 42.8137 22.3819C42.4471 22.7486 42.2138 23.3152 42.2138 23.8485V28.0148C42.2138 28.5814 42.4471 29.1147 42.8137 29.4813C43.2137 29.8813 43.747 30.0813 44.2802 30.0813C44.8135 30.0813 45.3801 29.8479 45.7468 29.4813C46.1467 29.0814 46.3467 28.5481 46.3467 28.0148V23.8485C46.3467 23.2819 46.1134 22.7486 45.7468 22.3819C45.3801 22.0153 44.8135 21.782 44.2802 21.782ZM21.349 10.3164H13.0164C11.3499 10.3164 9.75011 10.983 8.58355 12.1496C7.41699 13.3161 6.75037 14.916 6.75037 16.5825V35.3474C6.75037 37.0139 7.41699 38.6138 8.58355 39.7804C9.75011 40.9469 11.3499 41.6135 13.0164 41.6135H33.8812C35.5477 41.6135 37.1476 40.9469 38.3141 39.7804C39.4807 38.6138 40.1473 37.0139 40.1473 35.3474V16.5825C40.1473 14.916 39.4807 13.3161 38.3141 12.1496C37.1476 10.983 35.5477 10.3164 33.8812 10.3164H25.5486M19.8491 14.4827H27.0152ZM31.3148 14.4827H33.8478C34.4145 14.4827 34.9478 14.716 35.3144 15.0826C35.7144 15.4826 35.9144 16.0159 35.9144 16.5492V35.3141C35.9144 35.8807 35.681 36.3807 35.3144 36.7806C34.9144 37.1806 34.3811 37.3806 33.8478 37.3806H12.9831C12.4165 37.3806 11.8832 37.1473 11.5166 36.7806C11.1166 36.3807 10.9167 35.8474 10.9167 35.3141V16.5492C10.9167 15.9825 11.1499 15.4493 11.5166 15.0826C11.9165 14.6827 12.4498 14.4827 12.9831 14.4827H15.5162M17.7494 21.782C17.1828 21.782 16.6495 22.0153 16.2828 22.3819C15.9162 22.7486 15.6829 23.3152 15.6829 23.8485V28.0148C15.6829 28.5814 15.9162 29.1147 16.2828 29.4813C16.6828 29.8813 17.2161 30.0813 17.7494 30.0813C18.2827 30.0813 18.8492 29.8479 19.2159 29.4813C19.6158 29.0814 19.8158 28.5481 19.8158 28.0148V23.8485C19.8158 23.2819 19.5825 22.7486 19.2159 22.3819C18.8492 22.0153 18.2827 21.782 17.7494 21.782ZM29.0816 21.782C28.515 21.782 27.9817 22.0153 27.6151 22.3819C27.2485 22.7486 27.0152 23.3152 27.0152 23.8485V28.0148C27.0152 28.5814 27.2485 29.1147 27.6151 29.4813C28.0151 29.8813 28.5484 30.0813 29.0816 30.0813C29.6149 30.0813 30.1816 29.8479 30.5482 29.4813C30.9481 29.0814 31.1481 28.5481 31.1481 28.0148V23.8485C31.1481 23.2819 30.9148 22.7486 30.5482 22.3819C30.1816 22.0153 29.6149 21.782 29.0816 21.782Z" fill="currentColor"/>
|
||||
<path d="M23.4122 0.851806C22.7122 0.851806 22.0123 1.05179 21.4123 1.45175C20.8124 1.85171 20.3791 2.41834 20.0791 3.05162C19.8125 3.71822 19.7458 4.41814 19.8792 5.11808C20.0125 5.81801 20.3458 6.4513 20.8457 6.95125C21.3457 7.45121 21.979 7.78451 22.6789 7.91783C23.3788 8.05115 24.1121 7.98448 24.7454 7.71783C25.412 7.45119 25.9452 6.98459 26.3452 6.38464C26.7452 5.7847 26.9452 5.11807 26.9452 4.38481C26.9452 3.41823 26.5785 2.51833 25.8786 1.85172C25.1786 1.18512 24.2787 0.785156 23.3455 0.785156L23.4122 0.851806Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,6 +1,8 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { GitRepository, Branch, PaginatedBranchesResponse } 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 = (
|
||||
@@ -19,6 +21,32 @@ 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"),
|
||||
@@ -26,6 +54,12 @@ 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
|
||||
@@ -154,4 +188,138 @@ 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,7 +169,6 @@ export const handlers = [
|
||||
APP_MODE: mockSaas ? "saas" : "oss",
|
||||
GITHUB_CLIENT_ID: "fake-github-client-id",
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
STRIPE_PUBLISHABLE_KEY: "",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: mockSaas,
|
||||
|
||||
@@ -186,9 +186,13 @@ function LlmSettingsScreen() {
|
||||
const condenserMaxSizeStr = formData
|
||||
.get("condenser-max-size-input")
|
||||
?.toString();
|
||||
const condenserMaxSize = condenserMaxSizeStr
|
||||
const condenserMaxSizeRaw = condenserMaxSizeStr
|
||||
? Number.parseInt(condenserMaxSizeStr, 10)
|
||||
: undefined;
|
||||
const condenserMaxSize =
|
||||
condenserMaxSizeRaw !== undefined
|
||||
? Math.max(20, condenserMaxSizeRaw)
|
||||
: undefined;
|
||||
|
||||
const securityAnalyzer = formData
|
||||
.get("security-analyzer-input")
|
||||
@@ -322,8 +326,9 @@ function LlmSettingsScreen() {
|
||||
|
||||
const handleCondenserMaxSizeIsDirty = (value: string) => {
|
||||
const parsed = value ? Number.parseInt(value, 10) : undefined;
|
||||
const bounded = parsed !== undefined ? Math.max(20, parsed) : undefined;
|
||||
const condenserMaxSizeIsDirty =
|
||||
(parsed ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE) !==
|
||||
(bounded ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE) !==
|
||||
(settings?.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE);
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
@@ -593,7 +598,7 @@ function LlmSettingsScreen() {
|
||||
testId="condenser-max-size-input"
|
||||
name="condenser-max-size-input"
|
||||
type="number"
|
||||
min={10}
|
||||
min={20}
|
||||
step={1}
|
||||
label={t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE)}
|
||||
defaultValue={(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { NavLink, Outlet, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
@@ -8,6 +9,7 @@ import { Route } from "./+types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
|
||||
const SAAS_ONLY_PATHS = [
|
||||
"/settings/user",
|
||||
@@ -62,10 +64,22 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
// this is used to determine which settings are available in the UI
|
||||
const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
|
||||
const navItems = useMemo(() => {
|
||||
const items = [];
|
||||
if (isSaas) {
|
||||
if (subscriptionAccess) {
|
||||
items.push({ to: "/settings", text: "SETTINGS$NAV_LLM" });
|
||||
}
|
||||
items.push(...SAAS_NAV_ITEMS);
|
||||
} else {
|
||||
items.push(...OSS_NAV_ITEMS);
|
||||
}
|
||||
return items;
|
||||
}, [isSaas, !!subscriptionAccess]);
|
||||
|
||||
return (
|
||||
<main
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type SubscriptionAccess = {
|
||||
status: "ACTIVE" | "DISABLED";
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
@@ -127,6 +127,7 @@ export interface RecallObservation extends OpenHandsObservationEvent<"recall"> {
|
||||
runtime_hosts?: Record<string, number>;
|
||||
custom_secrets_descriptions?: Record<string, string>;
|
||||
additional_agent_instructions?: string;
|
||||
conversation_instructions?: string;
|
||||
date?: string;
|
||||
microagent_knowledge?: MicroagentKnowledge[];
|
||||
};
|
||||
|
||||
Vendored
+9
@@ -22,6 +22,14 @@ 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;
|
||||
@@ -31,6 +39,7 @@ interface GitRepository {
|
||||
link_header?: string;
|
||||
pushed_at?: string;
|
||||
owner_type?: "user" | "organization";
|
||||
main_branch?: string;
|
||||
}
|
||||
|
||||
interface GitHubCommit {
|
||||
|
||||
@@ -17,11 +17,9 @@ export interface IMicroagentItem {
|
||||
export interface MicroagentFormData {
|
||||
query: string;
|
||||
triggers: string[];
|
||||
selectedBranch: string;
|
||||
microagentPath: string;
|
||||
}
|
||||
|
||||
export interface LearnThisRepoFormData {
|
||||
query: string;
|
||||
selectedBranch: string;
|
||||
}
|
||||
|
||||
@@ -244,3 +244,31 @@ export const extractRepositoryInfo = (
|
||||
|
||||
return { owner, repo, filePath };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the repository markdown creation prompt with additional PR creation instructions
|
||||
* @param gitProvider The git provider to use for generating provider-specific text
|
||||
* @param query Optional custom query to use instead of the default prompt
|
||||
* @returns The complete prompt for creating repository markdown and PR instructions
|
||||
*/
|
||||
export const getRepoMdCreatePrompt = (
|
||||
gitProvider: Provider,
|
||||
query?: string,
|
||||
): string => {
|
||||
const providerName = getProviderName(gitProvider);
|
||||
const pr = getPR(gitProvider === "gitlab");
|
||||
const prShort = getPRShort(gitProvider === "gitlab");
|
||||
|
||||
return `Please explore this repository. Create the file .openhands/microagents/repo.md with:
|
||||
${
|
||||
query
|
||||
? `- ${query}`
|
||||
: `- A description of the project
|
||||
- An overview of the file structure
|
||||
- Any information on how to run tests or other relevant commands
|
||||
- Any other information that would be helpful to a brand new developer
|
||||
Keep it short--just a few paragraphs will do.`
|
||||
}
|
||||
|
||||
Please push the changes to your branch on ${providerName} and create a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ 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=self.llm.format_messages_for_llm(messages),
|
||||
messages=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': self.llm.format_messages_for_llm(messages),
|
||||
'messages': messages,
|
||||
}
|
||||
params['tools'] = check_tools(self.tools, self.llm.config)
|
||||
params['extra_body'] = {
|
||||
|
||||
@@ -263,9 +263,36 @@ 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=arguments.get('task_list', []),
|
||||
task_list=normalized_task_list,
|
||||
)
|
||||
|
||||
# ================================================
|
||||
|
||||
@@ -38,3 +38,9 @@
|
||||
[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,10 +301,8 @@ 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=flat_messages,
|
||||
messages=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_message(event.thought)
|
||||
display_thought_if_new(event.thought)
|
||||
|
||||
# Format the command and task list for display
|
||||
display_text = f'Command: {event.command}'
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket.service import (
|
||||
BitBucketBranchesMixin,
|
||||
BitBucketFeaturesMixin,
|
||||
BitBucketPRsMixin,
|
||||
BitBucketReposMixin,
|
||||
)
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
ResourceNotFoundError,
|
||||
SuggestedTask,
|
||||
User,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentContentResponse
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
class BitBucketService(
|
||||
BitBucketReposMixin,
|
||||
BitBucketBranchesMixin,
|
||||
BitBucketPRsMixin,
|
||||
BitBucketFeaturesMixin,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
):
|
||||
"""Default implementation of GitService for Bitbucket integration.
|
||||
|
||||
This is an extension point in OpenHands that allows applications to customize Bitbucket
|
||||
@@ -37,10 +35,6 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
The class is instantiated via get_impl() in openhands.server.shared.py.
|
||||
"""
|
||||
|
||||
BASE_URL = 'https://api.bitbucket.org/2.0'
|
||||
token: SecretStr = SecretStr('')
|
||||
refresh = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
@@ -49,7 +43,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
token: SecretStr | None = None,
|
||||
external_token_manager: bool = False,
|
||||
base_domain: str | None = None,
|
||||
):
|
||||
) -> None:
|
||||
self.user_id = user_id
|
||||
self.external_token_manager = external_token_manager
|
||||
self.external_auth_id = external_auth_id
|
||||
@@ -65,569 +59,6 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
def provider(self) -> str:
|
||||
return ProviderType.BITBUCKET.value
|
||||
|
||||
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."""
|
||||
# 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}'
|
||||
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."""
|
||||
# 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'
|
||||
|
||||
# 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
|
||||
"""
|
||||
# Extract owner and repo from the repository string (e.g., "owner/repo")
|
||||
parts = repo_name.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repo_name}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
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_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from Bitbucket repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'workspace/repo_slug'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
# Step 1: Get repository details using existing method
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
|
||||
if not repo_details.main_branch:
|
||||
logger.warning(
|
||||
f'No main branch found in repository info for {repository}. '
|
||||
f'Repository response: mainbranch field missing'
|
||||
)
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
|
||||
# Step 2: Get file content using the main branch
|
||||
file_url = f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{file_path}'
|
||||
response, _ = await self._make_request(file_url)
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(response, file_path)
|
||||
|
||||
|
||||
bitbucket_service_cls = os.environ.get(
|
||||
'OPENHANDS_BITBUCKET_SERVICE_CLS',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from .base import BitBucketMixinBase
|
||||
from .branches import BitBucketBranchesMixin
|
||||
from .features import BitBucketFeaturesMixin
|
||||
from .prs import BitBucketPRsMixin
|
||||
from .repos import BitBucketReposMixin
|
||||
|
||||
__all__ = [
|
||||
'BitBucketMixinBase',
|
||||
'BitBucketBranchesMixin',
|
||||
'BitBucketFeaturesMixin',
|
||||
'BitBucketPRsMixin',
|
||||
'BitBucketReposMixin',
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user