Compare commits

..

6 Commits

Author SHA1 Message Date
mamoodi
c5e0de8ecd Update agent server image 2026-03-30 10:20:58 -04:00
mamoodi
2209a0713a Merge branch 'main' into oss-rel-1.6.0 2026-03-30 09:26:40 -04:00
Tim O'Farrell
72048be1f3 APP-1153 Fix for issue where popup menu does not display (#13635)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-28 10:36:59 -04:00
Tim O'Farrell
2d65d3517b APP-1152 Add legacy fallback variable when finding persistence directory (#13629) 2026-03-27 12:27:44 -04:00
mamoodi
bbaa86b8b7 Merge branch 'main' into oss-rel-1.6.0 2026-03-26 14:36:40 -04:00
mamoodi
93c567faf0 Release 1.6.0 2026-03-26 12:43:43 -04:00
63 changed files with 86 additions and 1659 deletions

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v4

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
@@ -34,7 +34,7 @@ jobs:
fi
- name: Find Comment
uses: peter-evans/find-comment@v4
uses: peter-evans/find-comment@v3
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}

View File

@@ -24,7 +24,7 @@ jobs:
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
with:

View File

@@ -28,7 +28,7 @@ jobs:
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
with:

View File

@@ -65,7 +65,7 @@ jobs:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -103,7 +103,7 @@ jobs:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -185,7 +185,7 @@ jobs:
if: github.event.pull_request.head.repo.fork != true
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -259,7 +259,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Get short SHA
id: short_sha

View File

@@ -14,7 +14,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -63,7 +63,7 @@ jobs:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}

View File

@@ -21,7 +21,7 @@ jobs:
name: Lint frontend
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
with:
@@ -42,7 +42,7 @@ jobs:
name: Lint python
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up python
@@ -59,7 +59,7 @@ jobs:
name: Lint enterprise python
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up python

View File

@@ -27,7 +27,7 @@ jobs:
current-version: ${{ steps.version-check.outputs.current-version }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need previous commit to compare
@@ -63,7 +63,7 @@ jobs:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -86,7 +86,7 @@ jobs:
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -30,7 +30,7 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
@@ -78,7 +78,7 @@ jobs:
matrix:
python-version: ["3.12"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
@@ -111,9 +111,9 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v6
id: download
with:
pattern: coverage-*

View File

@@ -23,7 +23,7 @@ jobs:
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli'))
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: useblacksmith/setup-python@v6
with:
python-version: 3.12

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
if: github.repository == 'OpenHands/OpenHands'
steps:
- uses: actions/stale@v10
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"

View File

@@ -36,42 +36,6 @@ then re-run the command to ensure it passes. Common issues include:
- Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files
- When remote has new changes, use `git fetch upstream && git rebase upstream/<branch>` on the same branch
## Lockfile Regeneration (Preserve Original Tool Versions)
When regenerating lockfiles (poetry.lock, uv.lock, etc.), you MUST use the same tool version that originally generated the lockfile to avoid unnecessary diff noise. Each lockfile contains a version header indicating which tool version was used.
### Poetry (poetry.lock)
1. Extract the version from the lockfile header:
```bash
POETRY_VERSION=$(grep -m1 "^# This file is automatically @generated by Poetry" poetry.lock | sed 's/.*Poetry \([0-9.]*\).*/\1/')
```
2. If a version is found, install that specific version:
```bash
pipx install poetry==$POETRY_VERSION --force
```
3. Then regenerate the lockfile:
```bash
poetry lock --no-update
```
### uv (uv.lock)
1. Extract the version from the lockfile header:
```bash
UV_VERSION=$(grep -m1 "^# This file was autogenerated by uv" uv.lock | sed 's/.*uv version \([0-9.]*\).*/\1/')
```
2. If a version is found, install that specific version:
```bash
pipx install uv==$UV_VERSION --force
```
3. Then regenerate the lockfile:
```bash
uv lock
```
This ensures that lockfile updates only contain actual dependency changes, not tool version migration artifacts.
## PR-Specific Artifacts (`.pr/` directory)
When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root.

View File

@@ -127,14 +127,14 @@ For example, a PR title could be:
## Becoming a Maintainer
For contributors who have made significant and sustained contributions to the project, there is a possibility of joining the maintainer team.
Contributors who have opened three meaningful PRs to the project may be eligible to join the maintainer team.
The process for this is as follows:
1. Any contributor who has made sustained and high-quality contributions to the codebase can be nominated by any maintainer. If you feel that you may qualify you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated.
1. Any contributor who has opened three meaningful PRs to the codebase can be nominated by any maintainer. If you feel that you may qualify, you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated.
2. Once a maintainer nominates a new maintainer, there will be a discussion period among the maintainers for at least 3 days.
3. If no concerns are raised the nomination will be accepted by acclamation, and if concerns are raised there will be a discussion and possible vote.
Note that just making many PRs does not immediately imply that you will become a maintainer. We will be looking at sustained high-quality contributions over a period of time, as well as good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).
Note that opening three meaningful PRs does not automatically mean that you will become a maintainer. We will also be looking at good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).
## Need Help?

View File

@@ -88,52 +88,17 @@ If you need help with anything, or just want to chat, [come find us on Slack](ht
<div align="center">
<strong>Trusted by engineers at</strong>
<br/><br/>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
<img src="https://assets.openhands.dev/logos/external/black/tiktok.svg" alt="TikTok" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/vmware.svg">
<img src="https://assets.openhands.dev/logos/external/black/vmware.svg" alt="VMware" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/roche.svg">
<img src="https://assets.openhands.dev/logos/external/black/roche.svg" alt="Roche" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/amazon.svg">
<img src="https://assets.openhands.dev/logos/external/black/amazon.svg" alt="Amazon" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/c3-ai.svg">
<img src="https://assets.openhands.dev/logos/external/black/c3-ai.svg" alt="C3 AI" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/netflix.svg">
<img src="https://assets.openhands.dev/logos/external/black/netflix.svg" alt="Netflix" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mastercard.svg">
<img src="https://assets.openhands.dev/logos/external/black/mastercard.svg" alt="Mastercard" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/red-hat.svg">
<img src="https://assets.openhands.dev/logos/external/black/red-hat.svg" alt="Red Hat" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mongodb.svg">
<img src="https://assets.openhands.dev/logos/external/black/mongodb.svg" alt="MongoDB" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/apple.svg">
<img src="https://assets.openhands.dev/logos/external/black/apple.svg" alt="Apple" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/nvidia.svg">
<img src="https://assets.openhands.dev/logos/external/black/nvidia.svg" alt="NVIDIA" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/google.svg">
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
</picture>
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137f6974b71a1a4a932f82_TikTok_logo.svg" alt="TikTok" height="40">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137f523b08f91a5aa905b9_Vmware.svg" alt="VMware" height="40">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137f2cb537758796a9dba1_Roche_Logo.svg" alt="Roche" height="40">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137f10c3975e28b3932320_Amazon_logo%201.svg" alt="Amazon" height="40">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137ec5a6f77dd174e557ce_C3ai_logo%201.svg" alt="C3 AI" height="40">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137eac8f27ca27f5e48420_Netflix_2015_logo%201.svg" alt="Netflix" height="40">
<br/>
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e8df2c028b9e1506ede_mastercard%201.svg" alt="Mastercard" height="40">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e783790933dd06f9d59_Red_Hat_Logo_2019%201.svg" alt="Red Hat" height="40">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e5fa006d963a1d1904d_mongodb-ar21%201.svg" alt="MongoDB" height="40">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e47b45195da10c50f49_apple-11%201.svg" alt="Apple" height="40">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e34e3a5ab71e37082a7_NVIDIA_logo%201.svg" alt="NVIDIA" height="40">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e199ce2cb594b0210ab_google-ar21%201.svg" alt="Google" height="40">
</div>

View File

@@ -73,35 +73,6 @@ ENV VIRTUAL_ENV=/app/.venv \
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
# Remove system pip from the image (leave venv pip intact).
# The runtime uses the venv pip because PATH is prefixed with ${VIRTUAL_ENV}/bin.
RUN sudo /usr/local/bin/python3 - <<'PY'
import ensurepip
import shutil
import sysconfig
from pathlib import Path
# Remove the system pip installation to reduce attack surface and avoid scanning both
# system + venv pip. The app uses the venv pip via PATH.
purelib = Path(sysconfig.get_paths()["purelib"])
for pattern in ("pip", "pip-*.dist-info", "pip-*.egg-info"):
for p in purelib.glob(pattern):
if p.is_dir():
shutil.rmtree(p, ignore_errors=True)
else:
p.unlink(missing_ok=True)
bin_dir = Path("/usr/local/bin")
for p in [bin_dir / "pip", bin_dir / "pip3", *bin_dir.glob("pip3.*")]:
p.unlink(missing_ok=True)
bundled = Path(ensurepip.__file__).parent / "_bundled"
if bundled.exists():
for whl in bundled.glob("pip-*.whl"):
whl.unlink(missing_ok=True)
PY
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins

View File

@@ -1,29 +0,0 @@
"""Add disabled_skills column to user table.
Migration 102 added disabled_skills to the legacy user_settings table,
but the active SaaS flow (SaasSettingsStore) reads from/writes to the
user table. This migration adds the column where it is actually needed.
Revision ID: 104
Revises: 103
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '104'
down_revision: Union[str, None] = '103'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('user', sa.Column('disabled_skills', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('user', 'disabled_skills')

View File

@@ -6,6 +6,7 @@ GITHUB_APP_WEBHOOK_SECRET = os.getenv('GITHUB_APP_WEBHOOK_SECRET', '')
GITHUB_APP_PRIVATE_KEY = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace('\\n', '\n')
KEYCLOAK_SERVER_URL = os.getenv('KEYCLOAK_SERVER_URL', '').rstrip('/')
KEYCLOAK_REALM_NAME = os.getenv('KEYCLOAK_REALM_NAME', '')
KEYCLOAK_PROVIDER_NAME = os.getenv('KEYCLOAK_PROVIDER_NAME', '')
KEYCLOAK_CLIENT_ID = os.getenv('KEYCLOAK_CLIENT_ID', '')
KEYCLOAK_CLIENT_SECRET = os.getenv('KEYCLOAK_CLIENT_SECRET', '')
KEYCLOAK_SERVER_URL_EXT = os.getenv(

View File

@@ -4,6 +4,7 @@ from server.auth.constants import (
KEYCLOAK_ADMIN_PASSWORD,
KEYCLOAK_CLIENT_ID,
KEYCLOAK_CLIENT_SECRET,
KEYCLOAK_PROVIDER_NAME,
KEYCLOAK_REALM_NAME,
KEYCLOAK_SERVER_URL,
KEYCLOAK_SERVER_URL_EXT,
@@ -11,7 +12,7 @@ from server.auth.constants import (
from server.logger import logger
logger.debug(
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_PROVIDER_NAME:{KEYCLOAK_PROVIDER_NAME}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
)
_keycloak_instances = {}

View File

@@ -9,7 +9,6 @@ from utils.identity import resolve_display_name
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
)
from openhands.integrations.service_types import (
Branch,
@@ -68,53 +67,6 @@ async def saas_get_user_installations(
)
@saas_user_router.get('/git-organizations')
async def saas_get_user_git_organizations(
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 not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value={},
)
if retval is not None:
return retval
# _check_idp returned None (tokens refreshed on Keycloak side),
# but provider_tokens is still None for this request.
return JSONResponse(
content='Git provider token required.',
status_code=status.HTTP_401_UNAUTHORIZED,
)
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
# SaaS users sign in with one provider at a time
provider = next(iter(provider_tokens))
if provider == ProviderType.GITHUB:
orgs = await client.get_github_organizations()
elif provider == ProviderType.GITLAB:
orgs = await client.get_gitlab_groups()
elif provider == ProviderType.BITBUCKET:
orgs = await client.get_bitbucket_workspaces()
else:
return JSONResponse(
content=f"Provider {provider.value} doesn't support git organizations",
status_code=status.HTTP_400_BAD_REQUEST,
)
return {
'provider': provider.value,
'organizations': orgs,
}
@saas_user_router.get('/repositories', response_model=list[Repository])
async def saas_get_user_repositories(
sort: str = 'pushed',

View File

@@ -5,7 +5,6 @@ SQLAlchemy model for User.
from uuid import uuid4
from sqlalchemy import (
JSON,
UUID,
Boolean,
Column,
@@ -35,7 +34,6 @@ class User(Base): # type: ignore
git_user_name = Column(String, nullable=True)
git_user_email = Column(String, nullable=True)
sandbox_grouping_strategy = Column(String, nullable=True)
disabled_skills = Column(JSON, nullable=True)
# Relationships
role = relationship('Role', back_populates='users')

View File

@@ -13,6 +13,7 @@ Required environment variables:
- RESEND_AUDIENCE_ID: ID of the Resend audience to add users to
Optional environment variables:
- KEYCLOAK_PROVIDER_NAME: Provider name for Keycloak
- KEYCLOAK_CLIENT_ID: Client ID for Keycloak
- KEYCLOAK_CLIENT_SECRET: Client secret for Keycloak
- RESEND_FROM_EMAIL: Email address to use as the sender (default: "OpenHands Team <no-reply@welcome.openhands.dev>")
@@ -48,6 +49,7 @@ from openhands.core.logger import openhands_logger as logger
# Get Keycloak configuration from environment variables
KEYCLOAK_SERVER_URL = os.environ.get('KEYCLOAK_SERVER_URL', '')
KEYCLOAK_REALM_NAME = os.environ.get('KEYCLOAK_REALM_NAME', '')
KEYCLOAK_PROVIDER_NAME = os.environ.get('KEYCLOAK_PROVIDER_NAME', '')
KEYCLOAK_CLIENT_ID = os.environ.get('KEYCLOAK_CLIENT_ID', '')
KEYCLOAK_CLIENT_SECRET = os.environ.get('KEYCLOAK_CLIENT_SECRET', '')
KEYCLOAK_ADMIN_PASSWORD = os.environ.get('KEYCLOAK_ADMIN_PASSWORD', '')

View File

@@ -1,141 +0,0 @@
"""Tests for the GET /api/user/git-organizations endpoint.
This endpoint returns git organizations for the user's active provider
in SaaS mode (single provider at a time).
"""
from types import MappingProxyType
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.responses import JSONResponse
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken
from openhands.integrations.service_types import ProviderType
@pytest.fixture
def github_provider_tokens():
return MappingProxyType(
{ProviderType.GITHUB: ProviderToken(token=SecretStr('gh-token'))}
)
@pytest.fixture
def gitlab_provider_tokens():
return MappingProxyType(
{ProviderType.GITLAB: ProviderToken(token=SecretStr('gl-token'))}
)
@pytest.fixture
def bitbucket_provider_tokens():
return MappingProxyType(
{ProviderType.BITBUCKET: ProviderToken(token=SecretStr('bb-token'))}
)
@pytest.fixture
def azure_devops_provider_tokens():
return MappingProxyType(
{ProviderType.AZURE_DEVOPS: ProviderToken(token=SecretStr('az-token'))}
)
@pytest.fixture
def mock_check_idp():
with patch('server.routes.user._check_idp', new_callable=AsyncMock) as mock_fn:
yield mock_fn
@pytest.mark.asyncio
async def test_no_provider_tokens_falls_back_to_idp(mock_check_idp):
"""When no provider tokens exist, falls back to IDP check."""
from server.routes.user import saas_get_user_git_organizations
mock_check_idp.return_value = {}
result = await saas_get_user_git_organizations(
provider_tokens=None,
access_token=SecretStr('token'),
user_id='user-1',
)
assert result == {}
mock_check_idp.assert_called_once()
@pytest.mark.asyncio
async def test_unsupported_provider_returns_400(azure_devops_provider_tokens):
"""Unsupported provider returns a 400 error."""
from server.routes.user import saas_get_user_git_organizations
with patch('server.routes.user.ProviderHandler'):
result = await saas_get_user_git_organizations(
provider_tokens=azure_devops_provider_tokens,
access_token=SecretStr('token'),
user_id='user-1',
)
assert isinstance(result, JSONResponse)
assert result.status_code == 400
@pytest.mark.asyncio
@pytest.mark.parametrize(
'provider_tokens_fixture, mock_method, mock_return, expected_provider',
[
(
'github_provider_tokens',
'get_organizations_from_installations',
['All-Hands-AI', 'OpenHands'],
'github',
),
(
'gitlab_provider_tokens',
'get_user_groups',
['my-team', 'open-source'],
'gitlab',
),
(
'bitbucket_provider_tokens',
'get_installations',
['my-workspace'],
'bitbucket',
),
],
ids=['github', 'gitlab', 'bitbucket'],
)
async def test_provider_routing_with_real_handler(
provider_tokens_fixture,
mock_method,
mock_return,
expected_provider,
request,
):
"""Each provider routes to the correct service method and returns the expected JSON structure.
Uses a real ProviderHandler so the endpoint's if/elif routing and ProviderHandler's
delegation are both exercised. Only the low-level git service call is mocked.
"""
from server.routes.user import saas_get_user_git_organizations
provider_tokens = request.getfixturevalue(provider_tokens_fixture)
with patch(
'openhands.integrations.provider.ProviderHandler.get_service'
) as mock_get_service:
mock_service = mock_get_service.return_value
setattr(mock_service, mock_method, AsyncMock(return_value=mock_return))
result = await saas_get_user_git_organizations(
provider_tokens=provider_tokens,
access_token=SecretStr('token'),
user_id='user-1',
)
assert result == {
'provider': expected_provider,
'organizations': mock_return,
}

View File

@@ -2,7 +2,6 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
import { useConversationStore } from "#/stores/conversation-store";
const CONVERSATION_ID = "conv-abc123";
@@ -22,11 +21,6 @@ describe("ConversationTabsContextMenu", () => {
beforeEach(() => {
localStorage.clear();
mockHasTaskList = false;
useConversationStore.setState({
selectedTab: "editor",
isRightPanelShown: true,
hasRightPanelToggled: true,
});
});
it("should render nothing when isOpen is false", () => {
@@ -75,33 +69,6 @@ describe("ConversationTabsContextMenu", () => {
expect(storedState.unpinnedTabs).not.toContain("terminal");
});
it("should close the right panel when unpinning the currently active tab", async () => {
const user = userEvent.setup();
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
await user.click(screen.getByText("COMMON$CHANGES"));
const storeState = useConversationStore.getState();
expect(storeState.hasRightPanelToggled).toBe(false);
const storedState = JSON.parse(
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
);
expect(storedState.rightPanelShown).toBe(false);
});
it("should not close the right panel when unpinning a non-active tab", async () => {
const user = userEvent.setup();
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
await user.click(screen.getByText("COMMON$TERMINAL"));
const storeState = useConversationStore.getState();
expect(storeState.hasRightPanelToggled).toBe(true);
});
describe("with tasklist", () => {
beforeEach(() => {
mockHasTaskList = true;

View File

@@ -1,112 +0,0 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import {
ClaimButton,
getButtonState,
} from "#/components/features/org/claim-button";
import type { GitOrg } from "#/hooks/organizations/use-git-conversation-routing";
const createOrg = (overrides: Partial<GitOrg> = {}): GitOrg => ({
id: "1",
provider: "GitHub",
name: "TestOrg",
status: "unclaimed",
...overrides,
});
describe("getButtonState", () => {
it("returns 'claiming' during claiming transition regardless of hover", () => {
expect(getButtonState("claiming", false)).toBe("claiming");
expect(getButtonState("claiming", true)).toBe("claiming");
});
it("returns 'disconnecting' during disconnecting transition regardless of hover", () => {
expect(getButtonState("disconnecting", false)).toBe("disconnecting");
expect(getButtonState("disconnecting", true)).toBe("disconnecting");
});
it("returns 'disconnect' when claimed and hovered", () => {
expect(getButtonState("claimed", true)).toBe("disconnect");
});
it("returns 'claimed' when claimed and not hovered", () => {
expect(getButtonState("claimed", false)).toBe("claimed");
});
it("returns 'unclaimed' when unclaimed", () => {
expect(getButtonState("unclaimed", false)).toBe("unclaimed");
expect(getButtonState("unclaimed", true)).toBe("unclaimed");
});
});
describe("ClaimButton", () => {
it("calls onClaim when clicking an unclaimed org", async () => {
// Arrange
const onClaim = vi.fn();
const org = createOrg({ status: "unclaimed" });
renderWithProviders(
<ClaimButton org={org} onClaim={onClaim} onDisconnect={vi.fn()} />,
);
const user = userEvent.setup();
// Act
await user.click(screen.getByTestId("claim-button-1"));
// Assert
expect(onClaim).toHaveBeenCalledWith("1");
});
it("calls onDisconnect when clicking a claimed org", async () => {
// Arrange
const onDisconnect = vi.fn();
const org = createOrg({ status: "claimed" });
renderWithProviders(
<ClaimButton org={org} onClaim={vi.fn()} onDisconnect={onDisconnect} />,
);
const user = userEvent.setup();
// Act
await user.click(screen.getByTestId("claim-button-1"));
// Assert
expect(onDisconnect).toHaveBeenCalledWith("1");
});
it("does not call handlers when button is disabled during claiming", async () => {
// Arrange
const onClaim = vi.fn();
const onDisconnect = vi.fn();
const org = createOrg({ status: "claiming" });
renderWithProviders(
<ClaimButton org={org} onClaim={onClaim} onDisconnect={onDisconnect} />,
);
const user = userEvent.setup();
// Act
await user.click(screen.getByTestId("claim-button-1"));
// Assert
expect(onClaim).not.toHaveBeenCalled();
expect(onDisconnect).not.toHaveBeenCalled();
expect(screen.getByTestId("claim-button-1")).toBeDisabled();
});
it("shows 'Disconnect' label on hover when claimed", async () => {
// Arrange
const org = createOrg({ status: "claimed" });
renderWithProviders(
<ClaimButton org={org} onClaim={vi.fn()} onDisconnect={vi.fn()} />,
);
const user = userEvent.setup();
// Act
await user.hover(screen.getByTestId("claim-button-1"));
// Assert
expect(screen.getByTestId("claim-button-1")).toHaveTextContent(
"ORG$DISCONNECT",
);
});
});

View File

@@ -1,133 +0,0 @@
import { screen, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { GitConversationRouting } from "#/components/features/org/git-conversation-routing";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
describe("GitConversationRouting", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("should render all mock organizations", () => {
// Arrange & Act
renderWithProviders(<GitConversationRouting />);
// Assert
expect(screen.getByTestId("org-row-1")).toHaveTextContent(
"GitHub/OpenHands",
);
expect(screen.getByTestId("org-row-2")).toHaveTextContent("GitHub/AcmeCo");
expect(screen.getByTestId("org-row-3")).toHaveTextContent(
"GitHub/already-claimed",
);
expect(screen.getByTestId("org-row-4")).toHaveTextContent(
"GitLab/OpenHands",
);
});
it("should show pre-claimed org with 'Claimed' label", () => {
// Arrange & Act
renderWithProviders(<GitConversationRouting />);
// Assert
const claimedButton = screen.getByTestId("claim-button-1");
expect(claimedButton).toHaveTextContent("ORG$CLAIMED");
});
it("should show unclaimed orgs with 'Claim' label", () => {
// Arrange & Act
renderWithProviders(<GitConversationRouting />);
// Assert
expect(screen.getByTestId("claim-button-2")).toHaveTextContent("ORG$CLAIM");
});
it("should claim an organization and show success toast", async () => {
// Arrange
const successToastSpy = vi.spyOn(ToastHandlers, "displaySuccessToast");
renderWithProviders(<GitConversationRouting />);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Act
await user.click(screen.getByTestId("claim-button-2"));
// Move pointer away so hover state resets after transition
await user.unhover(screen.getByTestId("claim-button-2"));
act(() => {
vi.advanceTimersByTime(1000);
});
// Assert
await waitFor(() => {
expect(screen.getByTestId("claim-button-2")).toHaveTextContent(
"ORG$CLAIMED",
);
});
expect(successToastSpy).toHaveBeenCalledWith("ORG$CLAIM_SUCCESS");
});
it("should show error toast when claiming an already-claimed org", async () => {
// Arrange
const errorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
renderWithProviders(<GitConversationRouting />);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Act
await user.click(screen.getByTestId("claim-button-3"));
act(() => {
vi.advanceTimersByTime(1000);
});
// Assert
await waitFor(() => {
expect(screen.getByTestId("claim-button-3")).toHaveTextContent(
"ORG$CLAIM",
);
});
expect(errorToastSpy).toHaveBeenCalledWith("ORG$CLAIM_ERROR");
});
it("should disconnect a claimed org and show success toast", async () => {
// Arrange
const successToastSpy = vi.spyOn(ToastHandlers, "displaySuccessToast");
renderWithProviders(<GitConversationRouting />);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Act — disconnect the pre-claimed org (id: 1)
await user.click(screen.getByTestId("claim-button-1"));
act(() => {
vi.advanceTimersByTime(1000);
});
// Assert
await waitFor(() => {
expect(screen.getByTestId("claim-button-1")).toHaveTextContent(
"ORG$CLAIM",
);
});
expect(successToastSpy).toHaveBeenCalledWith("ORG$DISCONNECT_SUCCESS");
});
it("should disable the button during claiming transition", async () => {
// Arrange
renderWithProviders(<GitConversationRouting />);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Act
await user.click(screen.getByTestId("claim-button-2"));
// Assert — button is disabled while claiming
expect(screen.getByTestId("claim-button-2")).toBeDisabled();
// Cleanup — advance timer to complete transition
act(() => {
vi.advanceTimersByTime(1000);
});
});
});

View File

@@ -1,45 +0,0 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { GitOrgRow } from "#/components/features/org/git-org-row";
import type { GitOrg } from "#/hooks/organizations/use-git-conversation-routing";
const createOrg = (overrides: Partial<GitOrg> = {}): GitOrg => ({
id: "1",
provider: "GitHub",
name: "TestOrg",
status: "unclaimed",
...overrides,
});
describe("GitOrgRow", () => {
it("renders the provider and organization name", () => {
// Arrange & Act
renderWithProviders(
<GitOrgRow
org={createOrg({ provider: "GitLab", name: "MyOrg" })}
isLast={false}
onClaim={vi.fn()}
onDisconnect={vi.fn()}
/>,
);
// Assert
expect(screen.getByTestId("org-row-1")).toHaveTextContent("GitLab/MyOrg");
});
it("renders a claim button for the organization", () => {
// Arrange & Act
renderWithProviders(
<GitOrgRow
org={createOrg()}
isLast={false}
onClaim={vi.fn()}
onDisconnect={vi.fn()}
/>,
);
// Assert
expect(screen.getByTestId("claim-button-1")).toBeInTheDocument();
});
});

View File

@@ -21,7 +21,6 @@ import {
createMockMessageEvent,
createMockUserMessageEvent,
createMockConversationErrorEvent,
createMockServerErrorEvent,
createMockAgentErrorEvent,
createMockBrowserObservationEvent,
createMockBrowserNavigateActionEvent,
@@ -365,119 +364,6 @@ describe("Conversation WebSocket Handler", () => {
});
});
it("should update error message store on ServerErrorEvent", async () => {
// ServerErrorEvent represents server-side errors (e.g., MCP configuration errors)
// that should be shown as a banner to the user.
const mockServerErrorEvent = createMockServerErrorEvent();
// Set up MSW to send the error event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock error event after connection
client.send(JSON.stringify(mockServerErrorEvent));
}),
);
// Render components that use both WebSocket and error message store
renderWithWebSocketContext(<ErrorMessageStoreComponent />);
// Initially should show "none"
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
// Wait for connection and error event processing
await waitFor(() => {
expect(screen.getByTestId("error-message")).toHaveTextContent(
"MCP server connection failed: Invalid configuration",
);
});
});
it("should handle different ServerErrorEvent error codes", async () => {
// Test different error codes for ServerErrorEvent
const mockServerErrorEvent = createMockServerErrorEvent({
code: "RuntimeError",
detail: "Agent server runtime error: Out of memory",
});
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
client.send(JSON.stringify(mockServerErrorEvent));
}),
);
renderWithWebSocketContext(<ErrorMessageStoreComponent />);
await waitFor(() => {
expect(screen.getByTestId("error-message")).toHaveTextContent(
"Agent server runtime error: Out of memory",
);
});
});
it("should clear error message when a successful event is received after a ServerErrorEvent", async () => {
// This test verifies that error banners disappear when follow-up messages
// are sent and received after a ServerErrorEvent.
// Note: This test was originally commented out because the implementation
// didn't properly clear ServerErrorEvent errors on subsequent events.
// After the fix using isDisplayableErrorEvent, this now works correctly.
const conversationId = "test-server-error-clear";
// Set up MSW to mock event count API and send events
mswServer.use(
http.get(
`http://localhost:3000/api/conversations/${conversationId}/events/count`,
() => HttpResponse.json(2),
),
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send ServerErrorEvent first (sets the error banner)
const mockServerErrorEvent = createMockServerErrorEvent();
client.send(JSON.stringify(mockServerErrorEvent));
// Send a successful (non-error) event immediately after
// This simulates the user sending a follow-up message and receiving a response
const mockSuccessEvent = createMockMessageEvent({
id: "success-event-after-server-error",
});
client.send(JSON.stringify(mockSuccessEvent));
}),
);
// Verify error message store is initially empty
expect(useErrorMessageStore.getState().errorMessage).toBeNull();
// Render with WebSocket context (minimal component just to trigger connection)
renderWithWebSocketContext(
<ConnectionStatusComponent />,
conversationId,
`http://localhost:3000/api/conversations/${conversationId}`,
);
// Wait for connection
await waitFor(
() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
},
{ timeout: 5000 },
);
// Wait for both events to be received and error to be cleared
// The error was set by the first event (ServerErrorEvent),
// then cleared by the second successful event (MessageEvent).
await waitFor(
() => {
expect(useEventStore.getState().events.length).toBe(2);
expect(useErrorMessageStore.getState().errorMessage).toBeNull();
},
{ timeout: 5000 },
);
});
it("should show friendly i18n message for budget ConversationErrorEvent", async () => {
const mockBudgetConversationError = createMockConversationErrorEvent({
detail:
@@ -1050,9 +936,7 @@ describe("Conversation WebSocket Handler", () => {
http.get(
`http://localhost:3000/api/v1/conversation/${conversationId}/events/search`,
async () => {
await new Promise<void>((resolve) => {
setTimeout(resolve, 10);
});
await new Promise((resolve) => setTimeout(resolve, 10));
return HttpResponse.json({
items: mockHistoryEvents,
});
@@ -1173,9 +1057,7 @@ describe("Conversation WebSocket Handler", () => {
http.get(
`http://localhost:3000/api/v1/conversation/${conversationId}/events/search`,
async () => {
await new Promise<void>((resolve) => {
setTimeout(resolve, 10);
});
await new Promise((resolve) => setTimeout(resolve, 10));
return HttpResponse.json({
items: mockHistoryEvents,
});

View File

@@ -25,19 +25,10 @@ vi.mock("react-i18next", async () => {
};
});
// Mock toast handlers
const mockDisplaySuccessToast = vi.fn();
const mockDisplayErrorToast = vi.fn();
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: (...args: unknown[]) => mockDisplaySuccessToast(...args),
displayErrorToast: (...args: unknown[]) => mockDisplayErrorToast(...args),
}));
// Mock useTracking hook
const mockTrackCreditsPurchased = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackCreditsPurchased: mockTrackCreditsPurchased,
trackCreditsPurchased: vi.fn(),
}),
}));
@@ -320,77 +311,6 @@ describe("Billing Route", () => {
});
});
describe("checkout success flow", () => {
beforeEach(() => {
mockUseBalance.mockReturnValue({
data: "150.00",
isLoading: false,
});
});
it("should display success toast exactly once and track credits on checkout success", async () => {
const RouterStub = createRoutesStub([
{
Component: BillingSettingsScreen,
path: "/settings/billing",
},
]);
render(
<RouterStub
initialEntries={[
"/settings/billing?checkout=success&amount=25&session_id=sess_123",
]}
/>,
{
wrapper: ({ children }) => (
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
},
);
await waitFor(() => {
expect(mockDisplaySuccessToast).toHaveBeenCalledTimes(1);
});
expect(mockTrackCreditsPurchased).toHaveBeenCalledTimes(1);
expect(mockTrackCreditsPurchased).toHaveBeenCalledWith({
amountUsd: 25,
stripeSessionId: "sess_123",
});
});
it("should display error toast exactly once on checkout cancel", async () => {
const RouterStub = createRoutesStub([
{
Component: BillingSettingsScreen,
path: "/settings/billing",
},
]);
render(
<RouterStub
initialEntries={["/settings/billing?checkout=cancel"]}
/>,
{
wrapper: ({ children }) => (
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
},
);
await waitFor(() => {
expect(mockDisplayErrorToast).toHaveBeenCalledTimes(1);
});
expect(mockTrackCreditsPurchased).not.toHaveBeenCalled();
});
});
describe("PaymentForm permission behavior", () => {
beforeEach(() => {
mockUseBalance.mockReturnValue({

View File

@@ -18,7 +18,7 @@
"@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "1.13.5",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"downshift": "^9.0.13",

View File

@@ -17,7 +17,7 @@
"@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "1.13.5",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"downshift": "^9.0.13",

View File

@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import CircleIcon from "#/icons/u-circle.svg?react";
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
import CheckCircleHalfIcon from "#/icons/u-check-circle-half.svg?react";
import LoadingIcon from "#/icons/loading.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import { Typography } from "#/ui/typography";
@@ -24,7 +24,7 @@ export function TaskItem({ task }: TaskItemProps) {
case "todo":
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
case "in_progress":
return <CheckCircleHalfIcon className="w-4 h-4 text-[#ffffff]" />;
return <LoadingIcon className="w-4 h-4 text-[#ffffff] animate-spin" />;
case "done":
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
default:

View File

@@ -4,7 +4,6 @@ import { ContextMenuListItem } from "../../context-menu/context-menu-list-item";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useConversationLocalStorageState } from "#/utils/conversation-local-storage";
import { useConversationStore } from "#/stores/conversation-store";
import { I18nKey } from "#/i18n/declaration";
import TerminalIcon from "#/icons/terminal.svg?react";
import GlobeIcon from "#/icons/globe.svg?react";
@@ -29,10 +28,8 @@ export function ConversationTabsContextMenu({
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
const { conversationId } = useConversationId();
const { state, setUnpinnedTabs, setRightPanelShown } =
const { state, setUnpinnedTabs } =
useConversationLocalStorageState(conversationId);
const { selectedTab, isRightPanelShown, setHasRightPanelToggled } =
useConversationStore();
const { hasTaskList } = useTaskList();
@@ -64,10 +61,6 @@ export function ConversationTabsContextMenu({
setUnpinnedTabs(state.unpinnedTabs.filter((item) => item !== tab));
} else {
setUnpinnedTabs([...state.unpinnedTabs, tab]);
if (selectedTab === tab && isRightPanelShown) {
setHasRightPanelToggled(false);
setRightPanelShown(false);
}
}
};

View File

@@ -1,84 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import type { GitOrg } from "#/hooks/organizations/use-git-conversation-routing";
type ButtonState =
| "claiming"
| "disconnecting"
| "disconnect"
| "claimed"
| "unclaimed";
const BUTTON_STYLES: Record<ButtonState, string> = {
claiming:
"bg-[#050505] border border-[#242424] text-[#fafafa] opacity-50 cursor-not-allowed flex items-center justify-center",
disconnecting:
"bg-[#050505] border border-[#242424] text-[#fafafa] opacity-50 cursor-not-allowed",
disconnect:
"bg-[rgba(244,63,94,0.15)] border border-[rgba(244,63,94,0.6)] text-[#fda4af] font-medium cursor-pointer",
claimed:
"bg-[rgba(16,185,129,0.2)] border border-[rgba(16,185,129,0.6)] text-[#6ee7b7] font-medium cursor-pointer flex items-center justify-center",
unclaimed:
"bg-[#050505] border border-[#242424] text-[#fafafa] cursor-pointer flex items-center justify-center",
};
const BUTTON_HOVER_STYLES: Partial<Record<ButtonState, string>> = {
unclaimed: "bg-[rgba(31,31,31,0.6)]",
};
const BUTTON_LABELS: Record<ButtonState, I18nKey> = {
claiming: I18nKey.ORG$CLAIM,
disconnecting: I18nKey.ORG$DISCONNECT,
disconnect: I18nKey.ORG$DISCONNECT,
claimed: I18nKey.ORG$CLAIMED,
unclaimed: I18nKey.ORG$CLAIM,
};
export function getButtonState(
status: GitOrg["status"],
isHovered: boolean,
): ButtonState {
if (status === "claiming" || status === "disconnecting") return status;
if (status === "claimed" && isHovered) return "disconnect";
return status;
}
interface ClaimButtonProps {
org: GitOrg;
onClaim: (id: string) => void;
onDisconnect: (id: string) => void;
}
export function ClaimButton({ org, onClaim, onDisconnect }: ClaimButtonProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = React.useState(false);
const buttonState = getButtonState(org.status, isHovered);
const isDisabled =
org.status === "claiming" || org.status === "disconnecting";
const handleClick = () => {
if (org.status === "unclaimed") onClaim(org.id);
if (org.status === "claimed") onDisconnect(org.id);
};
return (
<button
type="button"
data-testid={`claim-button-${org.id}`}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
disabled={isDisabled}
className={cn(
"h-[28px] rounded px-[13px] text-xs leading-4 text-center whitespace-nowrap transition-colors",
BUTTON_STYLES[buttonState],
isHovered && BUTTON_HOVER_STYLES[buttonState],
)}
>
{t(BUTTON_LABELS[buttonState])}
</button>
);
}

View File

@@ -1,37 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { Text, Paragraph } from "#/ui/typography";
import { useGitConversationRouting } from "#/hooks/organizations/use-git-conversation-routing";
import { GitOrgRow } from "./git-org-row";
export function GitConversationRouting() {
const { t } = useTranslation();
const { orgs, claimOrg, disconnectOrg } = useGitConversationRouting();
return (
<div
data-testid="git-conversation-routing"
className="flex flex-col gap-3 w-full"
>
<Text className="text-[#fafafa] text-sm font-semibold leading-5">
{t(I18nKey.ORG$GIT_CONVERSATION_ROUTING)}
</Text>
<Paragraph className="text-[#8c8c8c] text-sm font-normal leading-5">
{t(I18nKey.ORG$GIT_CONVERSATION_ROUTING_DESCRIPTION)}
</Paragraph>
<div className="border border-[#242424] rounded-[6px] overflow-hidden">
{orgs.map((org, index) => (
<GitOrgRow
key={org.id}
org={org}
isLast={index === orgs.length - 1}
onClaim={claimOrg}
onDisconnect={disconnectOrg}
/>
))}
</div>
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { cn } from "#/utils/utils";
import { Text } from "#/ui/typography";
import type { GitOrg } from "#/hooks/organizations/use-git-conversation-routing";
import { ClaimButton } from "./claim-button";
interface GitOrgRowProps {
org: GitOrg;
isLast: boolean;
onClaim: (id: string) => void;
onDisconnect: (id: string) => void;
}
export function GitOrgRow({
org,
isLast,
onClaim,
onDisconnect,
}: GitOrgRowProps) {
return (
<div
data-testid={`org-row-${org.id}`}
className={cn(
"flex items-center justify-between px-3 h-[53px]",
!isLast && "border-b border-[#242424]",
)}
>
<span className="text-sm font-normal leading-5">
<Text className="text-[#8c8c8c]">{org.provider}/</Text>
<Text className="text-[#fafafa]">{org.name}</Text>
</span>
<ClaimButton org={org} onClaim={onClaim} onDisconnect={onDisconnect} />
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { TaskItem as TaskItemType } from "#/types/v1/core/base/common";
import CircleIcon from "#/icons/u-circle.svg?react";
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
import CheckCircleHalfIcon from "#/icons/u-check-circle-half.svg?react";
import LoadingIcon from "#/icons/loading.svg?react";
import { cn } from "#/utils/utils";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
@@ -20,7 +20,7 @@ export function TaskItem({ task }: TaskItemProps) {
case "todo":
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
case "in_progress":
return <CheckCircleHalfIcon className="w-4 h-4 text-[#ffffff]" />;
return <LoadingIcon className="w-4 h-4 text-[#ffffff] animate-spin" />;
case "done":
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
default:

View File

@@ -27,16 +27,12 @@ import {
isStatsConversationStateUpdateEvent,
isExecuteBashActionEvent,
isExecuteBashObservationEvent,
isDisplayableErrorEvent,
isConversationErrorEvent,
isPlanningFileEditorObservationEvent,
isBrowserObservationEvent,
isBrowserNavigateActionEvent,
} from "#/types/v1/type-guards";
import { ConversationStateUpdateEventStats } from "#/types/v1/core/events/conversation-state-event";
import type {
ConversationErrorEvent,
ServerErrorEvent,
} from "#/types/v1/core/events/conversation-state-event";
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
import { buildWebSocketUrl } from "#/utils/websocket-url";
import type {
@@ -356,28 +352,25 @@ export function ConversationWebSocketProvider({
if (isV1Event(event)) {
addEvent(event);
// Handle displayable error events - show error banner
// Handle ConversationErrorEvent specifically - show error banner
// AgentErrorEvent errors are displayed inline in the chat, not as banners
if (isDisplayableErrorEvent(event)) {
const errorEvent = event as
| ConversationErrorEvent
| ServerErrorEvent;
if (isConversationErrorEvent(event)) {
trackError({
message: errorEvent.detail,
message: event.detail,
source: "conversation",
metadata: {
eventId: errorEvent.id,
errorCode: errorEvent.code,
eventId: event.id,
errorCode: event.code,
},
posthog,
});
if (isBudgetOrCreditError(errorEvent.detail)) {
if (isBudgetOrCreditError(event.detail)) {
setErrorMessage(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS);
trackCreditLimitReached({
conversationId: conversationId || "unknown",
});
} else {
setErrorMessage(errorEvent.detail);
setErrorMessage(event.detail);
}
} else {
// Clear error message on any non-ConversationErrorEvent
@@ -522,28 +515,25 @@ export function ConversationWebSocketProvider({
};
addEvent(eventWithPlanningFlag);
// Handle displayable error events - show error banner
// Handle ConversationErrorEvent specifically - show error banner
// AgentErrorEvent errors are displayed inline in the chat, not as banners
if (isDisplayableErrorEvent(event)) {
const errorEvent = event as
| ConversationErrorEvent
| ServerErrorEvent;
if (isConversationErrorEvent(event)) {
trackError({
message: errorEvent.detail,
message: event.detail,
source: "planning_conversation",
metadata: {
eventId: errorEvent.id,
errorCode: errorEvent.code,
eventId: event.id,
errorCode: event.code,
},
posthog,
});
if (isBudgetOrCreditError(errorEvent.detail)) {
if (isBudgetOrCreditError(event.detail)) {
setErrorMessage(I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS);
trackCreditLimitReached({
conversationId: conversationId || "unknown",
});
} else {
setErrorMessage(errorEvent.detail);
setErrorMessage(event.detail);
}
} else {
// Clear error message on any non-ConversationErrorEvent

View File

@@ -1,81 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import {
displaySuccessToast,
displayErrorToast,
} from "#/utils/custom-toast-handlers";
// TODO: This entire hook uses mock data and simulated async behavior.
// Replace with real API calls (e.g., organizationService.claimOrg / disconnectOrg)
// once the backend endpoints for organization claims are implemented.
export interface GitOrg {
id: string;
provider: "GitHub" | "GitLab";
name: string;
status: "unclaimed" | "claimed" | "claiming" | "disconnecting";
}
// TODO: Remove mock data once the backend API for fetching available git organizations is ready.
const INITIAL_ORGS: GitOrg[] = [
{ id: "1", provider: "GitHub", name: "OpenHands", status: "claimed" },
{ id: "2", provider: "GitHub", name: "AcmeCo", status: "unclaimed" },
{
id: "3",
provider: "GitHub",
name: "already-claimed",
status: "unclaimed",
},
{ id: "4", provider: "GitLab", name: "OpenHands", status: "unclaimed" },
];
export function useGitConversationRouting() {
const { t } = useTranslation();
const [orgs, setOrgs] = React.useState<GitOrg[]>(INITIAL_ORGS);
const updateOrgStatus = React.useCallback(
(id: string, status: GitOrg["status"]) => {
setOrgs((prev) =>
prev.map((org) => (org.id === id ? { ...org, status } : org)),
);
},
[],
);
const claimOrg = React.useCallback(
(id: string) => {
const org = orgs.find((o) => o.id === id);
if (!org || org.status !== "unclaimed") return;
updateOrgStatus(id, "claiming");
setTimeout(() => {
if (org.name === "already-claimed") {
updateOrgStatus(id, "unclaimed");
displayErrorToast(t(I18nKey.ORG$CLAIM_ERROR));
} else {
updateOrgStatus(id, "claimed");
displaySuccessToast(t(I18nKey.ORG$CLAIM_SUCCESS));
}
}, 1000);
},
[orgs, updateOrgStatus, t],
);
const disconnectOrg = React.useCallback(
(id: string) => {
const org = orgs.find((o) => o.id === id);
if (!org || org.status !== "claimed") return;
updateOrgStatus(id, "disconnecting");
setTimeout(() => {
updateOrgStatus(id, "unclaimed");
displaySuccessToast(t(I18nKey.ORG$DISCONNECT_SUCCESS));
}, 1000);
},
[orgs, updateOrgStatus, t],
);
return { orgs, claimOrg, disconnectOrg };
}

View File

@@ -1271,12 +1271,4 @@ export enum I18nKey {
ENTERPRISE$DONE_BUTTON = "ENTERPRISE$DONE_BUTTON",
COMMON$BACK = "COMMON$BACK",
MODAL$CLOSE_BUTTON_LABEL = "MODAL$CLOSE_BUTTON_LABEL",
ORG$GIT_CONVERSATION_ROUTING = "ORG$GIT_CONVERSATION_ROUTING",
ORG$GIT_CONVERSATION_ROUTING_DESCRIPTION = "ORG$GIT_CONVERSATION_ROUTING_DESCRIPTION",
ORG$CLAIM = "ORG$CLAIM",
ORG$CLAIMED = "ORG$CLAIMED",
ORG$DISCONNECT = "ORG$DISCONNECT",
ORG$CLAIM_SUCCESS = "ORG$CLAIM_SUCCESS",
ORG$DISCONNECT_SUCCESS = "ORG$DISCONNECT_SUCCESS",
ORG$CLAIM_ERROR = "ORG$CLAIM_ERROR",
}

View File

@@ -21611,141 +21611,5 @@
"ca": "Tancar",
"tr": "Kapat",
"uk": "Закрити"
},
"ORG$GIT_CONVERSATION_ROUTING": {
"en": "Git Conversation Routing",
"ja": "Git会話ルーティング",
"zh-CN": "Git 会话路由",
"zh-TW": "Git 對話路由",
"ko-KR": "Git 대화 라우팅",
"no": "Git-samtale-ruting",
"it": "Instradamento conversazioni Git",
"pt": "Roteamento de conversas Git",
"es": "Enrutamiento de conversaciones Git",
"ar": "توجيه محادثات Git",
"fr": "Routage des conversations Git",
"tr": "Git Konuşma Yönlendirme",
"de": "Git-Gesprächsweiterleitung",
"uk": "Маршрутизація розмов Git",
"ca": "Encaminament de converses Git"
},
"ORG$GIT_CONVERSATION_ROUTING_DESCRIPTION": {
"en": "Claim organizations so resolver requests route to the appropriate OpenHands org for shared visibility, auditing, and ownership. If a requester is not a member of the claiming org, the conversation falls back to their Personal Workspace. Available organizations are derived from your connected GitHub/GitLab identity. Only organization admins and owners can manage organization claims.",
"ja": "組織を申請して、リゾルバーリクエストが適切なOpenHands組織にルーティングされるようにします。共有の可視性、監査、所有権のためです。リクエスターが申請元組織のメンバーでない場合、会話はパーソナルワークスペースにフォールバックします。利用可能な組織は、接続されたGitHub/GitLabアイデンティティから取得されます。組織の申請を管理できるのは、組織の管理者とオーナーのみです。",
"zh-CN": "认领组织,使解析器请求路由到适当的 OpenHands 组织,以实现共享可见性、审计和所有权。如果请求者不是认领组织的成员,对话将回退到其个人工作区。可用组织来源于您连接的 GitHub/GitLab 身份。只有组织管理员和所有者可以管理组织认领。",
"zh-TW": "認領組織,使解析器請求路由到適當的 OpenHands 組織,以實現共享可見性、審計和所有權。如果請求者不是認領組織的成員,對話將回退到其個人工作區。可用組織來源於您連接的 GitHub/GitLab 身份。只有組織管理員和所有者可以管理組織認領。",
"ko-KR": "조직을 클레임하여 리졸버 요청이 공유 가시성, 감사 및 소유권을 위해 적절한 OpenHands 조직으로 라우팅되도록 합니다. 요청자가 클레임 조직의 구성원이 아닌 경우 대화는 개인 워크스페이스로 대체됩니다. 사용 가능한 조직은 연결된 GitHub/GitLab ID에서 파생됩니다. 조직 관리자와 소유자만 조직 클레임을 관리할 수 있습니다.",
"no": "Krev organisasjoner slik at resolver-forespørsler rutes til riktig OpenHands-organisasjon for delt synlighet, revisjon og eierskap. Hvis en forespørrer ikke er medlem av den krevende organisasjonen, faller samtalen tilbake til deres personlige arbeidsområde. Tilgjengelige organisasjoner er hentet fra din tilkoblede GitHub/GitLab-identitet. Bare organisasjonsadministratorer og eiere kan administrere organisasjonskrav.",
"it": "Rivendica le organizzazioni in modo che le richieste del resolver vengano instradate all'organizzazione OpenHands appropriata per visibilità condivisa, auditing e proprietà. Se un richiedente non è membro dell'organizzazione richiedente, la conversazione torna al suo spazio di lavoro personale. Le organizzazioni disponibili derivano dalla tua identità GitHub/GitLab connessa. Solo gli amministratori e i proprietari dell'organizzazione possono gestire le rivendicazioni.",
"pt": "Reivindique organizações para que as solicitações do resolver sejam roteadas para a organização OpenHands apropriada para visibilidade compartilhada, auditoria e propriedade. Se um solicitante não for membro da organização reivindicante, a conversa retorna ao seu espaço de trabalho pessoal. As organizações disponíveis são derivadas da sua identidade GitHub/GitLab conectada. Apenas administradores e proprietários da organização podem gerenciar reivindicações.",
"es": "Reclame organizaciones para que las solicitudes del resolver se dirijan a la organización OpenHands apropiada para visibilidad compartida, auditoría y propiedad. Si un solicitante no es miembro de la organización reclamante, la conversación vuelve a su espacio de trabajo personal. Las organizaciones disponibles se derivan de su identidad de GitHub/GitLab conectada. Solo los administradores y propietarios de la organización pueden gestionar las reclamaciones.",
"ar": "اطلب المنظمات حتى يتم توجيه طلبات المحلل إلى منظمة OpenHands المناسبة للرؤية المشتركة والتدقيق والملكية. إذا لم يكن مقدم الطلب عضوًا في المنظمة المطالبة، تعود المحادثة إلى مساحة العمل الشخصية الخاصة به. المنظمات المتاحة مشتقة من هوية GitHub/GitLab المتصلة. يمكن فقط لمسؤولي ومالكي المنظمة إدارة مطالبات المنظمة.",
"fr": "Revendiquez des organisations pour que les demandes du résolveur soient acheminées vers l'organisation OpenHands appropriée pour la visibilité partagée, l'audit et la propriété. Si un demandeur n'est pas membre de l'organisation revendicatrice, la conversation revient à son espace de travail personnel. Les organisations disponibles sont dérivées de votre identité GitHub/GitLab connectée. Seuls les administrateurs et propriétaires d'organisation peuvent gérer les revendications.",
"tr": "Çözümleyici isteklerinin paylaşılan görünürlük, denetim ve sahiplik için uygun OpenHands organizasyonuna yönlendirilmesi amacıyla organizasyonları talep edin. İstekte bulunan kişi talep eden organizasyonun üyesi değilse, konuşma kişisel çalışma alanına geri döner. Kullanılabilir organizasyonlar, bağlı GitHub/GitLab kimliğinizden türetilir. Yalnızca organizasyon yöneticileri ve sahipleri organizasyon taleplerini yönetebilir.",
"de": "Beanspruchen Sie Organisationen, damit Resolver-Anfragen zur entsprechenden OpenHands-Organisation für gemeinsame Sichtbarkeit, Prüfung und Eigentümerschaft weitergeleitet werden. Wenn ein Anfragender kein Mitglied der beanspruchenden Organisation ist, fällt das Gespräch auf seinen persönlichen Arbeitsbereich zurück. Verfügbare Organisationen werden aus Ihrer verbundenen GitHub/GitLab-Identität abgeleitet. Nur Organisationsadministratoren und -eigentümer können Organisationsansprüche verwalten.",
"uk": "Заявляйте організації, щоб запити резолвера направлялися до відповідної організації OpenHands для спільної видимості, аудиту та власності. Якщо запитувач не є членом організації, що заявляє, розмова повертається до його персонального робочого простору. Доступні організації визначаються з вашої підключеної ідентифікації GitHub/GitLab. Лише адміністратори та власники організації можуть керувати заявками.",
"ca": "Reclameu organitzacions perquè les sol·licituds del resolver s'encaminin a l'organització OpenHands adequada per a visibilitat compartida, auditoria i propietat. Si un sol·licitant no és membre de l'organització reclamant, la conversa torna al seu espai de treball personal. Les organitzacions disponibles es deriven de la vostra identitat GitHub/GitLab connectada. Només els administradors i propietaris de l'organització poden gestionar les reclamacions."
},
"ORG$CLAIM": {
"en": "Claim",
"ja": "申請",
"zh-CN": "认领",
"zh-TW": "認領",
"ko-KR": "클레임",
"no": "Krev",
"it": "Rivendica",
"pt": "Reivindicar",
"es": "Reclamar",
"ar": "مطالبة",
"fr": "Revendiquer",
"tr": "Talep Et",
"de": "Beanspruchen",
"uk": "Заявити",
"ca": "Reclamar"
},
"ORG$CLAIMED": {
"en": "Claimed",
"ja": "申請済み",
"zh-CN": "已认领",
"zh-TW": "已認領",
"ko-KR": "클레임됨",
"no": "Krevd",
"it": "Rivendicata",
"pt": "Reivindicada",
"es": "Reclamada",
"ar": "تمت المطالبة",
"fr": "Revendiquée",
"tr": "Talep Edildi",
"de": "Beansprucht",
"uk": "Заявлено",
"ca": "Reclamada"
},
"ORG$DISCONNECT": {
"en": "Disconnect",
"ja": "切断",
"zh-CN": "断开连接",
"zh-TW": "斷開連接",
"ko-KR": "연결 해제",
"no": "Koble fra",
"it": "Disconnetti",
"pt": "Desconectar",
"es": "Desconectar",
"ar": "قطع الاتصال",
"fr": "Déconnecter",
"tr": "Bağlantıyı Kes",
"de": "Trennen",
"uk": "Від'єднати",
"ca": "Desconnectar"
},
"ORG$CLAIM_SUCCESS": {
"en": "Organization claimed successfully.",
"ja": "組織の申請が完了しました。",
"zh-CN": "组织认领成功。",
"zh-TW": "組織認領成功。",
"ko-KR": "조직이 성공적으로 클레임되었습니다.",
"no": "Organisasjonen ble krevd.",
"it": "Organizzazione rivendicata con successo.",
"pt": "Organização reivindicada com sucesso.",
"es": "Organización reclamada exitosamente.",
"ar": "تمت المطالبة بالمنظمة بنجاح.",
"fr": "Organisation revendiquée avec succès.",
"tr": "Organizasyon başarıyla talep edildi.",
"de": "Organisation erfolgreich beansprucht.",
"uk": "Організацію успішно заявлено.",
"ca": "Organització reclamada amb èxit."
},
"ORG$DISCONNECT_SUCCESS": {
"en": "Organization disconnected.",
"ja": "組織の接続が解除されました。",
"zh-CN": "组织已断开连接。",
"zh-TW": "組織已斷開連接。",
"ko-KR": "조직 연결이 해제되었습니다.",
"no": "Organisasjonen ble frakoblet.",
"it": "Organizzazione disconnessa.",
"pt": "Organização desconectada.",
"es": "Organización desconectada.",
"ar": "تم قطع اتصال المنظمة.",
"fr": "Organisation déconnectée.",
"tr": "Organizasyon bağlantısı kesildi.",
"de": "Organisation getrennt.",
"uk": "Організацію від'єднано.",
"ca": "Organització desconnectada."
},
"ORG$CLAIM_ERROR": {
"en": "Connection failed. Org is already claimed.",
"ja": "接続に失敗しました。組織はすでに申請されています。",
"zh-CN": "连接失败。组织已被认领。",
"zh-TW": "連接失敗。組織已被認領。",
"ko-KR": "연결에 실패했습니다. 조직이 이미 클레임되었습니다.",
"no": "Tilkobling mislyktes. Organisasjonen er allerede krevd.",
"it": "Connessione fallita. L'organizzazione è già stata rivendicata.",
"pt": "Falha na conexão. A organização já foi reivindicada.",
"es": "Error de conexión. La organización ya fue reclamada.",
"ar": "فشل الاتصال. المنظمة مطالب بها بالفعل.",
"fr": "Échec de la connexion. L'organisation est déjà revendiquée.",
"tr": "Bağlantı başarısız. Organizasyon zaten talep edilmiş.",
"de": "Verbindung fehlgeschlagen. Organisation wurde bereits beansprucht.",
"uk": "Помилка з'єднання. Організація вже заявлена.",
"ca": "Error de connexió. L'organització ja ha estat reclamada."
}
}

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M5.79029 12.9507C5.69629 12.9042 5.59557 12.8843 5.50157 12.8843C5.25314 12.8843 5.01142 13.0238 4.89728 13.2562C4.72942 13.5883 4.87042 13.9868 5.20614 14.1462C5.30014 14.1927 5.40086 14.2127 5.50157 14.2127C5.75 14.2127 5.985 14.0732 6.10586 13.8407C6.15286 13.7477 6.17301 13.6481 6.17301 13.5551C6.17301 13.3027 6.03201 13.0636 5.79029 12.9507ZM13.3641 12.3463C13.3641 12.3463 13.3909 12.333 13.3977 12.3198C13.4178 12.2998 13.4312 12.2733 13.4447 12.2533C13.4245 12.2865 13.3977 12.3198 13.3641 12.3463ZM3.91698 11.463C3.7827 11.2969 3.58798 11.2106 3.39326 11.2106C3.24555 11.2106 3.09783 11.2637 2.97697 11.3567C2.68154 11.5892 2.63454 12.0076 2.86283 12.2932C2.99712 12.4592 3.19183 12.5456 3.39326 12.5456C3.54098 12.5456 3.68869 12.4924 3.80955 12.3995C3.97741 12.2666 4.0647 12.074 4.0647 11.8814C4.0647 11.7353 4.0177 11.5892 3.91698 11.463ZM2.88297 9.32433C2.80911 9.01881 2.53382 8.80627 2.23168 8.80627C2.18468 8.80627 2.13096 8.81291 2.07725 8.8262C1.71467 8.9059 1.48638 9.26455 1.56696 9.61657C1.64081 9.92873 1.9161 10.1346 2.22496 10.1346C2.27196 10.1346 2.32568 10.1346 2.37268 10.1213C2.68825 10.0549 2.8964 9.77597 2.8964 9.47045C2.8964 9.42395 2.8964 9.37082 2.88297 9.32433ZM2.37939 6.15621C2.33239 6.14293 2.27868 6.14293 2.23168 6.14293C1.92282 6.14293 1.64753 6.34218 1.57367 6.65434C1.4931 7.013 1.71467 7.36501 2.07725 7.45135C2.13096 7.46464 2.17796 7.47128 2.22496 7.47128C2.53382 7.47128 2.80911 7.25874 2.88297 6.95322C2.8964 6.90673 2.8964 6.8536 2.8964 6.8071C2.8964 6.50158 2.69497 6.22263 2.37939 6.15621ZM3.82298 3.87809C3.70212 3.78511 3.55441 3.73197 3.40669 3.73197C3.20526 3.73197 3.01054 3.81831 2.87626 3.98436C2.64797 4.26995 2.69497 4.68838 2.98369 4.92085C3.10454 5.01383 3.25226 5.06696 3.39998 5.06696C3.59469 5.06696 3.79612 4.98062 3.9237 4.81458C4.02441 4.69503 4.07141 4.54227 4.07141 4.39615C4.07141 4.20354 3.99084 4.01093 3.82298 3.87809ZM6.11929 2.45011C6.00515 2.21101 5.76343 2.07153 5.50829 2.07153C5.41428 2.07153 5.31357 2.09146 5.21957 2.13795C4.88385 2.29735 4.74956 2.69586 4.90399 3.0213C5.02485 3.26041 5.25985 3.39988 5.50829 3.39988C5.609 3.39988 5.70972 3.37996 5.80372 3.33347C6.04543 3.22056 6.17972 2.98145 6.17972 2.73571C6.17972 2.63608 6.15958 2.5431 6.11929 2.45011Z" fill="currentColor"/>
<path d="M14.8809 8.14215C14.8809 8.65356 14.8205 9.15834 14.7063 9.64983C14.5251 10.4269 14.2028 11.1708 13.7462 11.8482C13.6589 11.9811 13.5649 12.1073 13.4642 12.2268C13.4642 12.2401 13.4575 12.2468 13.444 12.2534C13.4239 12.2866 13.397 12.3198 13.3635 12.3464C12.7457 13.1102 11.9602 13.7212 11.0604 14.1529C11.047 14.1596 11.0269 14.1662 11.0134 14.1729C10.9194 14.2194 10.8187 14.2658 10.718 14.3057C9.90558 14.6378 9.01928 14.8105 8.1397 14.8105H8.11955C7.74355 14.8038 7.45483 14.5116 7.45483 14.1463C7.45483 13.781 7.75698 13.4821 8.12627 13.4821H8.14641C8.85142 13.4821 9.55643 13.3426 10.2077 13.077C11.047 12.7316 11.7789 12.187 12.3496 11.4896C12.4436 11.37 12.5443 11.2438 12.6316 11.111C12.9942 10.5664 13.256 9.97527 13.397 9.35095C13.397 9.33766 13.397 9.32438 13.397 9.31774C13.4843 8.93252 13.5313 8.54065 13.5313 8.14215C13.5313 7.43812 13.3903 6.75402 13.1218 6.10313C12.927 5.64485 12.6786 5.21978 12.3697 4.83455C12.2422 4.67515 12.1012 4.51575 11.9535 4.36963C11.4499 3.8715 10.859 3.47964 10.201 3.21397C9.55643 2.9483 8.86485 2.81546 8.16655 2.80882H8.13298C7.76369 2.80882 7.46155 2.50994 7.46155 2.14464C7.46155 1.77935 7.76369 1.48047 8.13298 1.48047H8.17327C9.05285 1.48711 9.90557 1.65315 10.7113 1.98524C10.8322 2.03838 10.953 2.08487 11.0672 2.14464C11.0806 2.15129 11.094 2.15793 11.1007 2.15793C11.7722 2.48337 12.3697 2.90845 12.9002 3.43314C13.0815 3.61247 13.256 3.80508 13.4172 4.00434C13.4239 4.01098 13.4239 4.01762 13.4306 4.02426C13.8133 4.49583 14.1222 5.02717 14.3639 5.59171C14.4982 5.91052 14.6056 6.24261 14.6795 6.57469C14.6929 6.62119 14.7063 6.66768 14.7131 6.71417C14.8205 7.17909 14.8742 7.6573 14.8742 8.14215H14.8809Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -7,10 +7,7 @@ import {
import { AgentStateChangeObservation } from "#/types/core/observations";
import { MessageEvent } from "#/types/v1/core";
import { AgentErrorEvent } from "#/types/v1/core/events/observation-event";
import {
ConversationErrorEvent,
ServerErrorEvent,
} from "#/types/v1/core/events/conversation-state-event";
import { ConversationErrorEvent } from "#/types/v1/core/events/conversation-state-event";
import { MockSessionMessaage } from "./session-history.mock";
export const generateAgentStateChangeObservation = (
@@ -256,19 +253,3 @@ export const createMockConversationErrorEvent = (
detail: "Your session has expired. Please log in again.",
...overrides,
});
/**
* Creates a mock ServerErrorEvent for testing server-level error handling
* These are errors from the agent server (e.g., MCP configuration errors) that should show error banners
*/
export const createMockServerErrorEvent = (
overrides: Partial<ServerErrorEvent> = {},
): ServerErrorEvent => ({
id: "server-error-123",
timestamp: new Date().toISOString(),
source: "environment",
kind: "ServerErrorEvent",
code: "MCPError",
detail: "MCP server connection failed: Invalid configuration",
...overrides,
});

View File

@@ -56,11 +56,13 @@ function BillingSettingsScreen() {
const { hasPermission } = usePermission(me?.role ?? "member");
const canAddCredits = !!me && hasPermission("add_credits");
const checkoutStatus = searchParams.get("checkout");
const amount = searchParams.get("amount");
const sessionId = searchParams.get("session_id");
React.useEffect(() => {
if (checkoutStatus === "success") {
// Get purchase details from URL params
const amount = searchParams.get("amount");
const sessionId = searchParams.get("session_id");
// Track credits purchased if we have the necessary data
if (amount && sessionId) {
trackCreditsPurchased({
@@ -76,14 +78,7 @@ function BillingSettingsScreen() {
displayErrorToast(t(I18nKey.PAYMENT$CANCELLED));
setSearchParams({});
}
}, [
checkoutStatus,
amount,
sessionId,
setSearchParams,
t,
trackCreditsPurchased,
]);
}, [checkoutStatus, searchParams, setSearchParams, t, trackCreditsPurchased]);
return <PaymentForm isDisabled={!canAddCredits} />;
}

View File

@@ -9,9 +9,7 @@ import { InteractiveChip } from "#/ui/interactive-chip";
import { usePermission } from "#/hooks/organizations/use-permissions";
import { createPermissionGuard } from "#/utils/org/permission-guard";
import { isBillingHidden } from "#/utils/org/billing-visibility";
import { ENABLE_ORG_CLAIMS_RESOLVER_ROUTING } from "#/utils/feature-flags";
import { DeleteOrgConfirmationModal } from "#/components/features/org/delete-org-confirmation-modal";
import { GitConversationRouting } from "#/components/features/org/git-conversation-routing";
import { ChangeOrgNameModal } from "#/components/features/org/change-org-name-modal";
import { AddCreditsModal } from "#/components/features/org/add-credits-modal";
import { useBalance } from "#/hooks/query/use-balance";
@@ -39,7 +37,6 @@ function ManageOrg() {
const canChangeOrgName = !!me && hasPermission("change_organization_name");
const canDeleteOrg = !!me && hasPermission("delete_organization");
const canAddCredits = !!me && hasPermission("add_credits");
const canManageOrgClaims = !!me && hasPermission("manage_org_claims");
const shouldHideBilling = isBillingHidden(
config,
hasPermission("view_billing"),
@@ -116,10 +113,6 @@ function ManageOrg() {
{t(I18nKey.ORG$DELETE_ORGANIZATION)}
</button>
)}
{canManageOrgClaims && ENABLE_ORG_CLAIMS_RESOLVER_ROUTING() && (
<GitConversationRouting />
)}
</div>
);
}

View File

@@ -130,26 +130,3 @@ export interface ConversationErrorEvent extends BaseEvent {
*/
detail: string;
}
// Server error event - contains error information
export interface ServerErrorEvent extends BaseEvent {
/**
* Discriminator field for type guards
*/
kind: "ServerErrorEvent";
/**
* The source is always "environment" for server error events
*/
source: "environment";
/**
* Error code (e.g., "MCPError")
*/
code: string;
/**
* Detailed error message
*/
detail: string;
}

View File

@@ -13,7 +13,6 @@ import {
ConversationErrorEvent,
HookExecutionEvent,
PauseEvent,
ServerErrorEvent,
} from "./events/index";
/**
@@ -37,5 +36,4 @@ export type OpenHandsEvent =
| ConversationStateUpdateEvent
| ConversationErrorEvent
// Control events
| PauseEvent
| ServerErrorEvent;
| PauseEvent;

View File

@@ -19,7 +19,6 @@ import {
ConversationStateUpdateEventFullState,
ConversationStateUpdateEventStats,
ConversationErrorEvent,
ServerErrorEvent,
} from "./core/events/conversation-state-event";
import { HookExecutionEvent } from "./core/events/hook-execution-event";
import { SystemPromptEvent } from "./core/events/system-event";
@@ -194,21 +193,6 @@ export const isConversationErrorEvent = (
): event is ConversationErrorEvent =>
"kind" in event && event.kind === "ConversationErrorEvent";
/**
* Type guard function to check if an event is a server error event
*/
export const isServerErrorEvent = (
event: OpenHandsEvent,
): event is ServerErrorEvent =>
"kind" in event && event.kind === "ServerErrorEvent";
/**
* Type guard function to check if an event is a displayable error event
* (ConversationErrorEvent or ServerErrorEvent) - both should show as error banners
*/
export const isDisplayableErrorEvent = (event: OpenHandsEvent): boolean =>
isConversationErrorEvent(event) || isServerErrorEvent(event);
/**
* Type guard function to check if an event is a hook execution event
*/

View File

@@ -22,5 +22,3 @@ export const ENABLE_PROJ_USER_JOURNEY = () =>
loadFeatureFlag("PROJ_USER_JOURNEY");
export const ENABLE_SANDBOX_GROUPING = () =>
loadFeatureFlag("SANDBOX_GROUPING");
export const ENABLE_ORG_CLAIMS_RESOLVER_ROUTING = () =>
loadFeatureFlag("ORG_CLAIMS_RESOLVER_ROUTING");

View File

@@ -18,8 +18,6 @@ type ManageAPIKeysPermission = "manage_api_keys";
type ViewLLMSettingsPermission = "view_llm_settings";
type EditLLMSettingsPermission = "edit_llm_settings";
type ManageOrgClaimsPermission = "manage_org_claims";
// Union of all permission keys
export type PermissionKey =
| UserRoleChangePermissionKey
@@ -34,8 +32,7 @@ export type PermissionKey =
| ManageApplicationSettingsPermission
| ManageAPIKeysPermission
| ViewLLMSettingsPermission
| EditLLMSettingsPermission
| ManageOrgClaimsPermission;
| EditLLMSettingsPermission;
/* PERMISSION ARRAYS */
const memberPerms: PermissionKey[] = [
@@ -54,7 +51,6 @@ const adminOnly: PermissionKey[] = [
"invite_user_to_organization",
"change_user_role:member",
"change_user_role:admin",
"manage_org_claims",
];
const ownerOnly: PermissionKey[] = [

View File

@@ -1,6 +1,5 @@
import logging
import os
import shlex
import tempfile
from abc import ABC
from dataclasses import dataclass
@@ -42,7 +41,6 @@ from openhands.sdk.security.confirmation_policy import (
)
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.utils.git import ensure_valid_git_branch_name
_logger = logging.getLogger(__name__)
PRE_COMMIT_HOOK = '.git/hooks/pre-commit'
@@ -354,11 +352,9 @@ class AppConversationServiceBase(AppConversationService, ABC):
raise ValueError('Missing either Git token or valid repository')
dir_name = request.selected_repository.split('/')[-1]
quoted_remote_repo_url = shlex.quote(remote_repo_url)
quoted_dir_name = shlex.quote(dir_name)
# Clone the repo - this is the slow part!
clone_command = f'git clone {quoted_remote_repo_url} {quoted_dir_name}'
clone_command = f'git clone {remote_repo_url} {dir_name}'
result = await workspace.execute_command(
clone_command, workspace.working_dir, 120
)
@@ -367,15 +363,12 @@ class AppConversationServiceBase(AppConversationService, ABC):
# Checkout the appropriate branch
if request.selected_branch:
ensure_valid_git_branch_name(request.selected_branch)
checkout_command = f'git checkout {shlex.quote(request.selected_branch)}'
checkout_command = f'git checkout {request.selected_branch}'
else:
# Generate a random branch name to avoid conflicts
random_str = base62.encodebytes(os.urandom(16))
openhands_workspace_branch = f'openhands-workspace-{random_str}'
checkout_command = (
f'git checkout -b {shlex.quote(openhands_workspace_branch)}'
)
checkout_command = f'git checkout -b {openhands_workspace_branch}'
git_dir = Path(workspace.working_dir) / dir_name
result = await workspace.execute_command(checkout_command, git_dir)
if result.exit_code:

View File

@@ -103,7 +103,6 @@ from openhands.tools.preset.planning import (
format_plan_structure,
get_planning_tools,
)
from openhands.utils.git import ensure_valid_git_branch_name
_conversation_info_type_adapter = TypeAdapter(list[ConversationInfo | None])
_logger = logging.getLogger(__name__)
@@ -891,9 +890,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
"""
model = llm_model or user.llm_model
base_url = user.llm_base_url
if model and (
model.startswith('openhands/') or model.startswith('litellm_proxy/')
):
if model and model.startswith('openhands/'):
base_url = user.llm_base_url or self.openhands_provider_base_url
return LLM(
@@ -1711,7 +1708,9 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
if 'selected_branch' in request.model_fields_set:
branch = request.selected_branch
if branch is not None:
ensure_valid_git_branch_name(branch)
# Sanitize: check for dangerous characters
if any(c in branch for c in [';', '&', '|', '$', '`', '\n', '\r', ' ']):
raise ValueError(f"Invalid characters in branch name: '{branch}'")
async def update_app_conversation(
self, conversation_id: UUID, request: AppConversationUpdateRequest

View File

@@ -1,4 +1,3 @@
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.service.base import GitLabMixinBase
from openhands.integrations.service_types import OwnerType, ProviderType, Repository
from openhands.server.types import AppMode
@@ -167,18 +166,6 @@ class GitLabReposMixin(GitLabMixinBase):
all_repos = all_repos[:MAX_REPOS]
return [self._parse_repository(repo) for repo in all_repos]
async def get_user_groups(self) -> list[str]:
"""Get list of GitLab group paths that the user is a member of."""
url = f'{self.BASE_URL}/groups'
try:
# min_access_level 10 = Guest (includes all membership levels)
params = {'min_access_level': '10', 'per_page': '100'}
response, _ = await self._make_request(url, params)
return [group['path'] for group in response]
except Exception as e:
logger.warning(f'Failed to get user groups: {e}')
return []
async def get_repository_details_from_repo_name(
self, repository: str
) -> Repository:

View File

@@ -239,24 +239,6 @@ class ProviderHandler:
return []
async def get_github_organizations(self) -> list[str]:
service = self.get_service(ProviderType.GITHUB)
try:
return await service.get_organizations_from_installations() # type: ignore[attr-defined]
except Exception as e:
logger.warning(f'Failed to get github organizations {e}')
return []
async def get_gitlab_groups(self) -> list[str]:
service = self.get_service(ProviderType.GITLAB)
try:
return await service.get_user_groups() # type: ignore[attr-defined]
except Exception as e:
logger.warning(f'Failed to get gitlab groups {e}')
return []
async def get_azure_devops_organizations(self) -> list[str]:
service = cast(
InstallationsService, self.get_service(ProviderType.AZURE_DEVOPS)

View File

@@ -1,32 +0,0 @@
from __future__ import annotations
import subprocess
COMMON_BRANCH_EXAMPLES = "'main', 'feature/foo', or 'release/1.2.3'"
def is_valid_git_branch_name(branch_name: str) -> bool:
"""Return True when branch_name matches git branch naming rules."""
if not branch_name:
return False
return (
subprocess.run(
['git', 'check-ref-format', '--branch', branch_name],
check=False,
capture_output=True,
text=True,
).returncode
== 0
)
def ensure_valid_git_branch_name(branch_name: str) -> None:
"""Raise ValueError when branch_name is not safe to pass to git checkout."""
if is_valid_git_branch_name(branch_name):
return
raise ValueError(
f'Invalid git branch name. Common GitHub/GitLab/Bitbucket '
f'branch names look like {COMMON_BRANCH_EXAMPLES}.'
)

View File

@@ -51,7 +51,4 @@ Use the following structure in your output:
[src/database.py, Lines 7885] :mag: Readability: This nested if-else block is hard to follow. Consider refactoring into smaller functions or using early returns.
[src/auth.py, Line 102] :closed_lock_with_key: Security Risk: User input is directly concatenated into an SQL query. This could allow SQL injection. Use parameterized queries instead.
RELEASE PR POLICY:
If the PR author is @mamoodi and the PR is a release PR (e.g., version bumps, changelog updates, dependency updates, standard release branch merges), and nothing looks suspicious in the diff, approve the PR without requesting changes. Release PRs from trusted maintainers that contain only expected release artifacts do not need detailed code review feedback.
REMEMBER, DO NOT MODIFY THE CODE. ONLY PROVIDE FEEDBACK IN YOUR RESPONSE.

View File

@@ -5,7 +5,6 @@ and the recent bug fixes for git checkout operations.
"""
import subprocess
from pathlib import Path
from types import MethodType
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from uuid import uuid4
@@ -762,33 +761,6 @@ def mock_workspace():
return MockWorkspace(working_dir='/workspace/project')
@pytest.mark.asyncio
async def test_clone_or_init_git_repo_quotes_selected_branch_before_checkout(
mock_workspace,
):
user_info = MockUserInfo()
service, mock_user_context = _create_service_with_mock_user_context(
user_info, bind_methods=('clone_or_init_git_repo',)
)
service.init_git_in_empty_workspace = True
mock_user_context.get_authenticated_git_url = AsyncMock(
return_value='https://github.com/owner/repo.git'
)
task = Mock()
task.request = Mock(
selected_repository='owner/repo',
selected_branch='feature>tmp',
)
await service.clone_or_init_git_repo(task, mock_workspace)
mock_workspace.execute_command.assert_any_call(
"git checkout 'feature>tmp'",
Path(mock_workspace.working_dir) / 'repo',
)
@pytest.mark.asyncio
async def test_configure_git_user_settings_both_name_and_email(mock_workspace):
"""Test configuring both git user name and email."""

View File

@@ -548,40 +548,6 @@ class TestLiveStatusAppConversationService:
# Assert
assert llm.base_url == 'https://llm-proxy.app.all-hands.dev/'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_litellm_proxy_model_uses_provider_default(
self,
):
"""litellm_proxy/* model (inherited by sub-conversations) falls back to provider base URL."""
# Arrange — simulates a sub-conversation inheriting the SDK-transformed model name
self.mock_user.llm_base_url = None
self.mock_user_context.get_mcp_api_key.return_value = None
# Act
llm, _ = await self.service._configure_llm_and_mcp(
self.mock_user, 'litellm_proxy/minimax-2.5', self.conversation_id
)
# Assert
assert llm.base_url == 'https://provider.example.com'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_litellm_proxy_model_prefers_user_base_url(
self,
):
"""litellm_proxy/* model uses user.llm_base_url when provided."""
# Arrange
self.mock_user.llm_base_url = 'https://user-llm.example.com'
self.mock_user_context.get_mcp_api_key.return_value = None
# Act
llm, _ = await self.service._configure_llm_and_mcp(
self.mock_user, 'litellm_proxy/minimax-2.5', self.conversation_id
)
# Assert
assert llm.base_url == 'https://user-llm.example.com'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_non_openhands_model_ignores_provider(self):
"""Non-openhands model ignores provider base URL and uses user base URL."""

View File

@@ -10,41 +10,6 @@ from openhands.integrations.service_types import OwnerType, ProviderType, Reposi
from openhands.server.types import AppMode
@pytest.mark.asyncio
async def test_gitlab_get_user_groups_returns_group_paths():
"""Test that get_user_groups returns group paths the user belongs to."""
service = GitLabService(token=SecretStr('test-token'))
mock_groups = [
{'path': 'my-team', 'name': 'My Team', 'id': 1},
{'path': 'open-source', 'name': 'Open Source Projects', 'id': 2},
]
with patch.object(service, '_make_request') as mock_request:
mock_request.return_value = (mock_groups, {})
groups = await service.get_user_groups()
assert groups == ['my-team', 'open-source']
mock_request.assert_called_once_with(
f'{service.BASE_URL}/groups',
{'min_access_level': '10', 'per_page': '100'},
)
@pytest.mark.asyncio
async def test_gitlab_get_user_groups_returns_empty_on_error():
"""Test that get_user_groups returns empty list when the API call fails."""
service = GitLabService(token=SecretStr('test-token'))
with patch.object(service, '_make_request') as mock_request:
mock_request.side_effect = Exception('API error')
groups = await service.get_user_groups()
assert groups == []
@pytest.mark.asyncio
async def test_gitlab_get_repositories_with_user_owner_type():
"""Test that get_repositories correctly sets owner_type field for user repositories."""

View File

@@ -1,5 +1,4 @@
from types import MappingProxyType
from unittest.mock import AsyncMock, patch
import pytest
from pydantic import SecretStr, ValidationError
@@ -339,60 +338,3 @@ def test_get_provider_env_key():
"""Test provider environment key generation"""
assert ProviderHandler.get_provider_env_key(ProviderType.GITHUB) == 'github_token'
assert ProviderHandler.get_provider_env_key(ProviderType.GITLAB) == 'gitlab_token'
@pytest.mark.asyncio
async def test_get_github_organizations_delegates_to_service():
"""Test that get_github_organizations calls get_organizations_from_installations on the GitHub service."""
tokens = MappingProxyType(
{ProviderType.GITHUB: ProviderToken(token=SecretStr('gh-token'))}
)
handler = ProviderHandler(provider_tokens=tokens)
with patch.object(handler, 'get_service') as mock_get_service:
mock_service = mock_get_service.return_value
mock_service.get_organizations_from_installations = AsyncMock(
return_value=['org1', 'org2']
)
result = await handler.get_github_organizations()
assert result == ['org1', 'org2']
mock_get_service.assert_called_once_with(ProviderType.GITHUB)
@pytest.mark.asyncio
async def test_get_github_organizations_returns_empty_on_error():
"""Test that get_github_organizations returns empty list when the service call fails."""
tokens = MappingProxyType(
{ProviderType.GITHUB: ProviderToken(token=SecretStr('gh-token'))}
)
handler = ProviderHandler(provider_tokens=tokens)
with patch.object(handler, 'get_service') as mock_get_service:
mock_service = mock_get_service.return_value
mock_service.get_organizations_from_installations = AsyncMock(
side_effect=Exception('API error')
)
result = await handler.get_github_organizations()
assert result == []
@pytest.mark.asyncio
async def test_get_gitlab_groups_delegates_to_service():
"""Test that get_gitlab_groups calls get_user_groups on the GitLab service."""
tokens = MappingProxyType(
{ProviderType.GITLAB: ProviderToken(token=SecretStr('gl-token'))}
)
handler = ProviderHandler(provider_tokens=tokens)
with patch.object(handler, 'get_service') as mock_get_service:
mock_service = mock_get_service.return_value
mock_service.get_user_groups = AsyncMock(return_value=['group-a', 'group-b'])
result = await handler.get_gitlab_groups()
assert result == ['group-a', 'group-b']
mock_get_service.assert_called_once_with(ProviderType.GITLAB)

View File

@@ -1,28 +0,0 @@
import pytest
from openhands.utils.git import ensure_valid_git_branch_name, is_valid_git_branch_name
def test_is_valid_git_branch_name_accepts_common_hosted_git_branch_names():
for branch_name in (
'main',
'feature/test-branch',
'release/1.2.3',
'dependabot/npm_and_yarn/sdk-1.2.3',
'renovate/grouped-updates',
):
assert is_valid_git_branch_name(branch_name) is True
@pytest.mark.parametrize(
'branch_name',
[
'main; git -C /workspace/TylersTestRepo remote -v >/root/file.txt;',
'feature branch',
'feature..branch',
'-branch',
],
)
def test_ensure_valid_git_branch_name_rejects_invalid_git_syntax(branch_name):
with pytest.raises(ValueError, match='Common GitHub/GitLab/Bitbucket branch names'):
ensure_valid_git_branch_name(branch_name)