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

1import logging 

2import os 

3import re 

4from datetime import datetime 

5from typing import Any 

6 

7from atlassian import Jira 

8 

9from .config import JiraConfig 

10from .document_types import Document 

11from .preprocessing import TextPreprocessor 

12 

13# Configure logging 

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

15 

16 

17class JiraFetcher: 

18 """Handles fetching and parsing content from Jira.""" 

19 

20 def __init__(self): 

21 url = os.getenv("JIRA_URL") 

22 username = os.getenv("JIRA_USERNAME") 

23 token = os.getenv("JIRA_API_TOKEN") 

24 

25 if not all([url, username, token]): 

26 raise ValueError("Missing required Jira environment variables") 

27 

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) 

36 

37 # Field IDs cache 

38 self._field_ids_cache: dict[str, str] = {} 

39 

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

48 

49 return self.preprocessor.clean_jira_text(text) 

50 

51 def _get_account_id(self, assignee: str) -> str: 

52 """ 

53 Get account ID from email or full name. 

54 

55 Args: 

56 assignee: Email, full name, or account ID of the user 

57 

58 Returns: 

59 Account ID of the user 

60 

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 

68 

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 ) 

81 

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

95 

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

101 

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

107 

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 

113 

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. 

125 

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 

133 

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 } 

143 

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

148 

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 

156 

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 

161 

162 # Add assignee if provided 

163 if assignee: 

164 account_id = self._get_account_id(assignee) 

165 fields["assignee"] = {"accountId": account_id} 

166 

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

173 

174 for key, value in kwargs.items(): 

175 fields[key] = value 

176 

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

180 

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

186 

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 

191 

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. 

195 

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 

200 

201 Returns: 

202 Document representing the updated issue 

203 """ 

204 if fields is None: 

205 fields = {} 

206 

207 # Handle all kwargs 

208 for key, value in kwargs.items(): 

209 fields[key] = value 

210 

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

214 

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) 

222 

223 logger.info(f"Status update requested to: {requested_status}") 

224 

225 # Get available transitions 

226 transitions = self.get_available_transitions(issue_key) 

227 

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 

235 

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 ) 

248 

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 

255 

256 def get_jira_field_ids(self) -> dict[str, str]: 

257 """ 

258 Dynamically discover Jira field IDs relevant to Epic linking. 

259 

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. 

262 

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 

271 

272 # Fetch all fields from Jira API 

273 fields = self.jira.fields() 

274 field_ids = {} 

275 

276 # Look for Epic-related fields 

277 for field in fields: 

278 field_name = field.get("name", "").lower() 

279 

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

283 

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

287 

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

291 

292 # Epic Status field 

293 elif "epic status" in field_name: 

294 field_ids["epic_status"] = field["id"] 

295 

296 # Epic Color field 

297 elif "epic colour" in field_name or "epic color" in field_name: 

298 field_ids["epic_color"] = field["id"] 

299 

300 # Cache the results for future use 

301 self._field_ids_cache = field_ids 

302 

303 logger.info(f"Discovered Jira field IDs: {field_ids}") 

304 return field_ids 

305 

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 {} 

310 

311 def link_issue_to_epic(self, issue_key: str, epic_key: str) -> Document: 

312 """ 

313 Link an existing issue to an epic. 

314 

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

318 

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']}") 

327 

328 # Get the dynamic field IDs for this Jira instance 

329 field_ids = self.get_jira_field_ids() 

330 

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

339 

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

348 

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 ] 

355 

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 

363 

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 ) 

368 

369 except Exception as e: 

370 logger.error(f"Error linking issue {issue_key} to epic {epic_key}: {str(e)}") 

371 raise 

372 

373 def delete_issue(self, issue_key: str) -> bool: 

374 """ 

375 Delete an existing issue. 

376 

377 Args: 

378 issue_key: The key of the issue (e.g. 'PROJ-123') 

379 

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 

389 

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

394 

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

404 

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 

411 

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. 

415 

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) 

420 

421 Returns: 

422 Document containing issue content and metadata 

423 """ 

424 try: 

425 issue = self.jira.issue(issue_key, expand=expand) 

426 

427 # Process description and comments 

428 description = self._clean_text(issue["fields"].get("description", "")) 

429 

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) 

434 

435 # Format created date using new parser 

436 created_date = self._parse_date(issue["fields"]["created"]) 

437 

438 # Check for Epic information 

439 epic_key = None 

440 epic_name = None 

441 

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

446 

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

458 

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

466 

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" 

473 

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 ) 

482 

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 } 

493 

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 

499 

500 if comments: 

501 metadata["comments"] = comments 

502 

503 return Document(page_content=content, metadata=metadata) 

504 

505 except Exception as e: 

506 logger.error(f"Error fetching issue {issue_key}: {str(e)}") 

507 raise 

508 

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

519 

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) 

526 

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 = [] 

533 

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

542 

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 } 

553 

554 # Prepare content 

555 content = desc if desc else f"{summary} [{status}]" 

556 

557 documents.append(Document(page_content=content, metadata=metadata)) 

558 

559 return documents 

560 except Exception as e: 

561 logger.error(f"Error searching issues with JQL '{jql}': {str(e)}") 

562 raise 

563 

564 def get_epic_issues(self, epic_key: str, limit: int = 50) -> list[Document]: 

565 """ 

566 Get all issues linked to a specific epic. 

567 

568 Args: 

569 epic_key: The key of the epic (e.g. 'PROJ-123') 

570 limit: Maximum number of issues to return 

571 

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']}") 

580 

581 # Get the dynamic field IDs for this Jira instance 

582 field_ids = self.get_jira_field_ids() 

583 

584 # Build JQL queries based on discovered field IDs 

585 jql_queries = [] 

586 

587 # Add queries based on discovered fields 

588 if "parent" in field_ids: 

589 jql_queries.append(f"parent = {epic_key}") 

590 

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}') 

595 

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 ) 

605 

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 

617 

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 ) 

624 

625 return documents 

626 

627 except Exception as e: 

628 logger.error(f"Error getting issues for epic {epic_key}: {str(e)}") 

629 raise 

630 

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. 

634 

635 Args: 

636 project_key: The project key 

637 start: Starting index 

638 limit: Maximum results to return 

639 

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) 

645 

646 def get_current_user_account_id(self) -> str: 

647 """ 

648 Get the account ID of the current user. 

649 

650 Returns: 

651 The account ID string of the current user 

652 

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

665 

666 def get_issue_comments(self, issue_key: str, limit: int = 50) -> list[dict]: 

667 """ 

668 Get comments for a specific issue. 

669 

670 Args: 

671 issue_key: The issue key (e.g. 'PROJ-123') 

672 limit: Maximum number of comments to return 

673 

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 = [] 

680 

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) 

690 

691 return processed_comments 

692 except Exception as e: 

693 logger.error(f"Error getting comments for issue {issue_key}: {str(e)}") 

694 raise 

695 

696 def add_comment(self, issue_key: str, comment: str) -> dict: 

697 """ 

698 Add a comment to an issue. 

699 

700 Args: 

701 issue_key: The issue key (e.g. 'PROJ-123') 

702 comment: Comment text to add (in Markdown format) 

703 

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) 

710 

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 

721 

722 def _markdown_to_jira(self, markdown_text: str) -> str: 

723 """ 

724 Convert Markdown syntax to Jira markup syntax. 

725 

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

737 

738 Args: 

739 markdown_text: Text in Markdown format 

740 

741 Returns: 

742 Text in Jira markup format 

743 """ 

744 if not markdown_text: 

745 return "" 

746 

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) 

755 

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 __ 

760 

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) 

765 

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 ) 

774 

775 # Simple code blocks without language 

776 jira_text = re.sub(r"```(.*?)```", r"{code}\1{code}", jira_text, flags=re.DOTALL) 

777 

778 # Inline code 

779 jira_text = re.sub(r"`(.+?)`", r"{{{\1}}}", jira_text) 

780 

781 # Links 

782 jira_text = re.sub(r"\[(.+?)\]\((.+?)\)", r"[\1|\2]", jira_text) 

783 

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 

787 

788 # Ordered lists - improved to handle multi-digit numbers 

789 jira_text = re.sub(r"^(\d+)\. (.+)$", r"# \2", jira_text, flags=re.MULTILINE) 

790 

791 # Blockquotes 

792 jira_text = re.sub(r"^> (.+)$", r"bq. \1", jira_text, flags=re.MULTILINE) 

793 

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) 

797 

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 

809 

810 jira_text = "\n".join(lines) 

811 

812 return jira_text 

813 

814 def get_available_transitions(self, issue_key: str) -> list[dict]: 

815 """ 

816 Get the available status transitions for an issue. 

817 

818 Args: 

819 issue_key: The issue key (e.g. 'PROJ-123') 

820 

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 = [] 

827 

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

839 

840 for transition in transitions: 

841 if not isinstance(transition, dict): 

842 continue 

843 

844 # Extract the transition information safely 

845 transition_id = transition.get("id") 

846 transition_name = transition.get("name") 

847 

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

856 

857 result.append({"id": transition_id, "name": transition_name, "to_status": to_status}) 

858 

859 return result 

860 except Exception as e: 

861 logger.error(f"Error getting transitions for issue {issue_key}: {str(e)}") 

862 raise 

863 

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. 

869 

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 

875 

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) 

886 

887 transition_data: dict[str, Any] = {"transition": {"id": transition_id}} 

888 

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 

897 

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 

910 

911 if sanitized_fields: 

912 transition_data["fields"] = sanitized_fields 

913 

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) 

919 

920 jira_formatted_comment = self._markdown_to_jira(comment) 

921 transition_data["update"] = {"comment": [{"add": {"body": jira_formatted_comment}}]} 

922 

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

926 

927 # Perform the transition 

928 self.jira.issue_transition(issue_key, transition_data) 

929 

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)