feat: Allow attaching/changing repository for existing conversations (#12671)

Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
MkDev11
2026-02-25 03:09:12 -08:00
committed by GitHub
parent dc039d81d6
commit 51b989b5f8
15 changed files with 1431 additions and 37 deletions

View File

@@ -170,7 +170,15 @@ class AppConversationStartRequest(OpenHandsModel):
class AppConversationUpdateRequest(BaseModel):
public: bool
"""Request model for updating conversation metadata.
All fields are optional - only provided fields will be updated.
"""
public: bool | None = None
selected_repository: str | None = None
selected_branch: str | None = None
git_provider: ProviderType | None = None
class AppConversationStartTaskStatus(Enum):

View File

@@ -1283,20 +1283,97 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
f'Successfully updated agent-server conversation {conversation_id} title to "{new_title}"'
)
def _validate_repository_update(
self,
request: AppConversationUpdateRequest,
existing_branch: str | None = None,
) -> None:
"""Validate repository-related fields in the update request.
Args:
request: The update request containing fields to validate
existing_branch: The conversation's current branch (if any)
Raises:
ValueError: If validation fails
"""
# Check if repository is being set
if 'selected_repository' in request.model_fields_set:
repo = request.selected_repository
if repo is not None:
# Validate repository format (owner/repo)
if '/' not in repo or repo.count('/') != 1:
raise ValueError(
f"Invalid repository format: '{repo}'. Expected 'owner/repo'."
)
# Sanitize: check for dangerous characters
if any(c in repo for c in [';', '&', '|', '$', '`', '\n', '\r']):
raise ValueError(f"Invalid characters in repository name: '{repo}'")
# If setting a repository, branch should also be provided
# (either in this request or already exists in conversation)
if (
'selected_branch' not in request.model_fields_set
and existing_branch is None
):
_logger.warning(
f'Repository {repo} set without branch in the same request '
'and no existing branch in conversation'
)
else:
# Repository is being removed (set to null)
# Enforce consistency: branch and provider must also be cleared
if 'selected_branch' in request.model_fields_set:
if request.selected_branch is not None:
raise ValueError(
'When removing repository, branch must also be cleared'
)
if 'git_provider' in request.model_fields_set:
if request.git_provider is not None:
raise ValueError(
'When removing repository, git_provider must also be cleared'
)
# Validate branch if provided
if 'selected_branch' in request.model_fields_set:
branch = request.selected_branch
if branch is not None:
# Sanitize: check for dangerous characters
if any(c in branch for c in [';', '&', '|', '$', '`', '\n', '\r', ' ']):
raise ValueError(f"Invalid characters in branch name: '{branch}'")
async def update_app_conversation(
self, conversation_id: UUID, request: AppConversationUpdateRequest
) -> AppConversation | None:
"""Update an app conversation and return it. Return None if the conversation
did not exist.
"""Update an app conversation and return it.
Return None if the conversation did not exist.
Only fields that are explicitly set in the request will be updated.
This allows partial updates where only specific fields are modified.
Fields can be set to None to clear them (e.g., removing a repository).
Raises:
ValueError: If repository/branch validation fails
"""
info = await self.app_conversation_info_service.get_app_conversation_info(
conversation_id
)
if info is None:
return None
for field_name in AppConversationUpdateRequest.model_fields:
# Validate repository-related fields before updating
# Pass existing branch to avoid false warnings when only updating repository
self._validate_repository_update(request, existing_branch=info.selected_branch)
# Only update fields that were explicitly provided in the request
# This uses Pydantic's model_fields_set to detect which fields were set,
# allowing us to distinguish between "not provided" and "explicitly set to None"
for field_name in request.model_fields_set:
value = getattr(request, field_name)
setattr(info, field_name, value)
info = await self.app_conversation_info_service.save_app_conversation_info(info)
conversations = await self._build_app_conversations([info])
return conversations[0]