Coverage for src/mcp_atlassian/jira.py: 56%
415 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-08 18:10 +0900
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-08 18:10 +0900
1import logging
2import os
3import re
4from datetime import datetime
5from typing import Any
7from atlassian import Jira
9from .config import JiraConfig
10from .document_types import Document
11from .preprocessing import TextPreprocessor
13# Configure logging
14logger = logging.getLogger("mcp-jira")
17class JiraFetcher:
18 """Handles fetching and parsing content from Jira."""
20 def __init__(self):
21 url = os.getenv("JIRA_URL")
22 username = os.getenv("JIRA_USERNAME")
23 token = os.getenv("JIRA_API_TOKEN")
25 if not all([url, username, token]):
26 raise ValueError("Missing required Jira environment variables")
28 self.config = JiraConfig(url=url, username=username, api_token=token)
29 self.jira = Jira(
30 url=self.config.url,
31 username=self.config.username,
32 password=self.config.api_token, # API token is used as password
33 cloud=True,
34 )
35 self.preprocessor = TextPreprocessor(self.config.url)
37 # Field IDs cache
38 self._field_ids_cache: dict[str, str] = {}
40 def _clean_text(self, text: str) -> str:
41 """
42 Clean text content by:
43 1. Processing user mentions and links
44 2. Converting HTML/wiki markup to markdown
45 """
46 if not text:
47 return ""
49 return self.preprocessor.clean_jira_text(text)
51 def _get_account_id(self, assignee: str) -> str:
52 """
53 Get account ID from email or full name.
55 Args:
56 assignee: Email, full name, or account ID of the user
58 Returns:
59 Account ID of the user
61 Raises:
62 ValueError: If user cannot be found
63 """
64 # If it looks like an account ID (alphanumeric with hyphens), return as is
65 if assignee and assignee.replace("-", "").isalnum():
66 logger.info(f"Using '{assignee}' as account ID")
67 return assignee
69 try:
70 # First try direct user lookup
71 try:
72 users = self.jira.user_find_by_user_string(query=assignee)
73 if users:
74 if len(users) > 1:
75 # Log all found users for debugging
76 user_details = [f"{u.get('displayName')} ({u.get('emailAddress')})" for u in users]
77 logger.warning(
78 f"Multiple users found for '{assignee}', using first match. "
79 f"Found users: {', '.join(user_details)}"
80 )
82 user = users[0]
83 account_id = user.get("accountId")
84 if account_id and isinstance(account_id, str):
85 logger.info(
86 f"Found account ID via direct lookup: {account_id} "
87 f"({user.get('displayName')} - {user.get('emailAddress')})"
88 )
89 return str(account_id) # Explicit str conversion
90 logger.warning(f"Direct user lookup failed for '{assignee}': user found but no account ID present")
91 else:
92 logger.warning(f"Direct user lookup failed for '{assignee}': no users found")
93 except Exception as e:
94 logger.warning(f"Direct user lookup failed for '{assignee}': {str(e)}")
96 # Fall back to project permission based search
97 users = self.jira.get_users_with_browse_permission_to_a_project(username=assignee)
98 if not users:
99 logger.warning(f"No user found matching '{assignee}'")
100 raise ValueError(f"No user found matching '{assignee}'")
102 # Return the first matching user's account ID
103 account_id = users[0].get("accountId")
104 if not account_id or not isinstance(account_id, str):
105 logger.warning(f"Found user '{assignee}' but no account ID was returned")
106 raise ValueError(f"Found user '{assignee}' but no account ID was returned")
108 logger.info(f"Found account ID via browse permission lookup: {account_id}")
109 return str(account_id) # Explicit str conversion
110 except Exception as e:
111 logger.error(f"Error finding user '{assignee}': {str(e)}")
112 raise ValueError(f"Could not resolve account ID for '{assignee}'") from e
114 def create_issue(
115 self,
116 project_key: str,
117 summary: str,
118 issue_type: str,
119 description: str = "",
120 assignee: str | None = None,
121 **kwargs: Any,
122 ) -> Document:
123 """
124 Create a new issue in Jira and return it as a Document.
126 Args:
127 project_key: The key of the project (e.g. 'PROJ')
128 summary: Summary of the issue
129 issue_type: Issue type (e.g. 'Task', 'Bug', 'Story')
130 description: Issue description
131 assignee: Email, full name, or account ID of the user to assign the issue to
132 kwargs: Any other custom Jira fields
134 Returns:
135 Document representing the newly created issue
136 """
137 fields = {
138 "project": {"key": project_key},
139 "summary": summary,
140 "issuetype": {"name": issue_type},
141 "description": self._markdown_to_jira(description),
142 }
144 # If we're creating an Epic, check for Epic-specific fields
145 if issue_type.lower() == "epic":
146 # Get the dynamic field IDs
147 field_ids = self.get_jira_field_ids()
149 # Set the Epic Name field if available (required in many Jira instances)
150 if "epic_name" in field_ids and "epic_name" not in kwargs:
151 # Use the summary as the epic name if not provided
152 fields[field_ids["epic_name"]] = summary
153 elif "customfield_10011" not in kwargs: # Common default Epic Name field
154 # Fallback to common Epic Name field if not discovered
155 fields["customfield_10011"] = summary
157 # Check for other Epic fields from kwargs
158 epic_color = kwargs.pop("epic_color", None) or kwargs.pop("epic_colour", None)
159 if epic_color and "epic_color" in field_ids:
160 fields[field_ids["epic_color"]] = epic_color
162 # Add assignee if provided
163 if assignee:
164 account_id = self._get_account_id(assignee)
165 fields["assignee"] = {"accountId": account_id}
167 # Remove assignee from additional_fields if present to avoid conflicts
168 if "assignee" in kwargs:
169 logger.warning(
170 "Assignee found in additional_fields - this will be ignored. Please use the assignee parameter instead."
171 )
172 kwargs.pop("assignee")
174 for key, value in kwargs.items():
175 fields[key] = value
177 # Convert description to Jira format if present
178 if "description" in fields and fields["description"]:
179 fields["description"] = self._markdown_to_jira(fields["description"])
181 try:
182 created = self.jira.issue_create(fields=fields)
183 issue_key = created.get("key")
184 if not issue_key:
185 raise ValueError(f"Failed to create issue in project {project_key}")
187 return self.get_issue(issue_key)
188 except Exception as e:
189 logger.error(f"Error creating issue in project {project_key}: {str(e)}")
190 raise
192 def update_issue(self, issue_key: str, fields: dict[str, Any] = None, **kwargs: Any) -> Document:
193 """
194 Update an existing issue in Jira and return it as a Document.
196 Args:
197 issue_key: The key of the issue to update (e.g. 'PROJ-123')
198 fields: Fields to update
199 kwargs: Any other custom Jira fields
201 Returns:
202 Document representing the updated issue
203 """
204 if fields is None:
205 fields = {}
207 # Handle all kwargs
208 for key, value in kwargs.items():
209 fields[key] = value
211 # Convert description to Jira format if present
212 if "description" in fields and fields["description"]:
213 fields["description"] = self._markdown_to_jira(fields["description"])
215 # Check if status update is requested
216 if "status" in fields:
217 requested_status = fields.pop("status")
218 if not isinstance(requested_status, str):
219 logger.warning(f"Status must be a string, got {type(requested_status)}: {requested_status}")
220 # Try to convert to string if possible
221 requested_status = str(requested_status)
223 logger.info(f"Status update requested to: {requested_status}")
225 # Get available transitions
226 transitions = self.get_available_transitions(issue_key)
228 # Find matching transition
229 transition_id = None
230 for transition in transitions:
231 to_status = transition.get("to_status", "")
232 if isinstance(to_status, str) and to_status.lower() == requested_status.lower():
233 transition_id = transition["id"]
234 break
236 if transition_id:
237 # Use transition_issue method if we found a matching transition
238 logger.info(f"Found transition ID {transition_id} for status {requested_status}")
239 return self.transition_issue(issue_key, transition_id, fields)
240 else:
241 available_statuses = [t.get("to_status", "") for t in transitions]
242 logger.warning(
243 f"No transition found for status '{requested_status}'. Available transitions: {transitions}"
244 )
245 raise ValueError(
246 f"Cannot transition issue to status '{requested_status}'. Available status transitions: {available_statuses}"
247 )
249 try:
250 self.jira.issue_update(issue_key, fields=fields)
251 return self.get_issue(issue_key)
252 except Exception as e:
253 logger.error(f"Error updating issue {issue_key}: {str(e)}")
254 raise
256 def get_jira_field_ids(self) -> dict[str, str]:
257 """
258 Dynamically discover Jira field IDs relevant to Epic linking.
260 This method queries the Jira API to find the correct custom field IDs
261 for Epic-related fields, which can vary between different Jira instances.
263 Returns:
264 Dictionary mapping field names to their IDs
265 (e.g., {'epic_link': 'customfield_10014', 'epic_name': 'customfield_10011'})
266 """
267 try:
268 # Check if we've already cached the field IDs
269 if hasattr(self, "_field_ids_cache"):
270 return self._field_ids_cache
272 # Fetch all fields from Jira API
273 fields = self.jira.fields()
274 field_ids = {}
276 # Look for Epic-related fields
277 for field in fields:
278 field_name = field.get("name", "").lower()
280 # Epic Link field - used to link issues to epics
281 if "epic link" in field_name or "epic-link" in field_name:
282 field_ids["epic_link"] = field["id"]
284 # Epic Name field - used when creating epics
285 elif "epic name" in field_name or "epic-name" in field_name:
286 field_ids["epic_name"] = field["id"]
288 # Parent field - sometimes used instead of Epic Link
289 elif field_name == "parent" or field_name == "parent link":
290 field_ids["parent"] = field["id"]
292 # Epic Status field
293 elif "epic status" in field_name:
294 field_ids["epic_status"] = field["id"]
296 # Epic Color field
297 elif "epic colour" in field_name or "epic color" in field_name:
298 field_ids["epic_color"] = field["id"]
300 # Cache the results for future use
301 self._field_ids_cache = field_ids
303 logger.info(f"Discovered Jira field IDs: {field_ids}")
304 return field_ids
306 except Exception as e:
307 logger.error(f"Error discovering Jira field IDs: {str(e)}")
308 # Return an empty dict as fallback
309 return {}
311 def link_issue_to_epic(self, issue_key: str, epic_key: str) -> Document:
312 """
313 Link an existing issue to an epic.
315 Args:
316 issue_key: The key of the issue to link (e.g. 'PROJ-123')
317 epic_key: The key of the epic to link to (e.g. 'PROJ-456')
319 Returns:
320 Document representing the updated issue
321 """
322 try:
323 # First, check if the epic exists and is an Epic type
324 epic = self.jira.issue(epic_key)
325 if epic["fields"]["issuetype"]["name"] != "Epic":
326 raise ValueError(f"Issue {epic_key} is not an Epic, it is a {epic['fields']['issuetype']['name']}")
328 # Get the dynamic field IDs for this Jira instance
329 field_ids = self.get_jira_field_ids()
331 # Try the parent field first (if discovered or natively supported)
332 if "parent" in field_ids or "parent" not in field_ids:
333 try:
334 fields = {"parent": {"key": epic_key}}
335 self.jira.issue_update(issue_key, fields=fields)
336 return self.get_issue(issue_key)
337 except Exception as e:
338 logger.info(f"Couldn't link using parent field: {str(e)}. Trying discovered fields...")
340 # Try using the discovered Epic Link field
341 if "epic_link" in field_ids:
342 try:
343 epic_link_fields: dict[str, str] = {field_ids["epic_link"]: epic_key}
344 self.jira.issue_update(issue_key, fields=epic_link_fields)
345 return self.get_issue(issue_key)
346 except Exception as e:
347 logger.info(f"Couldn't link using discovered epic_link field: {str(e)}. Trying fallback methods...")
349 # Fallback to common custom fields if dynamic discovery didn't work
350 custom_field_attempts: list[dict[str, str]] = [
351 {"customfield_10014": epic_key}, # Common in Jira Cloud
352 {"customfield_10000": epic_key}, # Common in Jira Server
353 {"epic_link": epic_key}, # Sometimes used
354 ]
356 for fields in custom_field_attempts:
357 try:
358 self.jira.issue_update(issue_key, fields=fields)
359 return self.get_issue(issue_key)
360 except Exception as e:
361 logger.info(f"Couldn't link using fields {fields}: {str(e)}")
362 continue
364 # If we get here, none of our attempts worked
365 raise ValueError(
366 f"Could not link issue {issue_key} to epic {epic_key}. Your Jira instance might use a different field for epic links."
367 )
369 except Exception as e:
370 logger.error(f"Error linking issue {issue_key} to epic {epic_key}: {str(e)}")
371 raise
373 def delete_issue(self, issue_key: str) -> bool:
374 """
375 Delete an existing issue.
377 Args:
378 issue_key: The key of the issue (e.g. 'PROJ-123')
380 Returns:
381 True if delete succeeded, otherwise raise an exception
382 """
383 try:
384 self.jira.delete_issue(issue_key)
385 return True
386 except Exception as e:
387 logger.error(f"Error deleting issue {issue_key}: {str(e)}")
388 raise
390 def _parse_date(self, date_str: str) -> str:
391 """Parse date string to handle various ISO formats."""
392 if not date_str:
393 return ""
395 # Handle various timezone formats
396 if "+0000" in date_str:
397 date_str = date_str.replace("+0000", "+00:00")
398 elif "-0000" in date_str:
399 date_str = date_str.replace("-0000", "+00:00")
400 # Handle other timezone formats like +0900, -0500, etc.
401 elif len(date_str) >= 5 and date_str[-5] in "+-" and date_str[-4:].isdigit():
402 # Insert colon between hours and minutes of timezone
403 date_str = date_str[:-2] + ":" + date_str[-2:]
405 try:
406 date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
407 return date.strftime("%Y-%m-%d")
408 except Exception as e:
409 logger.warning(f"Error parsing date {date_str}: {e}")
410 return date_str
412 def get_issue(self, issue_key: str, expand: str | None = None, comment_limit: int | None = None) -> Document:
413 """
414 Get a single issue with all its details.
416 Args:
417 issue_key: The issue key (e.g. 'PROJ-123')
418 expand: Optional fields to expand
419 comment_limit: Maximum number of comments to include (None for no comments)
421 Returns:
422 Document containing issue content and metadata
423 """
424 try:
425 issue = self.jira.issue(issue_key, expand=expand)
427 # Process description and comments
428 description = self._clean_text(issue["fields"].get("description", ""))
430 # Get comments if limit is specified
431 comments = []
432 if comment_limit is not None and comment_limit > 0:
433 comments = self.get_issue_comments(issue_key, limit=comment_limit)
435 # Format created date using new parser
436 created_date = self._parse_date(issue["fields"]["created"])
438 # Check for Epic information
439 epic_key = None
440 epic_name = None
442 # Most Jira instances use the "parent" field for Epic relationships
443 if "parent" in issue["fields"] and issue["fields"]["parent"]:
444 epic_key = issue["fields"]["parent"]["key"]
445 epic_name = issue["fields"]["parent"]["fields"]["summary"]
447 # Some Jira instances use custom fields for Epic links
448 # Common custom field names for Epic links
449 epic_field_names = ["customfield_10014", "customfield_10000", "epic_link"]
450 for field_name in epic_field_names:
451 if field_name in issue["fields"] and issue["fields"][field_name]:
452 # If it's a string, assume it's the epic key
453 if isinstance(issue["fields"][field_name], str):
454 epic_key = issue["fields"][field_name]
455 # If it's an object, extract the key
456 elif isinstance(issue["fields"][field_name], dict) and "key" in issue["fields"][field_name]:
457 epic_key = issue["fields"][field_name]["key"]
459 # Combine content in a more structured way
460 content = f"""Issue: {issue_key}
461Title: {issue['fields'].get('summary', '')}
462Type: {issue['fields']['issuetype']['name']}
463Status: {issue['fields']['status']['name']}
464Created: {created_date}
465"""
467 # Add Epic information if available
468 if epic_key:
469 content += f"Epic: {epic_key}"
470 if epic_name:
471 content += f" - {epic_name}"
472 content += "\n"
474 content += f"""
475Description:
476{description}
477"""
478 if comments:
479 content += "\nComments:\n" + "\n".join(
480 [f"{c['created']} - {c['author']}: {c['body']}" for c in comments]
481 )
483 # Streamlined metadata with only essential information
484 metadata = {
485 "key": issue_key,
486 "title": issue["fields"].get("summary", ""),
487 "type": issue["fields"]["issuetype"]["name"],
488 "status": issue["fields"]["status"]["name"],
489 "created_date": created_date,
490 "priority": issue["fields"].get("priority", {}).get("name", "None"),
491 "link": f"{self.config.url.rstrip('/')}/browse/{issue_key}",
492 }
494 # Add Epic information to metadata
495 if epic_key:
496 metadata["epic_key"] = epic_key
497 if epic_name:
498 metadata["epic_name"] = epic_name
500 if comments:
501 metadata["comments"] = comments
503 return Document(page_content=content, metadata=metadata)
505 except Exception as e:
506 logger.error(f"Error fetching issue {issue_key}: {str(e)}")
507 raise
509 def search_issues(
510 self,
511 jql: str,
512 fields: str = "*all",
513 start: int = 0,
514 limit: int = 50,
515 expand: str | None = None,
516 ) -> list[Document]:
517 """
518 Search for issues using JQL (Jira Query Language).
520 Args:
521 jql: JQL query string
522 fields: Fields to return (comma-separated string or "*all")
523 start: Starting index
524 limit: Maximum issues to return
525 expand: Optional items to expand (comma-separated)
527 Returns:
528 List of Documents representing the search results
529 """
530 try:
531 issues = self.jira.jql(jql, fields=fields, start=start, limit=limit, expand=expand)
532 documents = []
534 for issue in issues.get("issues", []):
535 issue_key = issue["key"]
536 summary = issue["fields"].get("summary", "")
537 issue_type = issue["fields"]["issuetype"]["name"]
538 status = issue["fields"]["status"]["name"]
539 desc = self._clean_text(issue["fields"].get("description", ""))
540 created_date = self._parse_date(issue["fields"]["created"])
541 priority = issue["fields"].get("priority", {}).get("name", "None")
543 # Add basic metadata
544 metadata = {
545 "key": issue_key,
546 "title": summary,
547 "type": issue_type,
548 "status": status,
549 "created_date": created_date,
550 "priority": priority,
551 "link": f"{self.config.url.rstrip('/')}/browse/{issue_key}",
552 }
554 # Prepare content
555 content = desc if desc else f"{summary} [{status}]"
557 documents.append(Document(page_content=content, metadata=metadata))
559 return documents
560 except Exception as e:
561 logger.error(f"Error searching issues with JQL '{jql}': {str(e)}")
562 raise
564 def get_epic_issues(self, epic_key: str, limit: int = 50) -> list[Document]:
565 """
566 Get all issues linked to a specific epic.
568 Args:
569 epic_key: The key of the epic (e.g. 'PROJ-123')
570 limit: Maximum number of issues to return
572 Returns:
573 List of Documents representing the issues linked to the epic
574 """
575 try:
576 # First, check if the issue is an Epic
577 epic = self.jira.issue(epic_key)
578 if epic["fields"]["issuetype"]["name"] != "Epic":
579 raise ValueError(f"Issue {epic_key} is not an Epic, it is a {epic['fields']['issuetype']['name']}")
581 # Get the dynamic field IDs for this Jira instance
582 field_ids = self.get_jira_field_ids()
584 # Build JQL queries based on discovered field IDs
585 jql_queries = []
587 # Add queries based on discovered fields
588 if "parent" in field_ids:
589 jql_queries.append(f"parent = {epic_key}")
591 if "epic_link" in field_ids:
592 field_name = field_ids["epic_link"]
593 jql_queries.append(f'"{field_name}" = {epic_key}')
594 jql_queries.append(f'"{field_name}" ~ {epic_key}')
596 # Add standard fallback queries
597 jql_queries.extend(
598 [
599 f"parent = {epic_key}", # Common in most instances
600 f"'Epic Link' = {epic_key}", # Some instances
601 f"'Epic' = {epic_key}", # Some instances
602 f"issue in childIssuesOf('{epic_key}')", # Some instances
603 ]
604 )
606 # Try each query until we get results or run out of options
607 documents = []
608 for jql in jql_queries:
609 try:
610 logger.info(f"Trying to get epic issues with JQL: {jql}")
611 documents = self.search_issues(jql, limit=limit)
612 if documents:
613 return documents
614 except Exception as e:
615 logger.info(f"Failed to get epic issues with JQL '{jql}': {str(e)}")
616 continue
618 # If we've tried all queries and got no results, return an empty list
619 # but also log a warning that we might be missing the right field
620 if not documents:
621 logger.warning(
622 f"Couldn't find issues linked to epic {epic_key}. Your Jira instance might use a different field for epic links."
623 )
625 return documents
627 except Exception as e:
628 logger.error(f"Error getting issues for epic {epic_key}: {str(e)}")
629 raise
631 def get_project_issues(self, project_key: str, start: int = 0, limit: int = 50) -> list[Document]:
632 """
633 Get all issues for a project.
635 Args:
636 project_key: The project key
637 start: Starting index
638 limit: Maximum results to return
640 Returns:
641 List of Documents containing project issues
642 """
643 jql = f"project = {project_key} ORDER BY created DESC"
644 return self.search_issues(jql, start=start, limit=limit)
646 def get_current_user_account_id(self) -> str:
647 """
648 Get the account ID of the current user.
650 Returns:
651 The account ID string of the current user
653 Raises:
654 ValueError: If unable to get the current user's account ID
655 """
656 try:
657 myself = self.jira.myself()
658 account_id: str | None = myself.get("accountId")
659 if not account_id:
660 raise ValueError("Unable to get account ID from user profile")
661 return account_id
662 except Exception as e:
663 logger.error(f"Error getting current user account ID: {str(e)}")
664 raise ValueError(f"Failed to get current user account ID: {str(e)}")
666 def get_issue_comments(self, issue_key: str, limit: int = 50) -> list[dict]:
667 """
668 Get comments for a specific issue.
670 Args:
671 issue_key: The issue key (e.g. 'PROJ-123')
672 limit: Maximum number of comments to return
674 Returns:
675 List of comments with author, creation date, and content
676 """
677 try:
678 comments = self.jira.issue_get_comments(issue_key)
679 processed_comments = []
681 for comment in comments.get("comments", [])[:limit]:
682 processed_comment = {
683 "id": comment.get("id"),
684 "body": self._clean_text(comment.get("body", "")),
685 "created": self._parse_date(comment.get("created")),
686 "updated": self._parse_date(comment.get("updated")),
687 "author": comment.get("author", {}).get("displayName", "Unknown"),
688 }
689 processed_comments.append(processed_comment)
691 return processed_comments
692 except Exception as e:
693 logger.error(f"Error getting comments for issue {issue_key}: {str(e)}")
694 raise
696 def add_comment(self, issue_key: str, comment: str) -> dict:
697 """
698 Add a comment to an issue.
700 Args:
701 issue_key: The issue key (e.g. 'PROJ-123')
702 comment: Comment text to add (in Markdown format)
704 Returns:
705 The created comment details
706 """
707 try:
708 # Convert Markdown to Jira's markup format
709 jira_formatted_comment = self._markdown_to_jira(comment)
711 result = self.jira.issue_add_comment(issue_key, jira_formatted_comment)
712 return {
713 "id": result.get("id"),
714 "body": self._clean_text(result.get("body", "")),
715 "created": self._parse_date(result.get("created")),
716 "author": result.get("author", {}).get("displayName", "Unknown"),
717 }
718 except Exception as e:
719 logger.error(f"Error adding comment to issue {issue_key}: {str(e)}")
720 raise
722 def _markdown_to_jira(self, markdown_text: str) -> str:
723 """
724 Convert Markdown syntax to Jira markup syntax.
726 Supported Markdown syntax:
727 - Headers: # Heading 1, ## Heading 2, etc.
728 - Bold: **bold text** or __bold text__
729 - Italic: *italic text* or _italic text_
730 - Code blocks: ```code``` (triple backticks)
731 - Inline code: `code` (single backticks)
732 - Links: [link text](URL)
733 - Unordered lists: * item or - item
734 - Ordered lists: 1. item, 2. item, etc.
735 - Blockquotes: > quoted text
736 - Horizontal rules: --- or ****
738 Args:
739 markdown_text: Text in Markdown format
741 Returns:
742 Text in Jira markup format
743 """
744 if not markdown_text:
745 return ""
747 # Basic Markdown to Jira markup conversions
748 # Headers
749 jira_text = re.sub(r"^# (.+)$", r"h1. \1", markdown_text, flags=re.MULTILINE)
750 jira_text = re.sub(r"^## (.+)$", r"h2. \1", jira_text, flags=re.MULTILINE)
751 jira_text = re.sub(r"^### (.+)$", r"h3. \1", jira_text, flags=re.MULTILINE)
752 jira_text = re.sub(r"^#### (.+)$", r"h4. \1", jira_text, flags=re.MULTILINE)
753 jira_text = re.sub(r"^##### (.+)$", r"h5. \1", jira_text, flags=re.MULTILINE)
754 jira_text = re.sub(r"^###### (.+)$", r"h6. \1", jira_text, flags=re.MULTILINE)
756 # Bold and italic - handle both asterisks and underscores
757 # Note: Order matters here - process bold first, then italic
758 jira_text = re.sub(r"\*\*(.+?)\*\*", r"*\1*", jira_text) # Bold with **
759 jira_text = re.sub(r"__(.+?)__", r"*\1*", jira_text) # Bold with __
761 # Be careful with italic conversion to avoid over-replacing
762 # Look for single asterisks or underscores not preceded or followed by the same character
763 jira_text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"_\1_", jira_text) # Italic with *
764 jira_text = re.sub(r"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", r"_\1_", jira_text) # Italic with _ (keep as is)
766 # Code blocks with language support
767 # Match ```language\ncode\n``` pattern
768 jira_text = re.sub(
769 r"```(\w*)\n(.*?)\n```",
770 lambda m: "{code:" + (m.group(1) or "none") + "}\n" + m.group(2) + "\n{code}",
771 jira_text,
772 flags=re.DOTALL,
773 )
775 # Simple code blocks without language
776 jira_text = re.sub(r"```(.*?)```", r"{code}\1{code}", jira_text, flags=re.DOTALL)
778 # Inline code
779 jira_text = re.sub(r"`(.+?)`", r"{{{\1}}}", jira_text)
781 # Links
782 jira_text = re.sub(r"\[(.+?)\]\((.+?)\)", r"[\1|\2]", jira_text)
784 # Unordered lists
785 jira_text = re.sub(r"^- (.+)$", r"* \1", jira_text, flags=re.MULTILINE)
786 jira_text = re.sub(r"^\* (.+)$", r"* \1", jira_text, flags=re.MULTILINE) # Keep as is
788 # Ordered lists - improved to handle multi-digit numbers
789 jira_text = re.sub(r"^(\d+)\. (.+)$", r"# \2", jira_text, flags=re.MULTILINE)
791 # Blockquotes
792 jira_text = re.sub(r"^> (.+)$", r"bq. \1", jira_text, flags=re.MULTILINE)
794 # Horizontal rules
795 jira_text = re.sub(r"^---+$", r"----", jira_text, flags=re.MULTILINE)
796 jira_text = re.sub(r"^\*\*\*+$", r"----", jira_text, flags=re.MULTILINE)
798 # Handle consecutive ordered list items to ensure they render properly
799 lines = jira_text.split("\n")
800 in_list = False
801 for i in range(len(lines)):
802 if lines[i].startswith("# "):
803 if not in_list:
804 # First item in a list
805 in_list = True
806 # No need to modify subsequent items as Jira continues the numbering
807 else:
808 in_list = False
810 jira_text = "\n".join(lines)
812 return jira_text
814 def get_available_transitions(self, issue_key: str) -> list[dict]:
815 """
816 Get the available status transitions for an issue.
818 Args:
819 issue_key: The issue key (e.g. 'PROJ-123')
821 Returns:
822 List of available transitions with id, name, and to status details
823 """
824 try:
825 transitions_data = self.jira.get_issue_transitions(issue_key)
826 result = []
828 # Handle different response formats from the Jira API
829 transitions = []
830 if isinstance(transitions_data, dict) and "transitions" in transitions_data:
831 # Handle the case where the response is a dict with a "transitions" key
832 transitions = transitions_data.get("transitions", [])
833 elif isinstance(transitions_data, list):
834 # Handle the case where the response is a list of transitions directly
835 transitions = transitions_data
836 else:
837 logger.warning(f"Unexpected format for transitions data: {type(transitions_data)}")
838 return []
840 for transition in transitions:
841 if not isinstance(transition, dict):
842 continue
844 # Extract the transition information safely
845 transition_id = transition.get("id")
846 transition_name = transition.get("name")
848 # Handle different formats for the "to" status
849 to_status = None
850 if "to" in transition and isinstance(transition["to"], dict):
851 to_status = transition["to"].get("name")
852 elif "to_status" in transition:
853 to_status = transition["to_status"]
854 elif "status" in transition:
855 to_status = transition["status"]
857 result.append({"id": transition_id, "name": transition_name, "to_status": to_status})
859 return result
860 except Exception as e:
861 logger.error(f"Error getting transitions for issue {issue_key}: {str(e)}")
862 raise
864 def transition_issue(
865 self, issue_key: str, transition_id: str, fields: dict | None = None, comment: str | None = None
866 ) -> Document:
867 """
868 Transition an issue to a new status using the appropriate workflow transition.
870 Args:
871 issue_key: The issue key (e.g. 'PROJ-123')
872 transition_id: The ID of the transition to perform (get this from get_available_transitions)
873 fields: Additional fields to update during the transition
874 comment: Optional comment to add during the transition
876 Returns:
877 Document representing the updated issue
878 """
879 try:
880 # Ensure transition_id is a string
881 if not isinstance(transition_id, str):
882 logger.warning(
883 f"transition_id must be a string, converting from {type(transition_id)}: {transition_id}"
884 )
885 transition_id = str(transition_id)
887 transition_data: dict[str, Any] = {"transition": {"id": transition_id}}
889 # Add fields if provided
890 if fields:
891 # Sanitize fields to ensure they're valid for the API
892 sanitized_fields = {}
893 for key, value in fields.items():
894 # Skip None values
895 if value is None:
896 continue
898 # Handle special case for assignee
899 if key == "assignee" and isinstance(value, str):
900 try:
901 account_id = self._get_account_id(value)
902 sanitized_fields[key] = {"accountId": account_id}
903 except Exception as e:
904 error_msg = f"Could not resolve assignee '{value}': {str(e)}"
905 logger.warning(error_msg)
906 # Skip this field
907 continue
908 else:
909 sanitized_fields[key] = value
911 if sanitized_fields:
912 transition_data["fields"] = sanitized_fields
914 # Add comment if provided
915 if comment:
916 if not isinstance(comment, str):
917 logger.warning(f"Comment must be a string, converting from {type(comment)}: {comment}")
918 comment = str(comment)
920 jira_formatted_comment = self._markdown_to_jira(comment)
921 transition_data["update"] = {"comment": [{"add": {"body": jira_formatted_comment}}]}
923 # Log the transition request for debugging
924 logger.info(f"Transitioning issue {issue_key} with transition ID {transition_id}")
925 logger.debug(f"Transition data: {transition_data}")
927 # Perform the transition
928 self.jira.issue_transition(issue_key, transition_data)
930 # Return the updated issue
931 return self.get_issue(issue_key)
932 except Exception as e:
933 error_msg = f"Error transitioning issue {issue_key} with transition ID {transition_id}: {str(e)}"
934 logger.error(error_msg)
935 raise ValueError(error_msg)