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

1"""Module for Jira content formatting utilities.""" 

2 

3import html 

4import logging 

5import re 

6from datetime import datetime 

7from typing import Any, Dict, List, Optional, Union, cast 

8 

9from ..document_types import Document 

10from .client import JiraClient 

11 

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

13 

14 

15class FormattingMixin(JiraClient): 

16 """Mixin for Jira content formatting operations. 

17  

18 This mixin provides utilities for converting between different formats, 

19 formatting issue content for display, parsing dates, and sanitizing content. 

20 """ 

21 

22 def markdown_to_jira(self, markdown_text: str) -> str: 

23 """ 

24 Convert Markdown syntax to Jira markup syntax. 

25  

26 This method uses the TextPreprocessor implementation for consistent 

27 conversion between Markdown and Jira markup. 

28  

29 Args: 

30 markdown_text: Text in Markdown format 

31  

32 Returns: 

33 Text in Jira markup format 

34 """ 

35 if not markdown_text: 

36 return "" 

37 

38 try: 

39 # Use the existing preprocessor 

40 return self.preprocessor.markdown_to_jira(markdown_text) 

41 

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 

46 

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. 

58  

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 

66  

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""" 

77 

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" 

84 

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 ) 

94 

95 return content 

96 

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. 

107  

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 

114  

115 Returns: 

116 Metadata dictionary 

117 """ 

118 # Extract fields 

119 fields = issue.get("fields", {}) 

120 

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 } 

130 

131 # Add assignee if present 

132 if fields.get("assignee"): 

133 metadata["assignee"] = fields["assignee"].get("displayName", fields["assignee"].get("name", "")) 

134 

135 # Add reporter if present 

136 if fields.get("reporter"): 

137 metadata["reporter"] = fields["reporter"].get("displayName", fields["reporter"].get("name", "")) 

138 

139 # Add priority if present 

140 if fields.get("priority"): 

141 metadata["priority"] = fields["priority"].get("name", "") 

142 

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"] 

148 

149 # Add project information 

150 if fields.get("project"): 

151 metadata["project"] = fields["project"].get("key", "") 

152 metadata["project_name"] = fields["project"].get("name", "") 

153 

154 # Add comment count 

155 metadata["comment_count"] = len(comments) 

156 

157 return metadata 

158 

159 def format_date(self, date_str: str) -> str: 

160 """ 

161 Parse a date string from ISO format to a more readable format. 

162  

163 Args: 

164 date_str: Date string in ISO format 

165  

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") 

173 

174 except Exception as e: 

175 logger.warning(f"Invalid date format for {date_str}: {e}") 

176 return date_str 

177 

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. 

181  

182 Args: 

183 date_str: Date string in ISO format or None 

184  

185 Returns: 

186 Formatted date string or empty string if date_str is None 

187 """ 

188 if not date_str: 

189 return "" 

190 

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") 

195 

196 except Exception as e: 

197 logger.warning(f"Invalid date format for {date_str}: {e}") 

198 return date_str or "" 

199 

200 def parse_date_for_api(self, date_str: str) -> str: 

201 """ 

202 Parse a date string into a consistent format (YYYY-MM-DD). 

203  

204 Args: 

205 date_str: Date string in various formats 

206  

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") 

214 

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 

219 

220 def extract_epic_information(self, issue: Dict[str, Any]) -> Dict[str, Optional[str]]: 

221 """ 

222 Extract epic information from issue data. 

223  

224 Args: 

225 issue: Issue data dictionary 

226  

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 } 

234 

235 # Check if the issue has fields 

236 if "fields" not in issue: 

237 return epic_info 

238 

239 fields = issue["fields"] 

240 

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() 

247 

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] 

252 

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", {}) 

258 

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] 

263 

264 except Exception as e: 

265 logger.warning(f"Error getting epic details: {str(e)}") 

266 

267 except Exception as e: 

268 logger.warning(f"Error extracting epic information: {str(e)}") 

269 

270 return epic_info 

271 

272 def sanitize_html(self, html_content: str) -> str: 

273 """ 

274 Sanitize HTML content by removing HTML tags. 

275  

276 Args: 

277 html_content: HTML content to sanitize 

278  

279 Returns: 

280 Plaintext content with HTML tags removed 

281 """ 

282 if not html_content: 

283 return "" 

284 

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() 

292 

293 return plain_text 

294 

295 except Exception as e: 

296 logger.warning(f"Error sanitizing HTML: {str(e)}") 

297 return html_content 

298 

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. 

302  

303 This is used for transition data to properly format field values. 

304  

305 Args: 

306 fields: Dictionary of fields to sanitize 

307  

308 Returns: 

309 Dictionary of sanitized fields 

310 """ 

311 sanitized_fields = {} 

312 

313 for key, value in fields.items(): 

314 # Skip empty values 

315 if value is None: 

316 continue 

317 

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 

335 

336 return sanitized_fields 

337 

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. 

343  

344 Args: 

345 transition_data: Transition data dictionary 

346 comment: Comment text (in Markdown format) or None 

347  

348 Returns: 

349 Updated transition data 

350 """ 

351 if not comment: 

352 return transition_data 

353 

354 # Convert markdown to Jira format 

355 jira_formatted_comment = self.markdown_to_jira(comment) 

356 

357 # Add the comment to the transition data 

358 transition_data["update"] = { 

359 "comment": [{"add": {"body": jira_formatted_comment}}] 

360 } 

361 

362 return transition_data