Coverage for src/mcp_atlassian/jira/fields.py: 89%
129 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-10 03:26 +0900
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-10 03:26 +0900
1"""Module for Jira field operations."""
3import logging
4from typing import Any, Dict, List, Optional, Set, cast
6from .client import JiraClient
8logger = logging.getLogger("mcp-jira")
11class FieldsMixin(JiraClient):
12 """Mixin for Jira field operations.
14 This mixin provides methods for discovering, caching, and working with Jira fields.
15 Field IDs in Jira are crucial for many operations since they can differ across
16 different Jira instances, especially for custom fields.
17 """
19 def get_fields(self, refresh: bool = False) -> List[Dict[str, Any]]:
20 """
21 Get all available fields from Jira.
23 Args:
24 refresh: When True, forces a refresh from the server instead of using cache
26 Returns:
27 List of field definitions
28 """
29 try:
30 # Use cached field data if available and refresh is not requested
31 if hasattr(self, "_fields_cache") and self._fields_cache and not refresh:
32 return self._fields_cache
34 # Fetch fields from Jira API
35 fields = self.jira.fields()
37 # Cache the fields
38 self._fields_cache = fields
40 # Log available fields for debugging
41 self._log_available_fields(fields)
43 return fields
45 except Exception as e:
46 logger.error(f"Error getting Jira fields: {str(e)}")
47 return []
49 def get_field_id(self, field_name: str, refresh: bool = False) -> Optional[str]:
50 """
51 Get the ID for a specific field by name.
53 Args:
54 field_name: The name of the field to look for (case-insensitive)
55 refresh: When True, forces a refresh from the server
57 Returns:
58 Field ID if found, None otherwise
59 """
60 try:
61 # Normalize the field name to lowercase for case-insensitive matching
62 normalized_name = field_name.lower()
64 # Get all fields and search for the requested field
65 fields = self.get_fields(refresh=refresh)
67 for field in fields:
68 name = field.get("name", "")
69 if name and name.lower() == normalized_name:
70 return field.get("id")
72 # If not found by exact match, try partial match
73 for field in fields:
74 name = field.get("name", "")
75 if name and normalized_name in name.lower():
76 logger.info(f"Found field '{name}' as partial match for '{field_name}'")
77 return field.get("id")
79 logger.warning(f"Field '{field_name}' not found")
80 return None
82 except Exception as e:
83 logger.error(f"Error getting field ID for '{field_name}': {str(e)}")
84 return None
86 def get_field_by_id(self, field_id: str, refresh: bool = False) -> Optional[Dict[str, Any]]:
87 """
88 Get field definition by ID.
90 Args:
91 field_id: The ID of the field to look for
92 refresh: When True, forces a refresh from the server
94 Returns:
95 Field definition if found, None otherwise
96 """
97 try:
98 fields = self.get_fields(refresh=refresh)
100 for field in fields:
101 if field.get("id") == field_id:
102 return field
104 logger.warning(f"Field with ID '{field_id}' not found")
105 return None
107 except Exception as e:
108 logger.error(f"Error getting field by ID '{field_id}': {str(e)}")
109 return None
111 def get_custom_fields(self, refresh: bool = False) -> List[Dict[str, Any]]:
112 """
113 Get all custom fields.
115 Args:
116 refresh: When True, forces a refresh from the server
118 Returns:
119 List of custom field definitions
120 """
121 try:
122 fields = self.get_fields(refresh=refresh)
123 custom_fields = [
124 field for field in fields
125 if field.get("id", "").startswith("customfield_")
126 ]
128 return custom_fields
130 except Exception as e:
131 logger.error(f"Error getting custom fields: {str(e)}")
132 return []
134 def get_required_fields(self, issue_type: str, project_key: str) -> Dict[str, Any]:
135 """
136 Get required fields for creating an issue of a specific type in a project.
138 Args:
139 issue_type: The issue type (e.g., 'Bug', 'Story', 'Epic')
140 project_key: The project key (e.g., 'PROJ')
142 Returns:
143 Dictionary mapping required field names to their definitions
144 """
145 try:
146 # Create meta provides field requirements for different issue types
147 create_meta = self.jira.createmeta(
148 projectKeys=project_key,
149 issuetypeNames=issue_type,
150 expand="projects.issuetypes.fields"
151 )
153 required_fields = {}
155 # Navigate the nested structure to find required fields
156 if "projects" in create_meta:
157 for project in create_meta["projects"]:
158 if project.get("key") == project_key:
159 if "issuetypes" in project:
160 for issuetype in project["issuetypes"]:
161 if issuetype.get("name") == issue_type:
162 fields = issuetype.get("fields", {})
163 # Extract required fields
164 for field_id, field_meta in fields.items():
165 if field_meta.get("required", False):
166 required_fields[field_id] = field_meta
168 if not required_fields:
169 logger.warning(
170 f"No required fields found for issue type '{issue_type}' "
171 f"in project '{project_key}'"
172 )
174 return required_fields
176 except Exception as e:
177 logger.error(
178 f"Error getting required fields for issue type '{issue_type}' "
179 f"in project '{project_key}': {str(e)}"
180 )
181 return {}
183 def get_jira_field_ids(self) -> Dict[str, str]:
184 """
185 Get a mapping of field names to their IDs.
187 This method is maintained for backward compatibility and is used
188 by multiple other mixins like EpicsMixin.
190 Returns:
191 Dictionary mapping field names to their IDs
192 """
193 # Check if we've already cached the field_ids
194 if hasattr(self, "_field_ids_cache") and self._field_ids_cache:
195 return self._field_ids_cache
197 # Initialize cache if needed
198 if not hasattr(self, "_field_ids_cache"):
199 self._field_ids_cache = {}
201 try:
202 # Get all fields
203 fields = self.get_fields()
204 field_ids = {}
206 # Extract field IDs
207 for field in fields:
208 name = field.get("name")
209 field_id = field.get("id")
210 if name and field_id:
211 field_ids[name] = field_id
213 # Cache the results
214 self._field_ids_cache = field_ids
215 return field_ids
217 except Exception as e:
218 logger.error(f"Error getting field IDs: {str(e)}")
219 return {}
221 def _log_available_fields(self, fields: List[Dict]) -> None:
222 """
223 Log available fields for debugging.
225 Args:
226 fields: List of field definitions
227 """
228 logger.debug("Available Jira fields:")
229 for field in fields:
230 field_id = field.get("id", "")
231 name = field.get("name", "")
232 field_type = field.get("schema", {}).get("type", "")
233 logger.debug(f"{field_id}: {name} ({field_type})")
235 def is_custom_field(self, field_id: str) -> bool:
236 """
237 Check if a field is a custom field.
239 Args:
240 field_id: The field ID to check
242 Returns:
243 True if it's a custom field, False otherwise
244 """
245 return field_id.startswith("customfield_")
247 def format_field_value(self, field_id: str, value: Any) -> Dict[str, Any]:
248 """
249 Format a field value based on its type for update operations.
251 Different field types in Jira require different JSON formats when updating.
252 This method helps format the value correctly for the specific field type.
254 Args:
255 field_id: The ID of the field
256 value: The value to format
258 Returns:
259 Properly formatted value for the field
260 """
261 try:
262 # Get field definition
263 field = self.get_field_by_id(field_id)
265 if not field:
266 # For unknown fields, return value as-is
267 return value
269 field_type = field.get("schema", {}).get("type")
271 # Format based on field type
272 if field_type == "user":
273 # Handle user fields - need accountId for cloud or name for server
274 if isinstance(value, str):
275 if hasattr(self, '_get_account_id') and callable(self._get_account_id):
276 try:
277 account_id = self._get_account_id(value)
278 return {"accountId": account_id}
279 except Exception as e:
280 logger.warning(f"Could not resolve user '{value}': {str(e)}")
281 return value
282 else:
283 # For server/DC, just use the name
284 return {"name": value}
285 else:
286 return value
288 elif field_type == "array":
289 # Handle array fields - convert single value to list if needed
290 if not isinstance(value, list):
291 return [value]
292 return value
294 elif field_type == "option":
295 # Handle option fields - convert to {"value": value} format
296 if isinstance(value, str):
297 return {"value": value}
298 return value
300 # For other types, return as-is
301 return value
303 except Exception as e:
304 logger.warning(f"Error formatting field value for '{field_id}': {str(e)}")
305 return value