Compare commits

..

9 Commits

Author SHA1 Message Date
openhands 6ec03098ad fix: add integration tests, store prompt in config, remove AST extraction, extract pagination helper, simplify update
- Thread 1: Added integration tests using real SQLite database (aiosqlite)
  that exercise actual SQL queries for list, get, create, delete, pagination
- Thread 3: Store prompt in config JSON column so DB is source of truth,
  not the generated file
- Thread 4: Removed _extract_prompt_from_file (AST extraction) entirely
- Thread 5: Extracted _paginate() helper used by search_automations and
  list_automation_runs
- Thread 6: Simplified update endpoint - reads prompt from config instead
  of parsing file content

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 18:01:25 +00:00
openhands 20e0ebacf0 fix: address PR review feedback for automation CRUD API
- Fix pagination cursor bug: next_page_id now points to rows[limit]
  (first item of next page) instead of rows[limit-1] in both
  search_automations and list_automation_runs
- Simplify update endpoint: use model_dump(exclude_unset=True) to
  extract changed fields and set intersection for regen detection
- Add user isolation security tests: verify user B cannot GET, PATCH,
  DELETE, or search user A's automations
- Move automation_router import to top of saas_server.py with other
  router imports
- Consistent error handling: capture file_store_key before session
  close in delete endpoint
- Safe isoformat() calls: guard against None timestamps in
  _automation_to_response and _run_to_response

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 08:23:20 +00:00
openhands 3230813a95 [Automations Phase 1] Task 2: CRUD API
Implement REST API for creating, reading, updating, and deleting
automations. Simple mode only for Phase 1 (form input → generated file).

New files:
- enterprise/server/routes/automation_models.py: Pydantic request/response models
- enterprise/server/routes/automations.py: FastAPI router with 8 endpoints
- enterprise/storage/automation.py: SQLAlchemy models (stub for Task 1)
- enterprise/storage/automation_event.py: SQLAlchemy model (stub for Task 1)
- enterprise/services/: Config, file generator, event publisher stubs

Endpoints:
- POST /api/v1/automations — Create automation
- GET /api/v1/automations/search — List automations (paginated)
- GET /api/v1/automations/{id} — Get automation
- PATCH /api/v1/automations/{id} — Update automation
- DELETE /api/v1/automations/{id} — Delete automation and runs
- POST /api/v1/automations/{id}/run — Manual trigger
- GET /api/v1/automations/{id}/runs — List runs (paginated)
- GET /api/v1/automations/{id}/runs/{run_id} — Get run detail

Part of RFC: https://github.com/OpenHands/OpenHands/issues/13275

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-10 18:45:48 +00:00
Juan Michelini 5e5950b091 Add Gemini-3.1-Pro-Preview model support to frontend (#13253)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-03-10 16:18:13 +00:00
John-Mason P. Shackelford c7ff560465 Fix getGitPath to handle nested GitLab group paths (#13006)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-10 11:12:08 -05:00
Joe Laverty 3432bbbb88 fix: Remove N+1 request from Bitbucket Data Center integration (#13281) 2026-03-10 11:08:30 -05:00
Hiep Le fc24be2627 fix(frontend): preserve login_method param to enable session re-authentication (#13310) 2026-03-10 22:52:40 +07:00
Hiep Le bc72b38d6e fix(backend): propagate LLM settings to all org members when admin saves settings (#13326) 2026-03-10 22:52:01 +07:00
Dream 145f1266e6 feat(frontend): create a separate UI tab for monitoring tasks (#13065)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-10 22:31:38 +07:00
45 changed files with 2966 additions and 161 deletions
+2 -2
View File
@@ -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
+38 -10
View File
@@ -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"
+1
View File
@@ -61,6 +61,7 @@ types-requests = "^2.32.4.20250611"
pytest = "*"
pytest-cov = "*"
pytest-asyncio = "*"
pytest-forked = "*"
pytest-xdist = "*"
flake8 = "*"
openai = "*"
+3
View File
@@ -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
+465
View File
@@ -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)
View File
+52
View File
@@ -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,
)
+47
View File
@@ -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()
)
+25
View File
@@ -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)
+26 -1
View File
@@ -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}'
@@ -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(
@@ -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);
});
});
});
+71 -13
View File
@@ -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}
@@ -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,
@@ -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
@@ -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),
+64
View File
@@ -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]);
}
+2
View File
@@ -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",
+32
View File
@@ -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",
+4
View File
@@ -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

+9 -2
View File
@@ -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 (
+12 -8
View File
@@ -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" />
+41
View File
@@ -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;
+2 -1
View File
@@ -11,7 +11,8 @@ export type ConversationTab =
| "served"
| "vscode"
| "terminal"
| "planner";
| "planner"
| "tasklist";
export type ConversationMode = "code" | "plan";
+4 -4
View File
@@ -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}`;
}
+2
View File
@@ -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."""
+2
View 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
+1
View File
@@ -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
View File
@@ -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"
+2
View File
@@ -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)