mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
6 Commits
draft/remo
...
1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5e0de8ecd | ||
|
|
2209a0713a | ||
|
|
72048be1f3 | ||
|
|
2d65d3517b | ||
|
|
bbaa86b8b7 | ||
|
|
93c567faf0 |
2
.github/workflows/check-package-versions.yml
vendored
2
.github/workflows/check-package-versions.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
2
.github/workflows/fe-e2e-tests.yml
vendored
2
.github/workflows/fe-e2e-tests.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/fe-unit-tests.yml
vendored
2
.github/workflows/fe-unit-tests.yml
vendored
@@ -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:
|
||||
|
||||
8
.github/workflows/ghcr-build.yml
vendored
8
.github/workflows/ghcr-build.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/lint-fix.yml
vendored
4
.github/workflows/lint-fix.yml
vendored
@@ -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 }}
|
||||
|
||||
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/npm-publish-ui.yml
vendored
4
.github/workflows/npm-publish-ui.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/openhands-resolver.yml
vendored
2
.github/workflows/openhands-resolver.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/py-tests.yml
vendored
8
.github/workflows/py-tests.yml
vendored
@@ -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-*
|
||||
|
||||
2
.github/workflows/pypi-release.yml
vendored
2
.github/workflows/pypi-release.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -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.'
|
||||
|
||||
2
.github/workflows/ui-build.yml
vendored
2
.github/workflows/ui-build.yml
vendored
@@ -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"
|
||||
|
||||
36
AGENTS.md
36
AGENTS.md
@@ -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.
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
61
README.md
61
README.md
@@ -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">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137f523b08f91a5aa905b9_Vmware.svg" alt="VMware" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137f2cb537758796a9dba1_Roche_Logo.svg" alt="Roche" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137f10c3975e28b3932320_Amazon_logo%201.svg" alt="Amazon" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137ec5a6f77dd174e557ce_C3ai_logo%201.svg" alt="C3 AI" height="40">
|
||||
<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">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e783790933dd06f9d59_Red_Hat_Logo_2019%201.svg" alt="Red Hat" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e5fa006d963a1d1904d_mongodb-ar21%201.svg" alt="MongoDB" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e47b45195da10c50f49_apple-11%201.svg" alt="Apple" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e34e3a5ab71e37082a7_NVIDIA_logo%201.svg" alt="NVIDIA" height="40">
|
||||
<img src="https://cdn.prod.website-files.com/68ff4058b35616cdd47d5b59/69137e199ce2cb594b0210ab_google-ar21%201.svg" alt="Google" height="40">
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
2
frontend/package-lock.json
generated
2
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}.'
|
||||
)
|
||||
@@ -51,7 +51,4 @@ Use the following structure in your output:
|
||||
[src/database.py, Lines 78–85] :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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user