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

1"""Module for Jira transition operations.""" 

2 

3import logging 

4from typing import Any, Dict, List, Optional, Union, cast 

5 

6from ..document_types import Document 

7from .client import JiraClient 

8 

9logger = logging.getLogger("mcp-jira") 

10 

11 

12class TransitionsMixin(JiraClient): 

13 """Mixin for Jira transition operations.""" 

14 

15 def get_available_transitions(self, issue_key: str) -> List[Dict[str, Any]]: 

16 """ 

17 Get the available status transitions for an issue. 

18 

19 Args: 

20 issue_key: The issue key (e.g. 'PROJ-123') 

21 

22 Returns: 

23 List of available transitions with id, name, and to status details 

24 

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]] = [] 

31 

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 [] 

45 

46 for transition in transitions: 

47 if not isinstance(transition, dict): 

48 continue 

49 

50 # Extract the transition information safely 

51 transition_id = transition.get("id") 

52 transition_name = transition.get("name") 

53 

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"] 

62 

63 result.append( 

64 { 

65 "id": transition_id, 

66 "name": transition_name, 

67 "to_status": to_status, 

68 } 

69 ) 

70 

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 

75 

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. 

85 

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 

91 

92 Returns: 

93 Document representing the transitioned issue 

94 

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) 

101 

102 # Prepare transition data 

103 transition_data: Dict[str, Any] = {"transition": {"id": transition_id_str}} 

104 

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 

110 

111 # Add comment if provided 

112 if comment: 

113 self._add_comment_to_transition_data(transition_data, comment) 

114 

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}") 

120 

121 # Perform the transition 

122 self.jira.issue_transition(issue_key, transition_data) 

123 

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 

139 

140 def _normalize_transition_id(self, transition_id: Union[str, int]) -> str: 

141 """ 

142 Normalize transition ID to a string. 

143 

144 Args: 

145 transition_id: Transition ID as string or int 

146 

147 Returns: 

148 String representation of transition ID 

149 """ 

150 return str(transition_id) 

151 

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. 

155 

156 Args: 

157 fields: Dictionary of fields to sanitize 

158 

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 

167 

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 

188 

189 return sanitized_fields 

190 

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. 

196 

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 

209 

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) 

214 

215 # Add to transition data 

216 transition_data["update"] = { 

217 "comment": [{"add": {"body": jira_formatted_comment}}] 

218 }