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

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

2

3 import logging

4 from datetime import datetime

5 from typing import Any, Dict, Optional, List

6

7 import requests

8

9 from ..document_types import Document

10 from .client import JiraClient

11 from .users import UsersMixin

12

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

14

15

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