Coverage for src/mcp_atlassian/jira/transitions.py: 100%
84 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 transition operations."""
3import logging
4from typing import Any, Dict, List, Optional, Union, cast
6from ..document_types import Document
7from .client import JiraClient
9logger = logging.getLogger("mcp-jira")
12class TransitionsMixin(JiraClient):
13 """Mixin for Jira transition operations."""
15 def get_available_transitions(self, issue_key: str) -> List[Dict[str, Any]]:
16 """
17 Get the available status transitions for an issue.
19 Args:
20 issue_key: The issue key (e.g. 'PROJ-123')
22 Returns:
23 List of available transitions with id, name, and to status details
25 Raises:
26 Exception: If there is an error getting transitions
27 """
28 try:
29 transitions_data = self.jira.get_issue_transitions(issue_key)
30 result: List[Dict[str, Any]] = []
32 # Handle different response formats from the Jira API
33 transitions = []
34 if isinstance(transitions_data, dict) and "transitions" in transitions_data:
35 # Handle the case where the response is a dict with a "transitions" key
36 transitions = transitions_data.get("transitions", [])
37 elif isinstance(transitions_data, list):
38 # Handle the case where the response is a list of transitions directly
39 transitions = transitions_data
40 else:
41 logger.warning(
42 f"Unexpected format for transitions data: {type(transitions_data)}"
43 )
44 return []
46 for transition in transitions:
47 if not isinstance(transition, dict):
48 continue
50 # Extract the transition information safely
51 transition_id = transition.get("id")
52 transition_name = transition.get("name")
54 # Handle different formats for the "to" status
55 to_status = None
56 if "to" in transition and isinstance(transition["to"], dict):
57 to_status = transition["to"].get("name")
58 elif "to_status" in transition:
59 to_status = transition["to_status"]
60 elif "status" in transition:
61 to_status = transition["status"]
63 result.append(
64 {
65 "id": transition_id,
66 "name": transition_name,
67 "to_status": to_status,
68 }
69 )
71 return result
72 except Exception as e:
73 logger.error(f"Error getting transitions for issue {issue_key}: {str(e)}")
74 raise Exception(f"Error getting transitions: {str(e)}") from e
76 def transition_issue(
77 self,
78 issue_key: str,
79 transition_id: Union[str, int],
80 fields: Optional[Dict[str, Any]] = None,
81 comment: Optional[str] = None,
82 ) -> Document:
83 """
84 Transition a Jira issue to a new status.
86 Args:
87 issue_key: The key of the issue to transition
88 transition_id: The ID of the transition to perform
89 fields: Optional fields to set during the transition
90 comment: Optional comment to add during the transition
92 Returns:
93 Document representing the transitioned issue
95 Raises:
96 ValueError: If there is an error transitioning the issue
97 """
98 try:
99 # Ensure transition_id is a string
100 transition_id_str = self._normalize_transition_id(transition_id)
102 # Prepare transition data
103 transition_data: Dict[str, Any] = {"transition": {"id": transition_id_str}}
105 # Add fields if provided
106 if fields:
107 sanitized_fields = self._sanitize_transition_fields(fields)
108 if sanitized_fields:
109 transition_data["fields"] = sanitized_fields
111 # Add comment if provided
112 if comment:
113 self._add_comment_to_transition_data(transition_data, comment)
115 # Log the transition request for debugging
116 logger.info(
117 f"Transitioning issue {issue_key} with transition ID {transition_id_str}"
118 )
119 logger.debug(f"Transition data: {transition_data}")
121 # Perform the transition
122 self.jira.issue_transition(issue_key, transition_data)
124 # Return the updated issue
125 # Using get_issue from the base class or IssuesMixin if available
126 if hasattr(self, 'get_issue') and callable(self.get_issue):
127 return self.get_issue(issue_key)
128 else:
129 # Fallback if get_issue is not available
130 logger.warning("get_issue method not available, returning empty Document")
131 return Document(page_content="", metadata={"key": issue_key})
132 except Exception as e:
133 error_msg = (
134 f"Error transitioning issue {issue_key} with transition ID "
135 f"{transition_id}: {str(e)}"
136 )
137 logger.error(error_msg)
138 raise ValueError(error_msg) from e
140 def _normalize_transition_id(self, transition_id: Union[str, int]) -> str:
141 """
142 Normalize transition ID to a string.
144 Args:
145 transition_id: Transition ID as string or int
147 Returns:
148 String representation of transition ID
149 """
150 return str(transition_id)
152 def _sanitize_transition_fields(self, fields: Dict[str, Any]) -> Dict[str, Any]:
153 """
154 Sanitize fields to ensure they're valid for the Jira API.
156 Args:
157 fields: Dictionary of fields to sanitize
159 Returns:
160 Dictionary of sanitized fields
161 """
162 sanitized_fields: Dict[str, Any] = {}
163 for key, value in fields.items():
164 # Skip None values
165 if value is None:
166 continue
168 # Handle special case for assignee
169 if key == "assignee" and isinstance(value, str):
170 try:
171 # Check if _get_account_id is available (from UsersMixin)
172 if hasattr(self, '_get_account_id'):
173 account_id = self._get_account_id(value)
174 sanitized_fields[key] = {"accountId": account_id}
175 else:
176 # If _get_account_id is not available, log warning and skip
177 logger.warning(
178 f"Cannot resolve assignee '{value}' without _get_account_id method"
179 )
180 continue
181 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
182 error_msg = f"Could not resolve assignee '{value}': {str(e)}"
183 logger.warning(error_msg)
184 # Skip this field
185 continue
186 else:
187 sanitized_fields[key] = value
189 return sanitized_fields
191 def _add_comment_to_transition_data(
192 self, transition_data: Dict[str, Any], comment: Union[str, int]
193 ) -> None:
194 """
195 Add comment to transition data.
197 Args:
198 transition_data: The transition data dictionary to update
199 comment: The comment to add
200 """
201 # Ensure comment is a string
202 if not isinstance(comment, str):
203 logger.warning(
204 f"Comment must be a string, converting from {type(comment)}: {comment}"
205 )
206 comment_str = str(comment)
207 else:
208 comment_str = comment
210 # Convert markdown to Jira format if _markdown_to_jira is available
211 jira_formatted_comment = comment_str
212 if hasattr(self, '_markdown_to_jira'):
213 jira_formatted_comment = self._markdown_to_jira(comment_str)
215 # Add to transition data
216 transition_data["update"] = {
217 "comment": [{"add": {"body": jira_formatted_comment}}]
218 }