From 35d8b34d7c81ad671985c5bdf0c15ad3550c15e0 Mon Sep 17 00:00:00 2001 From: Ryan Cheley Date: Wed, 18 Mar 2026 20:39:36 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20fix(#638):=20Add=20custom=20fiel?= =?UTF-8?q?d=20type=20discovery=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement dynamic field type discovery to support all YouTrack custom field types, not just Enum types. The CLI now automatically detects field types from project configuration with proper fallback behavior. Changes: - Add field type discovery method discover_custom_field() to ProjectService - Add _project_to_issue_field_type() mapping for type conversion - Add missing field creation methods to CustomFieldManager: - create_simple_field() for integer/float fields - create_date_field() for date fields - create_single_version_field() for version fields - create_single_build_field() for build fields - create_field_by_type() for polymorphic field creation - Update create_issue() and update_issue() to use field type discovery - Add caching for discovered field types to improve performance - Update documentation with examples for all supported field types - Update CHANGELOG with fix details Supported field types: - Enum (single/multi) - Text - Simple (integer/float) - User (single/multi) - Version (single/multi) - Build (single/multi) - Date/DateTime - Period - State Co-Authored-By: Claude Haiku 4.5 --- CHANGELOG.md | 5 ++ docs/commands/issues.rst | 38 ++++++++- youtrack_cli/custom_field_manager.py | 122 +++++++++++++++++++++++++++ youtrack_cli/services/issues.py | 62 +++++++++++++- youtrack_cli/services/projects.py | 115 +++++++++++++++++++++++++ 5 files changed, 337 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 274bfcb..44bbf40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Fixed custom fields feature to support all field types (Text, Simple, User, Version, Build, etc.), not just Enum types (#638) +- Custom fields now use dynamic type discovery to query the project's field configuration +- Added proper fallback behavior when field type cannot be determined + ## [0.22.0] - 2025-03-18 ### Added diff --git a/docs/commands/issues.rst b/docs/commands/issues.rst index 00fd80b..e49708d 100644 --- a/docs/commands/issues.rst +++ b/docs/commands/issues.rst @@ -48,13 +48,41 @@ Create new issues in YouTrack projects. * ``-t, --type TEXT`` - Issue type (e.g., Bug, Feature, Task) * ``-p, --priority TEXT`` - Issue priority (e.g., Critical, High, Medium, Low) * ``-a, --assignee TEXT`` - Username of the assignee + * ``-cf, --custom-field TEXT`` - Custom field in format "FieldName=value" (repeatable) -**Example:** +**Custom Fields** + +The ``--custom-field`` option supports all YouTrack custom field types: + +* **Enum fields**: Single and multi-value enum fields (e.g., Priority, Status) +* **Text fields**: Free-form text fields +* **Simple fields**: Integer and float numeric fields +* **User fields**: Single and multi-user fields (use login names) +* **Version fields**: Version bundle fields +* **Build fields**: Build bundle fields +* **Date/DateTime fields**: Date and date-time fields (use Unix timestamps in milliseconds) +* **Period fields**: Time period fields + +The CLI automatically detects the field type from the project configuration. If type discovery fails, it falls back to enum type as a safe default. + +**Examples:** .. code-block:: bash yt issues create PROJ-1 "Fix login bug" -d "Users cannot login with special characters" -t Bug -p High -a john.doe + # Using custom fields + yt issues create PROJ-1 "New task" -cf "Team=Backend" -cf "Sprint=Sprint 1" + + # Text field + yt issues create PROJ-1 "Task" -cf "Notes=Some implementation notes" + + # Integer field + yt issues create PROJ-1 "Task" -cf "StoryPoints=5" + + # User field (use login name) + yt issues create PROJ-1 "Task" -cf "Reviewer=john.doe" + List Issues ~~~~~~~~~~~ @@ -125,8 +153,13 @@ Update existing issues with new field values. * ``-p, --priority TEXT`` - New issue priority * ``-a, --assignee TEXT`` - New assignee username * ``-t, --type TEXT`` - New issue type + * ``-cf, --custom-field TEXT`` - Custom field in format "FieldName=value" (repeatable) * ``--show-details`` - Show current issue details instead of updating +**Custom Fields** + +The ``--custom-field`` option is repeatable and supports all YouTrack custom field types, consistent with the create command. See the Create Issues section for field type details. + **Examples:** .. code-block:: bash @@ -134,6 +167,9 @@ Update existing issues with new field values. # Update issue priority and assignee yt issues update PROJ-123 -p Critical -a jane.smith + # Update with custom fields + yt issues update PROJ-123 -cf "Team=Frontend" -cf "StoryPoints=8" + # View current issue details yt issues update PROJ-123 --show-details diff --git a/youtrack_cli/custom_field_manager.py b/youtrack_cli/custom_field_manager.py index c09cb74..960d13e 100644 --- a/youtrack_cli/custom_field_manager.py +++ b/youtrack_cli/custom_field_manager.py @@ -331,3 +331,125 @@ def is_multi_value_field(field_type: str) -> bool: ProjectCustomFieldTypes.MULTI_OWN_BUILD, } return field_type in multi_value_types + + @staticmethod + def create_simple_field(name: str, value: Union[int, float, str]) -> Dict[str, Any]: + """ + Create a simple (integer/float) custom field. + + Args: + name: The field name + value: The numeric value + + Returns: + Dictionary representing the custom field + """ + # Convert to appropriate numeric type + try: + if isinstance(value, (int, float)): + numeric_value = value + else: + # Try int first, then float + numeric_value = int(value) if str(value).isdigit() else float(value) + except (ValueError, TypeError): + # Fallback to string representation if conversion fails + numeric_value = str(value) + + return { + "$type": IssueCustomFieldTypes.INTEGER, + "name": name, + "value": numeric_value, + } + + @staticmethod + def create_date_field(name: str, value: str) -> Dict[str, Any]: + """ + Create a date custom field. + + Args: + name: The field name + value: The date value (Unix timestamp in milliseconds) + + Returns: + Dictionary representing the custom field + """ + try: + timestamp = int(value) + except (ValueError, TypeError): + timestamp = int(value) # Will raise if invalid + + return { + "$type": IssueCustomFieldTypes.DATE, + "name": name, + "value": timestamp, + } + + @staticmethod + def create_single_version_field(name: str, value: str) -> Dict[str, Any]: + """ + Create a single version custom field. + + Args: + name: The field name + value: The version name + + Returns: + Dictionary representing the custom field + """ + return { + "$type": IssueCustomFieldTypes.SINGLE_VERSION, + "name": name, + "value": {"$type": CustomFieldValueTypes.VERSION_BUNDLE_ELEMENT, "name": value}, + } + + @staticmethod + def create_single_build_field(name: str, value: str) -> Dict[str, Any]: + """ + Create a single build custom field. + + Args: + name: The field name + value: The build name + + Returns: + Dictionary representing the custom field + """ + return { + "$type": IssueCustomFieldTypes.SINGLE_BUILD, + "name": name, + "value": {"$type": CustomFieldValueTypes.BUILD_BUNDLE_ELEMENT, "name": value}, + } + + @staticmethod + def create_field_by_type(field_info: Dict[str, Any], name: str, value: str) -> Dict[str, Any]: + """ + Create a custom field using discovered type information. + + Args: + field_info: Field information from discover_custom_field + name: Field name + value: Field value (as string from CLI) + + Returns: + Formatted custom field dictionary + """ + issue_field_type = field_info.get("issue_field_type") + + # Map issue field types to creation methods + if issue_field_type == IssueCustomFieldTypes.TEXT: + return CustomFieldManager.create_text_field(name, value) + elif issue_field_type == IssueCustomFieldTypes.INTEGER: + return CustomFieldManager.create_simple_field(name, value) + elif issue_field_type == IssueCustomFieldTypes.SINGLE_USER: + return CustomFieldManager.create_single_user_field(name, value) + elif issue_field_type == IssueCustomFieldTypes.SINGLE_VERSION: + return CustomFieldManager.create_single_version_field(name, value) + elif issue_field_type == IssueCustomFieldTypes.SINGLE_BUILD: + return CustomFieldManager.create_single_build_field(name, value) + elif issue_field_type == IssueCustomFieldTypes.SINGLE_ENUM: + return CustomFieldManager.create_single_enum_field(name, value) + elif issue_field_type == IssueCustomFieldTypes.STATE: + return CustomFieldManager.create_state_field(name, value) + else: + # Fallback to enum for unknown types + return CustomFieldManager.create_single_enum_field(name, value) diff --git a/youtrack_cli/services/issues.py b/youtrack_cli/services/issues.py index 7c04b8a..68cc64a 100644 --- a/youtrack_cli/services/issues.py +++ b/youtrack_cli/services/issues.py @@ -78,10 +78,37 @@ async def create_issue( } ) - # Handle generic custom fields - default to enum type + # Handle generic custom fields with field type discovery if custom_fields: for field_name, field_value in custom_fields.items(): - custom_fields_list.append(CustomFieldManager.create_single_enum_field(field_name, field_value)) + try: + # Discover field type from project configuration + from .projects import ProjectService + + project_service = ProjectService(self.auth_manager) + field_info_result = await project_service.discover_custom_field(project_id, field_name) + + if field_info_result["status"] == "success": + # Use discovered field type + field_info = field_info_result["data"] + custom_fields_list.append( + CustomFieldManager.create_field_by_type(field_info, field_name, field_value) + ) + else: + # Fallback to enum type with helpful error message + logger.warning( + f"Could not discover type for field '{field_name}', " + f"falling back to enum type. Error: {field_info_result.get('message')}" + ) + custom_fields_list.append( + CustomFieldManager.create_single_enum_field(field_name, field_value) + ) + except Exception as e: + # Fallback to enum type on any error + logger.warning( + f"Error discovering field type for '{field_name}': {str(e)}, falling back to enum type" + ) + custom_fields_list.append(CustomFieldManager.create_single_enum_field(field_name, field_value)) # Add custom fields if any were specified if custom_fields_list: @@ -246,10 +273,37 @@ async def update_issue( } ) - # Handle generic custom fields - default to enum type + # Handle generic custom fields with field type discovery if custom_fields: for field_name, field_value in custom_fields.items(): - custom_fields_list.append(CustomFieldManager.create_single_enum_field(field_name, field_value)) + try: + # Discover field type from project configuration + from .projects import ProjectService + + project_service = ProjectService(self.auth_manager) + field_info_result = await project_service.discover_custom_field(project_id, field_name) + + if field_info_result["status"] == "success": + # Use discovered field type + field_info = field_info_result["data"] + custom_fields_list.append( + CustomFieldManager.create_field_by_type(field_info, field_name, field_value) + ) + else: + # Fallback to enum type with helpful error message + logger.warning( + f"Could not discover type for field '{field_name}', " + f"falling back to enum type. Error: {field_info_result.get('message')}" + ) + custom_fields_list.append( + CustomFieldManager.create_single_enum_field(field_name, field_value) + ) + except Exception as e: + # Fallback to enum type on any error + logger.warning( + f"Error discovering field type for '{field_name}': {str(e)}, falling back to enum type" + ) + custom_fields_list.append(CustomFieldManager.create_single_enum_field(field_name, field_value)) # Add custom fields if any if custom_fields_list: diff --git a/youtrack_cli/services/projects.py b/youtrack_cli/services/projects.py index 06d336d..d20ed40 100644 --- a/youtrack_cli/services/projects.py +++ b/youtrack_cli/services/projects.py @@ -593,3 +593,118 @@ async def discover_state_field(self, project_id: str) -> Dict[str, Any]: return self._create_error_response(str(e)) except Exception as e: return self._create_error_response(f"Error discovering state field: {str(e)}") + + async def discover_custom_field(self, project_id: str, field_name: str) -> Dict[str, Any]: + """Discover custom field type information for a specific field. + + Args: + project_id: Project ID or short name + field_name: Name of the custom field to discover + + Returns: + Dict with field information including type, bundle info, etc. + """ + try: + # Check cache first + cache = get_field_cache() + cache_key = f"custom_field:{field_name}" + cached_result = cache.get(project_id, cache_key) + if cached_result is not None: + return {"status": "success", "data": cached_result} + + # Get all custom fields for the project + fields_response = await self.get_project_custom_fields( + project_id, + fields="id,name,fieldType,localizedName,isPublic,ordinal,field(fieldType,name,$type)", + ) + + if fields_response["status"] != "success": + return fields_response + + custom_fields = fields_response["data"] + if not isinstance(custom_fields, list): + return self._create_error_response("Invalid custom fields response format") + + # Find the field by name (case-insensitive) + discovered_field = None + for field in custom_fields: + actual_field_name = field.get("field", {}).get("name", "") + if actual_field_name.lower() == field_name.lower(): + discovered_field = field + break + + if not discovered_field: + return { + "status": "error", + "message": f"Custom field '{field_name}' not found in project '{project_id}'", + "available_fields": [ + f.get("field", {}).get("name", "") for f in custom_fields if f.get("field", {}).get("name") + ], + } + + # Get detailed information about the field + field_details = await self.get_custom_field_details(project_id, discovered_field["id"], include_bundle=True) + + if field_details["status"] != "success": + return field_details + + field_data = field_details["data"] + + # Determine the issue field type from project field type + project_field_type = field_data.get("$type", "") + issue_field_type = self._project_to_issue_field_type(project_field_type) + + # Determine bundle element type if applicable + bundle_element_type = None + if "bundle" in field_data and "values" in field_data["bundle"]: + bundle_values = field_data["bundle"]["values"] + if isinstance(bundle_values, list) and len(bundle_values) > 0: + bundle_element_type = bundle_values[0].get("$type") + + result_data = { + "field_name": field_name, + "field_id": discovered_field["id"], + "project_field_type": project_field_type, + "issue_field_type": issue_field_type, + "bundle_element_type": bundle_element_type, + "is_multi_value": "Multi" in project_field_type, + "field_details": field_data, + } + + # Cache the result + cache.set(project_id, result_data, cache_key) + + return {"status": "success", "data": result_data} + + except ValueError as e: + return self._create_error_response(str(e)) + except Exception as e: + return self._create_error_response(f"Error discovering custom field '{field_name}': {str(e)}") + + def _project_to_issue_field_type(self, project_field_type: str) -> str: + """Convert project field type to issue field type. + + Args: + project_field_type: The project field type string + + Returns: + The corresponding issue field type string + """ + from ..custom_field_types import IssueCustomFieldTypes, ProjectCustomFieldTypes + + mapping = { + ProjectCustomFieldTypes.ENUM: IssueCustomFieldTypes.SINGLE_ENUM, + ProjectCustomFieldTypes.MULTI_ENUM: IssueCustomFieldTypes.MULTI_ENUM, + ProjectCustomFieldTypes.STATE: IssueCustomFieldTypes.STATE, + ProjectCustomFieldTypes.SINGLE_USER: IssueCustomFieldTypes.SINGLE_USER, + ProjectCustomFieldTypes.MULTI_USER: IssueCustomFieldTypes.MULTI_USER, + ProjectCustomFieldTypes.TEXT: IssueCustomFieldTypes.TEXT, + ProjectCustomFieldTypes.INTEGER: IssueCustomFieldTypes.INTEGER, + ProjectCustomFieldTypes.SINGLE_VERSION: IssueCustomFieldTypes.SINGLE_VERSION, + ProjectCustomFieldTypes.MULTI_VERSION: IssueCustomFieldTypes.MULTI_VERSION, + ProjectCustomFieldTypes.SINGLE_BUILD: IssueCustomFieldTypes.SINGLE_BUILD, + ProjectCustomFieldTypes.MULTI_BUILD: IssueCustomFieldTypes.MULTI_BUILD, + ProjectCustomFieldTypes.SINGLE_OWN_BUILD: IssueCustomFieldTypes.SINGLE_OWN_BUILD, + ProjectCustomFieldTypes.MULTI_OWN_BUILD: IssueCustomFieldTypes.MULTI_OWN_BUILD, + } + return mapping.get(project_field_type, IssueCustomFieldTypes.SINGLE_ENUM)