Coverage for src/mcp_atlassian/jira/formatting.py: 97%
126 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 content formatting utilities."""
3import html
4import logging
5import re
6from datetime import datetime
7from typing import Any, Dict, List, Optional, Union, cast
9from ..document_types import Document
10from .client import JiraClient
12logger = logging.getLogger("mcp-jira")
15class FormattingMixin(JiraClient):
16 """Mixin for Jira content formatting operations.
18 This mixin provides utilities for converting between different formats,
19 formatting issue content for display, parsing dates, and sanitizing content.
20 """
22 def markdown_to_jira(self, markdown_text: str) -> str:
23 """
24 Convert Markdown syntax to Jira markup syntax.
26 This method uses the TextPreprocessor implementation for consistent
27 conversion between Markdown and Jira markup.
29 Args:
30 markdown_text: Text in Markdown format
32 Returns:
33 Text in Jira markup format
34 """
35 if not markdown_text:
36 return ""
38 try:
39 # Use the existing preprocessor
40 return self.preprocessor.markdown_to_jira(markdown_text)
42 except Exception as e:
43 logger.warning(f"Error converting markdown to Jira format: {str(e)}")
44 # Return the original text if conversion fails
45 return markdown_text
47 def format_issue_content(
48 self,
49 issue_key: str,
50 issue: Dict[str, Any],
51 description: str,
52 comments: List[Dict[str, Any]],
53 created_date: str,
54 epic_info: Dict[str, Optional[str]],
55 ) -> str:
56 """
57 Format the issue content for display.
59 Args:
60 issue_key: The issue key
61 issue: The issue data from Jira
62 description: Processed description text
63 comments: List of comment dictionaries
64 created_date: Formatted created date
65 epic_info: Dictionary with epic_key and epic_name
67 Returns:
68 Formatted content string
69 """
70 # Basic issue information
71 content = f"""Issue: {issue_key}
72Title: {issue["fields"].get("summary", "")}
73Type: {issue["fields"]["issuetype"]["name"]}
74Status: {issue["fields"]["status"]["name"]}
75Created: {created_date}
76"""
78 # Add Epic information if available
79 if epic_info.get("epic_key"):
80 content += f"Epic: {epic_info['epic_key']}"
81 if epic_info.get("epic_name"):
82 content += f" - {epic_info['epic_name']}"
83 content += "\n"
85 content += f"""
86Description:
87{description}
88"""
89 # Add comments if present
90 if comments:
91 content += "\nComments:\n" + "\n".join(
92 [f"{c['created']} - {c['author']}: {c['body']}" for c in comments]
93 )
95 return content
97 def create_issue_metadata(
98 self,
99 issue_key: str,
100 issue: Dict[str, Any],
101 comments: List[Dict[str, Any]],
102 created_date: str,
103 epic_info: Dict[str, Optional[str]],
104 ) -> Dict[str, Any]:
105 """
106 Create metadata for the issue document.
108 Args:
109 issue_key: The issue key
110 issue: The issue data from Jira
111 comments: List of comment dictionaries
112 created_date: Formatted created date
113 epic_info: Dictionary with epic_key and epic_name
115 Returns:
116 Metadata dictionary
117 """
118 # Extract fields
119 fields = issue.get("fields", {})
121 # Basic metadata
122 metadata = {
123 "key": issue_key,
124 "summary": fields.get("summary", ""),
125 "type": fields.get("issuetype", {}).get("name", ""),
126 "status": fields.get("status", {}).get("name", ""),
127 "created": created_date,
128 "source": "jira",
129 }
131 # Add assignee if present
132 if fields.get("assignee"):
133 metadata["assignee"] = fields["assignee"].get("displayName", fields["assignee"].get("name", ""))
135 # Add reporter if present
136 if fields.get("reporter"):
137 metadata["reporter"] = fields["reporter"].get("displayName", fields["reporter"].get("name", ""))
139 # Add priority if present
140 if fields.get("priority"):
141 metadata["priority"] = fields["priority"].get("name", "")
143 # Add Epic information to metadata if available
144 if epic_info.get("epic_key"):
145 metadata["epic_key"] = epic_info["epic_key"]
146 if epic_info.get("epic_name"):
147 metadata["epic_name"] = epic_info["epic_name"]
149 # Add project information
150 if fields.get("project"):
151 metadata["project"] = fields["project"].get("key", "")
152 metadata["project_name"] = fields["project"].get("name", "")
154 # Add comment count
155 metadata["comment_count"] = len(comments)
157 return metadata
159 def format_date(self, date_str: str) -> str:
160 """
161 Parse a date string from ISO format to a more readable format.
163 Args:
164 date_str: Date string in ISO format
166 Returns:
167 Formatted date string
168 """
169 try:
170 # Handle ISO format with timezone
171 date_obj = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
172 return date_obj.strftime("%Y-%m-%d %H:%M:%S")
174 except Exception as e:
175 logger.warning(f"Invalid date format for {date_str}: {e}")
176 return date_str
178 def format_jira_date(self, date_str: Optional[str]) -> str:
179 """
180 Parse a date string from ISO format to a more readable format.
182 Args:
183 date_str: Date string in ISO format or None
185 Returns:
186 Formatted date string or empty string if date_str is None
187 """
188 if not date_str:
189 return ""
191 try:
192 # Handle ISO format with timezone
193 date_obj = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
194 return date_obj.strftime("%Y-%m-%d %H:%M:%S")
196 except Exception as e:
197 logger.warning(f"Invalid date format for {date_str}: {e}")
198 return date_str or ""
200 def parse_date_for_api(self, date_str: str) -> str:
201 """
202 Parse a date string into a consistent format (YYYY-MM-DD).
204 Args:
205 date_str: Date string in various formats
207 Returns:
208 Formatted date string
209 """
210 try:
211 # Handle various formats of date strings from Jira
212 date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
213 return date.strftime("%Y-%m-%d")
215 except ValueError as e:
216 # This handles parsing errors in the date format
217 logger.warning(f"Invalid date format for {date_str}: {e}")
218 return date_str
220 def extract_epic_information(self, issue: Dict[str, Any]) -> Dict[str, Optional[str]]:
221 """
222 Extract epic information from issue data.
224 Args:
225 issue: Issue data dictionary
227 Returns:
228 Dictionary containing epic_key and epic_name (or None if not found)
229 """
230 epic_info = {
231 "epic_key": None,
232 "epic_name": None
233 }
235 # Check if the issue has fields
236 if "fields" not in issue:
237 return epic_info
239 fields = issue["fields"]
241 # Try to get the epic link from issue
242 # (requires the correct field ID which varies across instances)
243 if hasattr(self, 'get_jira_field_ids'):
244 # Use the field discovery mechanism if available
245 try:
246 field_ids = self.get_jira_field_ids()
248 # Get the epic link field ID
249 epic_link_field = field_ids.get("Epic Link")
250 if epic_link_field and epic_link_field in fields and fields[epic_link_field]:
251 epic_info["epic_key"] = fields[epic_link_field]
253 # If the issue is linked to an epic, try to get the epic name
254 if epic_info["epic_key"] and hasattr(self, 'get_issue'):
255 try:
256 epic_issue = self.get_issue(epic_info["epic_key"])
257 epic_fields = epic_issue.get("fields", {})
259 # Get the epic name field ID
260 epic_name_field = field_ids.get("Epic Name")
261 if epic_name_field and epic_name_field in epic_fields:
262 epic_info["epic_name"] = epic_fields[epic_name_field]
264 except Exception as e:
265 logger.warning(f"Error getting epic details: {str(e)}")
267 except Exception as e:
268 logger.warning(f"Error extracting epic information: {str(e)}")
270 return epic_info
272 def sanitize_html(self, html_content: str) -> str:
273 """
274 Sanitize HTML content by removing HTML tags.
276 Args:
277 html_content: HTML content to sanitize
279 Returns:
280 Plaintext content with HTML tags removed
281 """
282 if not html_content:
283 return ""
285 try:
286 # Remove HTML tags
287 plain_text = re.sub(r'<[^>]+>', '', html_content)
288 # Decode HTML entities
289 plain_text = html.unescape(plain_text)
290 # Normalize whitespace
291 plain_text = re.sub(r'\s+', ' ', plain_text).strip()
293 return plain_text
295 except Exception as e:
296 logger.warning(f"Error sanitizing HTML: {str(e)}")
297 return html_content
299 def sanitize_transition_fields(self, fields: Dict[str, Any]) -> Dict[str, Any]:
300 """
301 Sanitize fields to ensure they're valid for the Jira API.
303 This is used for transition data to properly format field values.
305 Args:
306 fields: Dictionary of fields to sanitize
308 Returns:
309 Dictionary of sanitized fields
310 """
311 sanitized_fields = {}
313 for key, value in fields.items():
314 # Skip empty values
315 if value is None:
316 continue
318 # Handle assignee field specially
319 if key in ["assignee", "reporter"]:
320 # If the value is already a dictionary, use it as is
321 if isinstance(value, dict) and "accountId" in value:
322 sanitized_fields[key] = value
323 else:
324 # Otherwise, look up the account ID
325 if hasattr(self, '_get_account_id'):
326 try:
327 account_id = self._get_account_id(value)
328 if account_id:
329 sanitized_fields[key] = {"accountId": account_id}
330 except Exception as e:
331 logger.warning(f"Error getting account ID for {value}: {str(e)}")
332 # All other fields pass through as is
333 else:
334 sanitized_fields[key] = value
336 return sanitized_fields
338 def add_comment_to_transition_data(
339 self, transition_data: Dict[str, Any], comment: Optional[str]
340 ) -> Dict[str, Any]:
341 """
342 Add a comment to transition data.
344 Args:
345 transition_data: Transition data dictionary
346 comment: Comment text (in Markdown format) or None
348 Returns:
349 Updated transition data
350 """
351 if not comment:
352 return transition_data
354 # Convert markdown to Jira format
355 jira_formatted_comment = self.markdown_to_jira(comment)
357 # Add the comment to the transition data
358 transition_data["update"] = {
359 "comment": [{"add": {"body": jira_formatted_comment}}]
360 }
362 return transition_data