Coverage for src/mcp_atlassian/jira/epics.py: 79%

196 statements  

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

1"""Module for Jira epic operations.""" 

2 

3import logging 

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

5 

6import requests 

7 

8from ..document_types import Document 

9from .client import JiraClient 

10 

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

12 

13 

14class EpicsMixin(JiraClient): 

15 """Mixin for Jira epic operations.""" 

16 

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

18 """ 

19 Get mappings of field names to IDs. 

20 

21 This method discovers and caches various Jira field IDs, with a focus 

22 on Epic-related fields, which can vary between different Jira instances. 

23 

24 Returns: 

25 Dictionary mapping field names to their IDs 

26 (e.g., {'epic_link': 'customfield_10014', 'epic_name': 'customfield_10011'}) 

27 """ 

28 # Use cached field IDs if available 

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

30 return self._field_ids_cache 

31 

32 # Get cached field IDs or fetch from server 

33 return self._get_cached_field_ids() 

34 

35 def _get_cached_field_ids(self) -> Dict[str, str]: 

36 """ 

37 Get cached field IDs or fetch from server. 

38 

39 Returns: 

40 Dictionary mapping field names to their IDs 

41 """ 

42 # Initialize cache if needed 

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

44 self._field_ids_cache = {} 

45 

46 # Return cache if not empty 

47 if self._field_ids_cache: 

48 return self._field_ids_cache 

49 

50 # Fetch field IDs from server 

51 try: 

52 fields = self.jira.get_all_fields() 

53 field_ids = {} 

54 

55 # Log available fields to help with debugging 

56 self._log_available_fields(fields) 

57 

58 # Process each field to identify Epic-related fields 

59 for field in fields: 

60 self._process_field_for_epic_data(field, field_ids) 

61 

62 # If we couldn't find all essential fields, try other discovery methods 

63 if "epic_name" not in field_ids or "epic_link" not in field_ids: 

64 logger.warning( 

65 "Could not find all essential Epic fields through schema. " 

66 "This may cause issues with Epic operations." 

67 ) 

68 # Try to find fields by looking at an existing Epic if possible 

69 self._try_discover_fields_from_existing_epic(field_ids) 

70 

71 # Cache the results 

72 self._field_ids_cache = field_ids 

73 return field_ids 

74 

75 except Exception as e: 

76 logger.warning(f"Error getting field IDs: {str(e)}") 

77 return {} 

78 

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

80 """ 

81 Log available fields for debugging. 

82 

83 Args: 

84 fields: List of field definitions 

85 """ 

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

87 for field in fields: 

88 logger.debug(f"{field.get('id')}: {field.get('name')} ({field.get('schema', {}).get('type')})") 

89 

90 def _process_field_for_epic_data( 

91 self, field: Dict, field_ids: Dict[str, str] 

92 ) -> None: 

93 """ 

94 Process a single field to identify if it's an Epic-related field. 

95 

96 Args: 

97 field: The field definition 

98 field_ids: Dictionary of field IDs to update 

99 """ 

100 try: 

101 field_id = field.get("id") 

102 original_name = field.get("name", "") 

103 field_name = original_name.lower() if original_name else "" 

104 

105 # Skip if no field ID or name 

106 if not field_id or not field_name: 

107 return 

108 

109 # Get the custom schema type if available 

110 field_custom = "" 

111 schema = field.get("schema", {}) 

112 if schema: 

113 field_custom = schema.get("custom", "").lower() 

114 

115 # Epic Link field - used to link issues to epics 

116 if ( 

117 "epic link" in field_name 

118 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-link" 

119 ): 

120 self.epic_link_field_id = field_id 

121 field_ids["epic_link"] = field_id 

122 logger.info(f"Found Epic Link field: {original_name} ({field_id})") 

123 

124 # Epic Name field - used for the title of epics 

125 elif ( 

126 "epic name" in field_name 

127 or "epic-name" in field_name 

128 or original_name == "Epic Name" 

129 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-label" 

130 ): 

131 field_ids["epic_name"] = field_id 

132 logger.info(f"Found Epic Name field: {original_name} ({field_id})") 

133 

134 # Parent field - sometimes used instead of Epic Link 

135 elif original_name == "Parent" or field_name == "parent" or field_name == "parent link": 

136 field_ids["parent"] = field_id 

137 logger.info(f"Found Parent field: {original_name} ({field_id})") 

138 

139 # Epic Status field 

140 elif "epic status" in field_name or original_name == "Epic Status": 

141 field_ids["epic_status"] = field_id 

142 logger.info(f"Found Epic Status field: {original_name} ({field_id})") 

143 

144 # Epic Color field 

145 elif ( 

146 "epic colour" in field_name 

147 or "epic color" in field_name 

148 or original_name == "Epic Colour" 

149 or original_name == "Epic Color" 

150 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-color" 

151 ): 

152 field_ids["epic_color"] = field_id 

153 logger.info(f"Found Epic Color field: {original_name} ({field_id})") 

154 

155 # Try to detect any other fields that might be related to Epics 

156 elif ("epic" in field_name or "epic" in field_custom) and not any( 

157 key in field_ids for key in ["epic_link", "epic_name", "epic_status", "epic_color"] 

158 ): 

159 key = f"epic_{field_name.replace(' ', '_')}" 

160 field_ids[key] = field_id 

161 logger.info( 

162 f"Found additional Epic-related field: {original_name} ({field_id})" 

163 ) 

164 except Exception as e: 

165 logger.warning(f"Error processing field for Epic data: {str(e)}") 

166 

167 def _try_discover_fields_from_existing_epic(self, field_ids: Dict[str, str]) -> None: 

168 """ 

169 Attempt to discover Epic fields by examining an existing Epic issue. 

170 

171 This is a fallback method that attempts to find Epic fields by looking 

172 at actual Epic issues already in the system. 

173 

174 Args: 

175 field_ids: Dictionary of field IDs to update 

176 """ 

177 # If we already have both epic fields, no need to search 

178 if "epic_link" in field_ids and "epic_name" in field_ids: 

179 return 

180 

181 try: 

182 # Find an Epic in the system 

183 epics_jql = "issuetype = Epic ORDER BY created DESC" 

184 results = self.jira.jql(epics_jql, limit=1) 

185 

186 # If no epics found, we can't use this method 

187 if not results or not results.get("issues"): 

188 logger.warning("No existing Epics found to analyze field structure") 

189 return 

190 

191 epic = results["issues"][0] 

192 fields = epic.get("fields", {}) 

193 

194 # Inspect every custom field for values that look like epic fields 

195 for field_id, value in fields.items(): 

196 if not field_id.startswith("customfield_"): 

197 continue 

198 

199 # If it's a string value for a customfield, it might be the Epic Name 

200 if "epic_name" not in field_ids and isinstance(value, str) and value: 

201 field_ids["epic_name"] = field_id 

202 logger.info(f"Discovered Epic Name field from existing epic: {field_id}") 

203 

204 # Now try to find issues linked to this Epic to discover the Epic Link field 

205 if "epic_link" not in field_ids: 

206 epic_key = epic.get("key") 

207 if not epic_key: 

208 return 

209 

210 # Try several query formats to find linked issues 

211 link_queries = [ 

212 f"'Epic Link' = {epic_key}", 

213 f"'Epic' = {epic_key}", 

214 f"parent = {epic_key}", 

215 ] 

216 

217 for query in link_queries: 

218 try: 

219 link_results = self.jira.jql(query, limit=1) 

220 if link_results and link_results.get("issues"): 

221 # Found an issue linked to our epic, now inspect its fields 

222 linked_issue = link_results["issues"][0] 

223 linked_fields = linked_issue.get("fields", {}) 

224 

225 # Check each field to see if it contains our epic key 

226 for field_id, value in linked_fields.items(): 

227 if ( 

228 field_id.startswith("customfield_") 

229 and isinstance(value, str) 

230 and value == epic_key 

231 ): 

232 field_ids["epic_link"] = field_id 

233 logger.info( 

234 f"Discovered Epic Link field from linked issue: {field_id}" 

235 ) 

236 break 

237 

238 # If we found the epic link field, we can stop 

239 if "epic_link" in field_ids: 

240 break 

241 except Exception: # noqa: BLE001 - Intentional fallback with logging 

242 continue 

243 

244 except Exception as e: 

245 logger.warning(f"Error discovering fields from existing Epics: {str(e)}") 

246 

247 def prepare_epic_fields( 

248 self, fields: Dict[str, Any], summary: str, kwargs: Dict[str, Any] 

249 ) -> None: 

250 """ 

251 Prepare epic-specific fields for issue creation. 

252 

253 Args: 

254 fields: The fields dictionary to update 

255 summary: The issue summary that can be used as a default epic name 

256 kwargs: Additional fields from the user 

257 """ 

258 try: 

259 # Get all field IDs 

260 field_ids = self.get_jira_field_ids() 

261 logger.info(f"Discovered Jira field IDs for Epic creation: {field_ids}") 

262 

263 # Handle Epic Name - might be required in some instances, not in others 

264 if "epic_name" in field_ids: 

265 epic_name = kwargs.pop( 

266 "epic_name", summary 

267 ) # Use summary as default if epic_name not provided 

268 fields[field_ids["epic_name"]] = epic_name 

269 logger.info( 

270 f"Setting Epic Name field {field_ids['epic_name']} to: {epic_name}" 

271 ) 

272 

273 # Handle Epic Color if the field was discovered 

274 if "epic_color" in field_ids: 

275 epic_color = ( 

276 kwargs.pop("epic_color", None) 

277 or kwargs.pop("epic_colour", None) 

278 or "green" # Default color 

279 ) 

280 fields[field_ids["epic_color"]] = epic_color 

281 logger.info( 

282 f"Setting Epic Color field {field_ids['epic_color']} " 

283 f"to: {epic_color}" 

284 ) 

285 

286 # Add any other epic-related fields provided 

287 for key, value in list(kwargs.items()): 

288 if key.startswith("epic_") and key != "epic_name" and key != "epic_color": 

289 field_key = key.replace("epic_", "") 

290 if f"epic_{field_key}" in field_ids: 

291 fields[field_ids[f"epic_{field_key}"]] = value 

292 kwargs.pop(key) 

293 

294 # Warn if epic_name field is required but wasn't discovered 

295 if "epic_name" not in field_ids: 

296 logger.warning( 

297 "Epic Name field not found in Jira schema. " 

298 "Epic creation may fail if this field is required." 

299 ) 

300 

301 except Exception as e: 

302 logger.error(f"Error preparing Epic-specific fields: {str(e)}") 

303 

304 def link_issue_to_epic(self, issue_key: str, epic_key: str) -> Document: 

305 """ 

306 Link an issue to an epic. 

307 

308 Args: 

309 issue_key: The key of the issue to link 

310 epic_key: The key of the epic to link to 

311 

312 Returns: 

313 Document with the updated issue 

314 

315 Raises: 

316 Exception: If there is an error linking the issue 

317 """ 

318 try: 

319 # Verify both keys exist 

320 self.jira.get_issue(issue_key) 

321 epic = self.jira.get_issue(epic_key) 

322 

323 # Verify epic_key is actually an epic 

324 fields = epic.get("fields", {}) 

325 issue_type = fields.get("issuetype", {}).get("name", "").lower() 

326 

327 if issue_type != "epic": 

328 error_msg = f"{epic_key} is not an Epic" 

329 raise ValueError(error_msg) 

330 

331 # Get the epic link field ID 

332 field_ids = self.get_jira_field_ids() 

333 epic_link_field = field_ids.get("epic_link") 

334 

335 if not epic_link_field: 

336 error_msg = "Could not determine Epic Link field" 

337 raise ValueError(error_msg) 

338 

339 # Update the issue to link it to the epic 

340 update_fields = {epic_link_field: epic_key} 

341 self.jira.update_issue(issue_key, fields=update_fields) 

342 

343 # Return the updated issue 

344 if hasattr(self, 'get_issue') and callable(self.get_issue): 

345 return self.get_issue(issue_key) 

346 else: 

347 # Fallback if get_issue is not available 

348 logger.warning("get_issue method not available, returning empty Document") 

349 return Document(page_content="", metadata={"key": issue_key}) 

350 

351 except Exception as e: 

352 logger.error(f"Error linking {issue_key} to epic {epic_key}: {str(e)}") 

353 raise Exception(f"Error linking issue to epic: {str(e)}") from e 

354 

355 def get_epic_issues(self, epic_key: str, limit: int = 50) -> List[Document]: 

356 """ 

357 Get all issues linked to a specific epic. 

358 

359 Args: 

360 epic_key: The key of the epic (e.g. 'PROJ-123') 

361 limit: Maximum number of issues to return 

362 

363 Returns: 

364 List of Documents representing the issues linked to the epic 

365 

366 Raises: 

367 ValueError: If the issue is not an Epic 

368 Exception: If there is an error getting epic issues 

369 """ 

370 try: 

371 # First, check if the issue is an Epic 

372 epic = self.jira.issue(epic_key) 

373 fields_data = epic.get("fields", {}) 

374 

375 # Safely check if the issue is an Epic 

376 issue_type = None 

377 issuetype_data = fields_data.get("issuetype") 

378 if issuetype_data is not None: 

379 issue_type = issuetype_data.get("name", "") 

380 

381 if issue_type != "Epic": 

382 error_msg = ( 

383 f"Issue {epic_key} is not an Epic, it is a " 

384 f"{issue_type or 'unknown type'}" 

385 ) 

386 raise ValueError(error_msg) 

387 

388 # Get the dynamic field IDs for this Jira instance 

389 field_ids = self.get_jira_field_ids() 

390 

391 # Build JQL queries based on discovered field IDs 

392 jql_queries = [] 

393 

394 # Add queries based on discovered fields 

395 if "parent" in field_ids: 

396 jql_queries.append(f"parent = {epic_key}") 

397 

398 if "epic_link" in field_ids: 

399 field_name = field_ids["epic_link"] 

400 jql_queries.append(f'"{field_name}" = {epic_key}') 

401 jql_queries.append(f'"{field_name}" ~ {epic_key}') 

402 

403 # Add standard fallback queries 

404 jql_queries.extend( 

405 [ 

406 f"parent = {epic_key}", # Common in most instances 

407 f"'Epic Link' = {epic_key}", # Some instances 

408 f"'Epic' = {epic_key}", # Some instances 

409 f"issue in childIssuesOf('{epic_key}')", # Some instances 

410 ] 

411 ) 

412 

413 # Try each query until we get results or run out of options 

414 documents = [] 

415 for jql in jql_queries: 

416 try: 

417 logger.info(f"Trying to get epic issues with JQL: {jql}") 

418 if hasattr(self, 'search_issues') and callable(self.search_issues): 

419 documents = self.search_issues(jql, limit=limit) 

420 else: 

421 # Fallback if search_issues is not available 

422 results = self.jira.jql(jql, limit=limit) 

423 documents = [] 

424 for issue in results.get("issues", []): 

425 key = issue.get("key", "") 

426 summary = issue.get("fields", {}).get("summary", "") 

427 documents.append( 

428 Document( 

429 page_content=summary, 

430 metadata={"key": key, "type": "issue"} 

431 ) 

432 ) 

433 

434 if documents: 

435 return documents 

436 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

437 logger.info(f"Failed to get epic issues with JQL '{jql}': {str(e)}") 

438 continue 

439 

440 # If we've tried all queries and got no results, return an empty list 

441 # but also log a warning that we might be missing the right field 

442 if not documents: 

443 logger.warning( 

444 f"Couldn't find issues linked to epic {epic_key}. " 

445 "Your Jira instance might use a different field for epic links." 

446 ) 

447 

448 return documents 

449 

450 except ValueError as e: 

451 # Re-raise ValueError for non-epic issues 

452 raise 

453 

454 except Exception as e: 

455 logger.error(f"Error getting issues for epic {epic_key}: {str(e)}") 

456 raise Exception(f"Error getting epic issues: {str(e)}") from e