Coverage for src/mcp_atlassian/jira.py: 54%
588 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-10 01:37 +0900
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-10 01:37 +0900
1import logging
2import os
3import re
4from datetime import datetime
5from typing import Any
7import requests
8from atlassian import Jira
10from .config import JiraConfig
11from .document_types import Document
12from .preprocessing import TextPreprocessor
14# Configure logging
15logger = logging.getLogger("mcp-jira")
18class JiraFetcher:
19 """Handles fetching and parsing content from Jira."""
21 def __init__(self) -> None:
22 """Initialize the Jira client."""
23 url = os.getenv("JIRA_URL")
25 if not url:
26 error_msg = "Missing required JIRA_URL environment variable"
27 raise ValueError(error_msg)
29 # Initialize variables with default values
30 username = ""
31 token = ""
32 personal_token = ""
34 # Determine if this is a cloud or server installation based on URL
35 is_cloud = url.endswith(".atlassian.net")
37 if is_cloud:
38 username = os.getenv("JIRA_USERNAME", "")
39 token = os.getenv("JIRA_API_TOKEN", "")
40 if not username or not token:
41 error_msg = (
42 "Cloud authentication requires JIRA_USERNAME and JIRA_API_TOKEN"
43 )
44 raise ValueError(error_msg)
45 else:
46 # Server/Data Center authentication uses a Personal Access Token
47 personal_token = os.getenv("JIRA_PERSONAL_TOKEN", "")
48 if not personal_token:
49 error_msg = (
50 "Server/Data Center authentication requires JIRA_PERSONAL_TOKEN"
51 )
52 raise ValueError(error_msg)
54 # For self-signed certificates in on-premise installations
55 verify_ssl = os.getenv("JIRA_SSL_VERIFY", "true").lower() != "false"
57 self.config = JiraConfig(
58 url=url,
59 username=username,
60 api_token=token,
61 personal_token=personal_token,
62 verify_ssl=verify_ssl,
63 )
65 # Initialize Jira client based on instance type
66 if self.config.is_cloud:
67 self.jira = Jira(
68 url=self.config.url,
69 username=self.config.username,
70 password=self.config.api_token, # API token is used as password
71 cloud=True,
72 verify_ssl=self.config.verify_ssl,
73 )
74 else:
75 # For Server/Data Center, use token-based authentication
76 # Note: The token param is used for Bearer token authentication
77 # as per atlassian-python-api implementation
78 self.jira = Jira(
79 url=self.config.url,
80 token=self.config.personal_token,
81 cloud=False,
82 verify_ssl=self.config.verify_ssl,
83 )
85 self.preprocessor = TextPreprocessor(self.config.url)
87 # Field IDs cache
88 self._field_ids_cache: dict[str, str] = {}
90 def _clean_text(self, text: str) -> str:
91 """
92 Clean text content by:
93 1. Processing user mentions and links
94 2. Converting HTML/wiki markup to markdown
95 """
96 if not text:
97 return ""
99 return self.preprocessor.clean_jira_text(text)
101 def _get_account_id(self, assignee: str) -> str:
102 """
103 Convert a username or display name to an account ID.
105 Args:
106 assignee: Username, email, or display name
108 Returns:
109 The account ID string
110 """
111 # Handle direct account ID assignment
112 if assignee and assignee.startswith("accountid:"):
113 return assignee.replace("accountid:", "")
115 try:
116 # First try direct user lookup
117 account_id = self._lookup_user_directly(assignee)
118 if account_id:
119 return account_id
121 # Fall back to project permission based search
122 account_id = self._lookup_user_by_permissions(assignee)
123 if account_id:
124 return account_id
126 # If we get here, we couldn't find a user
127 logger.warning(f"No user found matching '{assignee}'")
128 error_msg = f"No user found matching '{assignee}'"
129 raise ValueError(error_msg)
131 except Exception as e:
132 logger.error(f"Error finding user '{assignee}': {str(e)}")
133 error_msg = f"Could not resolve account ID for '{assignee}'"
134 raise ValueError(error_msg) from e
136 def _lookup_user_directly(self, username: str) -> str | None:
137 """
138 Look up a user directly by username or email.
140 Args:
141 username: The username or email to look up
143 Returns:
144 The account ID as a string if found, None otherwise
145 """
146 try:
147 users = self.jira.user(username)
148 if isinstance(users, dict):
149 users = [users]
151 account_id = users[0].get("accountId") if users else None
152 if account_id:
153 return str(account_id) # Ensure we return a string
154 else:
155 logger.warning(
156 f"Direct user lookup failed for '{username}': "
157 "user found but no account ID present"
158 )
159 return None
161 except IndexError:
162 logger.warning(
163 f"Direct user lookup failed for '{username}': "
164 "user result has unexpected format"
165 )
166 except KeyError:
167 logger.warning(
168 f"Direct user lookup failed for '{username}': "
169 "missing accountId in response"
170 )
171 except (ValueError, TypeError) as e:
172 logger.warning(
173 f"Direct user lookup failed for '{username}': "
174 f"invalid data format: {str(e)}"
175 )
176 except requests.RequestException as e:
177 logger.warning(
178 f"Direct user lookup failed for '{username}': API error: {str(e)}"
179 )
180 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
181 logger.warning(
182 f"Direct user lookup failed for '{username}': "
183 f"unexpected error: {str(e)}"
184 )
185 logger.debug(
186 f"Full exception details for user lookup '{username}':", exc_info=True
187 )
189 return None
191 def _lookup_user_by_permissions(self, username: str) -> str | None:
192 """
193 Look up a user by checking project permissions.
195 Args:
196 username: The username or email to look up
198 Returns:
199 The account ID as a string if found, None otherwise
200 """
201 users = self.jira.get_users_with_browse_permission_to_a_project(
202 username=username
203 )
205 if not users:
206 return None
208 # Return the first matching user's account ID
209 account_id = users[0].get("accountId")
210 if not account_id or not isinstance(account_id, str):
211 logger.warning(
212 f"Permission-based user lookup failed for '{username}': "
213 "invalid string format in response"
214 )
215 return None
217 logger.info(f"Found account ID via browse permission lookup: {account_id}")
218 return str(account_id) # Explicit str conversion
220 def create_issue(
221 self,
222 project_key: str,
223 summary: str,
224 issue_type: str,
225 description: str = "",
226 assignee: str | None = None,
227 **kwargs: Any, # noqa: ANN401 - Dynamic field types are necessary for Jira API
228 ) -> Document:
229 """
230 Create a new issue in Jira and return it as a Document.
232 Args:
233 project_key: The key of the project (e.g. 'PROJ')
234 summary: Summary of the issue
235 issue_type: Issue type (e.g. 'Task', 'Bug', 'Story')
236 description: Issue description
237 assignee: Email, full name, or account ID of the user to assign the issue to
238 kwargs: Any other custom Jira fields
240 Returns:
241 Document representing the newly created issue
243 Raises:
244 ValueError: If required fields for the issue type cannot be determined
245 """
246 # Prepare base fields
247 fields = {
248 "project": {"key": project_key},
249 "summary": summary,
250 "issuetype": {"name": issue_type},
251 "description": self._markdown_to_jira(description),
252 }
254 # Handle epic-specific fields if needed
255 if issue_type.lower() == "epic":
256 self._prepare_epic_fields(fields, summary, kwargs)
258 # Add assignee if provided
259 if assignee:
260 self._add_assignee_to_fields(fields, assignee)
262 # Add any remaining custom fields
263 self._add_custom_fields(fields, kwargs)
265 # Create the issue
266 try:
267 response = self.jira.create_issue(fields=fields)
268 issue_key = response["key"]
269 logger.info(f"Created issue {issue_key}")
270 return self.get_issue(issue_key)
271 except Exception as e:
272 self._handle_create_issue_error(e, issue_type)
273 raise
275 def _prepare_epic_fields(
276 self, fields: dict[str, Any], summary: str, kwargs: dict[str, Any]
277 ) -> None:
278 """
279 Prepare epic-specific fields for issue creation.
281 Args:
282 fields: The fields dictionary being prepared for issue creation
283 summary: The issue summary that can be used as a default epic name
284 kwargs: Additional fields provided by the caller
285 """
286 try:
287 # Get the dynamic field IDs
288 field_ids = self.get_jira_field_ids()
289 logger.info(f"Discovered Jira field IDs for Epic creation: {field_ids}")
291 # Handle Epic Name - might be required in some instances, not in others
292 if "epic_name" in field_ids:
293 epic_name = kwargs.pop(
294 "epic_name", summary
295 ) # Use summary as default if not provided
296 fields[field_ids["epic_name"]] = epic_name
297 logger.info(
298 f"Setting Epic Name field {field_ids['epic_name']} to: {epic_name}"
299 )
301 # Handle Epic Color if the field was discovered
302 if "epic_color" in field_ids:
303 epic_color = (
304 kwargs.pop("epic_color", None)
305 or kwargs.pop("epic_colour", None)
306 or "green"
307 )
308 fields[field_ids["epic_color"]] = epic_color
309 logger.info(
310 f"Setting Epic Color field {field_ids['epic_color']} "
311 f"to: {epic_color}"
312 )
314 # Pass through any explicitly provided custom fields
315 # that might be instance-specific
316 for field_key, field_value in list(kwargs.items()):
317 if field_key.startswith("customfield_"):
318 fields[field_key] = field_value
319 kwargs.pop(field_key)
320 logger.info(
321 f"Using explicitly provided custom field {field_key}: "
322 f"{field_value}"
323 )
325 # Warn if epic_name field is required but wasn't discovered
326 if "epic_name" not in field_ids:
327 logger.warning(
328 "Epic Name field not found in Jira schema. "
329 "If your Jira instance requires it, please provide "
330 "the customfield_* ID directly."
331 )
332 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
333 logger.error(f"Error preparing Epic-specific fields: {str(e)}")
334 # Continue with creation anyway, as some instances might not
335 # require special fields
337 def _add_assignee_to_fields(self, fields: dict[str, Any], assignee: str) -> None:
338 """
339 Add assignee information to the fields dictionary.
341 Args:
342 fields: The fields dictionary being prepared for issue creation
343 assignee: The assignee value to process
344 """
345 account_id = self._get_account_id(assignee)
346 fields["assignee"] = {"accountId": account_id}
348 def _add_custom_fields(
349 self, fields: dict[str, Any], kwargs: dict[str, Any]
350 ) -> None:
351 """
352 Add any remaining custom fields to the fields dictionary.
354 Args:
355 fields: The fields dictionary being prepared for issue creation
356 kwargs: Additional fields provided by the caller
357 """
358 # Remove assignee from additional_fields if present to avoid conflicts
359 if "assignee" in kwargs:
360 logger.warning(
361 "Assignee found in additional_fields - this will be ignored. "
362 "Please use the assignee parameter instead."
363 )
364 kwargs.pop("assignee")
366 # Add remaining kwargs to fields
367 for key, value in kwargs.items():
368 fields[key] = value
370 # Ensure description is in Jira format
371 if "description" in fields and fields["description"]:
372 fields["description"] = self._markdown_to_jira(fields["description"])
374 def _handle_create_issue_error(self, exception: Exception, issue_type: str) -> None:
375 """
376 Handle errors that occur during issue creation with better error messages.
378 Args:
379 exception: The exception that was raised
380 issue_type: The type of issue being created
381 """
382 error_msg = str(exception)
384 # Provide more helpful error messages for common issues
385 if issue_type.lower() == "epic" and "customfield_" in error_msg:
386 # Handle the case where a specific Epic field is required but missing
387 missing_field_match = re.search(
388 r"(?:Field '(customfield_\d+)'|'(customfield_\d+)' cannot be set)",
389 error_msg,
390 )
391 if missing_field_match:
392 field_id = missing_field_match.group(1) or missing_field_match.group(2)
393 logger.error(
394 f"Failed to create Epic: Your Jira instance requires field "
395 f"'{field_id}'. "
396 "This is typically the Epic Name field. Try setting this field "
397 "explicitly using "
398 f"'{field_id}': 'Epic Name Value' in the "
399 "additional_fields parameter."
400 )
401 else:
402 logger.error(
403 "Failed to create Epic: Your Jira instance has custom field "
404 "requirements. You may need to provide specific custom fields "
405 f"for Epics in your instance. Original error: {error_msg}"
406 )
407 else:
408 logger.error(f"Error creating issue: {error_msg}")
410 def update_issue(
411 self,
412 issue_key: str,
413 fields: dict[str, Any] | None = None,
414 **kwargs: Any, # noqa: ANN401 - Dynamic field types are necessary for Jira API
415 ) -> Document:
416 """
417 Update an existing Jira issue.
419 Args:
420 issue_key: The key of the issue to update
421 fields: Fields to update in the Jira API format
422 **kwargs: Additional fields to update
424 Returns:
425 Document with updated issue info
426 """
427 # Ensure we have a fields dictionary
428 if fields is None:
429 fields = {}
431 # Process any custom fields passed via kwargs
432 if kwargs:
433 # Combine any fields that might be in kwargs into our fields dict
434 self._add_custom_fields(fields, kwargs)
436 # Check if status is being updated
437 if "status" in fields:
438 return self._update_issue_with_status(issue_key, fields)
440 # Regular update (no status change)
441 try:
442 logger.info(f"Updating issue {issue_key} with fields {fields}")
443 self.jira.issue_update(issue_key, fields=fields)
444 # Return the updated issue
445 return self.get_issue(issue_key)
446 except Exception as e:
447 error_msg = f"Error updating issue {issue_key}: {str(e)}"
448 logger.error(error_msg)
449 raise
451 def _update_issue_with_status(
452 self, issue_key: str, fields: dict[str, Any]
453 ) -> Document:
454 """
455 Update an issue that includes a status change, using transitions.
457 Args:
458 issue_key: The key of the issue to update
459 fields: Fields to update, including status
461 Returns:
462 Document with updated issue info
463 """
464 target_status = fields.pop("status")
465 logger.info(
466 f"Updating issue {issue_key} with status change to '{target_status}'"
467 )
469 # Get available transitions
470 transitions = self.jira.get_issue_transitions(issue_key)
472 # Find the transition that matches the target status
473 transition_id = None
474 for transition in transitions.get("transitions", []):
475 if (
476 transition.get("to", {}).get("name", "").lower()
477 == target_status.lower()
478 ):
479 transition_id = transition["id"]
480 break
482 if not transition_id:
483 error_msg = (
484 f"No transition found for status '{target_status}' on issue {issue_key}"
485 )
486 logger.error(error_msg)
487 raise ValueError(error_msg)
489 # Create transition data
490 transition_data = {"transition": {"id": transition_id}}
492 # Add remaining fields if any
493 if fields:
494 transition_data["fields"] = fields
496 # Execute the transition
497 self.jira.issue_transition(issue_key, transition_data)
499 # Return the updated issue
500 return self.get_issue(issue_key)
502 def get_jira_field_ids(self) -> dict[str, str]:
503 """
504 Dynamically discover Jira field IDs relevant to Epic linking.
506 This method queries the Jira API to find the correct custom field IDs
507 for Epic-related fields, which can vary between different Jira instances.
509 Returns:
510 Dictionary mapping field names to their IDs
511 (e.g., {'epic_link': 'customfield_10014', 'epic_name': 'customfield_10011'})
512 """
513 try:
514 # Check if we've already cached the field IDs
515 cached_fields = self._get_cached_field_ids()
516 if cached_fields:
517 return cached_fields
519 # Fetch all fields from Jira API
520 fields = self.jira.fields()
521 field_ids: dict[str, str] = {}
523 # Log all fields for debugging
524 self._log_available_fields(fields)
526 # Process each field to identify Epic-related fields
527 for field in fields:
528 self._process_field_for_epic_data(field, field_ids)
530 # Cache the results for future use
531 self._field_ids_cache = field_ids
533 # If we couldn't find certain key fields, try alternative approaches
534 if "epic_name" not in field_ids or "epic_link" not in field_ids:
535 logger.warning(
536 "Could not find all essential Epic fields through schema. "
537 "This may cause issues with Epic operations."
538 )
540 # Try to find fields by looking at an existing Epic if possible
541 self._try_discover_fields_from_existing_epic(field_ids)
543 return field_ids
545 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
546 logger.error(f"Error discovering Jira field IDs: {str(e)}")
547 # Return an empty dict as fallback
548 return {}
550 def _get_cached_field_ids(self) -> dict[str, str]:
551 """
552 Retrieve cached field IDs if available.
554 Returns:
555 Dictionary of cached field IDs or empty dict if no cache exists
556 """
557 if hasattr(self, "_field_ids_cache"):
558 return self._field_ids_cache
559 return {}
561 def _log_available_fields(self, fields: list[dict]) -> None:
562 """
563 Log all available Jira fields for debugging purposes.
565 Args:
566 fields: List of field definitions from Jira API
567 """
568 all_field_names = [
569 f"{field.get('name', '')} ({field.get('id', '')})" for field in fields
570 ]
571 logger.debug(f"All available Jira fields: {all_field_names}")
573 def _process_field_for_epic_data(
574 self, field: dict, field_ids: dict[str, str]
575 ) -> None:
576 """
577 Process a single field to identify if it's an Epic-related field
578 and add to field_ids.
580 Args:
581 field: Field definition from Jira API
582 field_ids: Dictionary to update with identified fields
583 """
584 field_name = field.get("name", "").lower()
585 original_name = field.get("name", "")
586 field_id = field.get("id", "")
587 field_schema = field.get("schema", {})
588 field_type = field_schema.get("type", "")
589 field_custom = field_schema.get("custom", "")
591 # Epic Link field - used to link issues to epics
592 if (
593 "epic link" in field_name
594 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-link"
595 or field_type == "any"
596 ) and field_id:
597 self.epic_link_field_id = field_id
598 field_ids["epic_link"] = field_id
599 logger.info(f"Found Epic Link field: {original_name} ({field_id})")
601 # Epic Name field - used for the title of epics
602 if (
603 "epic name" in field_name
604 or "epic-name" in field_name
605 or original_name == "Epic Name"
606 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-label"
607 ):
608 field_ids["epic_name"] = field_id
609 logger.info(f"Found Epic Name field: {original_name} ({field_id})")
611 # Parent field - sometimes used instead of Epic Link
612 elif (
613 field_name == "parent"
614 or field_name == "parent link"
615 or original_name == "Parent Link"
616 ):
617 field_ids["parent"] = field_id
618 logger.info(f"Found Parent field: {original_name} ({field_id})")
620 # Epic Status field
621 elif "epic status" in field_name or original_name == "Epic Status":
622 field_ids["epic_status"] = field_id
623 logger.info(f"Found Epic Status field: {original_name} ({field_id})")
625 # Epic Color field
626 elif (
627 "epic colour" in field_name
628 or "epic color" in field_name
629 or original_name == "Epic Colour"
630 or original_name == "Epic Color"
631 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-color"
632 ):
633 field_ids["epic_color"] = field_id
634 logger.info(f"Found Epic Color field: {original_name} ({field_id})")
636 # Try to detect any other fields that might be related to Epics
637 elif ("epic" in field_name or "epic" in field_custom) and not any(
638 k in field_ids.values() for k in [field_id]
639 ):
640 key = f"epic_{field_name.replace(' ', '_')}"
641 field_ids[key] = field_id
642 logger.info(
643 f"Found additional Epic-related field: {original_name} ({field_id})"
644 )
646 def _try_discover_fields_from_existing_epic(self, field_ids: dict) -> None:
647 """
648 Attempt to discover Epic fields by examining an existing Epic issue.
649 This is a fallback method when we can't find fields through the schema.
651 Args:
652 field_ids: Existing field_ids dictionary to update
653 """
654 try:
655 # Find an Epic in the system
656 epics_jql = "issuetype = Epic ORDER BY created DESC"
657 results = self.jira.jql(epics_jql, limit=1)
659 if not results.get("issues"):
660 logger.warning("No existing Epics found to analyze field structure")
661 return
663 epic = results["issues"][0]
664 epic_key = epic.get("key")
666 logger.info(
667 f"Analyzing existing Epic {epic_key} to discover field structure"
668 )
670 # Examine the fields of this Epic
671 fields = epic.get("fields", {})
672 for field_id, field_value in fields.items():
673 if field_id.startswith("customfield_") and field_value is not None:
674 # Look for fields with non-null values that might be Epic-related
675 if (
676 "epic_name" not in field_ids
677 and isinstance(field_value, str)
678 and field_id not in field_ids.values()
679 ):
680 logger.info(
681 f"Potential Epic Name field discovered: {field_id} "
682 f"with value {field_value}"
683 )
684 if len(field_value) < 100: # Epic names are typically short
685 field_ids["epic_name"] = field_id
687 # Color values are often simple strings like "green", "blue", etc.
688 if (
689 "epic_color" not in field_ids
690 and isinstance(field_value, str)
691 and field_id not in field_ids.values()
692 ):
693 colors = [
694 "green",
695 "blue",
696 "red",
697 "yellow",
698 "orange",
699 "purple",
700 "gray",
701 "grey",
702 "teal",
703 ]
704 if field_value.lower() in colors:
705 logger.info(
706 f"Potential Epic Color field discovered: {field_id} "
707 f"with value {field_value}"
708 )
709 field_ids["epic_color"] = field_id
711 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
712 logger.warning(
713 f"Could not discover Epic fields from existing Epics: {str(e)}"
714 )
716 def link_issue_to_epic(self, issue_key: str, epic_key: str) -> Document:
717 """
718 Link an issue to an epic.
720 Args:
721 issue_key: The key of the issue to link
722 epic_key: The key of the epic to link to
724 Returns:
725 Document with updated issue info
726 """
727 # Try to get the field IDs - if we haven't initialized them yet
728 field_ids = self.get_jira_field_ids()
730 # Check if we've identified the epic link field
731 if not field_ids.get("Epic Link"):
732 logger.error("Cannot link issue to epic: Epic Link field not found")
733 # Try to discover the fields by examining an existing epic
734 self._try_discover_fields_from_existing_epic(field_ids)
736 # Multiple attempts to link the issue using different field names
737 attempts = [
738 # Standard Jira Software method
739 lambda: self.update_issue(
740 issue_key,
741 fields={
742 k: epic_key for k in [field_ids.get("Epic Link")] if k is not None
743 },
744 ),
745 # Advanced Roadmaps method using Epic Name
746 lambda: self.update_issue(
747 issue_key,
748 fields={
749 k: epic_key for k in [field_ids.get("Epic Name")] if k is not None
750 },
751 ),
752 # Using the custom field directly
753 lambda: self.update_issue(
754 issue_key, fields={"customfield_10014": epic_key}
755 ),
756 ]
758 # Try each method
759 for attempt_fn in attempts:
760 try:
761 return attempt_fn()
762 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
763 logger.error(
764 f"Failed to link issue {issue_key} to epic {epic_key}: {str(e)}"
765 )
767 # If we get here, none of our attempts worked
768 error_msg = (
769 f"Couldn't link issue {issue_key} to epic {epic_key}. "
770 "Your Jira instance might use a different field for epic links."
771 )
772 raise ValueError(error_msg)
774 def delete_issue(self, issue_key: str) -> bool:
775 """
776 Delete an existing issue.
778 Args:
779 issue_key: The key of the issue (e.g. 'PROJ-123')
781 Returns:
782 True if delete succeeded, otherwise raise an exception
783 """
784 try:
785 self.jira.delete_issue(issue_key)
786 return True
787 except Exception as e:
788 logger.error(f"Error deleting issue {issue_key}: {str(e)}")
789 raise
791 def _parse_date(self, date_str: str) -> str:
792 """
793 Parse a date string into a consistent format (YYYY-MM-DD).
795 Args:
796 date_str: The date string to parse
798 Returns:
799 Formatted date string
800 """
801 # Handle various formats of date strings from Jira
802 try:
803 date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
804 return date.strftime("%Y-%m-%d")
805 except ValueError as e:
806 # This handles parsing errors in the date format
807 logger.warning(f"Invalid date format for {date_str}: {e}")
808 return date_str
809 except AttributeError as e:
810 # This handles cases where date_str isn't a string
811 logger.warning(f"Invalid date type {type(date_str)}: {e}")
812 return str(date_str)
813 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
814 logger.warning(f"Error parsing date {date_str}: {e}")
815 logger.debug("Full exception details for date parsing:", exc_info=True)
816 return date_str
818 def get_issue(
819 self,
820 issue_key: str,
821 expand: str | None = None,
822 comment_limit: int | str | None = 10,
823 ) -> Document:
824 """
825 Get a single issue with all its details.
827 Args:
828 issue_key: The issue key (e.g. 'PROJ-123')
829 expand: Optional fields to expand
830 comment_limit: Maximum number of comments to include
831 (None for no comments, defaults to 10)
832 Can be an integer or a string that can be converted
833 to an integer.
835 Returns:
836 Document containing issue content and metadata
837 """
838 try:
839 # Fetch the issue from Jira
840 issue = self.jira.issue(issue_key, expand=expand)
842 # Process and normalize the comment limit
843 comment_limit = self._normalize_comment_limit(comment_limit)
845 # Get the issue description and comments
846 description = self._clean_text(issue["fields"].get("description", ""))
847 comments = self._get_issue_comments_if_needed(issue_key, comment_limit)
849 # Get Epic information if applicable
850 epic_info = self._extract_epic_information(issue)
852 # Format the created date properly
853 created_date = self._parse_date(issue["fields"]["created"])
855 # Generate the content for the document
856 content = self._format_issue_content(
857 issue_key, issue, description, comments, created_date, epic_info
858 )
860 # Create the metadata for the document
861 metadata = self._create_issue_metadata(
862 issue_key, issue, comments, created_date, epic_info
863 )
865 return Document(page_content=content, metadata=metadata)
867 except Exception as e:
868 logger.error(f"Error fetching issue {issue_key}: {str(e)}")
869 raise
871 def _normalize_comment_limit(self, comment_limit: int | str | None) -> int | None:
872 """
873 Convert comment_limit to int if it's a string.
875 Args:
876 comment_limit: The comment limit value to normalize
878 Returns:
879 Normalized comment limit as int or None
880 """
881 if comment_limit is not None and isinstance(comment_limit, str):
882 try:
883 return int(comment_limit)
884 except ValueError:
885 logger.warning(
886 f"Invalid comment_limit value: {comment_limit}. "
887 "Using default of 10."
888 )
889 return 10
890 return comment_limit
892 def _get_issue_comments_if_needed(
893 self, issue_key: str, comment_limit: int | None
894 ) -> list[dict]:
895 """
896 Get comments for an issue if a valid limit is specified.
898 Args:
899 issue_key: The issue key to get comments for
900 comment_limit: Maximum number of comments to get
902 Returns:
903 List of comment dictionaries or empty list if no comments needed
904 """
905 if comment_limit is not None and comment_limit > 0:
906 return self.get_issue_comments(issue_key, limit=comment_limit)
907 return []
909 def _extract_epic_information(self, issue: dict) -> dict[str, str | None]:
910 """
911 Extract epic information from issue data.
913 Args:
914 issue: Issue data from Jira API
916 Returns:
917 Dictionary with epic_key and epic_name
918 """
919 epic_info: dict[str, str | None] = {"epic_key": None, "epic_name": None}
921 # Try both "Epic Link" and "Parent"
922 if "customfield_10014" in issue["fields"]:
923 epic_info["epic_key"] = issue["fields"]["customfield_10014"]
924 elif (
925 "parent" in issue["fields"]
926 and issue["fields"]["parent"]["fields"]["issuetype"]["name"] == "Epic"
927 ):
928 epic_info["epic_key"] = issue["fields"]["parent"]["key"]
929 epic_info["epic_name"] = issue["fields"]["parent"]["fields"]["summary"]
931 # Look for Epic Name if we have an Epic Key but no name yet
932 if epic_info["epic_key"] and not epic_info["epic_name"]:
933 try:
934 epic = self.jira.issue(epic_info["epic_key"])
935 epic_info["epic_name"] = epic["fields"]["summary"]
936 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
937 logger.warning(f"Error fetching epic details: {str(e)}")
939 return epic_info
941 def _format_issue_content(
942 self,
943 issue_key: str,
944 issue: dict,
945 description: str,
946 comments: list[dict],
947 created_date: str,
948 epic_info: dict[str, str | None],
949 ) -> str:
950 """
951 Format the issue content for display.
953 Args:
954 issue_key: The issue key
955 issue: The issue data from Jira
956 description: Processed description text
957 comments: List of comment dictionaries
958 created_date: Formatted created date
959 epic_info: Dictionary with epic_key and epic_name
961 Returns:
962 Formatted content string
963 """
964 # Basic issue information
965 content = f"""Issue: {issue_key}
966Title: {issue["fields"].get("summary", "")}
967Type: {issue["fields"]["issuetype"]["name"]}
968Status: {issue["fields"]["status"]["name"]}
969Created: {created_date}
970"""
972 # Add Epic information if available
973 if epic_info["epic_key"]:
974 content += f"Epic: {epic_info['epic_key']}"
975 if epic_info["epic_name"]:
976 content += f" - {epic_info['epic_name']}"
977 content += "\n"
979 content += f"""
980Description:
981{description}
982"""
983 # Add comments if present
984 if comments:
985 content += "\nComments:\n" + "\n".join(
986 [f"{c['created']} - {c['author']}: {c['body']}" for c in comments]
987 )
989 return content
991 def _create_issue_metadata(
992 self,
993 issue_key: str,
994 issue: dict,
995 comments: list[dict],
996 created_date: str,
997 epic_info: dict[str, str | None],
998 ) -> dict[str, Any]:
999 """
1000 Create metadata for the issue document.
1002 Args:
1003 issue_key: The issue key
1004 issue: The issue data from Jira
1005 comments: List of comment dictionaries
1006 created_date: Formatted created date
1007 epic_info: Dictionary with epic_key and epic_name
1009 Returns:
1010 Metadata dictionary
1011 """
1012 # Basic metadata
1013 metadata = {
1014 "key": issue_key,
1015 "title": issue["fields"].get("summary", ""),
1016 "type": issue["fields"]["issuetype"]["name"],
1017 "status": issue["fields"]["status"]["name"],
1018 "created_date": created_date,
1019 "priority": issue["fields"].get("priority", {}).get("name", "None"),
1020 "link": f"{self.config.url.rstrip('/')}/browse/{issue_key}",
1021 }
1023 # Add Epic information to metadata if available
1024 if epic_info["epic_key"]:
1025 metadata["epic_key"] = epic_info["epic_key"]
1026 if epic_info["epic_name"]:
1027 metadata["epic_name"] = epic_info["epic_name"]
1029 # Add comments to metadata if present
1030 if comments:
1031 metadata["comments"] = comments
1033 return metadata
1035 def search_issues(
1036 self,
1037 jql: str,
1038 fields: str = "*all",
1039 start: int = 0,
1040 limit: int = 50,
1041 expand: str | None = None,
1042 ) -> list[Document]:
1043 """
1044 Search for issues using JQL (Jira Query Language).
1046 Args:
1047 jql: JQL query string
1048 fields: Fields to return (comma-separated string or "*all")
1049 start: Starting index
1050 limit: Maximum issues to return
1051 expand: Optional items to expand (comma-separated)
1053 Returns:
1054 List of Documents representing the search results
1055 """
1056 try:
1057 issues = self.jira.jql(
1058 jql, fields=fields, start=start, limit=limit, expand=expand
1059 )
1060 documents = []
1062 for issue in issues.get("issues", []):
1063 issue_key = issue["key"]
1064 fields_data = issue.get("fields", {})
1066 # Safely handle fields that might not be included in the response
1067 summary = fields_data.get("summary", "")
1069 # Handle issuetype field with fallback to "Unknown" if missing
1070 issue_type = "Unknown"
1071 issuetype_data = fields_data.get("issuetype")
1072 if issuetype_data is not None:
1073 issue_type = issuetype_data.get("name", "Unknown")
1075 # Handle status field with fallback to "Unknown" if missing
1076 status = "Unknown"
1077 status_data = fields_data.get("status")
1078 if status_data is not None:
1079 status = status_data.get("name", "Unknown")
1081 # Process description field
1082 description = fields_data.get("description")
1083 desc = self._clean_text(description) if description is not None else ""
1085 # Process created date field
1086 created_date = ""
1087 created = fields_data.get("created")
1088 if created is not None:
1089 created_date = self._parse_date(created)
1091 # Process priority field
1092 priority = "None"
1093 priority_data = fields_data.get("priority")
1094 if priority_data is not None:
1095 priority = priority_data.get("name", "None")
1097 # Add basic metadata
1098 metadata = {
1099 "key": issue_key,
1100 "title": summary,
1101 "type": issue_type,
1102 "status": status,
1103 "created_date": created_date,
1104 "priority": priority,
1105 "link": f"{self.config.url.rstrip('/')}/browse/{issue_key}",
1106 }
1108 # Prepare content
1109 content = desc if desc else f"{summary} [{status}]"
1111 documents.append(Document(page_content=content, metadata=metadata))
1113 return documents
1114 except Exception as e:
1115 logger.error(f"Error searching issues with JQL '{jql}': {str(e)}")
1116 raise
1118 def get_epic_issues(self, epic_key: str, limit: int = 50) -> list[Document]:
1119 """
1120 Get all issues linked to a specific epic.
1122 Args:
1123 epic_key: The key of the epic (e.g. 'PROJ-123')
1124 limit: Maximum number of issues to return
1126 Returns:
1127 List of Documents representing the issues linked to the epic
1128 """
1129 try:
1130 # First, check if the issue is an Epic
1131 epic = self.jira.issue(epic_key)
1132 fields_data = epic.get("fields", {})
1134 # Safely check if the issue is an Epic
1135 issue_type = None
1136 issuetype_data = fields_data.get("issuetype")
1137 if issuetype_data is not None:
1138 issue_type = issuetype_data.get("name", "")
1140 if issue_type != "Epic":
1141 error_msg = (
1142 f"Issue {epic_key} is not an Epic, it is a "
1143 f"{issue_type or 'unknown type'}"
1144 )
1145 raise ValueError(error_msg)
1147 # Get the dynamic field IDs for this Jira instance
1148 field_ids = self.get_jira_field_ids()
1150 # Build JQL queries based on discovered field IDs
1151 jql_queries = []
1153 # Add queries based on discovered fields
1154 if "parent" in field_ids:
1155 jql_queries.append(f"parent = {epic_key}")
1157 if "epic_link" in field_ids:
1158 field_name = field_ids["epic_link"]
1159 jql_queries.append(f'"{field_name}" = {epic_key}')
1160 jql_queries.append(f'"{field_name}" ~ {epic_key}')
1162 # Add standard fallback queries
1163 jql_queries.extend(
1164 [
1165 f"parent = {epic_key}", # Common in most instances
1166 f"'Epic Link' = {epic_key}", # Some instances
1167 f"'Epic' = {epic_key}", # Some instances
1168 f"issue in childIssuesOf('{epic_key}')", # Some instances
1169 ]
1170 )
1172 # Try each query until we get results or run out of options
1173 documents = []
1174 for jql in jql_queries:
1175 try:
1176 logger.info(f"Trying to get epic issues with JQL: {jql}")
1177 documents = self.search_issues(jql, limit=limit)
1178 if documents:
1179 return documents
1180 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
1181 logger.info(f"Failed to get epic issues with JQL '{jql}': {str(e)}")
1182 continue
1184 # If we've tried all queries and got no results, return an empty list
1185 # but also log a warning that we might be missing the right field
1186 if not documents:
1187 logger.warning(
1188 f"Couldn't find issues linked to epic {epic_key}. "
1189 "Your Jira instance might use a different field for epic links."
1190 )
1192 return documents
1194 except Exception as e:
1195 logger.error(f"Error getting issues for epic {epic_key}: {str(e)}")
1196 raise
1198 def get_project_issues(
1199 self, project_key: str, start: int = 0, limit: int = 50
1200 ) -> list[Document]:
1201 """
1202 Get all issues for a project.
1204 Args:
1205 project_key: The project key
1206 start: Starting index
1207 limit: Maximum results to return
1209 Returns:
1210 List of Documents containing project issues
1211 """
1212 jql = f"project = {project_key} ORDER BY created DESC"
1213 return self.search_issues(jql, start=start, limit=limit)
1215 def get_current_user_account_id(self) -> str:
1216 """
1217 Get the account ID of the current user.
1219 Returns:
1220 String with the account ID
1221 """
1222 try:
1223 user = self.jira.myself()
1224 account_id = user.get("accountId")
1225 if not account_id:
1226 error_msg = "No account ID found in user profile"
1227 raise ValueError(error_msg)
1228 return str(account_id) # Ensure we return a string
1229 except Exception as e:
1230 logger.error(f"Error getting current user account ID: {str(e)}")
1231 error_msg = f"Failed to get current user account ID: {str(e)}"
1232 raise ValueError(error_msg) from e
1234 def get_issue_comments(self, issue_key: str, limit: int = 50) -> list[dict]:
1235 """
1236 Get comments for a specific issue.
1238 Args:
1239 issue_key: The issue key (e.g. 'PROJ-123')
1240 limit: Maximum number of comments to return
1242 Returns:
1243 List of comments with author, creation date, and content
1244 """
1245 try:
1246 comments = self.jira.issue_get_comments(issue_key)
1247 processed_comments = []
1249 for comment in comments.get("comments", [])[:limit]:
1250 processed_comment = {
1251 "id": comment.get("id"),
1252 "body": self._clean_text(comment.get("body", "")),
1253 "created": self._parse_date(comment.get("created")),
1254 "updated": self._parse_date(comment.get("updated")),
1255 "author": comment.get("author", {}).get("displayName", "Unknown"),
1256 }
1257 processed_comments.append(processed_comment)
1259 return processed_comments
1260 except Exception as e:
1261 logger.error(f"Error getting comments for issue {issue_key}: {str(e)}")
1262 raise
1264 def add_comment(self, issue_key: str, comment: str) -> dict:
1265 """
1266 Add a comment to an issue.
1268 Args:
1269 issue_key: The issue key (e.g. 'PROJ-123')
1270 comment: Comment text to add (in Markdown format)
1272 Returns:
1273 The created comment details
1274 """
1275 try:
1276 # Convert Markdown to Jira's markup format
1277 jira_formatted_comment = self._markdown_to_jira(comment)
1279 result = self.jira.issue_add_comment(issue_key, jira_formatted_comment)
1280 return {
1281 "id": result.get("id"),
1282 "body": self._clean_text(result.get("body", "")),
1283 "created": self._parse_date(result.get("created")),
1284 "author": result.get("author", {}).get("displayName", "Unknown"),
1285 }
1286 except Exception as e:
1287 logger.error(f"Error adding comment to issue {issue_key}: {str(e)}")
1288 raise
1290 def _parse_time_spent(self, time_spent: str) -> int:
1291 """
1292 Parse time spent string into seconds.
1294 Args:
1295 time_spent: Time spent string (e.g. 1h 30m, 1d, etc.)
1297 Returns:
1298 Time spent in seconds
1299 """
1300 # Base case for direct specification in seconds
1301 if time_spent.endswith("s"):
1302 try:
1303 return int(time_spent[:-1])
1304 except ValueError:
1305 pass
1307 total_seconds = 0
1308 time_units = {
1309 "w": 7 * 24 * 60 * 60, # weeks to seconds
1310 "d": 24 * 60 * 60, # days to seconds
1311 "h": 60 * 60, # hours to seconds
1312 "m": 60, # minutes to seconds
1313 }
1315 # Regular expression to find time components like 1w, 2d, 3h, 4m
1316 pattern = r"(\d+)([wdhm])"
1317 matches = re.findall(pattern, time_spent)
1319 for value, unit in matches:
1320 # Convert value to int and multiply by the unit in seconds
1321 seconds = int(value) * time_units[unit]
1322 total_seconds += seconds
1324 if total_seconds == 0:
1325 # If we couldn't parse anything, try using the raw value
1326 try:
1327 return int(float(time_spent)) # Convert to float first, then to int
1328 except ValueError:
1329 # If all else fails, default to 60 seconds (1 minute)
1330 logger.warning(
1331 f"Could not parse time: {time_spent}, defaulting to 60 seconds"
1332 )
1333 return 60
1335 return total_seconds
1337 def add_worklog(
1338 self,
1339 issue_key: str,
1340 time_spent: str,
1341 comment: str | None = None,
1342 started: str | None = None,
1343 original_estimate: str | None = None,
1344 remaining_estimate: str | None = None,
1345 ) -> dict:
1346 """
1347 Add a worklog to an issue with optional estimate updates.
1349 Args:
1350 issue_key: The issue key (e.g. 'PROJ-123')
1351 time_spent: Time spent in Jira format (e.g., '1h 30m', '1d', '30m')
1352 comment: Optional comment for the worklog (in Markdown format)
1353 started: Optional start time in ISO format
1354 (e.g. '2023-08-01T12:00:00.000+0000').
1355 If not provided, current time will be used.
1356 original_estimate: Optional original estimate in Jira format
1357 (e.g., '1h 30m', '1d')
1358 This will update the original estimate for the issue.
1359 remaining_estimate: Optional remaining estimate in Jira format
1360 (e.g., '1h', '30m')
1361 This will update the remaining estimate for the issue.
1363 Returns:
1364 The created worklog details
1365 """
1366 try:
1367 # Convert time_spent string to seconds
1368 time_spent_seconds = self._parse_time_spent(time_spent)
1370 # Convert Markdown comment to Jira format if provided
1371 if comment:
1372 comment = self._markdown_to_jira(comment)
1374 # Step 1: Update original estimate if provided (separate API call)
1375 original_estimate_updated = False
1376 if original_estimate:
1377 try:
1378 fields = {"timetracking": {"originalEstimate": original_estimate}}
1379 self.jira.edit_issue(issue_id_or_key=issue_key, fields=fields)
1380 original_estimate_updated = True
1381 logger.info(f"Updated original estimate for issue {issue_key}")
1382 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
1383 logger.error(
1384 f"Failed to update original estimate for issue {issue_key}: "
1385 f"{str(e)}"
1386 )
1387 # Continue with worklog creation even if estimate update fails
1389 # Step 2: Prepare worklog data
1390 worklog_data = {"timeSpentSeconds": time_spent_seconds}
1391 if comment:
1392 worklog_data["comment"] = comment
1393 if started:
1394 worklog_data["started"] = started
1396 # Step 3: Prepare query parameters for remaining estimate
1397 params = {}
1398 remaining_estimate_updated = False
1399 if remaining_estimate:
1400 params["adjustEstimate"] = "new"
1401 params["newEstimate"] = remaining_estimate
1402 remaining_estimate_updated = True
1404 # Step 4: Add the worklog with remaining estimate adjustment
1405 base_url = self.jira.resource_url("issue")
1406 url = f"{base_url}/{issue_key}/worklog"
1407 result = self.jira.post(url, data=worklog_data, params=params)
1409 # Format and return the result
1410 return {
1411 "id": result.get("id"),
1412 "comment": self._clean_text(result.get("comment", "")),
1413 "created": self._parse_date(result.get("created", "")),
1414 "updated": self._parse_date(result.get("updated", "")),
1415 "started": self._parse_date(result.get("started", "")),
1416 "timeSpent": result.get("timeSpent", ""),
1417 "timeSpentSeconds": result.get("timeSpentSeconds", 0),
1418 "author": result.get("author", {}).get("displayName", "Unknown"),
1419 "original_estimate_updated": original_estimate_updated,
1420 "remaining_estimate_updated": remaining_estimate_updated,
1421 }
1422 except Exception as e:
1423 logger.error(f"Error adding worklog to issue {issue_key}: {str(e)}")
1424 raise
1426 def get_worklogs(self, issue_key: str) -> list[dict]:
1427 """
1428 Get worklogs for an issue.
1430 Args:
1431 issue_key: The issue key (e.g. 'PROJ-123')
1433 Returns:
1434 List of worklog entries
1435 """
1436 try:
1437 result = self.jira.issue_get_worklog(issue_key)
1439 # Process the worklogs
1440 worklogs = []
1441 for worklog in result.get("worklogs", []):
1442 worklogs.append(
1443 {
1444 "id": worklog.get("id"),
1445 "comment": self._clean_text(worklog.get("comment", "")),
1446 "created": self._parse_date(worklog.get("created", "")),
1447 "updated": self._parse_date(worklog.get("updated", "")),
1448 "started": self._parse_date(worklog.get("started", "")),
1449 "timeSpent": worklog.get("timeSpent", ""),
1450 "timeSpentSeconds": worklog.get("timeSpentSeconds", 0),
1451 "author": worklog.get("author", {}).get(
1452 "displayName", "Unknown"
1453 ),
1454 }
1455 )
1457 return worklogs
1458 except Exception as e:
1459 logger.error(f"Error getting worklogs for issue {issue_key}: {str(e)}")
1460 raise
1462 def _markdown_to_jira(self, markdown_text: str) -> str:
1463 """
1464 Convert Markdown syntax to Jira markup syntax.
1466 This method uses the TextPreprocessor implementation for consistent
1467 conversion between Markdown and Jira markup.
1469 Args:
1470 markdown_text: Text in Markdown format
1472 Returns:
1473 Text in Jira markup format
1474 """
1475 if not markdown_text:
1476 return ""
1478 # Use the existing preprocessor
1479 return self.preprocessor.markdown_to_jira(markdown_text)
1481 def get_available_transitions(self, issue_key: str) -> list[dict]:
1482 """
1483 Get the available status transitions for an issue.
1485 Args:
1486 issue_key: The issue key (e.g. 'PROJ-123')
1488 Returns:
1489 List of available transitions with id, name, and to status details
1490 """
1491 try:
1492 transitions_data = self.jira.get_issue_transitions(issue_key)
1493 result = []
1495 # Handle different response formats from the Jira API
1496 transitions = []
1497 if isinstance(transitions_data, dict) and "transitions" in transitions_data:
1498 # Handle the case where the response is a dict with a "transitions" key
1499 transitions = transitions_data.get("transitions", [])
1500 elif isinstance(transitions_data, list):
1501 # Handle the case where the response is a list of transitions directly
1502 transitions = transitions_data
1503 else:
1504 logger.warning(
1505 f"Unexpected format for transitions data: {type(transitions_data)}"
1506 )
1507 return []
1509 for transition in transitions:
1510 if not isinstance(transition, dict):
1511 continue
1513 # Extract the transition information safely
1514 transition_id = transition.get("id")
1515 transition_name = transition.get("name")
1517 # Handle different formats for the "to" status
1518 to_status = None
1519 if "to" in transition and isinstance(transition["to"], dict):
1520 to_status = transition["to"].get("name")
1521 elif "to_status" in transition:
1522 to_status = transition["to_status"]
1523 elif "status" in transition:
1524 to_status = transition["status"]
1526 result.append(
1527 {
1528 "id": transition_id,
1529 "name": transition_name,
1530 "to_status": to_status,
1531 }
1532 )
1534 return result
1535 except Exception as e:
1536 logger.error(f"Error getting transitions for issue {issue_key}: {str(e)}")
1537 raise
1539 def transition_issue(
1540 self,
1541 issue_key: str,
1542 transition_id: str,
1543 fields: dict | None = None,
1544 comment: str | None = None,
1545 ) -> Document:
1546 """
1547 Transition a Jira issue to a new status.
1549 Args:
1550 issue_key: The key of the issue to transition
1551 transition_id: The ID of the transition to perform
1552 fields: Optional fields to set during the transition
1553 comment: Optional comment to add during the transition
1555 Returns:
1556 Document representing the transitioned issue
1557 """
1558 try:
1559 # Ensure transition_id is a string
1560 transition_id = self._normalize_transition_id(transition_id)
1562 # Prepare transition data
1563 transition_data = {"transition": {"id": transition_id}}
1565 # Add fields if provided
1566 if fields:
1567 sanitized_fields = self._sanitize_transition_fields(fields)
1568 if sanitized_fields:
1569 transition_data["fields"] = sanitized_fields
1571 # Add comment if provided
1572 if comment:
1573 self._add_comment_to_transition_data(transition_data, comment)
1575 # Log the transition request for debugging
1576 logger.info(
1577 f"Transitioning issue {issue_key} with transition ID {transition_id}"
1578 )
1579 logger.debug(f"Transition data: {transition_data}")
1581 # Perform the transition
1582 self.jira.issue_transition(issue_key, transition_data)
1584 # Return the updated issue
1585 return self.get_issue(issue_key)
1586 except Exception as e:
1587 error_msg = (
1588 f"Error transitioning issue {issue_key} with transition ID "
1589 f"{transition_id}: {str(e)}"
1590 )
1591 logger.error(error_msg)
1592 raise ValueError(error_msg) from e
1594 def _normalize_transition_id(self, transition_id: str | int) -> str:
1595 """
1596 Normalize transition ID to a string.
1598 Args:
1599 transition_id: Transition ID as string or int
1601 Returns:
1602 String representation of transition ID
1603 """
1604 return str(transition_id)
1606 def _sanitize_transition_fields(self, fields: dict) -> dict:
1607 """
1608 Sanitize fields to ensure they're valid for the Jira API.
1610 Args:
1611 fields: Dictionary of fields to sanitize
1613 Returns:
1614 Dictionary of sanitized fields
1615 """
1616 sanitized_fields = {}
1617 for key, value in fields.items():
1618 # Skip None values
1619 if value is None:
1620 continue
1622 # Handle special case for assignee
1623 if key == "assignee" and isinstance(value, str):
1624 try:
1625 account_id = self._get_account_id(value)
1626 sanitized_fields[key] = {"accountId": account_id}
1627 except Exception as e: # noqa: BLE001 - Intentional fallback with logging
1628 error_msg = f"Could not resolve assignee '{value}': {str(e)}"
1629 logger.warning(error_msg)
1630 # Skip this field
1631 continue
1632 else:
1633 sanitized_fields[key] = value
1635 return sanitized_fields
1637 def _add_comment_to_transition_data(
1638 self, transition_data: dict[str, Any], comment: str | int
1639 ) -> None:
1640 """
1641 Add comment to transition data.
1643 Args:
1644 transition_data: The transition data dictionary to update
1645 comment: The comment to add
1646 """
1647 # Ensure comment is a string
1648 if not isinstance(comment, str):
1649 logger.warning(
1650 f"Comment must be a string, converting from {type(comment)}: {comment}"
1651 )
1652 comment = str(comment)
1654 # Convert markdown to Jira format and add to transition data
1655 jira_formatted_comment = self._markdown_to_jira(comment)
1656 transition_data["update"] = {
1657 "comment": [{"add": {"body": jira_formatted_comment}}]
1658 }