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)