mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ec03098ad | |||
| 20e0ebacf0 | |||
| 3230813a95 | |||
| 5e5950b091 | |||
| c7ff560465 | |||
| 3432bbbb88 | |||
| fc24be2627 | |||
| bc72b38d6e | |||
| 145f1266e6 |
@@ -55,7 +55,7 @@ jobs:
|
||||
- name: Build Environment
|
||||
run: make build
|
||||
- name: Run Unit Tests
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest -n auto -s ./tests/unit --cov=openhands --cov-branch
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
run: poetry install --with dev,test
|
||||
- name: Run Unit Tests
|
||||
# Use base working directory for coverage paths to line up.
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/unit --cov=enterprise --cov-branch
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest --forked -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/unit --cov=enterprise --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
|
||||
Generated
+38
-10
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
@@ -3501,7 +3501,7 @@ files = [
|
||||
[package.dependencies]
|
||||
googleapis-common-protos = ">=1.5.5"
|
||||
grpcio = ">=1.71.2"
|
||||
protobuf = ">=5.26.1,<6.0.dev0"
|
||||
protobuf = ">=5.26.1,<6.0dev"
|
||||
|
||||
[[package]]
|
||||
name = "gspread"
|
||||
@@ -3819,7 +3819,7 @@ pfzy = ">=0.3.1,<0.4.0"
|
||||
prompt-toolkit = ">=3.0.1,<4.0.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "installer"
|
||||
@@ -4258,7 +4258,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
referencing = ">=0.28.4"
|
||||
rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
@@ -4648,7 +4648,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=14.5.14"
|
||||
certifi = ">=14.05.14"
|
||||
durationpy = ">=0.7"
|
||||
google-auth = ">=1.0.1"
|
||||
oauthlib = ">=3.2.2"
|
||||
@@ -6889,7 +6889,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pg8000"
|
||||
@@ -7551,6 +7551,18 @@ files = [
|
||||
{file = "puremagic-1.30.tar.gz", hash = "sha256:f9ff7ac157d54e9cf3bff1addfd97233548e75e685282d84ae11e7ffee1614c9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py"
|
||||
version = "1.11.0"
|
||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
|
||||
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-key-value-aio"
|
||||
version = "0.4.4"
|
||||
@@ -11691,6 +11703,22 @@ pytest = ">=7"
|
||||
[package.extras]
|
||||
testing = ["process-tests", "pytest-xdist", "virtualenv"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-forked"
|
||||
version = "1.6.0"
|
||||
description = "run tests in isolated forked subprocesses"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "pytest-forked-1.6.0.tar.gz", hash = "sha256:4dafd46a9a600f65d822b8f605133ecf5b3e1941ebb3588e943b4e3eb71a5a3f"},
|
||||
{file = "pytest_forked-1.6.0-py3-none-any.whl", hash = "sha256:810958f66a91afb1a1e2ae83089d8dc1cd2437ac96b12963042fbb9fb4d16af0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
py = "*"
|
||||
pytest = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "pytest-xdist"
|
||||
version = "3.8.0"
|
||||
@@ -12838,10 +12866,10 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "scantree"
|
||||
@@ -14978,9 +15006,9 @@ files = [
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
|
||||
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "4221146bf5d0dda799dde9ecdec5d38db556db8a759549efe7d67372b5750b67"
|
||||
content-hash = "ef037f6d6085d26166d35c56ce266439f8f1a4fea90bc43ccf15cfeaf116cae5"
|
||||
|
||||
@@ -61,6 +61,7 @@ types-requests = "^2.32.4.20250611"
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
pytest-asyncio = "*"
|
||||
pytest-forked = "*"
|
||||
pytest-xdist = "*"
|
||||
flake8 = "*"
|
||||
openai = "*"
|
||||
|
||||
@@ -27,6 +27,7 @@ from server.middleware import SetAuthCookieMiddleware # noqa: E402
|
||||
from server.rate_limit import setup_rate_limit_handler # noqa: E402
|
||||
from server.routes.api_keys import api_router as api_keys_router # noqa: E402
|
||||
from server.routes.auth import api_router, oauth_router # noqa: E402
|
||||
from server.routes.automations import automation_router # noqa: E402
|
||||
from server.routes.billing import billing_router # noqa: E402
|
||||
from server.routes.email import api_router as email_router # noqa: E402
|
||||
from server.routes.event_webhook import event_webhook_router # noqa: E402
|
||||
@@ -139,6 +140,8 @@ if BITBUCKET_DATA_CENTER_HOST:
|
||||
base_app.include_router(bitbucket_dc_proxy_router)
|
||||
base_app.include_router(email_router) # Add routes for email management
|
||||
base_app.include_router(feedback_router) # Add routes for conversation feedback
|
||||
|
||||
base_app.include_router(automation_router) # Add routes for automation CRUD
|
||||
base_app.include_router(
|
||||
event_webhook_router
|
||||
) # Add routes for Events in nested runtimes
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Pydantic request/response models for automation CRUD API."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateAutomationRequest(BaseModel):
|
||||
"""Simple mode (Phase 1): form input → generated file."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
schedule: str # 5-field cron expression
|
||||
timezone: str = 'UTC'
|
||||
prompt: str = Field(min_length=1)
|
||||
repository: str | None = None # e.g., "owner/repo"
|
||||
branch: str | None = None
|
||||
|
||||
|
||||
class UpdateAutomationRequest(BaseModel):
|
||||
name: str | None = None
|
||||
schedule: str | None = None
|
||||
timezone: str | None = None
|
||||
prompt: str | None = None
|
||||
repository: str | None = None
|
||||
branch: str | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class AutomationResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
enabled: bool
|
||||
trigger_type: str
|
||||
config: dict
|
||||
file_url: str | None = None
|
||||
last_triggered_at: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class AutomationRunResponse(BaseModel):
|
||||
id: str
|
||||
automation_id: str
|
||||
conversation_id: str | None = None
|
||||
status: str
|
||||
error_detail: str | None = None
|
||||
started_at: str | None = None
|
||||
completed_at: str | None = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class PaginatedAutomationsResponse(BaseModel):
|
||||
items: list[AutomationResponse]
|
||||
total: int
|
||||
next_page_id: str | None = None
|
||||
|
||||
|
||||
class PaginatedRunsResponse(BaseModel):
|
||||
items: list[AutomationRunResponse]
|
||||
total: int
|
||||
next_page_id: str | None = None
|
||||
@@ -0,0 +1,465 @@
|
||||
"""FastAPI router for automation CRUD API (Phase 1: simple mode only)."""
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from services.automation_config import extract_config, validate_config
|
||||
from services.automation_event_publisher import publish_automation_event
|
||||
from services.automation_file_generator import generate_automation_file
|
||||
from sqlalchemy import delete, func, select
|
||||
from storage.automation import Automation, AutomationRun
|
||||
from storage.database import a_session_maker
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import file_store
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
from .automation_models import (
|
||||
AutomationResponse,
|
||||
AutomationRunResponse,
|
||||
CreateAutomationRequest,
|
||||
PaginatedAutomationsResponse,
|
||||
PaginatedRunsResponse,
|
||||
UpdateAutomationRequest,
|
||||
)
|
||||
|
||||
automation_router = APIRouter(
|
||||
prefix='/api/v1/automations',
|
||||
tags=['automations'],
|
||||
)
|
||||
|
||||
FILE_STORE_PREFIX = 'automations'
|
||||
|
||||
|
||||
def _file_store_key(automation_id: str) -> str:
|
||||
return f'{FILE_STORE_PREFIX}/{automation_id}/automation.py'
|
||||
|
||||
|
||||
def _paginate(rows: list, limit: int, id_attr: str = 'id') -> tuple[list, str | None]:
|
||||
"""Return (items, next_page_id) from an overfetched result set."""
|
||||
if len(rows) > limit:
|
||||
return rows[:limit], getattr(rows[limit], id_attr)
|
||||
return rows, None
|
||||
|
||||
|
||||
def _automation_to_response(automation: Automation) -> AutomationResponse:
|
||||
return AutomationResponse(
|
||||
id=automation.id,
|
||||
name=automation.name,
|
||||
enabled=automation.enabled,
|
||||
trigger_type=automation.trigger_type,
|
||||
config=automation.config or {},
|
||||
file_url=None,
|
||||
last_triggered_at=(
|
||||
automation.last_triggered_at.isoformat()
|
||||
if automation.last_triggered_at
|
||||
else None
|
||||
),
|
||||
created_at=automation.created_at.isoformat() if automation.created_at else '',
|
||||
updated_at=automation.updated_at.isoformat() if automation.updated_at else '',
|
||||
)
|
||||
|
||||
|
||||
def _run_to_response(run: AutomationRun) -> AutomationRunResponse:
|
||||
return AutomationRunResponse(
|
||||
id=run.id,
|
||||
automation_id=run.automation_id,
|
||||
conversation_id=run.conversation_id,
|
||||
status=run.status,
|
||||
error_detail=run.error_detail,
|
||||
started_at=run.started_at.isoformat() if run.started_at else None,
|
||||
completed_at=run.completed_at.isoformat() if run.completed_at else None,
|
||||
created_at=run.created_at.isoformat() if run.created_at else '',
|
||||
)
|
||||
|
||||
|
||||
def _generate_and_validate_file(
|
||||
name: str,
|
||||
schedule: str,
|
||||
timezone: str,
|
||||
prompt: str,
|
||||
repository: str | None = None,
|
||||
branch: str | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""Generate automation file, extract config, validate, and store prompt in config.
|
||||
|
||||
Returns (file_content, config_dict).
|
||||
Raises HTTPException on validation failure.
|
||||
"""
|
||||
file_content = generate_automation_file(
|
||||
name=name,
|
||||
schedule=schedule,
|
||||
timezone=timezone,
|
||||
prompt=prompt,
|
||||
repository=repository,
|
||||
branch=branch,
|
||||
)
|
||||
config = extract_config(file_content)
|
||||
try:
|
||||
validate_config(config)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f'Invalid automation config: {e}',
|
||||
)
|
||||
# Store prompt in config so DB is the source of truth (not the file)
|
||||
config['prompt'] = prompt
|
||||
return file_content, config
|
||||
|
||||
|
||||
@automation_router.post('', status_code=status.HTTP_201_CREATED)
|
||||
async def create_automation(
|
||||
request: CreateAutomationRequest,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> AutomationResponse:
|
||||
"""Create an automation from simple mode input (Phase 1).
|
||||
|
||||
Generates a .py file, uploads to object store, stores metadata in DB.
|
||||
"""
|
||||
file_content, config = _generate_and_validate_file(
|
||||
name=request.name,
|
||||
schedule=request.schedule,
|
||||
timezone=request.timezone,
|
||||
prompt=request.prompt,
|
||||
repository=request.repository,
|
||||
branch=request.branch,
|
||||
)
|
||||
|
||||
automation_id = uuid.uuid4().hex
|
||||
key = _file_store_key(automation_id)
|
||||
|
||||
try:
|
||||
file_store.write(key, file_content)
|
||||
except Exception:
|
||||
logger.exception('Failed to upload automation file to object store')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to store automation file',
|
||||
)
|
||||
|
||||
automation = Automation(
|
||||
id=automation_id,
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
enabled=True,
|
||||
config=config,
|
||||
trigger_type='cron',
|
||||
file_store_key=key,
|
||||
)
|
||||
|
||||
async with a_session_maker() as session:
|
||||
session.add(automation)
|
||||
await session.commit()
|
||||
await session.refresh(automation)
|
||||
|
||||
logger.info(
|
||||
'Created automation',
|
||||
extra={'automation_id': automation_id, 'user_id': user_id},
|
||||
)
|
||||
return _automation_to_response(automation)
|
||||
|
||||
|
||||
@automation_router.get('/search')
|
||||
async def search_automations(
|
||||
user_id: str = Depends(get_user_id),
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(title='Cursor for pagination (automation ID)'),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(title='Max results per page', gt=0, le=100),
|
||||
] = 20,
|
||||
) -> PaginatedAutomationsResponse:
|
||||
"""List automations for the current user, paginated."""
|
||||
async with a_session_maker() as session:
|
||||
base_filter = select(Automation).where(Automation.user_id == user_id)
|
||||
|
||||
# Total count
|
||||
count_q = select(func.count()).select_from(base_filter.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
# Paginated query ordered by created_at desc
|
||||
query = base_filter.order_by(Automation.created_at.desc())
|
||||
if page_id:
|
||||
cursor_row = (
|
||||
await session.execute(
|
||||
select(Automation.created_at).where(Automation.id == page_id)
|
||||
)
|
||||
).scalar()
|
||||
if cursor_row is not None:
|
||||
query = query.where(Automation.created_at < cursor_row)
|
||||
|
||||
query = query.limit(limit + 1)
|
||||
result = await session.execute(query)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
items, next_page_id = _paginate(rows, limit)
|
||||
return PaginatedAutomationsResponse(
|
||||
items=[_automation_to_response(a) for a in items],
|
||||
total=total,
|
||||
next_page_id=next_page_id,
|
||||
)
|
||||
|
||||
|
||||
@automation_router.get('/{automation_id}')
|
||||
async def get_automation(
|
||||
automation_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> AutomationResponse:
|
||||
"""Get a single automation by ID."""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(Automation).where(
|
||||
Automation.id == automation_id,
|
||||
Automation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
automation = result.scalars().first()
|
||||
|
||||
if not automation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Automation not found',
|
||||
)
|
||||
return _automation_to_response(automation)
|
||||
|
||||
|
||||
@automation_router.patch('/{automation_id}')
|
||||
async def update_automation(
|
||||
automation_id: str,
|
||||
request: UpdateAutomationRequest,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> AutomationResponse:
|
||||
"""Update an automation. Re-generates file if prompt/schedule/timezone/name changed."""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(Automation).where(
|
||||
Automation.id == automation_id,
|
||||
Automation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
automation = result.scalars().first()
|
||||
if not automation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Automation not found',
|
||||
)
|
||||
|
||||
updates = {
|
||||
k: v
|
||||
for k, v in request.model_dump(exclude_unset=True).items()
|
||||
if v is not None
|
||||
}
|
||||
|
||||
file_regen_fields = {'schedule', 'timezone', 'prompt', 'name'}
|
||||
needs_regen = bool(updates.keys() & file_regen_fields)
|
||||
|
||||
if needs_regen:
|
||||
current_config = automation.config or {}
|
||||
current_triggers = current_config.get('triggers', {}).get('cron', {})
|
||||
|
||||
# Merge: use request values if provided, else fall back to current config
|
||||
new_name = updates.get('name', automation.name)
|
||||
new_schedule = updates.get(
|
||||
'schedule', current_triggers.get('schedule', '')
|
||||
)
|
||||
new_timezone = updates.get(
|
||||
'timezone', current_triggers.get('timezone', 'UTC')
|
||||
)
|
||||
prompt = updates.get('prompt', current_config.get('prompt', ''))
|
||||
|
||||
file_content, config = _generate_and_validate_file(
|
||||
name=new_name,
|
||||
schedule=new_schedule,
|
||||
timezone=new_timezone,
|
||||
prompt=prompt,
|
||||
repository=updates.get('repository'),
|
||||
branch=updates.get('branch'),
|
||||
)
|
||||
try:
|
||||
file_store.write(automation.file_store_key, file_content)
|
||||
except Exception:
|
||||
logger.exception('Failed to upload updated automation file')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to store updated automation file',
|
||||
)
|
||||
automation.config = config
|
||||
automation.name = new_name
|
||||
|
||||
if 'name' in updates and not needs_regen:
|
||||
automation.name = updates['name']
|
||||
if 'enabled' in updates:
|
||||
automation.enabled = updates['enabled']
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(automation)
|
||||
|
||||
return _automation_to_response(automation)
|
||||
|
||||
|
||||
@automation_router.delete('/{automation_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_automation(
|
||||
automation_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> None:
|
||||
"""Delete an automation and all its runs."""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(Automation).where(
|
||||
Automation.id == automation_id,
|
||||
Automation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
automation = result.scalars().first()
|
||||
if not automation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Automation not found',
|
||||
)
|
||||
|
||||
file_key = automation.file_store_key
|
||||
|
||||
# Delete runs first
|
||||
await session.execute(
|
||||
delete(AutomationRun).where(AutomationRun.automation_id == automation_id)
|
||||
)
|
||||
await session.delete(automation)
|
||||
await session.commit()
|
||||
|
||||
# Best-effort cleanup of file store (DB is source of truth)
|
||||
try:
|
||||
file_store.delete(file_key)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'Failed to delete automation file from object store',
|
||||
extra={'automation_id': automation_id},
|
||||
)
|
||||
|
||||
|
||||
@automation_router.post('/{automation_id}/run', status_code=status.HTTP_202_ACCEPTED)
|
||||
async def trigger_manual_run(
|
||||
automation_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> dict:
|
||||
"""Manually trigger an automation run."""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(Automation).where(
|
||||
Automation.id == automation_id,
|
||||
Automation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
automation = result.scalars().first()
|
||||
if not automation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Automation not found',
|
||||
)
|
||||
|
||||
dedup_key = f'manual-{automation_id}-{uuid.uuid4().hex}'
|
||||
await publish_automation_event(
|
||||
session=session,
|
||||
source_type='manual',
|
||||
payload={'automation_id': automation_id},
|
||||
dedup_key=dedup_key,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return {'status': 'accepted', 'dedup_key': dedup_key}
|
||||
|
||||
|
||||
@automation_router.get('/{automation_id}/runs')
|
||||
async def list_automation_runs(
|
||||
automation_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(title='Cursor for pagination (run ID)'),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(title='Max results per page', gt=0, le=100),
|
||||
] = 20,
|
||||
) -> PaginatedRunsResponse:
|
||||
"""List runs for an automation, paginated."""
|
||||
# Verify ownership
|
||||
async with a_session_maker() as session:
|
||||
ownership = await session.execute(
|
||||
select(Automation.id).where(
|
||||
Automation.id == automation_id,
|
||||
Automation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if not ownership.scalar():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Automation not found',
|
||||
)
|
||||
|
||||
base_filter = select(AutomationRun).where(
|
||||
AutomationRun.automation_id == automation_id
|
||||
)
|
||||
|
||||
count_q = select(func.count()).select_from(base_filter.subquery())
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
query = base_filter.order_by(AutomationRun.created_at.desc())
|
||||
if page_id:
|
||||
cursor_row = (
|
||||
await session.execute(
|
||||
select(AutomationRun.created_at).where(AutomationRun.id == page_id)
|
||||
)
|
||||
).scalar()
|
||||
if cursor_row is not None:
|
||||
query = query.where(AutomationRun.created_at < cursor_row)
|
||||
|
||||
query = query.limit(limit + 1)
|
||||
result = await session.execute(query)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
items, next_page_id = _paginate(rows, limit)
|
||||
return PaginatedRunsResponse(
|
||||
items=[_run_to_response(r) for r in items],
|
||||
total=total,
|
||||
next_page_id=next_page_id,
|
||||
)
|
||||
|
||||
|
||||
@automation_router.get('/{automation_id}/runs/{run_id}')
|
||||
async def get_automation_run(
|
||||
automation_id: str,
|
||||
run_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> AutomationRunResponse:
|
||||
"""Get a single run detail."""
|
||||
async with a_session_maker() as session:
|
||||
# Verify ownership of the automation
|
||||
ownership = await session.execute(
|
||||
select(Automation.id).where(
|
||||
Automation.id == automation_id,
|
||||
Automation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if not ownership.scalar():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Automation not found',
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(AutomationRun).where(
|
||||
AutomationRun.id == run_id,
|
||||
AutomationRun.automation_id == automation_id,
|
||||
)
|
||||
)
|
||||
run = result.scalars().first()
|
||||
|
||||
if not run:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Run not found',
|
||||
)
|
||||
return _run_to_response(run)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Automation config extraction and validation.
|
||||
|
||||
NOTE: This is a stub for Task 2 (CRUD API) development.
|
||||
Task 1 (Data Foundation) will provide the full implementation.
|
||||
"""
|
||||
|
||||
import ast
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CronTriggerModel(BaseModel):
|
||||
schedule: str = Field(pattern=r'^(\S+\s+){4}\S+$')
|
||||
timezone: str = 'UTC'
|
||||
|
||||
|
||||
class TriggersModel(BaseModel):
|
||||
cron: CronTriggerModel | None = None
|
||||
|
||||
def model_post_init(self, __context: object) -> None:
|
||||
defined = [k for k in ('cron',) if getattr(self, k) is not None]
|
||||
if len(defined) != 1:
|
||||
raise ValueError(f'Exactly one trigger required, got: {defined or "none"}')
|
||||
|
||||
|
||||
class AutomationConfigModel(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
triggers: TriggersModel
|
||||
description: str = ''
|
||||
|
||||
|
||||
def extract_config(source: str) -> dict:
|
||||
"""Extract __config__ dict from a Python automation file using AST."""
|
||||
tree = ast.parse(source)
|
||||
for node in ast.iter_child_nodes(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == '__config__':
|
||||
return ast.literal_eval(node.value)
|
||||
if isinstance(node, ast.AnnAssign):
|
||||
if (
|
||||
isinstance(node.target, ast.Name)
|
||||
and node.target.id == '__config__'
|
||||
and node.value is not None
|
||||
):
|
||||
return ast.literal_eval(node.value)
|
||||
raise ValueError('No __config__ dict found in automation file')
|
||||
|
||||
|
||||
def validate_config(config: dict) -> AutomationConfigModel:
|
||||
"""Validate a __config__ dict. Returns parsed model or raises ValidationError."""
|
||||
return AutomationConfigModel.model_validate(config)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Automation event publisher.
|
||||
|
||||
NOTE: This is a stub for Task 2 (CRUD API) development.
|
||||
Task 1 (Data Foundation) will provide the full implementation.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from storage.automation_event import AutomationEvent
|
||||
|
||||
|
||||
async def publish_automation_event(
|
||||
session: Any,
|
||||
source_type: str,
|
||||
payload: dict,
|
||||
dedup_key: str,
|
||||
) -> AutomationEvent:
|
||||
"""Insert a new automation event into the automation_events table."""
|
||||
event = AutomationEvent(
|
||||
source_type=source_type,
|
||||
payload=payload,
|
||||
dedup_key=dedup_key,
|
||||
status='NEW',
|
||||
)
|
||||
session.add(event)
|
||||
return event
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Automation file generator for simple mode (Phase 1).
|
||||
|
||||
NOTE: This is a stub for Task 2 (CRUD API) development.
|
||||
Task 1 (Data Foundation) will provide the full implementation.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
PROMPT_TEMPLATE = '''\
|
||||
"""{name} — auto-generated from form input."""
|
||||
|
||||
__config__ = {config_json}
|
||||
|
||||
import os
|
||||
|
||||
from openhands.sdk import LLM, Conversation
|
||||
from openhands.tools.preset.default import get_default_agent
|
||||
|
||||
llm = LLM(
|
||||
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
|
||||
api_key=os.getenv("LLM_API_KEY"),
|
||||
base_url=os.getenv("LLM_BASE_URL"),
|
||||
)
|
||||
agent = get_default_agent(llm=llm, cli_mode=True)
|
||||
conversation = Conversation(agent=agent, workspace=os.getcwd())
|
||||
|
||||
conversation.send_message({prompt!r})
|
||||
conversation.run()
|
||||
'''
|
||||
|
||||
|
||||
def generate_automation_file(
|
||||
name: str,
|
||||
schedule: str,
|
||||
timezone: str,
|
||||
prompt: str,
|
||||
repository: str | None = None,
|
||||
branch: str | None = None,
|
||||
) -> str:
|
||||
"""Generate a Python automation file from form input."""
|
||||
config: dict = {
|
||||
'name': name,
|
||||
'triggers': {'cron': {'schedule': schedule, 'timezone': timezone}},
|
||||
}
|
||||
return PROMPT_TEMPLATE.format(
|
||||
name=name,
|
||||
config_json=json.dumps(config, indent=4),
|
||||
prompt=prompt,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""SQLAlchemy models for automations.
|
||||
|
||||
NOTE: This is a stub for Task 2 (CRUD API) development.
|
||||
Task 1 (Data Foundation) will provide the full implementation.
|
||||
"""
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, DateTime, String
|
||||
from sqlalchemy.sql import func
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class Automation(Base): # type: ignore
|
||||
__tablename__ = 'automations'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
user_id = Column(String, nullable=False, index=True)
|
||||
org_id = Column(String, nullable=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
enabled = Column(Boolean, nullable=False, default=True)
|
||||
config = Column(JSON, nullable=False)
|
||||
trigger_type = Column(String, nullable=False)
|
||||
file_store_key = Column(String, nullable=False)
|
||||
last_triggered_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
|
||||
class AutomationRun(Base): # type: ignore
|
||||
__tablename__ = 'automation_runs'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
automation_id = Column(String, nullable=False, index=True)
|
||||
conversation_id = Column(String, nullable=True)
|
||||
status = Column(String, nullable=False, default='PENDING')
|
||||
error_detail = Column(String, nullable=True)
|
||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""SQLAlchemy model for automation events.
|
||||
|
||||
NOTE: This is a stub for Task 2 (CRUD API) development.
|
||||
Task 1 (Data Foundation) will provide the full implementation.
|
||||
"""
|
||||
|
||||
from sqlalchemy import JSON, BigInteger, Column, DateTime, String
|
||||
from sqlalchemy.sql import func
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class AutomationEvent(Base): # type: ignore
|
||||
__tablename__ = 'automation_events'
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
source_type = Column(String, nullable=False)
|
||||
payload = Column(JSON, nullable=False)
|
||||
metadata_ = Column('metadata', JSON, nullable=True)
|
||||
dedup_key = Column(String, nullable=False, unique=True)
|
||||
status = Column(String, nullable=False, default='NEW')
|
||||
error_detail = Column(String, nullable=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
processed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
@@ -11,9 +11,10 @@ from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import LITE_LLM_API_URL
|
||||
from server.logger import logger
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import a_session_maker
|
||||
from storage.encrypt_utils import encrypt_value
|
||||
from storage.lite_llm_manager import LiteLlmManager, get_openhands_cloud_key_alias
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
@@ -186,6 +187,30 @@ class SaasSettingsStore(SettingsStore):
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
# Propagate LLM settings to all org members
|
||||
# This ensures all members see the same LLM configuration when an admin saves
|
||||
# Note: Concurrent saves by multiple admins will result in last-write-wins.
|
||||
# Consider adding optimistic locking if this becomes a problem.
|
||||
member_update_values: dict = {}
|
||||
if item.llm_model is not None:
|
||||
member_update_values['llm_model'] = item.llm_model
|
||||
if item.llm_base_url is not None:
|
||||
member_update_values['llm_base_url'] = item.llm_base_url
|
||||
if item.max_iterations is not None:
|
||||
member_update_values['max_iterations'] = item.max_iterations
|
||||
if item.llm_api_key is not None:
|
||||
member_update_values['_llm_api_key'] = encrypt_value(
|
||||
item.llm_api_key.get_secret_value()
|
||||
)
|
||||
|
||||
if member_update_values:
|
||||
stmt = (
|
||||
update(OrgMember)
|
||||
.where(OrgMember.org_id == org_id)
|
||||
.values(**member_update_values)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
|
||||
await session.commit()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
"""Unit tests for automation CRUD API routes."""
|
||||
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi.testclient import TestClient
|
||||
from server.routes.automations import automation_router
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
TEST_USER_ID = str(uuid.uuid4())
|
||||
OTHER_USER_ID = str(uuid.uuid4())
|
||||
|
||||
|
||||
def _make_automation(
|
||||
automation_id: str | None = None,
|
||||
user_id: str = TEST_USER_ID,
|
||||
name: str = 'Test Automation',
|
||||
enabled: bool = True,
|
||||
trigger_type: str = 'cron',
|
||||
schedule: str = '0 9 * * 5',
|
||||
timezone: str = 'UTC',
|
||||
file_store_key: str | None = None,
|
||||
):
|
||||
auto_id = automation_id or uuid.uuid4().hex
|
||||
mock = MagicMock()
|
||||
mock.id = auto_id
|
||||
mock.user_id = user_id
|
||||
mock.name = name
|
||||
mock.enabled = enabled
|
||||
mock.trigger_type = trigger_type
|
||||
mock.config = {
|
||||
'name': name,
|
||||
'triggers': {'cron': {'schedule': schedule, 'timezone': timezone}},
|
||||
}
|
||||
mock.file_store_key = file_store_key or f'automations/{auto_id}/automation.py'
|
||||
mock.last_triggered_at = None
|
||||
mock.created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
mock.updated_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
return mock
|
||||
|
||||
|
||||
def _make_run(
|
||||
run_id: str | None = None,
|
||||
automation_id: str = 'auto-1',
|
||||
conversation_id: str | None = None,
|
||||
run_status: str = 'PENDING',
|
||||
):
|
||||
rid = run_id or uuid.uuid4().hex
|
||||
mock = MagicMock()
|
||||
mock.id = rid
|
||||
mock.automation_id = automation_id
|
||||
mock.conversation_id = conversation_id
|
||||
mock.status = run_status
|
||||
mock.error_detail = None
|
||||
mock.started_at = None
|
||||
mock.completed_at = None
|
||||
mock.created_at = datetime(2026, 1, 2, tzinfo=UTC)
|
||||
return mock
|
||||
|
||||
|
||||
# --- Helpers to mock async DB sessions ---
|
||||
|
||||
|
||||
def _mock_session_with_results(results_by_call):
|
||||
"""Create a mock async session that returns preconfigured results.
|
||||
|
||||
results_by_call: list of values; each session.execute() returns
|
||||
the next value wrapped in a mock result.
|
||||
"""
|
||||
call_index = [0]
|
||||
|
||||
session = AsyncMock()
|
||||
|
||||
async def _execute(stmt):
|
||||
idx = call_index[0]
|
||||
call_index[0] += 1
|
||||
val = results_by_call[idx] if idx < len(results_by_call) else None
|
||||
result_mock = MagicMock()
|
||||
if isinstance(val, list):
|
||||
result_mock.scalars.return_value.all.return_value = val
|
||||
result_mock.scalars.return_value.first.return_value = (
|
||||
val[0] if val else None
|
||||
)
|
||||
result_mock.scalar.return_value = len(val)
|
||||
elif val is None:
|
||||
result_mock.scalars.return_value.first.return_value = None
|
||||
result_mock.scalars.return_value.all.return_value = []
|
||||
result_mock.scalar.return_value = None
|
||||
else:
|
||||
result_mock.scalars.return_value.first.return_value = val
|
||||
result_mock.scalar.return_value = val
|
||||
return result_mock
|
||||
|
||||
session.execute = AsyncMock(side_effect=_execute)
|
||||
session.commit = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
session.delete = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
return session
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _session_ctx(session):
|
||||
yield session
|
||||
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app():
|
||||
"""Create a test FastAPI app with automation routes and mocked auth."""
|
||||
app = FastAPI()
|
||||
app.include_router(automation_router)
|
||||
|
||||
def mock_get_user_id():
|
||||
return TEST_USER_ID
|
||||
|
||||
app.dependency_overrides[get_user_id] = mock_get_user_id
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_app):
|
||||
return TestClient(mock_app)
|
||||
|
||||
|
||||
# --- Test: POST /api/v1/automations ---
|
||||
|
||||
|
||||
class TestCreateAutomation:
|
||||
def test_create_success(self, client):
|
||||
"""POST with valid input → 201 with AutomationResponse."""
|
||||
mock_session = _mock_session_with_results([])
|
||||
|
||||
async def fake_refresh(obj):
|
||||
obj.created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
obj.updated_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
obj.last_triggered_at = None
|
||||
|
||||
mock_session.refresh = AsyncMock(side_effect=fake_refresh)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.automations.generate_automation_file',
|
||||
return_value='__config__ = {"name": "Test", "triggers": {"cron": {"schedule": "0 9 * * 5"}}}',
|
||||
),
|
||||
patch(
|
||||
'server.routes.automations.extract_config',
|
||||
return_value={
|
||||
'name': 'Test',
|
||||
'triggers': {'cron': {'schedule': '0 9 * * 5'}},
|
||||
},
|
||||
),
|
||||
patch('server.routes.automations.validate_config'),
|
||||
patch('server.routes.automations.file_store') as mock_fs,
|
||||
patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
'/api/v1/automations',
|
||||
json={
|
||||
'name': 'Test',
|
||||
'schedule': '0 9 * * 5',
|
||||
'prompt': 'Summarize PRs',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data['name'] == 'Test'
|
||||
assert data['enabled'] is True
|
||||
assert data['trigger_type'] == 'cron'
|
||||
assert 'id' in data
|
||||
mock_fs.write.assert_called_once()
|
||||
|
||||
def test_create_missing_name(self, client):
|
||||
"""POST with missing name → 422."""
|
||||
response = client.post(
|
||||
'/api/v1/automations',
|
||||
json={'schedule': '0 9 * * 5', 'prompt': 'Test'},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_empty_name(self, client):
|
||||
"""POST with empty name → 422."""
|
||||
response = client.post(
|
||||
'/api/v1/automations',
|
||||
json={'name': '', 'schedule': '0 9 * * 5', 'prompt': 'Test'},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_missing_prompt(self, client):
|
||||
"""POST with missing prompt → 422."""
|
||||
response = client.post(
|
||||
'/api/v1/automations',
|
||||
json={'name': 'Test', 'schedule': '0 9 * * 5'},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_create_invalid_config_rejected(self, client):
|
||||
"""POST where validate_config raises → 422."""
|
||||
with (
|
||||
patch(
|
||||
'server.routes.automations.generate_automation_file',
|
||||
return_value='__config__ = {}',
|
||||
),
|
||||
patch(
|
||||
'server.routes.automations.extract_config',
|
||||
return_value={},
|
||||
),
|
||||
patch(
|
||||
'server.routes.automations.validate_config',
|
||||
side_effect=ValueError('Invalid cron expression'),
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
'/api/v1/automations',
|
||||
json={
|
||||
'name': 'Bad Cron',
|
||||
'schedule': 'not-a-cron',
|
||||
'prompt': 'Test',
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert 'Invalid automation config' in response.json()['detail']
|
||||
|
||||
|
||||
# --- Test: GET /api/v1/automations/search ---
|
||||
|
||||
|
||||
class TestSearchAutomations:
|
||||
def test_list_returns_user_automations(self, client):
|
||||
"""GET /search → returns only current user's automations."""
|
||||
a1 = _make_automation(name='Auto 1')
|
||||
a2 = _make_automation(name='Auto 2')
|
||||
|
||||
# Session calls: count query → 2, paginated query → [a1, a2]
|
||||
mock_session = _mock_session_with_results([2, [a1, a2]])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.get('/api/v1/automations/search')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['total'] == 2
|
||||
assert len(data['items']) == 2
|
||||
assert data['items'][0]['name'] == 'Auto 1'
|
||||
|
||||
def test_list_empty(self, client):
|
||||
"""GET /search when no automations → empty list."""
|
||||
mock_session = _mock_session_with_results([0, []])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.get('/api/v1/automations/search')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['total'] == 0
|
||||
assert data['items'] == []
|
||||
assert data['next_page_id'] is None
|
||||
|
||||
|
||||
# --- Test: GET /api/v1/automations/{id} ---
|
||||
|
||||
|
||||
class TestGetAutomation:
|
||||
def test_get_existing(self, client):
|
||||
"""GET existing automation → 200."""
|
||||
auto = _make_automation(automation_id='auto-123')
|
||||
mock_session = _mock_session_with_results([auto])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.get('/api/v1/automations/auto-123')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()['id'] == 'auto-123'
|
||||
|
||||
def test_get_nonexistent(self, client):
|
||||
"""GET non-existent automation → 404."""
|
||||
mock_session = _mock_session_with_results([None])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.get('/api/v1/automations/does-not-exist')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
# --- Test: PATCH /api/v1/automations/{id} ---
|
||||
|
||||
|
||||
class TestUpdateAutomation:
|
||||
def test_update_name_and_enabled(self, client):
|
||||
"""PATCH with name + enabled → updates fields, returns 200."""
|
||||
auto = _make_automation(automation_id='auto-123')
|
||||
mock_session = _mock_session_with_results([auto])
|
||||
|
||||
async def fake_refresh(obj):
|
||||
obj.name = 'Updated Name'
|
||||
obj.enabled = False
|
||||
obj.id = 'auto-123'
|
||||
obj.trigger_type = 'cron'
|
||||
obj.config = auto.config
|
||||
obj.last_triggered_at = None
|
||||
obj.created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
obj.updated_at = datetime(2026, 1, 2, tzinfo=UTC)
|
||||
|
||||
mock_session.refresh = AsyncMock(side_effect=fake_refresh)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
),
|
||||
patch(
|
||||
'server.routes.automations.generate_automation_file',
|
||||
return_value='__config__ = {}',
|
||||
),
|
||||
patch(
|
||||
'server.routes.automations.extract_config',
|
||||
return_value=auto.config,
|
||||
),
|
||||
patch('server.routes.automations.validate_config'),
|
||||
patch('server.routes.automations.file_store') as mock_fs,
|
||||
):
|
||||
mock_fs.read.return_value = (
|
||||
'conversation.send_message("old prompt")\nconversation.run()'
|
||||
)
|
||||
response = client.patch(
|
||||
'/api/v1/automations/auto-123',
|
||||
json={'name': 'Updated Name', 'enabled': False},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['name'] == 'Updated Name'
|
||||
assert data['enabled'] is False
|
||||
|
||||
def test_update_nonexistent(self, client):
|
||||
"""PATCH non-existent → 404."""
|
||||
mock_session = _mock_session_with_results([None])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.patch(
|
||||
'/api/v1/automations/nope',
|
||||
json={'name': 'X'},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_update_prompt_regenerates_file(self, client):
|
||||
"""PATCH with new prompt → re-generates file and uploads."""
|
||||
auto = _make_automation(automation_id='auto-123')
|
||||
mock_session = _mock_session_with_results([auto])
|
||||
|
||||
async def fake_refresh(obj):
|
||||
obj.id = 'auto-123'
|
||||
obj.name = auto.name
|
||||
obj.enabled = auto.enabled
|
||||
obj.trigger_type = 'cron'
|
||||
obj.config = auto.config
|
||||
obj.last_triggered_at = None
|
||||
obj.created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
obj.updated_at = datetime(2026, 1, 2, tzinfo=UTC)
|
||||
|
||||
mock_session.refresh = AsyncMock(side_effect=fake_refresh)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
),
|
||||
patch(
|
||||
'server.routes.automations.generate_automation_file',
|
||||
return_value='__config__ = {}',
|
||||
) as mock_gen,
|
||||
patch(
|
||||
'server.routes.automations.extract_config',
|
||||
return_value=auto.config,
|
||||
),
|
||||
patch('server.routes.automations.validate_config'),
|
||||
patch('server.routes.automations.file_store') as mock_fs,
|
||||
):
|
||||
response = client.patch(
|
||||
'/api/v1/automations/auto-123',
|
||||
json={'prompt': 'New prompt text'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_gen.assert_called_once()
|
||||
mock_fs.write.assert_called_once()
|
||||
|
||||
|
||||
# --- Test: DELETE /api/v1/automations/{id} ---
|
||||
|
||||
|
||||
class TestDeleteAutomation:
|
||||
def test_delete_existing(self, client):
|
||||
"""DELETE existing → 204."""
|
||||
auto = _make_automation(automation_id='auto-123')
|
||||
# First execute: select automation, second: delete runs
|
||||
mock_session = _mock_session_with_results([auto, None])
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
),
|
||||
patch('server.routes.automations.file_store'),
|
||||
):
|
||||
response = client.delete('/api/v1/automations/auto-123')
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
def test_delete_nonexistent(self, client):
|
||||
"""DELETE non-existent → 404."""
|
||||
mock_session = _mock_session_with_results([None])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.delete('/api/v1/automations/nope')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
# --- Test: POST /api/v1/automations/{id}/run ---
|
||||
|
||||
|
||||
class TestManualTrigger:
|
||||
def test_manual_trigger_success(self, client):
|
||||
"""POST .../run on existing automation → 202."""
|
||||
auto = _make_automation(automation_id='auto-123')
|
||||
mock_session = _mock_session_with_results([auto])
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
),
|
||||
patch(
|
||||
'server.routes.automations.publish_automation_event',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_pub,
|
||||
):
|
||||
response = client.post('/api/v1/automations/auto-123/run')
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
data = response.json()
|
||||
assert data['status'] == 'accepted'
|
||||
assert 'dedup_key' in data
|
||||
assert data['dedup_key'].startswith('manual-auto-123-')
|
||||
mock_pub.assert_called_once()
|
||||
|
||||
def test_manual_trigger_nonexistent(self, client):
|
||||
"""POST .../run on non-existent → 404."""
|
||||
mock_session = _mock_session_with_results([None])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.post('/api/v1/automations/nope/run')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
# --- Test: GET /api/v1/automations/{id}/runs ---
|
||||
|
||||
|
||||
class TestListRuns:
|
||||
def test_list_runs_success(self, client):
|
||||
"""GET .../runs → paginated list."""
|
||||
r1 = _make_run(run_id='run-1', automation_id='auto-123')
|
||||
r2 = _make_run(run_id='run-2', automation_id='auto-123')
|
||||
|
||||
# Calls: ownership check → 'auto-123', count → 2, paginated → [r1, r2]
|
||||
mock_session = _mock_session_with_results(['auto-123', 2, [r1, r2]])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.get('/api/v1/automations/auto-123/runs')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['total'] == 2
|
||||
assert len(data['items']) == 2
|
||||
|
||||
def test_list_runs_automation_not_found(self, client):
|
||||
"""GET .../runs for non-existent automation → 404."""
|
||||
mock_session = _mock_session_with_results([None])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.get('/api/v1/automations/nope/runs')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
# --- Test: GET /api/v1/automations/{id}/runs/{run_id} ---
|
||||
|
||||
|
||||
class TestGetRun:
|
||||
def test_get_run_success(self, client):
|
||||
"""GET single run → 200."""
|
||||
run = _make_run(run_id='run-1', automation_id='auto-123')
|
||||
|
||||
# Calls: ownership check → 'auto-123', select run → run
|
||||
mock_session = _mock_session_with_results(['auto-123', run])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.get('/api/v1/automations/auto-123/runs/run-1')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()['id'] == 'run-1'
|
||||
|
||||
def test_get_run_not_found(self, client):
|
||||
"""GET non-existent run → 404."""
|
||||
mock_session = _mock_session_with_results(['auto-123', None])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.get('/api/v1/automations/auto-123/runs/nope')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_get_run_automation_not_found(self, client):
|
||||
"""GET run for non-existent automation → 404."""
|
||||
mock_session = _mock_session_with_results([None])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = client.get('/api/v1/automations/nope/runs/run-1')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
# --- Test: User Isolation (security) ---
|
||||
|
||||
|
||||
class TestUserIsolation:
|
||||
"""Verify that user A cannot access, update, or delete user B's automations.
|
||||
|
||||
The routes filter by user_id from the auth dependency, so automations owned by
|
||||
another user should never be returned (the DB query uses WHERE user_id = <caller>).
|
||||
We simulate this by having the mock session return None for cross-user lookups.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def other_user_app(self):
|
||||
"""App configured to authenticate as OTHER_USER_ID."""
|
||||
app = FastAPI()
|
||||
app.include_router(automation_router)
|
||||
|
||||
def mock_get_other_user_id():
|
||||
return OTHER_USER_ID
|
||||
|
||||
app.dependency_overrides[get_user_id] = mock_get_other_user_id
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def other_client(self, other_user_app):
|
||||
return TestClient(other_user_app)
|
||||
|
||||
def test_cannot_get_other_users_automation(self, other_client):
|
||||
"""User B cannot GET user A's automation → 404."""
|
||||
# The query filters by user_id=OTHER_USER_ID, so it won't find user A's row
|
||||
mock_session = _mock_session_with_results([None])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = other_client.get('/api/v1/automations/auto-owned-by-a')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_cannot_update_other_users_automation(self, other_client):
|
||||
"""User B cannot PATCH user A's automation → 404."""
|
||||
mock_session = _mock_session_with_results([None])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = other_client.patch(
|
||||
'/api/v1/automations/auto-owned-by-a',
|
||||
json={'name': 'Hijacked'},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_cannot_delete_other_users_automation(self, other_client):
|
||||
"""User B cannot DELETE user A's automation → 404."""
|
||||
mock_session = _mock_session_with_results([None])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = other_client.delete('/api/v1/automations/auto-owned-by-a')
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_search_returns_empty_for_other_user(self, other_client):
|
||||
"""User B's search returns empty even if user A has automations."""
|
||||
# count=0, rows=[]
|
||||
mock_session = _mock_session_with_results([0, []])
|
||||
|
||||
with patch(
|
||||
'server.routes.automations.a_session_maker',
|
||||
return_value=_session_ctx(mock_session),
|
||||
):
|
||||
response = other_client.get('/api/v1/automations/search')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['total'] == 0
|
||||
assert data['items'] == []
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Integration tests for automation CRUD API using a real in-memory SQLite database.
|
||||
|
||||
These tests exercise actual SQL queries (list, get, create+verify, pagination, delete)
|
||||
rather than mocking the database layer.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from server.routes.automations import automation_router
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from storage.automation import Automation, AutomationRun
|
||||
|
||||
from openhands.app_server.utils.sql_utils import Base
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
TEST_USER_ID = 'integration-test-user'
|
||||
OTHER_USER_ID = 'other-user'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine():
|
||||
engine = create_async_engine('sqlite+aiosqlite://', echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_maker(db_engine):
|
||||
return async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(session_maker):
|
||||
"""FastAPI app wired to a real SQLite database."""
|
||||
app = FastAPI()
|
||||
app.include_router(automation_router)
|
||||
app.dependency_overrides[get_user_id] = lambda: TEST_USER_ID
|
||||
|
||||
@asynccontextmanager
|
||||
async def _session_ctx():
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
|
||||
with patch('server.routes.automations.a_session_maker', _session_ctx):
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url='http://test') as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _make_automation_obj(
|
||||
user_id: str = TEST_USER_ID,
|
||||
name: str = 'Test Auto',
|
||||
created_at: datetime | None = None,
|
||||
**kwargs,
|
||||
) -> Automation:
|
||||
return Automation(
|
||||
id=kwargs.get('automation_id', uuid.uuid4().hex),
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
enabled=kwargs.get('enabled', True),
|
||||
config=kwargs.get(
|
||||
'config',
|
||||
{
|
||||
'name': name,
|
||||
'triggers': {'cron': {'schedule': '0 9 * * 5', 'timezone': 'UTC'}},
|
||||
'prompt': 'Do something',
|
||||
},
|
||||
),
|
||||
trigger_type='cron',
|
||||
file_store_key=kwargs.get('file_store_key', f'automations/{uuid.uuid4().hex}/automation.py'),
|
||||
created_at=created_at or datetime.now(UTC),
|
||||
updated_at=created_at or datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
# ---------- Test: list (search) returns correct results ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_returns_user_automations(client, session_maker):
|
||||
"""GET /search returns only automations owned by the requesting user."""
|
||||
async with session_maker() as session:
|
||||
a1 = _make_automation_obj(name='Auto A', created_at=datetime(2026, 1, 1, tzinfo=UTC))
|
||||
a2 = _make_automation_obj(name='Auto B', created_at=datetime(2026, 1, 2, tzinfo=UTC))
|
||||
a_other = _make_automation_obj(user_id=OTHER_USER_ID, name='Other User Auto')
|
||||
session.add_all([a1, a2, a_other])
|
||||
await session.commit()
|
||||
|
||||
response = await client.get('/api/v1/automations/search')
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['total'] == 2
|
||||
assert len(data['items']) == 2
|
||||
names = {item['name'] for item in data['items']}
|
||||
assert names == {'Auto A', 'Auto B'}
|
||||
|
||||
|
||||
# ---------- Test: get returns the right object ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_correct_automation(client, session_maker):
|
||||
"""GET /{id} returns the correct automation by ID."""
|
||||
auto_id = uuid.uuid4().hex
|
||||
async with session_maker() as session:
|
||||
auto = _make_automation_obj(automation_id=auto_id, name='Specific Auto')
|
||||
session.add(auto)
|
||||
await session.commit()
|
||||
|
||||
response = await client.get(f'/api/v1/automations/{auto_id}')
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['id'] == auto_id
|
||||
assert data['name'] == 'Specific Auto'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_returns_404(client):
|
||||
"""GET /{id} for non-existent automation returns 404."""
|
||||
response = await client.get('/api/v1/automations/does-not-exist')
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------- Test: create + verify in DB ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_stores_in_db(client, session_maker):
|
||||
"""POST creates an automation and it's readable from the database."""
|
||||
mock_file_store = MagicMock()
|
||||
config = {
|
||||
'name': 'New Auto',
|
||||
'triggers': {'cron': {'schedule': '0 9 * * 5', 'timezone': 'UTC'}},
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.automations.generate_automation_file',
|
||||
return_value='__config__ = {}',
|
||||
),
|
||||
patch('server.routes.automations.extract_config', return_value=config),
|
||||
patch('server.routes.automations.validate_config'),
|
||||
patch('server.routes.automations.file_store', mock_file_store),
|
||||
):
|
||||
response = await client.post(
|
||||
'/api/v1/automations',
|
||||
json={
|
||||
'name': 'New Auto',
|
||||
'schedule': '0 9 * * 5',
|
||||
'prompt': 'Summarize PRs',
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
created_id = data['id']
|
||||
|
||||
# Verify it's in the DB via the GET endpoint
|
||||
get_response = await client.get(f'/api/v1/automations/{created_id}')
|
||||
assert get_response.status_code == 200
|
||||
assert get_response.json()['name'] == 'New Auto'
|
||||
# Verify prompt is stored in config
|
||||
assert get_response.json()['config'].get('prompt') == 'Summarize PRs'
|
||||
|
||||
|
||||
# ---------- Test: delete actually deletes ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_from_db(client, session_maker):
|
||||
"""DELETE removes the automation from the database."""
|
||||
auto_id = uuid.uuid4().hex
|
||||
async with session_maker() as session:
|
||||
auto = _make_automation_obj(automation_id=auto_id, name='To Delete')
|
||||
session.add(auto)
|
||||
await session.commit()
|
||||
|
||||
mock_file_store = MagicMock()
|
||||
with patch('server.routes.automations.file_store', mock_file_store):
|
||||
response = await client.delete(f'/api/v1/automations/{auto_id}')
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify it's gone
|
||||
get_response = await client.get(f'/api/v1/automations/{auto_id}')
|
||||
assert get_response.status_code == 404
|
||||
|
||||
|
||||
# ---------- Test: pagination actually works ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagination_returns_correct_pages(client, session_maker):
|
||||
"""Pagination with limit returns correct page sizes and next_page_id."""
|
||||
base_time = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
async with session_maker() as session:
|
||||
for i in range(5):
|
||||
auto = _make_automation_obj(
|
||||
name=f'Auto {i}',
|
||||
created_at=base_time + timedelta(hours=i),
|
||||
)
|
||||
session.add(auto)
|
||||
await session.commit()
|
||||
|
||||
# First page with limit=2
|
||||
response = await client.get('/api/v1/automations/search?limit=2')
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['total'] == 5
|
||||
assert len(data['items']) == 2
|
||||
assert data['next_page_id'] is not None
|
||||
|
||||
# Second page using cursor — should return remaining items before cursor
|
||||
next_id = data['next_page_id']
|
||||
response2 = await client.get(f'/api/v1/automations/search?limit=2&page_id={next_id}')
|
||||
assert response2.status_code == 200
|
||||
data2 = response2.json()
|
||||
assert len(data2['items']) == 2
|
||||
|
||||
# Collect all items from both pages and verify no duplicates
|
||||
all_ids = [item['id'] for item in data['items']] + [
|
||||
item['id'] for item in data2['items']
|
||||
]
|
||||
assert len(all_ids) == len(set(all_ids)), 'Pages must not contain duplicate items'
|
||||
|
||||
|
||||
# ---------- Test: user isolation at DB level ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_isolation(client, session_maker):
|
||||
"""User A cannot see or access User B's automations via actual DB queries."""
|
||||
auto_id = uuid.uuid4().hex
|
||||
async with session_maker() as session:
|
||||
other_auto = _make_automation_obj(
|
||||
automation_id=auto_id,
|
||||
user_id=OTHER_USER_ID,
|
||||
name='Other User Auto',
|
||||
)
|
||||
session.add(other_auto)
|
||||
await session.commit()
|
||||
|
||||
# Should not be found by TEST_USER_ID
|
||||
response = await client.get(f'/api/v1/automations/{auto_id}')
|
||||
assert response.status_code == 404
|
||||
|
||||
# Should not appear in search
|
||||
search_response = await client.get('/api/v1/automations/search')
|
||||
assert search_response.status_code == 200
|
||||
assert search_response.json()['total'] == 0
|
||||
@@ -1,3 +1,4 @@
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -233,3 +234,165 @@ async def test_ensure_api_key_generates_new_key_when_verification_fails(
|
||||
|
||||
assert item.llm_api_key is not None
|
||||
assert item.llm_api_key.get_secret_value() == new_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_with_multiple_members_fixture(session_maker):
|
||||
"""Set up an organization with multiple members for testing LLM settings propagation.
|
||||
|
||||
Uses sync session to avoid UUID conversion issues with async SQLite.
|
||||
"""
|
||||
from storage.encrypt_utils import decrypt_value
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.user import User
|
||||
|
||||
# Use realistic UUIDs that work well with SQLite
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
admin_user_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5082')
|
||||
member1_user_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5083')
|
||||
member2_user_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5084')
|
||||
|
||||
with session_maker() as session:
|
||||
# Create role
|
||||
role = Role(id=10, name='member', rank=3)
|
||||
session.add(role)
|
||||
|
||||
# Create org
|
||||
org = Org(
|
||||
id=org_id,
|
||||
name='test-org',
|
||||
org_version=1,
|
||||
enable_default_condenser=True,
|
||||
enable_proactive_conversation_starters=True,
|
||||
)
|
||||
session.add(org)
|
||||
|
||||
# Create users
|
||||
admin_user = User(
|
||||
id=admin_user_id, current_org_id=org_id, user_consents_to_analytics=True
|
||||
)
|
||||
session.add(admin_user)
|
||||
|
||||
member1_user = User(
|
||||
id=member1_user_id, current_org_id=org_id, user_consents_to_analytics=True
|
||||
)
|
||||
session.add(member1_user)
|
||||
|
||||
member2_user = User(
|
||||
id=member2_user_id, current_org_id=org_id, user_consents_to_analytics=True
|
||||
)
|
||||
session.add(member2_user)
|
||||
|
||||
# Create org members with DIFFERENT initial LLM settings
|
||||
admin_member = OrgMember(
|
||||
org_id=org_id,
|
||||
user_id=admin_user_id,
|
||||
role_id=10,
|
||||
llm_api_key='admin-initial-key',
|
||||
llm_model='old-model-v1',
|
||||
llm_base_url='http://old-url-1.com',
|
||||
max_iterations=10,
|
||||
status='active',
|
||||
)
|
||||
session.add(admin_member)
|
||||
|
||||
member1 = OrgMember(
|
||||
org_id=org_id,
|
||||
user_id=member1_user_id,
|
||||
role_id=10,
|
||||
llm_api_key='member1-initial-key',
|
||||
llm_model='old-model-v2',
|
||||
llm_base_url='http://old-url-2.com',
|
||||
max_iterations=20,
|
||||
status='active',
|
||||
)
|
||||
session.add(member1)
|
||||
|
||||
member2 = OrgMember(
|
||||
org_id=org_id,
|
||||
user_id=member2_user_id,
|
||||
role_id=10,
|
||||
llm_api_key='member2-initial-key',
|
||||
llm_model='old-model-v3',
|
||||
llm_base_url='http://old-url-3.com',
|
||||
max_iterations=30,
|
||||
status='active',
|
||||
)
|
||||
session.add(member2)
|
||||
|
||||
session.commit()
|
||||
|
||||
return {
|
||||
'org_id': org_id,
|
||||
'admin_user_id': admin_user_id,
|
||||
'member1_user_id': member1_user_id,
|
||||
'member2_user_id': member2_user_id,
|
||||
'decrypt_value': decrypt_value,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_propagates_llm_settings_to_all_org_members(
|
||||
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
|
||||
):
|
||||
"""When admin saves LLM settings, all org members should receive the updated settings.
|
||||
|
||||
This test verifies using a real database that:
|
||||
1. The bulk UPDATE targets the correct organization (WHERE clause is correct)
|
||||
2. All LLM fields are correctly set (llm_model, llm_base_url, max_iterations, llm_api_key)
|
||||
3. The llm_api_key is properly encrypted
|
||||
4. All members in the org receive the same updated values
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from storage.org_member import OrgMember
|
||||
|
||||
# Arrange
|
||||
fixture = org_with_multiple_members_fixture
|
||||
org_id = fixture['org_id']
|
||||
admin_user_id = str(fixture['admin_user_id'])
|
||||
decrypt_value = fixture['decrypt_value']
|
||||
|
||||
store = SaasSettingsStore(admin_user_id, mock_config)
|
||||
|
||||
new_settings = DataSettings(
|
||||
llm_model='new-shared-model/gpt-4',
|
||||
llm_base_url='http://new-shared-url.com',
|
||||
max_iterations=100,
|
||||
llm_api_key=SecretStr('new-shared-api-key'),
|
||||
)
|
||||
|
||||
# Act - call store() with async session
|
||||
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
|
||||
await store.store(new_settings)
|
||||
|
||||
# Assert - verify ALL org members have the updated LLM settings using sync session
|
||||
with session_maker() as session:
|
||||
result = session.execute(select(OrgMember).where(OrgMember.org_id == org_id))
|
||||
members = result.scalars().all()
|
||||
|
||||
# Verify we have all 3 members
|
||||
assert len(members) == 3, f'Expected 3 org members, got {len(members)}'
|
||||
|
||||
for member in members:
|
||||
# Verify LLM model is updated
|
||||
assert (
|
||||
member.llm_model == 'new-shared-model/gpt-4'
|
||||
), f'Expected llm_model to be updated for member {member.user_id}'
|
||||
|
||||
# Verify LLM base URL is updated
|
||||
assert (
|
||||
member.llm_base_url == 'http://new-shared-url.com'
|
||||
), f'Expected llm_base_url to be updated for member {member.user_id}'
|
||||
|
||||
# Verify max_iterations is updated
|
||||
assert (
|
||||
member.max_iterations == 100
|
||||
), f'Expected max_iterations to be 100 for member {member.user_id}'
|
||||
|
||||
# Verify the API key is encrypted and decrypts to the correct value
|
||||
decrypted_key = decrypt_value(member._llm_api_key)
|
||||
assert (
|
||||
decrypted_key == 'new-shared-api-key'
|
||||
), f'Expected llm_api_key to decrypt to new-shared-api-key for member {member.user_id}'
|
||||
|
||||
+4
-4
@@ -84,12 +84,12 @@ describe("TaskTrackingObservationContent", () => {
|
||||
expect(taskItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("displays task IDs and notes", () => {
|
||||
it("does not display task IDs but displays notes", () => {
|
||||
render(<TaskTrackingObservationContent event={mockEvent} />);
|
||||
|
||||
expect(screen.getByText("ID: task-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: task-2")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: task-3")).toBeInTheDocument();
|
||||
expect(screen.queryByText("ID: task-1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ID: task-2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ID: task-3")).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Notes: This is a test task")).toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
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";
|
||||
|
||||
const CONVERSATION_ID = "conv-abc123";
|
||||
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: CONVERSATION_ID }),
|
||||
}));
|
||||
|
||||
let mockHasTaskList = false;
|
||||
vi.mock("#/hooks/use-task-list", () => ({
|
||||
useTaskList: () => ({
|
||||
hasTaskList: mockHasTaskList,
|
||||
taskList: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ConversationTabsContextMenu", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockHasTaskList = false;
|
||||
});
|
||||
|
||||
it("should render nothing when isOpen is false", () => {
|
||||
const { container } = render(
|
||||
<ConversationTabsContextMenu isOpen={false} onClose={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("should render all default tabs when open", () => {
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const expectedTabs = [
|
||||
"COMMON$PLANNER",
|
||||
"COMMON$CHANGES",
|
||||
"COMMON$CODE",
|
||||
"COMMON$TERMINAL",
|
||||
"COMMON$APP",
|
||||
"COMMON$BROWSER",
|
||||
];
|
||||
for (const tab of expectedTabs) {
|
||||
expect(screen.getByText(tab)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should re-pin a tab when clicking an unpinned tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
|
||||
// Unpin
|
||||
await user.click(terminalItem);
|
||||
let storedState = JSON.parse(
|
||||
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
|
||||
);
|
||||
expect(storedState.unpinnedTabs).toContain("terminal");
|
||||
|
||||
// Re-pin
|
||||
await user.click(terminalItem);
|
||||
storedState = JSON.parse(
|
||||
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
|
||||
);
|
||||
expect(storedState.unpinnedTabs).not.toContain("terminal");
|
||||
});
|
||||
|
||||
describe("with tasklist", () => {
|
||||
beforeEach(() => {
|
||||
mockHasTaskList = true;
|
||||
});
|
||||
|
||||
it("should show tasklist in context menu when hasTaskList is true", () => {
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("COMMON$TASK_LIST")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
|
||||
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
|
||||
const TASK_CONVERSATION_ID = "task-ec03fb2ab8604517b24af632b058c2fd";
|
||||
@@ -16,6 +15,14 @@ vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: mockConversationId }),
|
||||
}));
|
||||
|
||||
let mockHasTaskList = false;
|
||||
vi.mock("#/hooks/use-task-list", () => ({
|
||||
useTaskList: () => ({
|
||||
hasTaskList: mockHasTaskList,
|
||||
taskList: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createWrapper = (conversationId: string) => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<MemoryRouter initialEntries={[`/conversations/${conversationId}`]}>
|
||||
@@ -31,6 +38,7 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
localStorage.clear();
|
||||
vi.resetAllMocks();
|
||||
mockConversationId = TASK_CONVERSATION_ID;
|
||||
mockHasTaskList = false;
|
||||
useConversationStore.setState({
|
||||
selectedTab: null,
|
||||
isRightPanelShown: false,
|
||||
@@ -71,47 +79,6 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
expect(parsed).toHaveProperty("rightPanelShown");
|
||||
expect(parsed).toHaveProperty("unpinnedTabs");
|
||||
});
|
||||
|
||||
it("should store unpinned tabs in consolidated key via context menu", async () => {
|
||||
mockConversationId = REAL_CONVERSATION_ID;
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
await user.click(terminalItem);
|
||||
|
||||
const consolidatedKey = `conversation-state-${REAL_CONVERSATION_ID}`;
|
||||
const storedState = localStorage.getItem(consolidatedKey);
|
||||
expect(storedState).not.toBeNull();
|
||||
|
||||
const parsed = JSON.parse(storedState!);
|
||||
expect(parsed.unpinnedTabs).toContain("terminal");
|
||||
});
|
||||
|
||||
it("should hide a tab after unpinning it from context menu", async () => {
|
||||
mockConversationId = REAL_CONVERSATION_ID;
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<>
|
||||
<ConversationTabs />
|
||||
<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />
|
||||
</>,
|
||||
{ wrapper: createWrapper(REAL_CONVERSATION_ID) },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("conversation-tab-terminal"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
await user.click(terminalItem);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-tab-terminal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook integration", () => {
|
||||
@@ -205,4 +172,37 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
expect(storedState.selectedTab).toBe("browser");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tasklist tab", () => {
|
||||
beforeEach(() => {
|
||||
mockConversationId = REAL_CONVERSATION_ID;
|
||||
mockHasTaskList = true;
|
||||
});
|
||||
|
||||
it("should show tasklist tab when hasTaskList is true", () => {
|
||||
render(<ConversationTabs />, {
|
||||
wrapper: createWrapper(REAL_CONVERSATION_ID),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId("conversation-tab-tasklist"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should select tasklist tab when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabs />, {
|
||||
wrapper: createWrapper(REAL_CONVERSATION_ID),
|
||||
});
|
||||
|
||||
const tasklistTab = screen.getByTestId("conversation-tab-tasklist");
|
||||
await user.click(tasklistTab);
|
||||
|
||||
const { selectedTab, hasRightPanelToggled } =
|
||||
useConversationStore.getState();
|
||||
expect(selectedTab).toBe("tasklist");
|
||||
expect(hasRightPanelToggled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTaskList } from "#/hooks/use-task-list";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import type { OHEvent } from "#/stores/use-event-store";
|
||||
import type { TaskTrackingObservation } from "#/types/core/observations";
|
||||
|
||||
function createV0TaskTrackingObservation(
|
||||
id: number,
|
||||
command: string,
|
||||
taskList: TaskTrackingObservation["extras"]["task_list"],
|
||||
): TaskTrackingObservation {
|
||||
return {
|
||||
id,
|
||||
source: "agent",
|
||||
observation: "task_tracking",
|
||||
message: "Task tracking update",
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
cause: 0,
|
||||
content: "",
|
||||
extras: {
|
||||
command,
|
||||
task_list: taskList,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createV1TaskTrackerObservation(
|
||||
id: string,
|
||||
command: string,
|
||||
taskList: Array<{
|
||||
title: string;
|
||||
notes: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
}>,
|
||||
): OHEvent {
|
||||
return {
|
||||
id,
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
source: "environment",
|
||||
tool_name: "task_tracker",
|
||||
tool_call_id: `call_${id}`,
|
||||
action_id: `action_${id}`,
|
||||
observation: {
|
||||
kind: "TaskTrackerObservation",
|
||||
content: "Task list updated",
|
||||
command,
|
||||
task_list: taskList,
|
||||
},
|
||||
} as unknown as OHEvent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEventStore.setState({
|
||||
events: [],
|
||||
eventIds: new Set(),
|
||||
uiEvents: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskList", () => {
|
||||
it("returns empty taskList and hasTaskList=false when no events exist", () => {
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty taskList when no task tracking observations exist", () => {
|
||||
useEventStore.setState({
|
||||
events: [
|
||||
{
|
||||
id: 1,
|
||||
source: "user",
|
||||
action: "message",
|
||||
args: { content: "Hello", image_urls: [], file_urls: [] },
|
||||
message: "Hello",
|
||||
timestamp: "2025-07-01T00:00:01Z",
|
||||
},
|
||||
],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
|
||||
describe("v0 events", () => {
|
||||
it('returns the task list from a TaskTrackingObservation with command="plan"', () => {
|
||||
const tasks = [
|
||||
{ id: "1", title: "First task", status: "todo" as const },
|
||||
{ id: "2", title: "Second task", status: "in_progress" as const },
|
||||
];
|
||||
const event = createV0TaskTrackingObservation(1, "plan", tasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual(tasks);
|
||||
expect(result.current.hasTaskList).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores TaskTrackingObservation events with command !== "plan"', () => {
|
||||
const tasks = [{ id: "1", title: "First task", status: "todo" as const }];
|
||||
const event = createV0TaskTrackingObservation(1, "update", tasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the latest task list when multiple plan events exist", () => {
|
||||
const earlyTasks = [
|
||||
{ id: "1", title: "First task", status: "todo" as const },
|
||||
];
|
||||
const lateTasks = [
|
||||
{ id: "1", title: "First task", status: "done" as const },
|
||||
{ id: "2", title: "New task", status: "in_progress" as const },
|
||||
];
|
||||
|
||||
const event1 = createV0TaskTrackingObservation(1, "plan", earlyTasks);
|
||||
const event2 = createV0TaskTrackingObservation(2, "plan", lateTasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event1, event2],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [event1, event2],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual(lateTasks);
|
||||
expect(result.current.hasTaskList).toBe(true);
|
||||
});
|
||||
|
||||
it("updates when new events are added to the store", () => {
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
|
||||
const tasks = [{ id: "1", title: "New task", status: "todo" as const }];
|
||||
const event = createV0TaskTrackingObservation(1, "plan", tasks);
|
||||
|
||||
act(() => {
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.taskList).toEqual(tasks);
|
||||
expect(result.current.hasTaskList).toBe(true);
|
||||
});
|
||||
|
||||
it("returns hasTaskList=false when the latest plan has an empty task list", () => {
|
||||
const event = createV0TaskTrackingObservation(1, "plan", []);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1 events", () => {
|
||||
it('returns the task list from a v1 TaskTrackerObservation with command="plan"', () => {
|
||||
const tasks = [
|
||||
{ title: "First task", notes: "", status: "todo" as const },
|
||||
{
|
||||
title: "Second task",
|
||||
notes: "some note",
|
||||
status: "in_progress" as const,
|
||||
},
|
||||
];
|
||||
const event = createV1TaskTrackerObservation("1", "plan", tasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set(["1"]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([
|
||||
{ id: "1", title: "First task", notes: undefined, status: "todo" },
|
||||
{
|
||||
id: "2",
|
||||
title: "Second task",
|
||||
notes: "some note",
|
||||
status: "in_progress",
|
||||
},
|
||||
]);
|
||||
expect(result.current.hasTaskList).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores v1 TaskTrackerObservation with command !== "plan"', () => {
|
||||
const tasks = [
|
||||
{ title: "First task", notes: "", status: "todo" as const },
|
||||
];
|
||||
const event = createV1TaskTrackerObservation("1", "view", tasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set(["1"]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the latest v1 task list when multiple plan events exist", () => {
|
||||
const earlyTasks = [
|
||||
{ title: "First task", notes: "", status: "todo" as const },
|
||||
];
|
||||
const lateTasks = [
|
||||
{ title: "First task", notes: "", status: "done" as const },
|
||||
{ title: "New task", notes: "wip", status: "in_progress" as const },
|
||||
];
|
||||
|
||||
const event1 = createV1TaskTrackerObservation("1", "plan", earlyTasks);
|
||||
const event2 = createV1TaskTrackerObservation("2", "plan", lateTasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event1, event2],
|
||||
eventIds: new Set(["1", "2"]),
|
||||
uiEvents: [event1, event2],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([
|
||||
{ id: "1", title: "First task", notes: undefined, status: "done" },
|
||||
{ id: "2", title: "New task", notes: "wip", status: "in_progress" },
|
||||
]);
|
||||
expect(result.current.hasTaskList).toBe(true);
|
||||
});
|
||||
|
||||
it("returns hasTaskList=false when the latest v1 plan has an empty task list", () => {
|
||||
const event = createV1TaskTrackerObservation("1", "plan", []);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set(["1"]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { createRoutesStub, useSearchParams } from "react-router";
|
||||
import LoginPage from "#/routes/login";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
@@ -80,6 +80,29 @@ const RouterStub = createRoutesStub([
|
||||
},
|
||||
]);
|
||||
|
||||
function DestinationStub() {
|
||||
const [params] = useSearchParams();
|
||||
const loginMethod = params.get("login_method");
|
||||
return (
|
||||
<div data-testid="destination-page">
|
||||
{loginMethod && (
|
||||
<span data-testid="login-method-param">{loginMethod}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RouterStubWithDestination = createRoutesStub([
|
||||
{
|
||||
Component: LoginPage,
|
||||
path: "/login",
|
||||
},
|
||||
{
|
||||
Component: DestinationStub,
|
||||
path: "/settings",
|
||||
},
|
||||
]);
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -282,7 +305,9 @@ describe("LoginPage", () => {
|
||||
await user.click(gitlabButton);
|
||||
|
||||
// URL includes state parameter added by handleAuthRedirect
|
||||
expect(window.location.href).toContain("https://gitlab.com/oauth/authorize");
|
||||
expect(window.location.href).toContain(
|
||||
"https://gitlab.com/oauth/authorize",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redirect to Bitbucket auth URL when Bitbucket button is clicked", async () => {
|
||||
@@ -347,6 +372,30 @@ describe("LoginPage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve login_method param when redirecting authenticated users", async () => {
|
||||
// Arrange
|
||||
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
render(
|
||||
<RouterStubWithDestination
|
||||
initialEntries={["/login?returnTo=/settings&login_method=github"]}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Assert
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByTestId("destination-page")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("login-method-param")).toHaveTextContent(
|
||||
"github",
|
||||
);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should redirect OSS mode users to home", async () => {
|
||||
// @ts-expect-error - partial mock for testing
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
@@ -552,10 +601,12 @@ describe("LoginPage", () => {
|
||||
|
||||
it("should pass buildOAuthStateData to LoginContent for OAuth state encoding", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockBuildOAuthStateData = vi.fn((baseState: Record<string, string>) => ({
|
||||
...baseState,
|
||||
invitation_token: "inv-test-token-12345",
|
||||
}));
|
||||
const mockBuildOAuthStateData = vi.fn(
|
||||
(baseState: Record<string, string>) => ({
|
||||
...baseState,
|
||||
invitation_token: "inv-test-token-12345",
|
||||
}),
|
||||
);
|
||||
|
||||
useInvitationMock.mockReturnValue({
|
||||
invitationToken: "inv-test-token-12345",
|
||||
@@ -585,10 +636,12 @@ describe("LoginPage", () => {
|
||||
|
||||
it("should include invitation token in OAuth state when invitation is present", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockBuildOAuthStateData = vi.fn((baseState: Record<string, string>) => ({
|
||||
...baseState,
|
||||
invitation_token: "inv-test-token-12345",
|
||||
}));
|
||||
const mockBuildOAuthStateData = vi.fn(
|
||||
(baseState: Record<string, string>) => ({
|
||||
...baseState,
|
||||
invitation_token: "inv-test-token-12345",
|
||||
}),
|
||||
);
|
||||
|
||||
useInvitationMock.mockReturnValue({
|
||||
invitationToken: "inv-test-token-12345",
|
||||
@@ -634,9 +687,14 @@ describe("LoginPage", () => {
|
||||
clearInvitation: vi.fn(),
|
||||
});
|
||||
|
||||
render(<RouterStub initialEntries={["/login?invitation_token=inv-url-token-67890"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
render(
|
||||
<RouterStub
|
||||
initialEntries={["/login?invitation_token=inv-url-token-67890"]}
|
||||
/>,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("AUTH$INVITATION_PENDING")).toBeInTheDocument();
|
||||
|
||||
@@ -366,6 +366,130 @@ describe("MainApp", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Re-authentication with stored login method", () => {
|
||||
it("should show ReauthModal instead of redirecting to /login when login method exists", async () => {
|
||||
// Arrange - user is unauthenticated but has a stored login method
|
||||
vi.spyOn(AuthService, "authenticate").mockRejectedValue({
|
||||
response: { status: 401 },
|
||||
isAxiosError: true,
|
||||
});
|
||||
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => {
|
||||
if (key === "openhands_login_method") {
|
||||
return "github";
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
// Act
|
||||
renderWithLoginStub(RouterStubWithLogin, ["/"]);
|
||||
|
||||
// Assert - should show ReauthModal (with "Logging back in" text), not redirect to /login
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("AUTH$LOGGING_BACK_IN")).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
// Login page should NOT be shown when login method exists
|
||||
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect to /login when no login method is stored", async () => {
|
||||
// Arrange - user is unauthenticated and has no stored login method
|
||||
vi.spyOn(AuthService, "authenticate").mockRejectedValue({
|
||||
response: { status: 401 },
|
||||
isAxiosError: true,
|
||||
});
|
||||
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
// Act
|
||||
renderWithLoginStub(RouterStubWithLogin, ["/"]);
|
||||
|
||||
// Assert - should redirect to /login
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByTestId("login-page")).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loading states", () => {
|
||||
it("should show loading spinner while config is loading without redirecting", async () => {
|
||||
// Arrange - config never resolves (loading state)
|
||||
vi.spyOn(OptionService, "getConfig").mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => {
|
||||
if (key === "openhands_login_method") {
|
||||
return "github";
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
// Act
|
||||
renderWithLoginStub(RouterStubWithLogin, ["/"]);
|
||||
|
||||
// Assert - should show loading spinner
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should NOT redirect to login while loading
|
||||
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show loading spinner while auth is loading without redirecting", async () => {
|
||||
// Arrange - auth never resolves (loading state)
|
||||
vi.spyOn(AuthService, "authenticate").mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => {
|
||||
if (key === "openhands_login_method") {
|
||||
return "github";
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
// Act
|
||||
renderWithLoginStub(RouterStubWithLogin, ["/"]);
|
||||
|
||||
// Assert - should show loading spinner
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should NOT redirect to login while loading
|
||||
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Invitation URL Parameters", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(AuthService, "authenticate").mockRejectedValue({
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import TaskListTab from "#/routes/task-list-tab";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import type { TaskTrackingObservation } from "#/types/core/observations";
|
||||
|
||||
// Mock i18n
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
COMMON$NO_TASKS: "No tasks yet",
|
||||
TASK_TRACKING_OBSERVATION$TASK_NOTES: "Notes",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
function createTaskTrackingObservation(
|
||||
id: number,
|
||||
tasks: TaskTrackingObservation["extras"]["task_list"],
|
||||
): TaskTrackingObservation {
|
||||
return {
|
||||
id,
|
||||
source: "agent",
|
||||
observation: "task_tracking",
|
||||
message: "Task tracking update",
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
cause: 0,
|
||||
content: "",
|
||||
extras: {
|
||||
command: "plan",
|
||||
task_list: tasks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setTasks(tasks: TaskTrackingObservation["extras"]["task_list"]) {
|
||||
const event = createTaskTrackingObservation(1, tasks);
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEventStore.setState({
|
||||
events: [],
|
||||
eventIds: new Set(),
|
||||
uiEvents: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskListTab", () => {
|
||||
it("renders empty state with icon and message when there are no tasks", () => {
|
||||
const { container } = render(<TaskListTab />);
|
||||
|
||||
expect(screen.getByText("No tasks yet")).toBeInTheDocument();
|
||||
// Empty state should show the check-circle icon (rendered as SVG)
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state message using Text component (span)", () => {
|
||||
render(<TaskListTab />);
|
||||
|
||||
const message = screen.getByText("No tasks yet");
|
||||
expect(message.tagName).toBe("SPAN");
|
||||
});
|
||||
|
||||
it("renders task items when tasks exist", () => {
|
||||
setTasks([
|
||||
{ id: "1", title: "Implement feature", status: "todo" },
|
||||
{ id: "2", title: "Write tests", status: "in_progress" },
|
||||
{ id: "3", title: "Deploy", status: "done" },
|
||||
]);
|
||||
|
||||
const { container } = render(<TaskListTab />);
|
||||
|
||||
expect(screen.getByText("Implement feature")).toBeInTheDocument();
|
||||
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
||||
expect(screen.getByText("Deploy")).toBeInTheDocument();
|
||||
|
||||
const taskItems = container.querySelectorAll('[data-name="item"]');
|
||||
expect(taskItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not display task IDs", () => {
|
||||
setTasks([
|
||||
{ id: "task-1", title: "First task", status: "todo" },
|
||||
]);
|
||||
|
||||
render(<TaskListTab />);
|
||||
|
||||
expect(screen.queryByText(/task-1/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights in_progress tasks with a background", () => {
|
||||
setTasks([
|
||||
{ id: "1", title: "Todo task", status: "todo" },
|
||||
{ id: "2", title: "Active task", status: "in_progress" },
|
||||
{ id: "3", title: "Done task", status: "done" },
|
||||
]);
|
||||
|
||||
render(<TaskListTab />);
|
||||
|
||||
// Find each task item via its text, then check the wrapper div
|
||||
const activeItem = screen.getByText("Active task").closest("[data-name]");
|
||||
const activeWrapper = activeItem?.parentElement;
|
||||
expect(activeWrapper?.className).toContain("bg-[#2D3039]");
|
||||
|
||||
const todoItem = screen.getByText("Todo task").closest("[data-name]");
|
||||
expect(todoItem?.parentElement?.className).not.toContain("bg-[#2D3039]");
|
||||
|
||||
const doneItem = screen.getByText("Done task").closest("[data-name]");
|
||||
expect(doneItem?.parentElement?.className).not.toContain("bg-[#2D3039]");
|
||||
});
|
||||
|
||||
it("displays task notes when present and omits when absent", () => {
|
||||
setTasks([
|
||||
{
|
||||
id: "1",
|
||||
title: "Task with notes",
|
||||
status: "todo",
|
||||
notes: "Important note",
|
||||
},
|
||||
{ id: "2", title: "Task without notes", status: "todo" },
|
||||
]);
|
||||
|
||||
render(<TaskListTab />);
|
||||
|
||||
expect(screen.getByText("Notes: Important note")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/^Notes:/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the latest plan event when multiple exist", () => {
|
||||
const event1 = createTaskTrackingObservation(1, [
|
||||
{ id: "1", title: "Old task", status: "todo" },
|
||||
]);
|
||||
const event2 = createTaskTrackingObservation(2, [
|
||||
{ id: "1", title: "Updated task", status: "done" },
|
||||
{ id: "2", title: "New task", status: "in_progress" },
|
||||
]);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event1, event2],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [event1, event2],
|
||||
});
|
||||
|
||||
render(<TaskListTab />);
|
||||
|
||||
expect(screen.queryByText("Old task")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Updated task")).toBeInTheDocument();
|
||||
expect(screen.getByText("New task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders as a scrollable main element when tasks exist", () => {
|
||||
setTasks([{ id: "1", title: "A task", status: "todo" }]);
|
||||
|
||||
render(<TaskListTab />);
|
||||
|
||||
const main = screen.getByRole("main");
|
||||
expect(main).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getGitPath } from "#/utils/get-git-path";
|
||||
|
||||
describe("getGitPath", () => {
|
||||
it("should return /workspace/project when no repository is selected", () => {
|
||||
expect(getGitPath(null)).toBe("/workspace/project");
|
||||
expect(getGitPath(undefined)).toBe("/workspace/project");
|
||||
});
|
||||
|
||||
it("should handle standard owner/repo format (GitHub)", () => {
|
||||
expect(getGitPath("OpenHands/OpenHands")).toBe("/workspace/project/OpenHands");
|
||||
expect(getGitPath("facebook/react")).toBe("/workspace/project/react");
|
||||
});
|
||||
|
||||
it("should handle nested group paths (GitLab)", () => {
|
||||
expect(getGitPath("modernhealth/frontend-guild/pan")).toBe("/workspace/project/pan");
|
||||
expect(getGitPath("group/subgroup/repo")).toBe("/workspace/project/repo");
|
||||
expect(getGitPath("a/b/c/d/repo")).toBe("/workspace/project/repo");
|
||||
});
|
||||
|
||||
it("should handle single segment paths", () => {
|
||||
expect(getGitPath("repo")).toBe("/workspace/project/repo");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(getGitPath("")).toBe("/workspace/project");
|
||||
});
|
||||
});
|
||||
@@ -35,23 +35,17 @@ export function TaskItem({ task }: TaskItemProps) {
|
||||
const isDoneStatus = task.status === "done";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-[14px] items-center px-4 py-2 w-full"
|
||||
data-name="item"
|
||||
>
|
||||
<div className="flex gap-2 items-center w-full" data-name="item">
|
||||
<div className="shrink-0">{icon}</div>
|
||||
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
|
||||
<div className="flex flex-col items-start justify-center leading-[16px] text-nowrap whitespace-pre font-normal">
|
||||
<Typography.Text
|
||||
className={cn(
|
||||
"text-[12px] text-white",
|
||||
isDoneStatus && "text-[#A3A3A3]",
|
||||
"text-[12px]",
|
||||
isDoneStatus ? "text-[#A3A3A3]" : "text-white",
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-[10px] text-[#A3A3A3] font-normal">
|
||||
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_ID)}: {task.id}
|
||||
</Typography.Text>
|
||||
{task.notes && (
|
||||
<Typography.Text className="text-[10px] text-[#A3A3A3]">
|
||||
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
|
||||
|
||||
+5
@@ -16,8 +16,13 @@ const BrowserTab = lazy(() => import("#/routes/browser-tab"));
|
||||
const ServedTab = lazy(() => import("#/routes/served-tab"));
|
||||
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
|
||||
const PlannerTab = lazy(() => import("#/routes/planner-tab"));
|
||||
const TaskListTab = lazy(() => import("#/routes/task-list-tab"));
|
||||
|
||||
const TAB_CONFIG = {
|
||||
tasklist: {
|
||||
component: TaskListTab,
|
||||
titleKey: I18nKey.COMMON$TASK_LIST,
|
||||
},
|
||||
editor: {
|
||||
component: EditorTab,
|
||||
titleKey: I18nKey.COMMON$CHANGES,
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export function ConversationTabNav({
|
||||
data-testid={`conversation-tab-${tabValue}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md cursor-pointer",
|
||||
"pl-1.5 pr-2 py-1",
|
||||
"pl-1.5 pr-2 py-1 lg:py-1.5",
|
||||
"text-[#9299AA] bg-[#0D0F11]",
|
||||
isActive && "bg-[#25272D] text-white",
|
||||
isActive
|
||||
|
||||
+12
@@ -13,6 +13,8 @@ import VSCodeIcon from "#/icons/vscode.svg?react";
|
||||
import PillIcon from "#/icons/pill.svg?react";
|
||||
import PillFillIcon from "#/icons/pill-fill.svg?react";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import DoubleCheckIcon from "#/icons/double-check.svg?react";
|
||||
import { useTaskList } from "#/hooks/use-task-list";
|
||||
|
||||
interface ConversationTabsContextMenuProps {
|
||||
isOpen: boolean;
|
||||
@@ -29,6 +31,8 @@ export function ConversationTabsContextMenu({
|
||||
const { state, setUnpinnedTabs } =
|
||||
useConversationLocalStorageState(conversationId);
|
||||
|
||||
const { hasTaskList } = useTaskList();
|
||||
|
||||
const tabConfig = [
|
||||
{
|
||||
tab: "planner",
|
||||
@@ -42,6 +46,14 @@ export function ConversationTabsContextMenu({
|
||||
{ tab: "browser", icon: GlobeIcon, i18nKey: I18nKey.COMMON$BROWSER },
|
||||
];
|
||||
|
||||
if (hasTaskList) {
|
||||
tabConfig.unshift({
|
||||
tab: "tasklist",
|
||||
icon: DoubleCheckIcon,
|
||||
i18nKey: I18nKey.COMMON$TASK_LIST,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleTabClick = (tab: string) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import GitChanges from "#/icons/git_changes.svg?react";
|
||||
import VSCodeIcon from "#/icons/vscode.svg?react";
|
||||
import ThreeDotsVerticalIcon from "#/icons/three-dots-vertical.svg?react";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import DoubleCheckIcon from "#/icons/double-check.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useConversationLocalStorageState } from "#/utils/conversation-local-storage";
|
||||
import { ConversationTabNav } from "./conversation-tab-nav";
|
||||
@@ -17,6 +18,7 @@ import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useSelectConversationTab } from "#/hooks/use-select-conversation-tab";
|
||||
import { useTaskList } from "#/hooks/use-task-list";
|
||||
|
||||
export function ConversationTabs() {
|
||||
const { conversationId } = useConversationId();
|
||||
@@ -27,6 +29,8 @@ export function ConversationTabs() {
|
||||
const { state: persistedState } =
|
||||
useConversationLocalStorageState(conversationId);
|
||||
|
||||
const { hasTaskList } = useTaskList();
|
||||
|
||||
const {
|
||||
selectTab,
|
||||
isTabActive,
|
||||
@@ -120,6 +124,18 @@ export function ConversationTabs() {
|
||||
},
|
||||
];
|
||||
|
||||
if (hasTaskList) {
|
||||
tabs.unshift({
|
||||
tabValue: "tasklist",
|
||||
isActive: isTabActive("tasklist"),
|
||||
icon: DoubleCheckIcon,
|
||||
onClick: () => selectTab("tasklist"),
|
||||
tooltipContent: t(I18nKey.COMMON$TASK_LIST),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$TASK_LIST),
|
||||
label: t(I18nKey.COMMON$TASK_LIST),
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out unpinned tabs
|
||||
const visibleTabs = tabs.filter(
|
||||
(tab) => !persistedState.unpinnedTabs.includes(tab.tabValue),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import type { OHEvent } from "#/stores/use-event-store";
|
||||
import { isTaskTrackingObservation } from "#/types/core/guards";
|
||||
import type { OpenHandsParsedEvent } from "#/types/core";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
import type { OpenHandsEvent } from "#/types/v1/core";
|
||||
import type { TaskTrackerObservation } from "#/types/v1/core/base/observation";
|
||||
import type { ObservationEvent } from "#/types/v1/core/events/observation-event";
|
||||
|
||||
export interface TaskListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
function getTaskListFromEvent(event: OHEvent): TaskListItem[] | null {
|
||||
// v0 event format: observation is a string "task_tracking"
|
||||
const v0 = event as OpenHandsParsedEvent;
|
||||
if (isTaskTrackingObservation(v0) && v0.extras.command === "plan") {
|
||||
return v0.extras.task_list.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
notes: t.notes,
|
||||
}));
|
||||
}
|
||||
|
||||
// v1 event format: observation is an object with kind "TaskTrackerObservation"
|
||||
const v1 = event as OpenHandsEvent;
|
||||
if (
|
||||
isObservationEvent(v1) &&
|
||||
v1.observation.kind === "TaskTrackerObservation"
|
||||
) {
|
||||
const obs = (v1 as ObservationEvent<TaskTrackerObservation>).observation;
|
||||
if (obs.command === "plan") {
|
||||
return obs.task_list.map((t, i) => ({
|
||||
id: String(i + 1),
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
notes: t.notes || undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useTaskList() {
|
||||
const events = useEventStore((state) => state.events);
|
||||
|
||||
return useMemo(() => {
|
||||
// Iterate in reverse to find the latest TaskTrackingObservation with command="plan"
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const taskList = getTaskListFromEvent(events[i]);
|
||||
if (taskList) {
|
||||
return { taskList, hasTaskList: taskList.length > 0 };
|
||||
}
|
||||
}
|
||||
|
||||
return { taskList: [] as TaskListItem[], hasTaskList: false };
|
||||
}, [events]);
|
||||
}
|
||||
@@ -993,6 +993,8 @@ export enum I18nKey {
|
||||
COMMON$MORE_OPTIONS = "COMMON$MORE_OPTIONS",
|
||||
COMMON$CREATE_A_PLAN = "COMMON$CREATE_A_PLAN",
|
||||
COMMON$TASKS = "COMMON$TASKS",
|
||||
COMMON$TASK_LIST = "COMMON$TASK_LIST",
|
||||
COMMON$NO_TASKS = "COMMON$NO_TASKS",
|
||||
COMMON$PLAN_MD = "COMMON$PLAN_MD",
|
||||
COMMON$READ_MORE = "COMMON$READ_MORE",
|
||||
COMMON$BUILD = "COMMON$BUILD",
|
||||
|
||||
@@ -15891,6 +15891,38 @@
|
||||
"de": "Aufgaben",
|
||||
"uk": "Завдання"
|
||||
},
|
||||
"COMMON$TASK_LIST": {
|
||||
"en": "Task List",
|
||||
"ja": "タスクリスト",
|
||||
"zh-CN": "任务列表",
|
||||
"zh-TW": "任務列表",
|
||||
"ko-KR": "작업 목록",
|
||||
"no": "Oppgaveliste",
|
||||
"it": "Elenco attività",
|
||||
"pt": "Lista de tarefas",
|
||||
"es": "Lista de tareas",
|
||||
"ar": "قائمة المهام",
|
||||
"fr": "Liste des tâches",
|
||||
"tr": "Görev listesi",
|
||||
"de": "Aufgabenliste",
|
||||
"uk": "Список завдань"
|
||||
},
|
||||
"COMMON$NO_TASKS": {
|
||||
"en": "No tasks yet",
|
||||
"ja": "タスクはまだありません",
|
||||
"zh-CN": "暂无任务",
|
||||
"zh-TW": "尚無任務",
|
||||
"ko-KR": "아직 작업이 없습니다",
|
||||
"no": "Ingen oppgaver ennå",
|
||||
"it": "Nessuna attività",
|
||||
"pt": "Nenhuma tarefa ainda",
|
||||
"es": "Sin tareas aún",
|
||||
"ar": "لا توجد مهام بعد",
|
||||
"fr": "Aucune tâche pour le moment",
|
||||
"tr": "Henüz görev yok",
|
||||
"de": "Noch keine Aufgaben",
|
||||
"uk": "Завдань поки немає"
|
||||
},
|
||||
"COMMON$PLAN_MD": {
|
||||
"en": "Plan.md",
|
||||
"ja": "Plan.md",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 10.5L6.5 14L14 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7 10.5L10.5 14L18 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -40,11 +40,18 @@ export default function LoginPage() {
|
||||
}, [config.isLoading, config.data?.app_mode, navigate]);
|
||||
|
||||
// Redirect authenticated users away from login page
|
||||
// Preserve login_method param so useAuthCallback can store it for auto-login
|
||||
React.useEffect(() => {
|
||||
if (!isAuthLoading && isAuthed) {
|
||||
navigate(returnTo, { replace: true });
|
||||
const loginMethod = searchParams.get("login_method");
|
||||
let destination = returnTo;
|
||||
if (loginMethod) {
|
||||
const separator = returnTo.includes("?") ? "&" : "?";
|
||||
destination = `${returnTo}${separator}login_method=${encodeURIComponent(loginMethod)}`;
|
||||
}
|
||||
navigate(destination, { replace: true });
|
||||
}
|
||||
}, [isAuthed, isAuthLoading, navigate, returnTo]);
|
||||
}, [isAuthed, isAuthLoading, navigate, returnTo, searchParams]);
|
||||
|
||||
if (isAuthLoading || config.isLoading) {
|
||||
return (
|
||||
|
||||
@@ -173,14 +173,17 @@ export default function MainApp() {
|
||||
setLoginMethodExists(checkLoginMethodExists());
|
||||
}, [isAuthed, checkLoginMethodExists]);
|
||||
|
||||
// Show loading spinner while config or auth is loading
|
||||
const isLoading = config.isLoading || isAuthLoading;
|
||||
|
||||
// Only decide to redirect AFTER loading completes
|
||||
const shouldRedirectToLogin =
|
||||
config.isLoading ||
|
||||
isAuthLoading ||
|
||||
(!isAuthed &&
|
||||
!isAuthError &&
|
||||
!isOnIntermediatePage &&
|
||||
config.data?.app_mode === "saas" &&
|
||||
!loginMethodExists);
|
||||
!isLoading &&
|
||||
!isAuthed &&
|
||||
!isAuthError &&
|
||||
!isOnIntermediatePage &&
|
||||
config.data?.app_mode === "saas" &&
|
||||
!loginMethodExists;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shouldRedirectToLogin) {
|
||||
@@ -197,7 +200,8 @@ export default function MainApp() {
|
||||
}
|
||||
}, [shouldRedirectToLogin, pathname, searchParams, navigate]);
|
||||
|
||||
if (shouldRedirectToLogin) {
|
||||
// Show loading spinner while loading OR when about to redirect
|
||||
if (isLoading || shouldRedirectToLogin) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-base">
|
||||
<LoadingSpinner size="large" />
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
|
||||
import { TaskItem } from "#/components/features/chat/task-tracking/task-item";
|
||||
import { useTaskList } from "#/hooks/use-task-list";
|
||||
import { Text } from "#/ui/typography";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
function TaskListTab() {
|
||||
const { t } = useTranslation();
|
||||
const { taskList } = useTaskList();
|
||||
|
||||
if (taskList.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full p-10 gap-4">
|
||||
<CheckCircleIcon width={109} height={109} color="#A1A1A1" />
|
||||
<Text className="text-[#8D95A9] text-[19px] font-normal leading-5">
|
||||
{t(I18nKey.COMMON$NO_TASKS)}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-y-auto flex flex-col custom-scrollbar-always">
|
||||
{taskList.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className={cn(
|
||||
"px-4 py-2",
|
||||
task.status === "in_progress" && "bg-[#2D3039]",
|
||||
)}
|
||||
>
|
||||
<TaskItem task={task} />
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default TaskListTab;
|
||||
@@ -11,7 +11,8 @@ export type ConversationTab =
|
||||
| "served"
|
||||
| "vscode"
|
||||
| "terminal"
|
||||
| "planner";
|
||||
| "planner"
|
||||
| "tasklist";
|
||||
|
||||
export type ConversationMode = "code" | "plan";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* If a repository is selected, returns /workspace/project/{repo-name}
|
||||
* Otherwise, returns /workspace/project
|
||||
*
|
||||
* @param selectedRepository The selected repository (e.g., "OpenHands/OpenHands" or "owner/repo")
|
||||
* @param selectedRepository The selected repository (e.g., "OpenHands/OpenHands", "owner/repo", or "group/subgroup/repo")
|
||||
* @returns The git path to use
|
||||
*/
|
||||
export function getGitPath(
|
||||
@@ -13,10 +13,10 @@ export function getGitPath(
|
||||
return "/workspace/project";
|
||||
}
|
||||
|
||||
// Extract the repository name from "owner/repo" format
|
||||
// The folder name is the second part after "/"
|
||||
// Extract the repository name from the path
|
||||
// The folder name is always the last part (handles both "owner/repo" and "group/subgroup/repo" formats)
|
||||
const parts = selectedRepository.split("/");
|
||||
const repoName = parts.length > 1 ? parts[1] : parts[0];
|
||||
const repoName = parts[parts.length - 1];
|
||||
|
||||
return `/workspace/project/${repoName}`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export const VERIFIED_MODELS = [
|
||||
"gpt-5.2",
|
||||
"minimax-m2.5",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"deepseek-chat",
|
||||
"devstral-medium-2512",
|
||||
@@ -65,6 +66,7 @@ export const VERIFIED_OPENHANDS_MODELS = [
|
||||
"gpt-5.2",
|
||||
"minimax-m2.5",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"devstral-medium-2512",
|
||||
"kimi-k2-0711-preview",
|
||||
|
||||
@@ -216,13 +216,18 @@ class BitbucketDCMixinBase(BaseGitService, HTTPClient):
|
||||
)
|
||||
|
||||
async def _parse_repository(
|
||||
self, repo: dict, link_header: str | None = None
|
||||
self,
|
||||
repo: dict,
|
||||
link_header: str | None = None,
|
||||
fetch_default_branch: bool = False,
|
||||
) -> Repository:
|
||||
"""Parse a Bitbucket data center API repository response into a Repository object.
|
||||
|
||||
Args:
|
||||
repo: Repository data from Bitbucket data center API
|
||||
link_header: Optional link header for pagination
|
||||
fetch_default_branch: Whether to make an additional API call to fetch the
|
||||
default branch. Set to False for listing endpoints to avoid N+1 queries.
|
||||
|
||||
Returns:
|
||||
Repository object
|
||||
@@ -240,14 +245,15 @@ class BitbucketDCMixinBase(BaseGitService, HTTPClient):
|
||||
is_public = repo.get('public', False)
|
||||
|
||||
main_branch: str | None = None
|
||||
try:
|
||||
default_branch_url = (
|
||||
f'{self._repo_api_base(project_key, repo_slug)}/default-branch'
|
||||
)
|
||||
default_branch_data, _ = await self._make_request(default_branch_url)
|
||||
main_branch = default_branch_data.get('displayId') or None
|
||||
except Exception as e:
|
||||
logger.debug(f'Could not fetch default branch for {full_name}: {e}')
|
||||
if fetch_default_branch:
|
||||
try:
|
||||
default_branch_url = (
|
||||
f'{self._repo_api_base(project_key, repo_slug)}/default-branch'
|
||||
)
|
||||
default_branch_data, _ = await self._make_request(default_branch_url)
|
||||
main_branch = default_branch_data.get('displayId') or None
|
||||
except Exception as e:
|
||||
logger.debug(f'Could not fetch default branch for {full_name}: {e}')
|
||||
|
||||
return Repository(
|
||||
id=str(repo.get('id', '')),
|
||||
@@ -275,7 +281,7 @@ class BitbucketDCMixinBase(BaseGitService, HTTPClient):
|
||||
owner, repo = self._extract_owner_and_repo(repository)
|
||||
url = self._repo_api_base(owner, repo)
|
||||
data, _ = await self._make_request(url)
|
||||
return await self._parse_repository(data)
|
||||
return await self._parse_repository(data, fetch_default_branch=True)
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
|
||||
@@ -115,6 +115,7 @@ REASONING_EFFORT_PATTERNS: list[str] = [
|
||||
'o4-mini-2025-04-16',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-3.1-pro*',
|
||||
'gpt-5*',
|
||||
# DeepSeek reasoning family
|
||||
'deepseek-r1-0528*',
|
||||
@@ -139,6 +140,7 @@ PROMPT_CACHE_PATTERNS: list[str] = [
|
||||
'claude-3-opus-20240229',
|
||||
'claude-sonnet-4*',
|
||||
'claude-opus-4*',
|
||||
'gemini-3.1-pro*',
|
||||
# Kimi series - verified via litellm config
|
||||
'kimi-k2.5',
|
||||
# GLM series - verified via litellm config
|
||||
|
||||
@@ -22,6 +22,7 @@ OPENHANDS_MODELS = [
|
||||
'openhands/gpt-5.2',
|
||||
'openhands/minimax-m2.5',
|
||||
'openhands/gemini-3-pro-preview',
|
||||
'openhands/gemini-3.1-pro-preview',
|
||||
'openhands/gemini-3-flash-preview',
|
||||
'openhands/deepseek-chat',
|
||||
'openhands/devstral-medium-2512',
|
||||
|
||||
Generated
+38
-12
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
@@ -3468,7 +3468,7 @@ files = [
|
||||
[package.dependencies]
|
||||
googleapis-common-protos = ">=1.5.5"
|
||||
grpcio = ">=1.71.2"
|
||||
protobuf = ">=5.26.1,<6.0.dev0"
|
||||
protobuf = ">=5.26.1,<6.0dev"
|
||||
|
||||
[[package]]
|
||||
name = "grpclib"
|
||||
@@ -3836,7 +3836,7 @@ pfzy = ">=0.3.1,<0.4.0"
|
||||
prompt-toolkit = ">=3.0.1,<4.0.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "installer"
|
||||
@@ -4287,7 +4287,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
referencing = ">=0.28.4"
|
||||
rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""}
|
||||
@@ -4752,7 +4752,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=14.5.14"
|
||||
certifi = ">=14.05.14"
|
||||
durationpy = ">=0.7"
|
||||
google-auth = ">=1.0.1"
|
||||
oauthlib = ">=3.2.2"
|
||||
@@ -6942,7 +6942,6 @@ files = [
|
||||
{file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"},
|
||||
{file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"},
|
||||
]
|
||||
markers = {runtime = "sys_platform != \"win32\" and sys_platform != \"emscripten\""}
|
||||
|
||||
[package.dependencies]
|
||||
ptyprocess = ">=0.5"
|
||||
@@ -6960,7 +6959,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pg8000"
|
||||
@@ -7517,7 +7516,6 @@ files = [
|
||||
{file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
|
||||
{file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
|
||||
]
|
||||
markers = {runtime = "sys_platform != \"win32\" and sys_platform != \"emscripten\" or os_name != \"nt\""}
|
||||
|
||||
[[package]]
|
||||
name = "pure-eval"
|
||||
@@ -7546,6 +7544,18 @@ files = [
|
||||
{file = "puremagic-1.30.tar.gz", hash = "sha256:f9ff7ac157d54e9cf3bff1addfd97233548e75e685282d84ae11e7ffee1614c9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py"
|
||||
version = "1.11.0"
|
||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
|
||||
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-key-value-aio"
|
||||
version = "0.4.4"
|
||||
@@ -11690,6 +11700,22 @@ pytest = ">=7"
|
||||
[package.extras]
|
||||
testing = ["process-tests", "pytest-xdist", "virtualenv"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-forked"
|
||||
version = "1.6.0"
|
||||
description = "run tests in isolated forked subprocesses"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "pytest-forked-1.6.0.tar.gz", hash = "sha256:4dafd46a9a600f65d822b8f605133ecf5b3e1941ebb3588e943b4e3eb71a5a3f"},
|
||||
{file = "pytest_forked-1.6.0-py3-none-any.whl", hash = "sha256:810958f66a91afb1a1e2ae83089d8dc1cd2437ac96b12963042fbb9fb4d16af0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
py = "*"
|
||||
pytest = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "pytest-playwright"
|
||||
version = "0.7.2"
|
||||
@@ -12875,10 +12901,10 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "scantree"
|
||||
@@ -14814,7 +14840,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
|
||||
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
|
||||
|
||||
[extras]
|
||||
third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api-client"]
|
||||
@@ -14822,4 +14848,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "54d8d1b20ca7d88287c479f43f7bbe0402c9202cbdb24c9d091b2c23245d6c47"
|
||||
content-hash = "7319bfec87aed5ed2803ad7cb947f875e83fa62216b1662a87b9b84078dc03e4"
|
||||
|
||||
@@ -130,6 +130,7 @@ test = [
|
||||
"pytest",
|
||||
"pytest-asyncio",
|
||||
"pytest-cov",
|
||||
"pytest-forked",
|
||||
"pytest-playwright>=0.7",
|
||||
"pytest-timeout>=2.4",
|
||||
"pytest-xdist",
|
||||
@@ -280,6 +281,7 @@ optional = true
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
pytest-asyncio = "*"
|
||||
pytest-forked = "*"
|
||||
pytest-xdist = "*"
|
||||
pytest-playwright = "^0.7.0"
|
||||
pytest-timeout = "^2.4.0"
|
||||
|
||||
@@ -112,21 +112,15 @@ async def test_search_repositories_slash_query():
|
||||
query = 'PROJ/myrepo'
|
||||
|
||||
mock_repo = _repo_dict('PROJ', slug='myrepo', name='My Repository')
|
||||
mock_default_branch = {'displayId': 'main'}
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
'_fetch_paginated_data',
|
||||
new=AsyncMock(return_value=[mock_repo]),
|
||||
) as mock_fetch:
|
||||
with patch.object(
|
||||
svc,
|
||||
'_make_request',
|
||||
new=AsyncMock(return_value=(mock_default_branch, {})),
|
||||
):
|
||||
repos = await svc.search_repositories(
|
||||
query, 25, 'name', 'asc', False, AppMode.SAAS
|
||||
)
|
||||
repos = await svc.search_repositories(
|
||||
query, 25, 'name', 'asc', False, AppMode.SAAS
|
||||
)
|
||||
|
||||
mock_fetch.assert_called_once_with(
|
||||
'https://host.example.com/rest/api/1.0/projects/PROJ/repos',
|
||||
@@ -135,6 +129,7 @@ async def test_search_repositories_slash_query():
|
||||
)
|
||||
assert len(repos) == 1
|
||||
assert repos[0].full_name == 'PROJ/myrepo'
|
||||
assert repos[0].main_branch is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -143,24 +138,19 @@ async def test_search_repositories_slash_query_filters_by_name():
|
||||
svc = make_service()
|
||||
matching = _repo_dict('PROJ', slug='proj-alpha', name='My Repository')
|
||||
non_matching = _repo_dict('PROJ', slug='proj-beta', name='Other Repo')
|
||||
mock_default_branch = {'displayId': 'main'}
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
'_fetch_paginated_data',
|
||||
new=AsyncMock(return_value=[matching, non_matching]),
|
||||
):
|
||||
with patch.object(
|
||||
svc,
|
||||
'_make_request',
|
||||
new=AsyncMock(return_value=(mock_default_branch, {})),
|
||||
):
|
||||
repos = await svc.search_repositories(
|
||||
'PROJ/my repository', 25, 'name', 'asc', False, AppMode.SAAS
|
||||
)
|
||||
repos = await svc.search_repositories(
|
||||
'PROJ/my repository', 25, 'name', 'asc', False, AppMode.SAAS
|
||||
)
|
||||
|
||||
assert len(repos) == 1
|
||||
assert repos[0].full_name == 'PROJ/proj-alpha'
|
||||
assert repos[0].main_branch is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -169,24 +159,19 @@ async def test_search_repositories_slash_query_filters_by_slug():
|
||||
svc = make_service()
|
||||
matching = _repo_dict('PROJ', slug='my-repo', name='My Repository')
|
||||
non_matching = _repo_dict('PROJ', slug='other-repo', name='Other Repository')
|
||||
mock_default_branch = {'displayId': 'main'}
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
'_fetch_paginated_data',
|
||||
new=AsyncMock(return_value=[matching, non_matching]),
|
||||
):
|
||||
with patch.object(
|
||||
svc,
|
||||
'_make_request',
|
||||
new=AsyncMock(return_value=(mock_default_branch, {})),
|
||||
):
|
||||
repos = await svc.search_repositories(
|
||||
'PROJ/my-repo', 25, 'name', 'asc', False, AppMode.SAAS
|
||||
)
|
||||
repos = await svc.search_repositories(
|
||||
'PROJ/my-repo', 25, 'name', 'asc', False, AppMode.SAAS
|
||||
)
|
||||
|
||||
assert len(repos) == 1
|
||||
assert repos[0].full_name == 'PROJ/my-repo'
|
||||
assert repos[0].main_branch is None
|
||||
|
||||
|
||||
# ── get_paginated_repos ───────────────────────────────────────────────────────
|
||||
@@ -199,18 +184,18 @@ async def test_get_paginated_repos_parses_values():
|
||||
'values': [_repo_dict()],
|
||||
'isLastPage': True,
|
||||
}
|
||||
mock_default_branch = {'displayId': 'main'}
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
'_make_request',
|
||||
side_effect=[(mock_response, {}), (mock_default_branch, {})],
|
||||
return_value=(mock_response, {}),
|
||||
):
|
||||
repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ')
|
||||
|
||||
assert len(repos) == 1
|
||||
assert repos[0].full_name == 'PROJ/myrepo'
|
||||
assert repos[0].link_header == ''
|
||||
assert repos[0].main_branch is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -221,17 +206,17 @@ async def test_get_paginated_repos_has_next_page():
|
||||
'isLastPage': False,
|
||||
'nextPageStart': 25,
|
||||
}
|
||||
mock_default_branch = {'displayId': 'main'}
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
'_make_request',
|
||||
side_effect=[(mock_response, {}), (mock_default_branch, {})],
|
||||
return_value=(mock_response, {}),
|
||||
):
|
||||
repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ')
|
||||
|
||||
assert len(repos) == 1
|
||||
assert 'rel="next"' in repos[0].link_header
|
||||
assert repos[0].main_branch is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -241,17 +226,17 @@ async def test_get_paginated_repos_last_page():
|
||||
'values': [_repo_dict()],
|
||||
'isLastPage': True,
|
||||
}
|
||||
mock_default_branch = {'displayId': 'main'}
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
'_make_request',
|
||||
side_effect=[(mock_response, {}), (mock_default_branch, {})],
|
||||
return_value=(mock_response, {}),
|
||||
):
|
||||
repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ')
|
||||
|
||||
assert len(repos) == 1
|
||||
assert repos[0].link_header == ''
|
||||
assert repos[0].main_branch is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -265,17 +250,17 @@ async def test_get_paginated_repos_filters_by_slug():
|
||||
],
|
||||
'isLastPage': True,
|
||||
}
|
||||
mock_default_branch = {'displayId': 'main'}
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
'_make_request',
|
||||
side_effect=[(mock_response, {}), (mock_default_branch, {})],
|
||||
return_value=(mock_response, {}),
|
||||
):
|
||||
repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ', query='my-repo')
|
||||
|
||||
assert len(repos) == 1
|
||||
assert repos[0].full_name == 'PROJ/my-repo'
|
||||
assert repos[0].main_branch is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -289,12 +274,11 @@ async def test_get_paginated_repos_filters_by_name():
|
||||
],
|
||||
'isLastPage': True,
|
||||
}
|
||||
mock_default_branch = {'displayId': 'main'}
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
'_make_request',
|
||||
side_effect=[(mock_response, {}), (mock_default_branch, {})],
|
||||
return_value=(mock_response, {}),
|
||||
):
|
||||
repos = await svc.get_paginated_repos(
|
||||
1, 25, 'name', 'PROJ', query='my repository'
|
||||
@@ -302,6 +286,7 @@ async def test_get_paginated_repos_filters_by_name():
|
||||
|
||||
assert len(repos) == 1
|
||||
assert repos[0].full_name == 'PROJ/proj-alpha'
|
||||
assert repos[0].main_branch is None
|
||||
|
||||
|
||||
# ── get_all_repositories ──────────────────────────────────────────────────────
|
||||
@@ -320,14 +305,14 @@ async def test_get_all_repositories_iterates_projects():
|
||||
return [_repo_dict('PROJ2', 'repo2')]
|
||||
return []
|
||||
|
||||
mock_default_branch = {'displayId': 'main'}
|
||||
with patch.object(svc, '_fetch_paginated_data', side_effect=fake_fetch):
|
||||
with patch.object(svc, '_make_request', return_value=(mock_default_branch, {})):
|
||||
repos = await svc.get_all_repositories('name', AppMode.SAAS)
|
||||
repos = await svc.get_all_repositories('name', AppMode.SAAS)
|
||||
|
||||
full_names = {r.full_name for r in repos}
|
||||
assert 'PROJ1/repo1' in full_names
|
||||
assert 'PROJ2/repo2' in full_names
|
||||
for repo in repos:
|
||||
assert repo.main_branch is None
|
||||
|
||||
|
||||
# ── get_installations ─────────────────────────────────────────────────────────
|
||||
@@ -352,4 +337,4 @@ async def test_get_installations_returns_project_keys():
|
||||
async def _make_parsed_repo(svc, repo_dict):
|
||||
"""Helper to create a parsed Repository from a repo dict (with mocked default branch)."""
|
||||
with patch.object(svc, '_make_request', return_value=({'displayId': 'main'}, {})):
|
||||
return await svc._parse_repository(repo_dict)
|
||||
return await svc._parse_repository(repo_dict, fetch_default_branch=True)
|
||||
|
||||
Reference in New Issue
Block a user