Coverage for src/mcp_atlassian/jira/transitions.py: 100%

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

2

3 import logging

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

5

6 from ..document_types import Document

7 from .client import JiraClient

8

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

10

11

12 class 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 }