Coverage for src/mcp_atlassian/jira/epics.py: 79%
196 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 epic operations."""
3import logging
4from typing import Any, Dict, List, Optional, cast
6import requests
8from ..document_types import Document
9from .client import JiraClient
11logger = logging.getLogger("mcp-jira")
14class EpicsMixin(JiraClient):
15 """Mixin for Jira epic operations."""
17 def get_jira_field_ids(self) -> Dict[str, str]:
18 """
19 Get mappings of field names to IDs.
21 This method discovers and caches various Jira field IDs, with a focus
22 on Epic-related fields, which can vary between different Jira instances.
24 Returns:
25 Dictionary mapping field names to their IDs
26 (e.g., {'epic_link': 'customfield_10014', 'epic_name': 'customfield_10011'})
27 """
28 # Use cached field IDs if available
29 if hasattr(self, "_field_ids_cache") and self._field_ids_cache:
30 return self._field_ids_cache
32 # Get cached field IDs or fetch from server
33 return self._get_cached_field_ids()
35 def _get_cached_field_ids(self) -> Dict[str, str]:
36 """
37 Get cached field IDs or fetch from server.
39 Returns:
40 Dictionary mapping field names to their IDs
41 """
42 # Initialize cache if needed
43 if not hasattr(self, "_field_ids_cache"):
44 self._field_ids_cache = {}
46 # Return cache if not empty
47 if self._field_ids_cache:
48 return self._field_ids_cache
50 # Fetch field IDs from server
51 try:
52 fields = self.jira.get_all_fields()
53 field_ids = {}
55 # Log available fields to help with debugging
56 self._log_available_fields(fields)
58 # Process each field to identify Epic-related fields
59 for field in fields:
60 self._process_field_for_epic_data(field, field_ids)
62 # If we couldn't find all essential fields, try other discovery methods
63 if "epic_name" not in field_ids or "epic_link" not in field_ids:
64 logger.warning(
65 "Could not find all essential Epic fields through schema. "
66 "This may cause issues with Epic operations."
67 )
68 # Try to find fields by looking at an existing Epic if possible
69 self._try_discover_fields_from_existing_epic(field_ids)
71 # Cache the results
72 self._field_ids_cache = field_ids
73 return field_ids
75 except Exception as e:
76 logger.warning(f"Error getting field IDs: {str(e)}")
77 return {}
79 def _log_available_fields(self, fields: List[Dict]) -> None:
80 """
81 Log available fields for debugging.
83 Args:
84 fields: List of field definitions
85 """
86 logger.debug("Available Jira fields:")
87 for field in fields:
88 logger.debug(f"{field.get('id')}: {field.get('name')} ({field.get('schema', {}).get('type')})")
90 def _process_field_for_epic_data(
91 self, field: Dict, field_ids: Dict[str, str]
92 ) -> None:
93 """
94 Process a single field to identify if it's an Epic-related field.
96 Args:
97 field: The field definition
98 field_ids: Dictionary of field IDs to update
99 """
100 try:
101 field_id = field.get("id")
102 original_name = field.get("name", "")
103 field_name = original_name.lower() if original_name else ""
105 # Skip if no field ID or name
106 if not field_id or not field_name:
107 return
109 # Get the custom schema type if available
110 field_custom = ""
111 schema = field.get("schema", {})
112 if schema:
113 field_custom = schema.get("custom", "").lower()
115 # Epic Link field - used to link issues to epics
116 if (
117 "epic link" in field_name
118 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-link"
119 ):
120 self.epic_link_field_id = field_id
121 field_ids["epic_link"] = field_id
122 logger.info(f"Found Epic Link field: {original_name} ({field_id})")
124 # Epic Name field - used for the title of epics
125 elif (
126 "epic name" in field_name
127 or "epic-name" in field_name
128 or original_name == "Epic Name"
129 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-label"
130 ):
131 field_ids["epic_name"] = field_id
132 logger.info(f"Found Epic Name field: {original_name} ({field_id})")
134 # Parent field - sometimes used instead of Epic Link
135 elif original_name == "Parent" or field_name == "parent" or field_name == "parent link":
136 field_ids["parent"] = field_id
137 logger.info(f"Found Parent field: {original_name} ({field_id})")
139 # Epic Status field
140 elif "epic status" in field_name or original_name == "Epic Status":
141 field_ids["epic_status"] = field_id
142 logger.info(f"Found Epic Status field: {original_name} ({field_id})")
144 # Epic Color field
145 elif (
146 "epic colour" in field_name
147 or "epic color" in field_name
148 or original_name == "Epic Colour"
149 or original_name == "Epic Color"
150 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-color"
151 ):
152 field_ids["epic_color"] = field_id
153 logger.info(f"Found Epic Color field: {original_name} ({field_id})")
155 # Try to detect any other fields that might be related to Epics
156 elif ("epic" in field_name or "epic" in field_custom) and not any(
157 key in field_ids for key in ["epic_link", "epic_name", "epic_status", "epic_color"]
158 ):
159 key = f"epic_{field_name.replace(' ', '_')}"
160 field_ids[key] = field_id
161 logger.info(
162 f"Found additional Epic-related field: {original_name} ({field_id})"
163 )
164 except Exception as e:
165 logger.warning(f"Error processing field for Epic data: {str(e)}")
167 def _try_discover_fields_from_existing_epic(self, field_ids: Dict[str, str]) -> None:
168 """
169 Attempt to discover Epic fields by examining an existing Epic issue.
171 This is a fallback method that attempts to find Epic fields by looking
172 at actual Epic issues already in the system.
174 Args:
175 field_ids: Dictionary of field IDs to update
176 """
177 # If we already have both epic fields, no need to search
178 if "epic_link" in field_ids and "epic_name" in field_ids:
179 return
181 try:
182 # Find an Epic in the system
183 epics_jql = "issuetype = Epic ORDER BY created DESC"
184 results = self.jira.jql(epics_jql, limit=1)
186 # If no epics found, we can't use this method
187 if not results or not results.get("issues"):
188 logger.warning("No existing Epics found to analyze field structure")
189 return
191 epic = results["issues"][0]
192 fields = epic.get("fields", {})
194 # Inspect every custom field for values that look like epic fields
195 for field_id, value in fields.items():
196 if not field_id.startswith("customfield_"):
197 continue
199 # If it's a string value for a customfield, it might be the Epic Name
200 if "epic_name" not in field_ids and isinstance(value, str) and value:
201 field_ids["epic_name"] = field_id
202 logger.info(f"Discovered Epic Name field from existing epic: {field_id}")
204 # Now try to find issues linked to this Epic to discover the Epic Link field
205 if "epic_link" not in field_ids:
206 epic_key = epic.get("key")
207 if not epic_key:
208 return
210 # Try several query formats to find linked issues
211 link_queries = [
212 f"'Epic Link' = {epic_key}",
213 f"'Epic' = {epic_key}",
214 f"parent = {epic_key}",
215 ]
217 for query in link_queries:
218 try:
219 link_results = self.jira.jql(query, limit=1)
220 if link_results and link_results.get("issues"):
221 # Found an issue linked to our epic, now inspect its fields
222 linked_issue = link_results["issues"][0]
223 linked_fields = linked_issue.get("fields", {})
225 # Check each field to see if it contains our epic key
226 for field_id, value in linked_fields.items():
227 if (
228 field_id.startswith("customfield_")
229 and isinstance(value, str)
230 and value == epic_key
231 ):
232 field_ids["epic_link"] = field_id
233 logger.info(
234 f"Discovered Epic Link field from linked issue: {field_id}"
235 )
236 break
238 # If we found the epic link field, we can stop
239 if "epic_link" in field_ids:
240 break
241 except Exception: # noqa: BLE001 - Intentional fallback with logging
242 continue
244 except Exception as e:
245 logger.warning(f"Error discovering fields from existing Epics: {str(e)}")
247 def prepare_epic_fields(
248 self, fields: Dict[str, Any], summary: str, kwargs: Dict[str, Any]
249 ) -> None:
250 """
251 Prepare epic-specific fields for issue creation.
253 Args:
254 fields: The fields dictionary to update
255 summary: The issue summary that can be used as a default epic name
256 kwargs: Additional fields from the user
257 """
258 try:
259 # Get all field IDs
260 field_ids = self.get_jira_field_ids()
261 logger.info(f"Discovered Jira field IDs for Epic creation: {field_ids}")
263 # Handle Epic Name - might be required in some instances, not in others
264 if "epic_name" in field_ids:
265 epic_name = kwargs.pop(
266 "epic_name", summary
267 ) # Use summary as default if epic_name not provided
268 fields[field_ids["epic_name"]] = epic_name
269 logger.info(
270 f"Setting Epic Name field {field_ids['epic_name']} to: {epic_name}"
271 )
273 # Handle Epic Color if the field was discovered
274 if "epic_color" in field_ids:
275 epic_color = (
276 kwargs.pop("epic_color", None)
277 or kwargs.pop("epic_colour", None)
278 or "green" # Default color
279 )
280 fields[field_ids["epic_color"]] = epic_color
281 logger.info(
282 f"Setting Epic Color field {field_ids['epic_color']} "
283 f"to: {epic_color}"
284 )
286 # Add any other epic-related fields provided
287 for key, value in list(kwargs.items()):
288 if key.startswith("epic_") and key != "epic_name" and key != "epic_color":
289 field_key = key.replace("epic_", "")
290 if f"epic_{field_key}" in field_ids:
291 fields[field_ids[f"epic_{field_key}"]] = value
292 kwargs.pop(key)
294 # Warn if epic_name field is required but wasn't discovered
295 if "epic_name" not in field_ids:
296 logger.warning(
297 "Epic Name field not found in Jira schema. "
298 "Epic creation may fail if this field is required."
299 )
301 except Exception as e:
302 logger.error(f"Error preparing Epic-specific fields: {str(e)}")
304 def link_issue_to_epic(self, issue_key: str, epic_key: str) -> Document:
305 """
306 Link an issue to an epic.
308 Args:
309 issue_key: The key of the issue to link
310 epic_key: The key of the epic to link to
312 Returns:
313 Document with the updated issue
315 Raises:
316 Exception: If there is an error linking the issue
317 """
318 try:
319 # Verify both keys exist
320 self.jira.get_issue(issue_key)
321 epic = self.jira.get_issue(epic_key)
323 # Verify epic_key is actually an epic
324 fields = epic.get("fields", {})
325 issue_type = fields.get("issuetype", {}).get("name", "").lower()
327 if issue_type != "epic":
328 error_msg = f"{epic_key} is not an Epic"
329 raise ValueError(error_msg)
331 # Get the epic link field ID
332 field_ids = self.get_jira_field_ids()
333 epic_link_field = field_ids.get("epic_link")
335 if not epic_link_field:
336 error_msg = "Could not determine Epic Link field"
337 raise ValueError(error_msg)
339 # Update the issue to link it to the epic
340 update_fields = {epic_link_field: epic_key}
341 self.jira.update_issue(issue_key, fields=update_fields)
343 # Return the updated issue
344 if hasattr(self, 'get_issue') and callable(self.get_issue):
345 return self.get_issue(issue_key)
346 else:
347 # Fallback if get_issue is not available
348 logger.warning("get_issue method not available, returning empty Document")
349 return Document(page_content="", metadata={"key": issue_key})
351 except Exception as e:
352 logger.error(f"Error linking {issue_key} to epic {epic_key}: {str(e)}")
353 raise Exception(f"Error linking issue to epic: {str(e)}") from e
355 def get_epic_issues(self, epic_key: str, limit: int = 50) -> List[Document]:
356 """
357 Get all issues linked to a specific epic.
359 Args:
360 epic_key: The key of the epic (e.g. 'PROJ-123')
361 limit: Maximum number of issues to return
363 Returns:
364 List of Documents representing the issues linked to the epic
366 Raises:
367 ValueError: If the issue is not an Epic
368 Exception: If there is an error getting epic issues
369 """
370 try:
371 # First, check if the issue is an Epic
372 epic = self.jira.issue(epic_key)
373 fields_data = epic.get("fields", {})
375 # Safely check if the issue is an Epic
376 issue_type = None
377 issuetype_data = fields_data.get("issuetype")
378 if issuetype_data is not None:
379 issue_type = issuetype_data.get("name", "")
381 if issue_type != "Epic":
382 error_msg = (
383 f"Issue {epic_key} is not an Epic, it is a "
384 f"{issue_type or 'unknown type'}"
385 )
386 raise ValueError(error_msg)
388 # Get the dynamic field IDs for this Jira instance
389 field_ids = self.get_jira_field_ids()
391 # Build JQL queries based on discovered field IDs
392 jql_queries = []
394 # Add queries based on discovered fields
395 if "parent" in field_ids:
396 jql_queries.append(f"parent = {epic_key}")
398 if "epic_link" in field_ids:
399 field_name = field_ids["epic_link"]
400 jql_queries.append(f'"{field_name}" = {epic_key}')
401 jql_queries.append(f'"{field_name}" ~ {epic_key}')
403 # Add standard fallback queries
404 jql_queries.extend(
405 [
406 f"parent = {epic_key}", # Common in most instances
407 f"'Epic Link' = {epic_key}", # Some instances
408 f"'Epic' = {epic_key}", # Some instances
409 f"issue in childIssuesOf('{epic_key}')", # Some instances
410 ]
411 )
413 # Try each query until we get results or run out of options
414 documents = []
415 for jql in jql_queries:
416 try:
417 logger.info(f"Trying to get epic issues with JQL: {jql}")
418 if hasattr(self, 'search_issues') and callable(self.search_issues):
419 documents = self.search_issues(jql, limit=limit)
420 else:
421 # Fallback if search_issues is not available
422 results = self.jira.jql(jql, limit=limit)
423 documents = []
424 for issue in results.get("issues", []):
425 key = issue.get("key", "")
426 summary = issue.get("fields", {}).get("summary", "")
427 documents.append(
428 Document(
429 page_content=summary,
430 metadata={"key": key, "type": "issue"}
431 )
432 )
434 if documents:
435 return documents
436 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
437 logger.info(f"Failed to get epic issues with JQL '{jql}': {str(e)}")
438 continue
440 # If we've tried all queries and got no results, return an empty list
441 # but also log a warning that we might be missing the right field
442 if not documents:
443 logger.warning(
444 f"Couldn't find issues linked to epic {epic_key}. "
445 "Your Jira instance might use a different field for epic links."
446 )
448 return documents
450 except ValueError as e:
451 # Re-raise ValueError for non-epic issues
452 raise
454 except Exception as e:
455 logger.error(f"Error getting issues for epic {epic_key}: {str(e)}")
456 raise Exception(f"Error getting epic issues: {str(e)}") from e