Coverage for src/mcp_atlassian/jira/issues.py: 72%

326 statements  

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

1"""Module for Jira issue operations.""" 

2 

3import logging 

4from datetime import datetime 

5from typing import Any, Dict, Optional, List 

6 

7import requests 

8 

9from ..document_types import Document 

10from .client import JiraClient 

11from .users import UsersMixin 

12 

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

14 

15 

16class IssuesMixin(UsersMixin): 

17 """Mixin for Jira issue operations.""" 

18 

19 def get_issue( 

20 self, 

21 issue_key: str, 

22 expand: Optional[str] = None, 

23 comment_limit: Optional[int | str] = 10, 

24 ) -> Document: 

25 """ 

26 Get a Jira issue by key. 

27 

28 Args: 

29 issue_key: The issue key (e.g., PROJECT-123) 

30 expand: Fields to expand in the response 

31 comment_limit: Maximum number of comments to include, or "all" 

32 

33 Returns: 

34 Document with issue content and metadata 

35 

36 Raises: 

37 Exception: If there is an error retrieving the issue 

38 """ 

39 try: 

40 # Normalize comment limit 

41 normalized_comment_limit = self._normalize_comment_limit(comment_limit) 

42 

43 # Get issue details 

44 issue = self.jira.get_issue(issue_key, expand=expand) 

45 

46 # Extract relevant fields 

47 fields = issue.get("fields", {}) 

48 

49 # Process content 

50 description = self._clean_text(fields.get("description", "")) 

51 

52 # Convert date 

53 created_date_str = fields.get("created", "") 

54 created_date = self._parse_date(created_date_str) if created_date_str else "" 

55 

56 # Get comments if needed 

57 comments = [] 

58 if normalized_comment_limit is not None: 

59 comments = self._get_issue_comments_if_needed(issue_key, normalized_comment_limit) 

60 

61 # Extract epic information 

62 epic_info = self._extract_epic_information(issue) 

63 

64 # Format content 

65 content = self._format_issue_content( 

66 issue_key, issue, description, comments, created_date, epic_info 

67 ) 

68 

69 # Create metadata 

70 metadata = self._create_issue_metadata( 

71 issue_key, issue, comments, created_date, epic_info 

72 ) 

73 

74 # Create and return document 

75 return Document( 

76 page_content=content, 

77 metadata=metadata, 

78 ) 

79 except Exception as e: 

80 logger.error(f"Error getting issue {issue_key}: {str(e)}") 

81 raise Exception(f"Error retrieving issue {issue_key}: {str(e)}") from e 

82 

83 def _normalize_comment_limit(self, comment_limit: Optional[int | str]) -> Optional[int]: 

84 """ 

85 Normalize the comment limit to an integer or None. 

86 

87 Args: 

88 comment_limit: The comment limit as int, string, or None 

89 

90 Returns: 

91 Normalized comment limit as int or None 

92 """ 

93 if comment_limit is None: 

94 return None 

95 

96 if isinstance(comment_limit, int): 

97 return comment_limit 

98 

99 if comment_limit == "all": 

100 return None # No limit 

101 

102 # Try to convert to int 

103 try: 

104 return int(comment_limit) 

105 except ValueError: 

106 # If conversion fails, default to 10 

107 return 10 

108 

109 def _get_issue_comments_if_needed( 

110 self, issue_key: str, comment_limit: Optional[int] 

111 ) -> List[Dict]: 

112 """ 

113 Get comments for an issue if needed. 

114 

115 Args: 

116 issue_key: The issue key 

117 comment_limit: Maximum number of comments to include 

118 

119 Returns: 

120 List of comments 

121 """ 

122 if comment_limit is None or comment_limit > 0: 

123 try: 

124 comments = self.jira.issue_get_comments(issue_key) 

125 if isinstance(comments, dict) and "comments" in comments: 

126 comments = comments["comments"] 

127 

128 # Limit comments if needed 

129 if comment_limit is not None: 

130 comments = comments[:comment_limit] 

131 

132 return comments 

133 except Exception as e: 

134 logger.warning(f"Error getting comments for {issue_key}: {str(e)}") 

135 return [] 

136 return [] 

137 

138 def _extract_epic_information(self, issue: Dict) -> Dict[str, Optional[str]]: 

139 """ 

140 Extract epic information from an issue. 

141 

142 Args: 

143 issue: The issue data 

144 

145 Returns: 

146 Dictionary with epic information 

147 """ 

148 # Initialize with default values 

149 epic_info = { 

150 "epic_key": None, 

151 "epic_name": None, 

152 "epic_summary": None, 

153 "is_epic": False, 

154 } 

155 

156 fields = issue.get("fields", {}) 

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

158 

159 # Check if this is an epic 

160 if issue_type == "epic": 

161 epic_info["is_epic"] = True 

162 epic_info["epic_name"] = fields.get("customfield_10011", "") # Epic Name field 

163 

164 # If not an epic, check for epic link 

165 elif "customfield_10014" in fields and fields["customfield_10014"]: # Epic Link field 

166 epic_key = fields["customfield_10014"] 

167 epic_info["epic_key"] = epic_key 

168 

169 # Try to get epic details 

170 try: 

171 epic = self.jira.get_issue(epic_key) 

172 epic_fields = epic.get("fields", {}) 

173 epic_info["epic_name"] = epic_fields.get("customfield_10011", "") 

174 epic_info["epic_summary"] = epic_fields.get("summary", "") 

175 except Exception as e: 

176 logger.warning(f"Error getting epic details for {epic_key}: {str(e)}") 

177 

178 return epic_info 

179 

180 def _parse_date(self, date_str: str) -> str: 

181 """ 

182 Parse a date string to a formatted date. 

183 

184 Args: 

185 date_str: The date string to parse 

186 

187 Returns: 

188 Formatted date string 

189 """ 

190 try: 

191 # Parse ISO 8601 format 

192 date_obj = datetime.fromisoformat(date_str.replace("Z", "+00:00")) 

193 # Format: January 1, 2023 

194 return date_obj.strftime("%B %d, %Y") 

195 except (ValueError, TypeError): 

196 return date_str 

197 

198 def _format_issue_content( 

199 self, 

200 issue_key: str, 

201 issue: Dict, 

202 description: str, 

203 comments: List[Dict], 

204 created_date: str, 

205 epic_info: Dict[str, Optional[str]], 

206 ) -> str: 

207 """ 

208 Format issue content for display. 

209 

210 Args: 

211 issue_key: The issue key 

212 issue: The issue data 

213 description: The issue description 

214 comments: The issue comments 

215 created_date: The formatted creation date 

216 epic_info: Epic information 

217 

218 Returns: 

219 Formatted issue content 

220 """ 

221 fields = issue.get("fields", {}) 

222 

223 # Basic issue information 

224 summary = fields.get("summary", "") 

225 status = fields.get("status", {}).get("name", "") 

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

227 

228 # Format content 

229 content = [f"# {issue_key}: {summary}"] 

230 content.append(f"**Type**: {issue_type}") 

231 content.append(f"**Status**: {status}") 

232 content.append(f"**Created**: {created_date}") 

233 

234 # Add reporter 

235 reporter = fields.get("reporter", {}) 

236 reporter_name = reporter.get("displayName", "") or reporter.get("name", "") 

237 if reporter_name: 

238 content.append(f"**Reporter**: {reporter_name}") 

239 

240 # Add assignee 

241 assignee = fields.get("assignee", {}) 

242 assignee_name = assignee.get("displayName", "") or assignee.get("name", "") 

243 if assignee_name: 

244 content.append(f"**Assignee**: {assignee_name}") 

245 

246 # Add epic information 

247 if epic_info["is_epic"]: 

248 content.append(f"**Epic Name**: {epic_info['epic_name']}") 

249 elif epic_info["epic_key"]: 

250 content.append(f"**Epic**: [{epic_info['epic_key']}] {epic_info['epic_summary']}") 

251 

252 # Add description 

253 if description: 

254 content.append("\n## Description\n") 

255 content.append(description) 

256 

257 # Add comments 

258 if comments: 

259 content.append("\n## Comments\n") 

260 for comment in comments: 

261 author = comment.get("author", {}) 

262 author_name = author.get("displayName", "") or author.get("name", "") 

263 comment_body = self._clean_text(comment.get("body", "")) 

264 

265 if author_name and comment_body: 

266 comment_date = comment.get("created", "") 

267 if comment_date: 

268 comment_date = self._parse_date(comment_date) 

269 content.append(f"**{author_name}** ({comment_date}):") 

270 else: 

271 content.append(f"**{author_name}**:") 

272 

273 content.append(f"{comment_body}\n") 

274 

275 return "\n".join(content) 

276 

277 def _create_issue_metadata( 

278 self, 

279 issue_key: str, 

280 issue: Dict, 

281 comments: List[Dict], 

282 created_date: str, 

283 epic_info: Dict[str, Optional[str]], 

284 ) -> Dict[str, Any]: 

285 """ 

286 Create metadata for a Jira issue. 

287 

288 Args: 

289 issue_key: The issue key 

290 issue: The issue data 

291 comments: The issue comments 

292 created_date: The formatted creation date 

293 epic_info: Epic information 

294 

295 Returns: 

296 Metadata dictionary 

297 """ 

298 fields = issue.get("fields", {}) 

299 

300 # Initialize metadata 

301 metadata = { 

302 "key": issue_key, 

303 "title": fields.get("summary", ""), 

304 "status": fields.get("status", {}).get("name", ""), 

305 "type": fields.get("issuetype", {}).get("name", ""), 

306 "created": created_date, 

307 "url": f"{self.config.url}/browse/{issue_key}", 

308 } 

309 

310 # Add assignee if available 

311 assignee = fields.get("assignee", {}) 

312 if assignee: 

313 metadata["assignee"] = assignee.get("displayName", "") or assignee.get("name", "") 

314 

315 # Add epic information 

316 if epic_info["is_epic"]: 

317 metadata["is_epic"] = True 

318 metadata["epic_name"] = epic_info["epic_name"] 

319 elif epic_info["epic_key"]: 

320 metadata["epic_key"] = epic_info["epic_key"] 

321 metadata["epic_name"] = epic_info["epic_name"] 

322 metadata["epic_summary"] = epic_info["epic_summary"] 

323 

324 # Add comment count 

325 metadata["comment_count"] = len(comments) 

326 

327 return metadata 

328 

329 def create_issue( 

330 self, 

331 project_key: str, 

332 summary: str, 

333 issue_type: str, 

334 description: str = "", 

335 assignee: Optional[str] = None, 

336 **kwargs: Any, # noqa: ANN401 - Dynamic field types are necessary for Jira API 

337 ) -> Document: 

338 """ 

339 Create a new Jira issue. 

340 

341 Args: 

342 project_key: The key of the project 

343 summary: The issue summary 

344 issue_type: The type of issue to create 

345 description: The issue description 

346 assignee: The username or account ID of the assignee 

347 **kwargs: Additional fields to set on the issue 

348 

349 Returns: 

350 Document with the created issue 

351 

352 Raises: 

353 Exception: If there is an error creating the issue 

354 """ 

355 try: 

356 # Prepare fields 

357 fields: Dict[str, Any] = { 

358 "project": {"key": project_key}, 

359 "summary": summary, 

360 "issuetype": {"name": issue_type}, 

361 } 

362 

363 # Add description if provided 

364 if description: 

365 fields["description"] = description 

366 

367 # Add assignee if provided 

368 if assignee: 

369 try: 

370 account_id = self._get_account_id(assignee) 

371 self._add_assignee_to_fields(fields, account_id) 

372 except ValueError as e: 

373 logger.warning(f"Could not assign issue: {str(e)}") 

374 

375 # Prepare epic fields if this is an epic 

376 if issue_type.lower() == "epic": 

377 self._prepare_epic_fields(fields, summary, kwargs) 

378 

379 # Add custom fields 

380 self._add_custom_fields(fields, kwargs) 

381 

382 # Create the issue 

383 response = self.jira.create_issue(fields=fields) 

384 

385 # Get the created issue key 

386 issue_key = response.get("key") 

387 if not issue_key: 

388 error_msg = "No issue key in response" 

389 raise ValueError(error_msg) 

390 

391 # Return the newly created issue 

392 return self.get_issue(issue_key) 

393 

394 except Exception as e: 

395 self._handle_create_issue_error(e, issue_type) 

396 raise # Re-raise after logging 

397 

398 def _prepare_epic_fields( 

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

400 ) -> None: 

401 """ 

402 Prepare fields for epic creation. 

403 

404 Args: 

405 fields: The fields dictionary to update 

406 summary: The epic summary 

407 kwargs: Additional fields from the user 

408 """ 

409 # Get all field IDs 

410 field_ids = self.get_jira_field_ids() 

411 

412 # Epic Name field 

413 epic_name_field = field_ids.get("Epic Name") 

414 if epic_name_field and "epic_name" not in kwargs: 

415 fields[epic_name_field] = summary 

416 

417 # Override with user-provided epic name if available 

418 if "epic_name" in kwargs and epic_name_field: 

419 fields[epic_name_field] = kwargs["epic_name"] 

420 

421 def _add_assignee_to_fields(self, fields: Dict[str, Any], assignee: str) -> None: 

422 """ 

423 Add assignee to issue fields. 

424 

425 Args: 

426 fields: The fields dictionary to update 

427 assignee: The assignee account ID 

428 """ 

429 # Cloud instance uses accountId 

430 if self.config.is_cloud: 

431 fields["assignee"] = {"accountId": assignee} 

432 else: 

433 # Server/DC might use name instead of accountId 

434 fields["assignee"] = {"name": assignee} 

435 

436 def _add_custom_fields( 

437 self, fields: Dict[str, Any], kwargs: Dict[str, Any] 

438 ) -> None: 

439 """ 

440 Add custom fields to issue. 

441 

442 Args: 

443 fields: The fields dictionary to update 

444 kwargs: Additional fields from the user 

445 """ 

446 field_ids = self.get_jira_field_ids() 

447 

448 # Process each kwarg 

449 for key, value in kwargs.items(): 

450 if key in ("epic_name", "epic_link"): 

451 continue # Handled separately 

452 

453 # Check if this is a known field 

454 if key in field_ids: 

455 fields[field_ids[key]] = value 

456 elif key.startswith("customfield_"): 

457 # Direct custom field reference 

458 fields[key] = value 

459 

460 def _handle_create_issue_error(self, exception: Exception, issue_type: str) -> None: 

461 """ 

462 Handle errors when creating an issue. 

463 

464 Args: 

465 exception: The exception that occurred 

466 issue_type: The type of issue being created 

467 """ 

468 error_msg = str(exception) 

469 

470 # Check for specific error types 

471 if "epic name" in error_msg.lower() or "epicname" in error_msg.lower(): 

472 logger.error( 

473 f"Error creating {issue_type}: {error_msg}. " 

474 "Try specifying an epic_name in the additional fields" 

475 ) 

476 elif "customfield" in error_msg.lower(): 

477 logger.error( 

478 f"Error creating {issue_type}: {error_msg}. " 

479 "This may be due to a required custom field" 

480 ) 

481 else: 

482 logger.error(f"Error creating {issue_type}: {error_msg}") 

483 

484 def update_issue( 

485 self, 

486 issue_key: str, 

487 fields: Optional[Dict[str, Any]] = None, 

488 **kwargs: Any, # noqa: ANN401 - Dynamic field types are necessary for Jira API 

489 ) -> Document: 

490 """ 

491 Update a Jira issue. 

492 

493 Args: 

494 issue_key: The key of the issue to update 

495 fields: Dictionary of fields to update 

496 **kwargs: Additional fields to update 

497 

498 Returns: 

499 Document with the updated issue 

500 

501 Raises: 

502 Exception: If there is an error updating the issue 

503 """ 

504 try: 

505 update_fields = fields or {} 

506 

507 # Process kwargs 

508 for key, value in kwargs.items(): 

509 if key == "status": 

510 # Status changes are handled separately via transitions 

511 # Add status to fields so _update_issue_with_status can find it 

512 update_fields["status"] = value 

513 return self._update_issue_with_status(issue_key, update_fields) 

514 

515 if key == "assignee": 

516 # Handle assignee updates 

517 try: 

518 account_id = self._get_account_id(value) 

519 self._add_assignee_to_fields(update_fields, account_id) 

520 except ValueError as e: 

521 logger.warning(f"Could not update assignee: {str(e)}") 

522 else: 

523 # Process regular fields 

524 field_ids = self.get_jira_field_ids() 

525 if key in field_ids: 

526 update_fields[field_ids[key]] = value 

527 elif key.startswith("customfield_"): 

528 update_fields[key] = value 

529 else: 

530 update_fields[key] = value 

531 

532 # Update the issue 

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

534 

535 # Return the updated issue 

536 return self.get_issue(issue_key) 

537 

538 except Exception as e: 

539 logger.error(f"Error updating issue {issue_key}: {str(e)}") 

540 raise Exception(f"Error updating issue {issue_key}: {str(e)}") from e 

541 

542 def _update_issue_with_status( 

543 self, issue_key: str, fields: Dict[str, Any] 

544 ) -> Document: 

545 """ 

546 Update an issue with a status change. 

547 

548 Args: 

549 issue_key: The key of the issue to update 

550 fields: Dictionary of fields to update 

551 

552 Returns: 

553 Document with the updated issue 

554 

555 Raises: 

556 Exception: If there is an error updating the issue 

557 """ 

558 # First update any fields if needed 

559 if fields: 

560 self.jira.update_issue(issue_key, fields=fields) 

561 

562 # Get the status from fields 

563 status = fields.get("status") 

564 if not status: 

565 return self.get_issue(issue_key) 

566 

567 # Get available transitions 

568 transitions = self.get_available_transitions(issue_key) 

569 

570 # Find the right transition 

571 transition_id = None 

572 for transition in transitions: 

573 if transition.get("name", "").lower() == status.lower(): 

574 transition_id = transition.get("id") 

575 break 

576 

577 if not transition_id: 

578 error_msg = f"Could not find transition to status '{status}' for issue {issue_key}" 

579 raise ValueError(error_msg) 

580 

581 # Perform the transition 

582 return self.transition_issue(issue_key, transition_id) 

583 

584 def delete_issue(self, issue_key: str) -> bool: 

585 """ 

586 Delete a Jira issue. 

587 

588 Args: 

589 issue_key: The key of the issue to delete 

590 

591 Returns: 

592 True if the issue was deleted successfully 

593 

594 Raises: 

595 Exception: If there is an error deleting the issue 

596 """ 

597 try: 

598 self.jira.delete_issue(issue_key) 

599 return True 

600 except Exception as e: 

601 logger.error(f"Error deleting issue {issue_key}: {str(e)}") 

602 raise Exception(f"Error deleting issue {issue_key}: {str(e)}") from e 

603 

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

605 """ 

606 Get mappings of field names to IDs. 

607 

608 Returns: 

609 Dictionary mapping field names to their IDs 

610 """ 

611 # Use cached field IDs if available 

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

613 return self._field_ids_cache 

614 

615 # Get cached field IDs or fetch from server 

616 return self._get_cached_field_ids() 

617 

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

619 """ 

620 Get cached field IDs or fetch from server. 

621 

622 Returns: 

623 Dictionary mapping field names to their IDs 

624 """ 

625 # Initialize cache if needed 

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

627 self._field_ids_cache = {} 

628 

629 # Return cache if not empty 

630 if self._field_ids_cache: 

631 return self._field_ids_cache 

632 

633 # Fetch field IDs from server 

634 try: 

635 fields = self.jira.get_all_fields() 

636 field_ids = {} 

637 

638 for field in fields: 

639 name = field.get("name") 

640 field_id = field.get("id") 

641 if name and field_id: 

642 field_ids[name] = field_id 

643 

644 # Log available fields to help with debugging 

645 self._log_available_fields(fields) 

646 

647 # Try to discover EPIC field IDs 

648 for field in fields: 

649 self._process_field_for_epic_data(field, field_ids) 

650 

651 # Try to discover fields from existing epics 

652 self._try_discover_fields_from_existing_epic(field_ids) 

653 

654 # Cache the results 

655 self._field_ids_cache = field_ids 

656 return field_ids 

657 

658 except Exception as e: 

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

660 return {} 

661 

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

663 """ 

664 Log available fields for debugging. 

665 

666 Args: 

667 fields: List of field definitions 

668 """ 

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

670 for field in fields: 

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

672 

673 def _process_field_for_epic_data( 

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

675 ) -> None: 

676 """ 

677 Process a field for epic-related data. 

678 

679 Args: 

680 field: The field definition 

681 field_ids: Dictionary of field IDs to update 

682 """ 

683 name = field.get("name", "").lower() 

684 field_id = field.get("id") 

685 

686 # Check for epic-related fields 

687 if "epic" in name and field_id: 

688 if "link" in name: 

689 field_ids["Epic Link"] = field_id 

690 elif "name" in name: 

691 field_ids["Epic Name"] = field_id 

692 

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

694 """ 

695 Try to discover field IDs from an existing epic. 

696 

697 Args: 

698 field_ids: Dictionary of field IDs to update 

699 """ 

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

701 if "Epic Link" in field_ids and "Epic Name" in field_ids: 

702 return 

703 

704 try: 

705 # Search for an epic 

706 results = self.jira.jql("issuetype = Epic", fields="*all", limit=1) 

707 issues = results.get("issues", []) 

708 

709 if not issues: 

710 return 

711 

712 # Get the first epic 

713 epic = issues[0] 

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

715 

716 # Check each field for epic-related data 

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

718 if field_id.startswith("customfield_"): 

719 field_name = field_id.lower() 

720 

721 # Check for Epic Name field 

722 if "Epic Name" not in field_ids and isinstance(value, str): 

723 field_ids["Epic Name"] = field_id 

724 

725 # Also try to find Epic Link by searching for issues linked to an epic 

726 if "Epic Link" not in field_ids: 

727 # Search for issues that might be linked to epics 

728 results = self.jira.jql("project is not empty", fields="*all", limit=10) 

729 issues = results.get("issues", []) 

730 

731 for issue in issues: 

732 fields = issue.get("fields", {}) 

733 

734 # Check each field for a potential epic link 

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

736 if field_id.startswith("customfield_") and value and isinstance(value, str): 

737 # If it looks like a key (e.g., PRJ-123), it might be an epic link 

738 if "-" in value and any(c.isdigit() for c in value): 

739 field_ids["Epic Link"] = field_id 

740 break 

741 

742 except Exception as e: 

743 logger.debug(f"Error discovering epic fields: {str(e)}") 

744 

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

746 """ 

747 Link an issue to an epic. 

748 

749 Args: 

750 issue_key: The key of the issue to link 

751 epic_key: The key of the epic to link to 

752 

753 Returns: 

754 Document with the updated issue 

755 

756 Raises: 

757 Exception: If there is an error linking the issue 

758 """ 

759 try: 

760 # Verify both keys exist 

761 self.jira.get_issue(issue_key) 

762 epic = self.jira.get_issue(epic_key) 

763 

764 # Verify epic_key is actually an epic 

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

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

767 

768 if issue_type != "epic": 

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

770 raise ValueError(error_msg) 

771 

772 # Get the epic link field ID 

773 field_ids = self.get_jira_field_ids() 

774 epic_link_field = field_ids.get("Epic Link") 

775 

776 if not epic_link_field: 

777 error_msg = "Could not determine Epic Link field" 

778 raise ValueError(error_msg) 

779 

780 # Update the issue to link it to the epic 

781 update_fields = {epic_link_field: epic_key} 

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

783 

784 # Return the updated issue 

785 return self.get_issue(issue_key) 

786 

787 except Exception as e: 

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

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

790 

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

792 """ 

793 Get available transitions for an issue. 

794 

795 Args: 

796 issue_key: The key of the issue 

797 

798 Returns: 

799 List of available transitions 

800 

801 Raises: 

802 Exception: If there is an error getting transitions 

803 """ 

804 try: 

805 transitions = self.jira.issue_get_transitions(issue_key) 

806 if isinstance(transitions, dict) and "transitions" in transitions: 

807 return transitions["transitions"] 

808 return transitions 

809 except Exception as e: 

810 logger.error(f"Error getting transitions for issue {issue_key}: {str(e)}") 

811 raise Exception(f"Error getting transitions for issue {issue_key}: {str(e)}") from e 

812 

813 def transition_issue(self, issue_key: str, transition_id: str) -> Document: 

814 """ 

815 Transition an issue to a new status. 

816 

817 Args: 

818 issue_key: The key of the issue 

819 transition_id: The ID of the transition to perform 

820 

821 Returns: 

822 Document with the updated issue 

823 

824 Raises: 

825 Exception: If there is an error transitioning the issue 

826 """ 

827 try: 

828 self.jira.issue_transition(issue_key, transition_id) 

829 return self.get_issue(issue_key) 

830 except Exception as e: 

831 logger.error(f"Error transitioning issue {issue_key}: {str(e)}") 

832 raise Exception(f"Error transitioning issue {issue_key}: {str(e)}") from e