Coverage for src/mcp_atlassian/jira/formatting.py: 97%

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

2

3 import html

4 import logging

5 import re

6 from datetime import datetime

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

8

9 from ..document_types import Document

10 from .client import JiraClient

11

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

13

14

15 class 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}

72 Title: {issue["fields"].get("summary", "")}

73 Type: {issue["fields"]["issuetype"]["name"]}

74 Status: {issue["fields"]["status"]["name"]}

75 Created: {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"""

86 Description:

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