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

129 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-10 03:26 +0900

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

2 

3import logging 

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

5 

6from .client import JiraClient 

7 

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

9 

10 

11class 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