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
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-10 03:26 +0900
1"""Module for Jira issue operations."""
3import logging
4from datetime import datetime
5from typing import Any, Dict, Optional, List
7import requests
9from ..document_types import Document
10from .client import JiraClient
11from .users import UsersMixin
13logger = logging.getLogger("mcp-jira")
16class IssuesMixin(UsersMixin):
17 """Mixin for Jira issue operations."""
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.
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"
33 Returns:
34 Document with issue content and metadata
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)
43 # Get issue details
44 issue = self.jira.get_issue(issue_key, expand=expand)
46 # Extract relevant fields
47 fields = issue.get("fields", {})
49 # Process content
50 description = self._clean_text(fields.get("description", ""))
52 # Convert date
53 created_date_str = fields.get("created", "")
54 created_date = self._parse_date(created_date_str) if created_date_str else ""
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)
61 # Extract epic information
62 epic_info = self._extract_epic_information(issue)
64 # Format content
65 content = self._format_issue_content(
66 issue_key, issue, description, comments, created_date, epic_info
67 )
69 # Create metadata
70 metadata = self._create_issue_metadata(
71 issue_key, issue, comments, created_date, epic_info
72 )
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
83 def _normalize_comment_limit(self, comment_limit: Optional[int | str]) -> Optional[int]:
84 """
85 Normalize the comment limit to an integer or None.
87 Args:
88 comment_limit: The comment limit as int, string, or None
90 Returns:
91 Normalized comment limit as int or None
92 """
93 if comment_limit is None:
94 return None
96 if isinstance(comment_limit, int):
97 return comment_limit
99 if comment_limit == "all":
100 return None # No limit
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
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.
115 Args:
116 issue_key: The issue key
117 comment_limit: Maximum number of comments to include
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"]
128 # Limit comments if needed
129 if comment_limit is not None:
130 comments = comments[:comment_limit]
132 return comments
133 except Exception as e:
134 logger.warning(f"Error getting comments for {issue_key}: {str(e)}")
135 return []
136 return []
138 def _extract_epic_information(self, issue: Dict) -> Dict[str, Optional[str]]:
139 """
140 Extract epic information from an issue.
142 Args:
143 issue: The issue data
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 }
156 fields = issue.get("fields", {})
157 issue_type = fields.get("issuetype", {}).get("name", "").lower()
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
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
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)}")
178 return epic_info
180 def _parse_date(self, date_str: str) -> str:
181 """
182 Parse a date string to a formatted date.
184 Args:
185 date_str: The date string to parse
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
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.
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
218 Returns:
219 Formatted issue content
220 """
221 fields = issue.get("fields", {})
223 # Basic issue information
224 summary = fields.get("summary", "")
225 status = fields.get("status", {}).get("name", "")
226 issue_type = fields.get("issuetype", {}).get("name", "")
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}")
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}")
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}")
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']}")
252 # Add description
253 if description:
254 content.append("\n## Description\n")
255 content.append(description)
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", ""))
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}**:")
273 content.append(f"{comment_body}\n")
275 return "\n".join(content)
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.
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
295 Returns:
296 Metadata dictionary
297 """
298 fields = issue.get("fields", {})
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 }
310 # Add assignee if available
311 assignee = fields.get("assignee", {})
312 if assignee:
313 metadata["assignee"] = assignee.get("displayName", "") or assignee.get("name", "")
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"]
324 # Add comment count
325 metadata["comment_count"] = len(comments)
327 return metadata
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.
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
349 Returns:
350 Document with the created issue
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 }
363 # Add description if provided
364 if description:
365 fields["description"] = description
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)}")
375 # Prepare epic fields if this is an epic
376 if issue_type.lower() == "epic":
377 self._prepare_epic_fields(fields, summary, kwargs)
379 # Add custom fields
380 self._add_custom_fields(fields, kwargs)
382 # Create the issue
383 response = self.jira.create_issue(fields=fields)
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)
391 # Return the newly created issue
392 return self.get_issue(issue_key)
394 except Exception as e:
395 self._handle_create_issue_error(e, issue_type)
396 raise # Re-raise after logging
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.
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()
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
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"]
421 def _add_assignee_to_fields(self, fields: Dict[str, Any], assignee: str) -> None:
422 """
423 Add assignee to issue fields.
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}
436 def _add_custom_fields(
437 self, fields: Dict[str, Any], kwargs: Dict[str, Any]
438 ) -> None:
439 """
440 Add custom fields to issue.
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()
448 # Process each kwarg
449 for key, value in kwargs.items():
450 if key in ("epic_name", "epic_link"):
451 continue # Handled separately
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
460 def _handle_create_issue_error(self, exception: Exception, issue_type: str) -> None:
461 """
462 Handle errors when creating an issue.
464 Args:
465 exception: The exception that occurred
466 issue_type: The type of issue being created
467 """
468 error_msg = str(exception)
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}")
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.
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
498 Returns:
499 Document with the updated issue
501 Raises:
502 Exception: If there is an error updating the issue
503 """
504 try:
505 update_fields = fields or {}
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)
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
532 # Update the issue
533 self.jira.update_issue(issue_key, fields=update_fields)
535 # Return the updated issue
536 return self.get_issue(issue_key)
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
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.
548 Args:
549 issue_key: The key of the issue to update
550 fields: Dictionary of fields to update
552 Returns:
553 Document with the updated issue
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)
562 # Get the status from fields
563 status = fields.get("status")
564 if not status:
565 return self.get_issue(issue_key)
567 # Get available transitions
568 transitions = self.get_available_transitions(issue_key)
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
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)
581 # Perform the transition
582 return self.transition_issue(issue_key, transition_id)
584 def delete_issue(self, issue_key: str) -> bool:
585 """
586 Delete a Jira issue.
588 Args:
589 issue_key: The key of the issue to delete
591 Returns:
592 True if the issue was deleted successfully
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
604 def get_jira_field_ids(self) -> Dict[str, str]:
605 """
606 Get mappings of field names to IDs.
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
615 # Get cached field IDs or fetch from server
616 return self._get_cached_field_ids()
618 def _get_cached_field_ids(self) -> Dict[str, str]:
619 """
620 Get cached field IDs or fetch from server.
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 = {}
629 # Return cache if not empty
630 if self._field_ids_cache:
631 return self._field_ids_cache
633 # Fetch field IDs from server
634 try:
635 fields = self.jira.get_all_fields()
636 field_ids = {}
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
644 # Log available fields to help with debugging
645 self._log_available_fields(fields)
647 # Try to discover EPIC field IDs
648 for field in fields:
649 self._process_field_for_epic_data(field, field_ids)
651 # Try to discover fields from existing epics
652 self._try_discover_fields_from_existing_epic(field_ids)
654 # Cache the results
655 self._field_ids_cache = field_ids
656 return field_ids
658 except Exception as e:
659 logger.warning(f"Error getting field IDs: {str(e)}")
660 return {}
662 def _log_available_fields(self, fields: List[Dict]) -> None:
663 """
664 Log available fields for debugging.
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')})")
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.
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")
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
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.
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
704 try:
705 # Search for an epic
706 results = self.jira.jql("issuetype = Epic", fields="*all", limit=1)
707 issues = results.get("issues", [])
709 if not issues:
710 return
712 # Get the first epic
713 epic = issues[0]
714 fields = epic.get("fields", {})
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()
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
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", [])
731 for issue in issues:
732 fields = issue.get("fields", {})
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
742 except Exception as e:
743 logger.debug(f"Error discovering epic fields: {str(e)}")
745 def link_issue_to_epic(self, issue_key: str, epic_key: str) -> Document:
746 """
747 Link an issue to an epic.
749 Args:
750 issue_key: The key of the issue to link
751 epic_key: The key of the epic to link to
753 Returns:
754 Document with the updated issue
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)
764 # Verify epic_key is actually an epic
765 fields = epic.get("fields", {})
766 issue_type = fields.get("issuetype", {}).get("name", "").lower()
768 if issue_type != "epic":
769 error_msg = f"{epic_key} is not an Epic"
770 raise ValueError(error_msg)
772 # Get the epic link field ID
773 field_ids = self.get_jira_field_ids()
774 epic_link_field = field_ids.get("Epic Link")
776 if not epic_link_field:
777 error_msg = "Could not determine Epic Link field"
778 raise ValueError(error_msg)
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)
784 # Return the updated issue
785 return self.get_issue(issue_key)
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
791 def get_available_transitions(self, issue_key: str) -> List[Dict]:
792 """
793 Get available transitions for an issue.
795 Args:
796 issue_key: The key of the issue
798 Returns:
799 List of available transitions
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
813 def transition_issue(self, issue_key: str, transition_id: str) -> Document:
814 """
815 Transition an issue to a new status.
817 Args:
818 issue_key: The key of the issue
819 transition_id: The ID of the transition to perform
821 Returns:
822 Document with the updated issue
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