Coverage for src/mcp_atlassian/jira/fields.py: 89%

1"""Module for Jira field operations."""

2

3 import logging

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

5

6 from .client import JiraClient

7

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

9

10

11 class FieldsMixin(JiraClient):

12 """Mixin for Jira field operations.

13

14 This mixin provides methods for discovering, caching, and working with Jira fields.

15 Field IDs in Jira are crucial for many operations since they can differ across

16 different Jira instances, especially for custom fields.

17 """

18

19 def get_fields(self, refresh: bool = False) -> List[Dict[str, Any]]:

20 """

21 Get all available fields from Jira.

22

23 Args:

24 refresh: When True, forces a refresh from the server instead of using cache

25

26 Returns:

27 List of field definitions

28 """

29 try:

30 # Use cached field data if available and refresh is not requested

31 if hasattr(self, "_fields_cache") and self._fields_cache and not refresh:

32 return self._fields_cache

33

34 # Fetch fields from Jira API

35 fields = self.jira.fields()

36

37 # Cache the fields

38 self._fields_cache = fields

39

40 # Log available fields for debugging

41 self._log_available_fields(fields)

42

43 return fields

44

45 except Exception as e:

46 logger.error(f"Error getting Jira fields: {str(e)}")

47 return []

48

49 def get_field_id(self, field_name: str, refresh: bool = False) -> Optional[str]:

50 """

51 Get the ID for a specific field by name.

52

53 Args:

54 field_name: The name of the field to look for (case-insensitive)

55 refresh: When True, forces a refresh from the server

56

57 Returns:

58 Field ID if found, None otherwise

59 """

60 try:

61 # Normalize the field name to lowercase for case-insensitive matching

62 normalized_name = field_name.lower()

63

64 # Get all fields and search for the requested field

65 fields = self.get_fields(refresh=refresh)

66

67 for field in fields:

68 name = field.get("name", "")

69 if name and name.lower() == normalized_name:

70 return field.get("id")

71

72 # If not found by exact match, try partial match

73 for field in fields:

74 name = field.get("name", "")

75 if name and normalized_name in name.lower():

76 logger.info(f"Found field '{name}' as partial match for '{field_name}'")

77 return field.get("id")

78

79 logger.warning(f"Field '{field_name}' not found")

80 return None

81

82 except Exception as e:

83 logger.error(f"Error getting field ID for '{field_name}': {str(e)}")

84 return None

85

86 def get_field_by_id(self, field_id: str, refresh: bool = False) -> Optional[Dict[str, Any]]:

87 """

88 Get field definition by ID.

89

90 Args:

91 field_id: The ID of the field to look for

92 refresh: When True, forces a refresh from the server

93

94 Returns:

95 Field definition if found, None otherwise

96 """

97 try:

98 fields = self.get_fields(refresh=refresh)

99

100 for field in fields:

101 if field.get("id") == field_id:

102 return field

103

104 logger.warning(f"Field with ID '{field_id}' not found")

105 return None

106

107 except Exception as e:

108 logger.error(f"Error getting field by ID '{field_id}': {str(e)}")

109 return None

110

111 def get_custom_fields(self, refresh: bool = False) -> List[Dict[str, Any]]:

112 """

113 Get all custom fields.

114

115 Args:

116 refresh: When True, forces a refresh from the server

117

118 Returns:

119 List of custom field definitions

120 """

121 try:

122 fields = self.get_fields(refresh=refresh)

123 custom_fields = [

124 field for field in fields

125 if field.get("id", "").startswith("customfield_")

126 ]

127

128 return custom_fields

129

130 except Exception as e:

131 logger.error(f"Error getting custom fields: {str(e)}")

132 return []

133

134 def get_required_fields(self, issue_type: str, project_key: str) -> Dict[str, Any]:

135 """

136 Get required fields for creating an issue of a specific type in a project.

137

138 Args:

139 issue_type: The issue type (e.g., 'Bug', 'Story', 'Epic')

140 project_key: The project key (e.g., 'PROJ')

141

142 Returns:

143 Dictionary mapping required field names to their definitions

144 """

145 try:

146 # Create meta provides field requirements for different issue types

147 create_meta = self.jira.createmeta(

148 projectKeys=project_key,

149 issuetypeNames=issue_type,

150 expand="projects.issuetypes.fields"

151 )

152

153 required_fields = {}

154

155 # Navigate the nested structure to find required fields

156 if "projects" in create_meta:

157 for project in create_meta["projects"]:

158 if project.get("key") == project_key:

159 if "issuetypes" in project:

160 for issuetype in project["issuetypes"]:

161 if issuetype.get("name") == issue_type:

162 fields = issuetype.get("fields", {})

163 # Extract required fields

164 for field_id, field_meta in fields.items():

165 if field_meta.get("required", False):

166 required_fields[field_id] = field_meta

167

168 if not required_fields:

169 logger.warning(

170 f"No required fields found for issue type '{issue_type}' "

171 f"in project '{project_key}'"

172 )

173

174 return required_fields

175

176 except Exception as e:

177 logger.error(

178 f"Error getting required fields for issue type '{issue_type}' "

179 f"in project '{project_key}': {str(e)}"

180 )

181 return {}

182

183 def get_jira_field_ids(self) -> Dict[str, str]:

184 """

185 Get a mapping of field names to their IDs.

186

187 This method is maintained for backward compatibility and is used

188 by multiple other mixins like EpicsMixin.

189

190 Returns:

191 Dictionary mapping field names to their IDs

192 """

193 # Check if we've already cached the field_ids

194 if hasattr(self, "_field_ids_cache") and self._field_ids_cache:

195 return self._field_ids_cache

196

197 # Initialize cache if needed

198 if not hasattr(self, "_field_ids_cache"):

199 self._field_ids_cache = {}

200

201 try:

202 # Get all fields

203 fields = self.get_fields()

204 field_ids = {}

205

206 # Extract field IDs

207 for field in fields:

208 name = field.get("name")

209 field_id = field.get("id")

210 if name and field_id:

211 field_ids[name] = field_id

212

213 # Cache the results

214 self._field_ids_cache = field_ids

215 return field_ids

216

217 except Exception as e:

218 logger.error(f"Error getting field IDs: {str(e)}")

219 return {}

220

221 def _log_available_fields(self, fields: List[Dict]) -> None:

222 """

223 Log available fields for debugging.

224

225 Args:

226 fields: List of field definitions

227 """

228 logger.debug("Available Jira fields:")

229 for field in fields:

230 field_id = field.get("id", "")

231 name = field.get("name", "")

232 field_type = field.get("schema", {}).get("type", "")

233 logger.debug(f"{field_id}: {name} ({field_type})")

234

235 def is_custom_field(self, field_id: str) -> bool:

236 """

237 Check if a field is a custom field.

238

239 Args:

240 field_id: The field ID to check

241

242 Returns:

243 True if it's a custom field, False otherwise

244 """

245 return field_id.startswith("customfield_")

246

247 def format_field_value(self, field_id: str, value: Any) -> Dict[str, Any]:

248 """

249 Format a field value based on its type for update operations.

250

251 Different field types in Jira require different JSON formats when updating.

252 This method helps format the value correctly for the specific field type.

253

254 Args:

255 field_id: The ID of the field

256 value: The value to format

257

258 Returns:

259 Properly formatted value for the field

260 """

261 try:

262 # Get field definition

263 field = self.get_field_by_id(field_id)

264

265 if not field:

266 # For unknown fields, return value as-is

267 return value

268

269 field_type = field.get("schema", {}).get("type")

270

271 # Format based on field type

272 if field_type == "user":

273 # Handle user fields - need accountId for cloud or name for server

274 if isinstance(value, str):

275 if hasattr(self, '_get_account_id') and callable(self._get_account_id):

276 try:

277 account_id = self._get_account_id(value)

278 return {"accountId": account_id}

279 except Exception as e:

280 logger.warning(f"Could not resolve user '{value}': {str(e)}")

281 return value

282 else:

283 # For server/DC, just use the name

284 return {"name": value}

285 else:

286 return value

287

288 elif field_type == "array":

289 # Handle array fields - convert single value to list if needed

290 if not isinstance(value, list):

291 return [value]

292 return value

293

294 elif field_type == "option":

295 # Handle option fields - convert to {"value": value} format

296 if isinstance(value, str):

297 return {"value": value}

298 return value

299

300 # For other types, return as-is

301 return value

302

303 except Exception as e:

304 logger.warning(f"Error formatting field value for '{field_id}': {str(e)}")

305 return value