We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/uchinx/mcp-atlassian'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
"""Module for Jira epic operations."""
import logging
from typing import Any
from ..models.jira import JiraIssue
from .client import JiraClient
from .protocols import (
FieldsOperationsProto,
IssueOperationsProto,
SearchOperationsProto,
UsersOperationsProto,
)
logger = logging.getLogger("mcp-jira")
class EpicsMixin(
JiraClient,
FieldsOperationsProto,
IssueOperationsProto,
SearchOperationsProto,
UsersOperationsProto,
):
"""Mixin for Jira epic operations."""
def _try_discover_fields_from_existing_epic(
self, field_ids: dict[str, str]
) -> None:
"""
Try to discover Epic fields from existing epics in the Jira instance.
This is a fallback method used when standard field discovery doesn't find
all the necessary Epic-related fields. It searches for an existing Epic and
analyzes its field structure to identify Epic fields dynamically.
Args:
field_ids: Dictionary of field IDs to update with discovered fields
"""
try:
# If we already have both epic fields, no need to search
if all(field in field_ids for field in ["epic_name", "epic_link"]):
return
# Find an Epic in the system
epics_jql = "issuetype = Epic ORDER BY created DESC"
results = self.jira.jql(epics_jql, fields="*all", limit=1)
if not isinstance(results, dict):
msg = f"Unexpected return value type from `jira.jql`: {type(results)}"
logger.error(msg)
raise TypeError(msg)
# If no epics found, we can't use this method
if not results or not results.get("issues"):
logger.warning("No existing Epics found to analyze field structure")
return
# Get the most recent Epic
epic = results["issues"][0]
fields = epic.get("fields", {})
logger.debug(f"Found existing Epic {epic.get('key')} to analyze")
# Look for Epic Name and other Epic fields
for field_id, value in fields.items():
if not field_id.startswith("customfield_"):
continue
# Analyze the field value to determine what it might be
if isinstance(value, str) and field_id not in field_ids.values():
# Epic Name is typically a string value
if "epic_name" not in field_ids and 3 <= len(value) <= 255:
field_ids["epic_name"] = field_id
logger.debug(
f"Discovered possible Epic Name field from existing Epic: {field_id}"
)
# Look for color-related values (typically a string like "green", "blue", etc.)
elif isinstance(value, str) and value.lower() in [
"green",
"blue",
"red",
"yellow",
"orange",
"purple",
]:
if "epic_color" not in field_ids:
field_ids["epic_color"] = field_id
logger.debug(
f"Discovered possible Epic Color field from existing Epic: {field_id}"
)
# Look for fields that might be used for Epic Link
elif value is not None and "epic_link" not in field_ids:
# Epic Link typically references another issue key or ID
try:
# Sometimes the value itself is a string containing a key
if isinstance(value, str) and "-" in value and len(value) < 20:
field_ids["epic_link"] = field_id
logger.debug(
f"Discovered possible Epic Link field from existing Epic: {field_id}"
)
except Exception as e:
logger.debug(
f"Error analyzing potential Epic Link field: {str(e)}"
)
logger.debug(
f"Updated field IDs after analyzing existing Epic: {field_ids}"
)
except Exception as e:
logger.error(f"Error discovering fields from existing Epic: {str(e)}")
def prepare_epic_fields(
self,
fields: dict[str, Any],
summary: str,
kwargs: dict[str, Any],
project_key: str = None,
) -> None:
"""
Prepare epic-specific fields for issue creation.
Args:
fields: The fields dictionary to update
summary: The issue summary that can be used as a default epic name
kwargs: Additional fields from the user
project_key: Optional project key for checking field requirements
"""
try:
# Get all field IDs
field_ids = self.get_field_ids_to_epic()
logger.info(f"Discovered Jira field IDs for Epic creation: {field_ids}")
# Get required fields for Epic issue type if project_key provided
required_fields = {}
if project_key:
try:
required_fields = self.get_required_fields("Epic", project_key)
logger.debug(
f"Required fields for Epic in project {project_key}: {list(required_fields.keys())}"
)
except Exception as e:
logger.warning(f"Could not check field requirements: {e}")
# Extract and handle epic_name
epic_name_field = self._get_epic_name_field_id(field_ids)
if epic_name_field:
# Get epic name value
epic_name = kwargs.pop(
"epic_name", kwargs.pop("epicName", summary)
) # Use summary as default if epic_name not provided
# Check if this field is required
if epic_name_field in required_fields:
# Add to fields for initial creation
fields[epic_name_field] = epic_name
logger.info(
f"Adding required Epic Name ({epic_name_field}: {epic_name}) to creation fields"
)
else:
# Store for post-creation update as before
kwargs["__epic_name_field"] = epic_name_field
kwargs["__epic_name_value"] = epic_name
logger.info(
f"Storing optional Epic Name ({epic_name_field}: {epic_name}) for post-creation update"
)
# Extract and handle epic_color
epic_color_field = self._get_epic_color_field_id(field_ids)
if epic_color_field:
epic_color = (
kwargs.pop("epic_color", None)
or kwargs.pop("epicColor", None)
or kwargs.pop("epic_colour", None)
or "green" # Default color
)
# Check if this field is required
if epic_color_field in required_fields:
# Add to fields for initial creation
fields[epic_color_field] = epic_color
logger.info(
f"Adding required Epic Color ({epic_color_field}: {epic_color}) to creation fields"
)
else:
# Store for post-creation update
kwargs["__epic_color_field"] = epic_color_field
kwargs["__epic_color_value"] = epic_color
logger.info(
f"Storing optional Epic Color ({epic_color_field}: {epic_color}) for post-creation update"
)
# Handle any other epic-related fields
for key, value in list(kwargs.items()):
if key.startswith("epic_") and key in field_ids:
field_key = key.replace("epic_", "")
field_id = field_ids[key]
# Check if this field is required
if field_id in required_fields:
# Add to fields for initial creation
fields[field_id] = value
logger.info(
f"Adding required Epic field ({field_id} from {key}: {value}) to creation fields"
)
else:
# Store for post-creation update
kwargs[f"__epic_{field_key}_field"] = field_id
kwargs[f"__epic_{field_key}_value"] = value
logger.info(
f"Storing optional Epic field ({field_id} from {key}: {value}) for post-creation update"
)
kwargs.pop(key) # Remove from kwargs to avoid duplicate processing
# Warn if epic_name field is required but wasn't discovered
if not epic_name_field:
logger.warning(
"Could not find Epic Name field ID. Epic creation may fail. "
"Consider setting it explicitly in your kwargs."
)
except Exception as e:
logger.error(f"Error preparing Epic-specific fields: {str(e)}")
def _get_epic_name_field_id(self, field_ids: dict[str, str]) -> str | None:
"""
Get the field ID for Epic Name, using multiple strategies.
Args:
field_ids: Dictionary of field IDs
Returns:
The field ID for Epic Name if found, None otherwise
"""
# Strategy 1: Check if already discovered by get_field_ids_to_epic
if "epic_name" in field_ids:
return field_ids["epic_name"]
if "Epic Name" in field_ids:
return field_ids["Epic Name"]
# Strategy 2: Check common field IDs used across different Jira instances
common_ids = ["customfield_10011", "customfield_10005", "customfield_10004"]
for field_id in common_ids:
if field_id in field_ids.values():
logger.debug(f"Using common Epic Name field ID: {field_id}")
return field_id
# Strategy 3: Try to find by dynamic name pattern in available fields
for key, value in field_ids.items():
if (
"epic" in key.lower() and "name" in key.lower()
) or "epicname" in key.lower():
logger.debug(f"Found potential Epic Name field by pattern: {value}")
return value
logger.debug("Could not determine Epic Name field ID")
return None
def _get_epic_color_field_id(self, field_ids: dict[str, str]) -> str | None:
"""
Get the field ID for Epic Color, using multiple strategies.
Args:
field_ids: Dictionary of field IDs
Returns:
The field ID for Epic Color if found, None otherwise
"""
# Strategy 1: Check if already discovered by get_field_ids_to_epic
if "epic_color" in field_ids:
return field_ids["epic_color"]
if "epic_colour" in field_ids:
return field_ids["epic_colour"]
# Strategy 2: Check common field IDs
common_ids = ["customfield_10012", "customfield_10013"]
for field_id in common_ids:
if field_id in field_ids.values():
logger.debug(f"Using common Epic Color field ID: {field_id}")
return field_id
# Strategy 3: Find by dynamic name pattern
for key, value in field_ids.items():
if "epic" in key.lower() and (
"color" in key.lower() or "colour" in key.lower()
):
logger.debug(f"Found potential Epic Color field by pattern: {value}")
return value
logger.debug("Could not determine Epic Color field ID")
return None
def link_issue_to_epic(self, issue_key: str, epic_key: str) -> JiraIssue:
"""
Link an existing issue to an epic.
Args:
issue_key: The key of the issue to link (e.g. 'PROJ-123')
epic_key: The key of the epic to link to (e.g. 'PROJ-456')
Returns:
JiraIssue: The updated issue
Raises:
ValueError: If the epic_key is not an actual epic
Exception: If there is an error linking the issue to the epic
"""
try:
# Verify that both issue and epic exist
issue = self.jira.get_issue(issue_key)
epic = self.jira.get_issue(epic_key)
if not isinstance(issue, dict):
msg = (
f"Unexpected return value type from `jira.get_issue`: {type(issue)}"
)
logger.error(msg)
raise TypeError(msg)
if not isinstance(epic, dict):
msg = (
f"Unexpected return value type from `jira.get_issue`: {type(epic)}"
)
logger.error(msg)
raise TypeError(msg)
# Check if the epic key corresponds to an actual epic
fields = epic.get("fields", {})
issue_type = fields.get("issuetype", {}).get("name", "").lower()
if issue_type != "epic":
error_msg = f"Error linking issue to epic: {epic_key} is not an Epic"
raise ValueError(error_msg)
# Get the dynamic field IDs for this Jira instance
field_ids = self.get_field_ids_to_epic()
# Try the parent field first (if discovered or natively supported)
if "parent" in field_ids or "parent" not in field_ids:
try:
fields = {"parent": {"key": epic_key}}
self.jira.update_issue(
issue_key=issue_key, update={"fields": fields}
)
logger.info(
f"Successfully linked {issue_key} to {epic_key} using parent field"
)
return self.get_issue(issue_key)
except Exception as e:
logger.info(
f"Couldn't link using parent field: {str(e)}. Trying discovered fields..."
)
# Try using the discovered Epic Link field
if "epic_link" in field_ids:
try:
epic_link_fields: dict[str, str] = {
field_ids["epic_link"]: epic_key
}
self.jira.update_issue(
issue_key=issue_key, update={"fields": epic_link_fields}
)
logger.info(
f"Successfully linked {issue_key} to {epic_key} using discovered epic_link field: {field_ids['epic_link']}"
)
return self.get_issue(issue_key)
except Exception as e:
logger.info(
f"Couldn't link using discovered epic_link field: {str(e)}. Trying fallback methods..."
)
# Fallback to common custom fields if dynamic discovery didn't work
custom_field_attempts: list[dict[str, str]] = [
{"customfield_10014": epic_key}, # Common in Jira Cloud
{"customfield_10008": epic_key}, # Common in Jira Server
{"customfield_10000": epic_key}, # Also common
{"customfield_11703": epic_key}, # Known from previous error
{"epic_link": epic_key}, # Sometimes used
]
for fields in custom_field_attempts:
try:
self.jira.update_issue(
issue_key=issue_key, update={"fields": fields}
)
field_id = list(fields.keys())[0]
logger.info(
f"Successfully linked {issue_key} to {epic_key} using field: {field_id}"
)
# If we get here, it worked - update our cached field ID
if self._field_ids_cache is None:
self._field_ids_cache = []
self._field_ids_cache.append({"id": field_id, "name": "epic_link"})
return self.get_issue(issue_key)
except Exception as e:
logger.info(f"Couldn't link using fields {fields}: {str(e)}")
continue
# Method 2: Try to use direct issue linking (relates to, etc.)
try:
logger.info(
f"Attempting to create issue link between {issue_key} and {epic_key}"
)
link_data = {
"type": {"name": "Relates to"},
"inwardIssue": {"key": issue_key},
"outwardIssue": {"key": epic_key},
}
self.jira.create_issue_link(link_data)
logger.info(
f"Created relationship link between {issue_key} and {epic_key}"
)
return self.get_issue(issue_key)
except Exception as link_error:
logger.error(f"Error creating issue link: {str(link_error)}")
# If we get here, none of our attempts worked
raise ValueError(
f"Could not link issue {issue_key} to epic {epic_key}. Your Jira instance might use a different field for epic links."
)
except ValueError:
# Re-raise ValueError as is
raise
except Exception as e:
logger.error(f"Error linking {issue_key} to epic {epic_key}: {str(e)}")
# Ensure exception messages follow the expected format for tests
if "API error" in str(e):
raise Exception(f"Error linking issue to epic: {str(e)}")
raise
def get_epic_issues(
self, epic_key: str, start: int = 0, limit: int = 50
) -> list[JiraIssue]:
"""
Get all issues linked to a specific epic.
Args:
epic_key: The key of the epic (e.g. 'PROJ-123')
start: Starting index for pagination
limit: Maximum number of issues to return
Returns:
List of JiraIssue models representing the issues linked to the epic
Raises:
ValueError: If the issue is not an Epic
Exception: If there is an error getting epic issues
"""
try:
# First, check if the issue is an Epic
epic = self.jira.get_issue(epic_key)
if not isinstance(epic, dict):
msg = (
f"Unexpected return value type from `jira.get_issue`: {type(epic)}"
)
logger.error(msg)
raise TypeError(msg)
fields_data = epic.get("fields", {})
# Check if the issue is an Epic
issuetype_data = fields_data.get("issuetype", {})
issue_type_name = issuetype_data.get("name", "")
# Check if it's an Epic by looking for "epic" in the name (case-insensitive)
# This handles localized names like "에픽", "エピック", etc.
if "epic" not in issue_type_name.lower() and issue_type_name not in [
"에픽",
"エピック",
]:
# Try to verify via JQL as a fallback
is_epic = False
try:
verify_jql = f'key = "{epic_key}" AND issuetype = Epic'
verify_result = self.search_issues(verify_jql, limit=1)
if verify_result and len(verify_result.issues) > 0:
is_epic = True
except Exception as e:
# If JQL fails, stick with our previous determination
logger.debug(f"JQL verification failed: {e}")
if not is_epic:
error_msg = (
f"Issue {epic_key} is not an Epic, it is a "
f"{issue_type_name or 'unknown type'}"
)
raise ValueError(error_msg)
# Track which methods we've tried
tried_methods = set()
issues = []
# Find the Epic Link field
field_ids = self.get_field_ids_to_epic()
epic_link_field = self._find_epic_link_field(field_ids)
# METHOD 1: Try with 'issueFunction in issuesScopedToEpic' - this works in many Jira instances
if "issueFunction" not in tried_methods:
tried_methods.add("issueFunction")
try:
jql = f'issueFunction in issuesScopedToEpic("{epic_key}")'
logger.info(f"Trying to get epic issues with issueFunction: {jql}")
search_result = self.search_issues(jql, start=start, limit=limit)
if search_result:
logger.info(
f"Successfully found {len(search_result.issues)} issues for epic {epic_key} using issueFunction"
)
return search_result.issues
except Exception as e:
# Log exception but continue with fallback
logger.warning(
f"Error searching epic issues with issueFunction: {str(e)}"
)
# METHOD 2: Try using parent relationship - common in many Jira setups
if "parent" not in tried_methods:
tried_methods.add("parent")
try:
jql = f'parent = "{epic_key}"'
logger.info(
f"Trying to get epic issues with parent relationship: {jql}"
)
issues = self._get_epic_issues_by_jql(epic_key, jql, start, limit)
if issues:
logger.info(
f"Successfully found {len(issues)} issues for epic {epic_key} using parent relationship"
)
return issues
except Exception as parent_error:
logger.warning(
f"Error with parent relationship approach: {str(parent_error)}"
)
# METHOD 3: If we found an Epic Link field, try using it
if epic_link_field and "epicLinkField" not in tried_methods:
tried_methods.add("epicLinkField")
try:
jql = f'"{epic_link_field}" = "{epic_key}"'
logger.info(
f"Trying to get epic issues with epic link field: {jql}"
)
issues = self._get_epic_issues_by_jql(epic_key, jql, start, limit)
if issues:
logger.info(
f"Successfully found {len(issues)} issues for epic {epic_key} using epic link field {epic_link_field}"
)
return issues
except Exception as e:
logger.warning(
f"Error using epic link field {epic_link_field}: {str(e)}"
)
# METHOD 4: Try using 'Epic Link' as a textual field name
if "epicLinkName" not in tried_methods:
tried_methods.add("epicLinkName")
try:
jql = f'"Epic Link" = "{epic_key}"'
logger.info(
f"Trying to get epic issues with 'Epic Link' field name: {jql}"
)
issues = self._get_epic_issues_by_jql(epic_key, jql, start, limit)
if issues:
logger.info(
f"Successfully found {len(issues)} issues for epic {epic_key} using 'Epic Link' field name"
)
return issues
except Exception as e:
logger.warning(f"Error using 'Epic Link' field name: {str(e)}")
# METHOD 5: Try using issue links with a specific link type
if "issueLinks" not in tried_methods:
tried_methods.add("issueLinks")
try:
# Try to find issues linked to this epic with standard link types
link_types = ["relates to", "blocks", "is blocked by", "is part of"]
for link_type in link_types:
jql = f'issueLink = "{link_type}" and issueLink = "{epic_key}"'
logger.info(
f"Trying to get epic issues with issue links: {jql}"
)
try:
issues = self._get_epic_issues_by_jql(
epic_key, jql, start, limit
)
if issues:
logger.info(
f"Successfully found {len(issues)} issues for epic {epic_key} using issue links with type '{link_type}'"
)
return issues
except Exception:
# Just try the next link type
continue
except Exception as e:
logger.warning(f"Error using issue links approach: {str(e)}")
# METHOD 6: Last resort - try each common Epic Link field ID directly
if "commonFields" not in tried_methods:
tried_methods.add("commonFields")
common_epic_fields = [
"customfield_10014",
"customfield_10008",
"customfield_10100",
"customfield_10001",
"customfield_10002",
"customfield_10003",
"customfield_10004",
"customfield_10005",
"customfield_10006",
"customfield_10007",
"customfield_11703",
]
for field_id in common_epic_fields:
try:
jql = f'"{field_id}" = "{epic_key}"'
logger.info(
f"Trying to get epic issues with common field ID: {jql}"
)
issues = self._get_epic_issues_by_jql(
epic_key, jql, start, limit
)
if issues:
logger.info(
f"Successfully found {len(issues)} issues for epic {epic_key} using field ID {field_id}"
)
# Cache this successful field ID for future use
if self._field_ids_cache is None:
self._field_ids_cache = []
self._field_ids_cache.append(
{"id": field_id, "name": "epic_link"}
)
return issues
except Exception:
# Just try the next field ID
continue
# If we've tried everything and found no issues, return an empty list
logger.warning(
f"No issues found for epic {epic_key} after trying multiple approaches"
)
return []
except ValueError:
# Re-raise ValueError (like "not an Epic") as is
raise
except Exception as e:
# Wrap other exceptions
logger.error(f"Error getting issues for epic {epic_key}: {str(e)}")
raise Exception(f"Error getting epic issues: {str(e)}") from e
def _find_epic_link_field(self, field_ids: dict[str, str]) -> str | None:
"""
Find the Epic Link field with fallback mechanisms.
Args:
field_ids: Dictionary of field IDs
Returns:
The field ID for Epic Link if found, None otherwise
"""
# First try the standard field name with case-insensitive matching
for name in ["epic_link", "epiclink", "Epic Link", "epic link", "EPIC LINK"]:
if name in field_ids:
logger.info(
f"Found Epic Link field by name: {name} -> {field_ids[name]}"
)
return field_ids[name]
# Next, look for any field ID that contains "epic" and "link"
# (case-insensitive) in the name
for field_name, field_id in field_ids.items():
if (
isinstance(field_name, str)
and "epic" in field_name.lower()
and "link" in field_name.lower()
):
logger.info(
f"Found potential Epic Link field: {field_name} -> {field_id}"
)
return field_id
# Look for any customfield that might be an epic link
# Common epic link field IDs across different Jira instances
known_epic_fields = [
"customfield_10014", # Common in Jira Cloud
"customfield_10008", # Common in Jira Server
"customfield_10100",
"customfield_10001",
"customfield_10002",
"customfield_10003",
"customfield_10004",
"customfield_10005",
"customfield_10006",
"customfield_10007",
"customfield_11703", # Added based on error message
]
# Check if any of these known fields exist in our field IDs values
for field_id in known_epic_fields:
if field_id in field_ids.values():
logger.info(f"Using known epic link field ID: {field_id}")
return field_id
# Try with common system names for epic link field
system_names = ["system.epic-link", "com.pyxis.greenhopper.jira:gh-epic-link"]
for name in system_names:
if name in field_ids:
logger.info(
f"Found Epic Link field by system name: {name} -> {field_ids[name]}"
)
return field_ids[name]
# If we still can't find it, try to detect it from issue links
try:
# Try to find an existing epic
epics = self._find_sample_epic()
if epics:
epic_key = epics[0].get("key")
if not isinstance(epic_key, str):
msg = f"Unexpected return value type from `_find_sample_epic`: {type(epic_key)}"
logger.error(msg)
raise TypeError(msg)
# Try to find issues linked to this epic
issues = self._find_issues_linked_to_epic(epic_key)
for issue in issues:
# Check fields for any that contain the epic key
fields = issue.get("fields", {})
for field_id, value in fields.items():
if (
field_id.startswith("customfield_")
and isinstance(value, str)
and value == epic_key
):
logger.info(
f"Detected epic link field {field_id} from linked issue"
)
return field_id
except Exception as e:
logger.warning(f"Error detecting epic link field from issues: {str(e)}")
# As a last resort, look for any customfield that starts with customfield_
# and has "epic" in its schema name or description
try:
all_fields = self.jira.get_all_fields()
if not isinstance(all_fields, list):
msg = f"Unexpected return value type from `jira.get_all_fields`: {type(all_fields)}"
logger.error(msg)
raise TypeError(msg)
for field in all_fields:
field_id = field.get("id", "")
schema = field.get("schema", {})
custom_type = schema.get("custom", "")
if field_id.startswith("customfield_") and (
"epic" in custom_type.lower()
or "epic" in field.get("name", "").lower()
or "epic" in field.get("description", "").lower()
):
logger.info(
f"Found potential Epic Link field by schema inspection: {field_id}"
)
return field_id
except Exception as e:
logger.warning(
f"Error during schema inspection for Epic Link field: {str(e)}"
)
# No Epic Link field found
logger.warning("Could not determine Epic Link field with any method")
return None
def _find_sample_epic(self) -> list[dict]:
"""
Find a sample epic to use for detecting the epic link field.
Returns:
List of epics found
"""
try:
# Search for issues with type=Epic
jql = "issuetype = Epic ORDER BY updated DESC"
response = self.jira.jql(jql, limit=1)
if not isinstance(response, dict):
msg = f"Unexpected return value type from `jira.jql`: {type(response)}"
logger.error(msg)
raise TypeError(msg)
if response and "issues" in response and response["issues"]:
return response["issues"]
except Exception as e:
logger.warning(f"Error finding sample epic: {str(e)}")
return []
def _find_issues_linked_to_epic(self, epic_key: str) -> list[dict]:
"""
Find issues linked to a specific epic.
Args:
epic_key: The key of the epic
Returns:
List of issues found
"""
try:
# Try several JQL formats to find linked issues
for query in [
f"'Epic Link' = {epic_key}",
f"'Epic' = {epic_key}",
f"parent = {epic_key}",
f"issueFunction in issuesScopedToEpic('{epic_key}')",
]:
try:
response = self.jira.jql(query, limit=5)
if not isinstance(response, dict):
msg = f"Unexpected return value type from `jira.jql`: {type(response)}"
logger.error(msg)
raise TypeError(msg)
if response.get("issues"):
return response["issues"]
except Exception:
# Try next query format
continue
except Exception as e:
logger.warning(f"Error finding issues linked to epic {epic_key}: {str(e)}")
return []
def _get_epic_issues_by_jql(
self, epic_key: str, jql: str, start: int, limit: int
) -> list[JiraIssue]:
"""
Helper method to get issues using a JQL query.
Args:
epic_key: The key of the epic
jql: JQL query to execute
start: Starting index for pagination
limit: Maximum number of issues to return
Returns:
List of JiraIssue models
"""
search_result = self.search_issues(jql, start=start, limit=limit)
if not search_result:
logger.warning(f"No issues found for epic {epic_key} with query: {jql}")
return search_result.issues
def update_epic_fields(self, issue_key: str, kwargs: dict[str, Any]) -> JiraIssue:
"""
Update Epic-specific fields after Epic creation.
This method implements the second step of the two-step Epic creation process,
applying Epic-specific fields that may be rejected during initial creation
due to screen configuration restrictions.
Args:
issue_key: The key of the created Epic
kwargs: Dictionary containing special keys with Epic field information
Returns:
JiraIssue: The updated Epic
Raises:
Exception: If there is an error updating the Epic fields
"""
try:
# Extract Epic fields from kwargs
update_fields = {}
# Process Epic Name field
if "__epic_name_field" in kwargs and "__epic_name_value" in kwargs:
epic_name_field = kwargs.pop("__epic_name_field")
epic_name_value = kwargs.pop("__epic_name_value")
update_fields[epic_name_field] = epic_name_value
logger.info(
f"Updating Epic Name field ({epic_name_field}): {epic_name_value}"
)
# Process Epic Color field
if "__epic_color_field" in kwargs and "__epic_color_value" in kwargs:
epic_color_field = kwargs.pop("__epic_color_field")
epic_color_value = kwargs.pop("__epic_color_value")
update_fields[epic_color_field] = epic_color_value
logger.info(
f"Updating Epic Color field ({epic_color_field}): {epic_color_value}"
)
# Process any other stored Epic fields
epic_field_keys = [
k for k in kwargs if k.startswith("__epic_") and k.endswith("_field")
]
for field_key in epic_field_keys:
# Get corresponding value key
value_key = field_key.replace("_field", "_value")
if value_key in kwargs:
field_id = kwargs.pop(field_key)
field_value = kwargs.pop(value_key)
update_fields[field_id] = field_value
logger.info(f"Updating Epic field ({field_id}): {field_value}")
# If we have fields to update, make the API call
if update_fields:
logger.info(f"Updating Epic {issue_key} with fields: {update_fields}")
try:
# First try using the generic update_issue method
self.jira.update_issue(issue_key, update={"fields": update_fields})
logger.info(
f"Successfully updated Epic {issue_key} with Epic-specific fields"
)
except Exception as update_error:
# Log the error but don't raise yet - try alternative approaches
logger.warning(
f"Error updating Epic with primary method: {str(update_error)}"
)
# Try updating fields one by one as fallback
success = False
for field_id, field_value in update_fields.items():
try:
self.jira.update_issue(
issue_key, update={"fields": {field_id: field_value}}
)
logger.info(
f"Successfully updated Epic field {field_id} with value {field_value}"
)
success = True
except Exception as field_error:
logger.warning(
f"Failed to update Epic field {field_id}: {str(field_error)}"
)
# If even individual updates failed, log but continue
if not success:
logger.error(
f"Failed to update Epic {issue_key} with Epic-specific fields. "
f"The Epic was created but may be missing required attributes."
)
# Return the updated Epic
return self.get_issue(issue_key)
except Exception as e:
logger.error(f"Error in update_epic_fields: {str(e)}")
# Return the Epic even if the update failed
return self.get_issue(issue_key)