Coverage for src/mcp_atlassian/jira.py: 54%

588 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-10 01:37 +0900

1import logging 

2import os 

3import re 

4from datetime import datetime 

5from typing import Any 

6 

7import requests 

8from atlassian import Jira 

9 

10from .config import JiraConfig 

11from .document_types import Document 

12from .preprocessing import TextPreprocessor 

13 

14# Configure logging 

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

16 

17 

18class JiraFetcher: 

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

20 

21 def __init__(self) -> None: 

22 """Initialize the Jira client.""" 

23 url = os.getenv("JIRA_URL") 

24 

25 if not url: 

26 error_msg = "Missing required JIRA_URL environment variable" 

27 raise ValueError(error_msg) 

28 

29 # Initialize variables with default values 

30 username = "" 

31 token = "" 

32 personal_token = "" 

33 

34 # Determine if this is a cloud or server installation based on URL 

35 is_cloud = url.endswith(".atlassian.net") 

36 

37 if is_cloud: 

38 username = os.getenv("JIRA_USERNAME", "") 

39 token = os.getenv("JIRA_API_TOKEN", "") 

40 if not username or not token: 

41 error_msg = ( 

42 "Cloud authentication requires JIRA_USERNAME and JIRA_API_TOKEN" 

43 ) 

44 raise ValueError(error_msg) 

45 else: 

46 # Server/Data Center authentication uses a Personal Access Token 

47 personal_token = os.getenv("JIRA_PERSONAL_TOKEN", "") 

48 if not personal_token: 

49 error_msg = ( 

50 "Server/Data Center authentication requires JIRA_PERSONAL_TOKEN" 

51 ) 

52 raise ValueError(error_msg) 

53 

54 # For self-signed certificates in on-premise installations 

55 verify_ssl = os.getenv("JIRA_SSL_VERIFY", "true").lower() != "false" 

56 

57 self.config = JiraConfig( 

58 url=url, 

59 username=username, 

60 api_token=token, 

61 personal_token=personal_token, 

62 verify_ssl=verify_ssl, 

63 ) 

64 

65 # Initialize Jira client based on instance type 

66 if self.config.is_cloud: 

67 self.jira = Jira( 

68 url=self.config.url, 

69 username=self.config.username, 

70 password=self.config.api_token, # API token is used as password 

71 cloud=True, 

72 verify_ssl=self.config.verify_ssl, 

73 ) 

74 else: 

75 # For Server/Data Center, use token-based authentication 

76 # Note: The token param is used for Bearer token authentication 

77 # as per atlassian-python-api implementation 

78 self.jira = Jira( 

79 url=self.config.url, 

80 token=self.config.personal_token, 

81 cloud=False, 

82 verify_ssl=self.config.verify_ssl, 

83 ) 

84 

85 self.preprocessor = TextPreprocessor(self.config.url) 

86 

87 # Field IDs cache 

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

89 

90 def _clean_text(self, text: str) -> str: 

91 """ 

92 Clean text content by: 

93 1. Processing user mentions and links 

94 2. Converting HTML/wiki markup to markdown 

95 """ 

96 if not text: 

97 return "" 

98 

99 return self.preprocessor.clean_jira_text(text) 

100 

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

102 """ 

103 Convert a username or display name to an account ID. 

104 

105 Args: 

106 assignee: Username, email, or display name 

107 

108 Returns: 

109 The account ID string 

110 """ 

111 # Handle direct account ID assignment 

112 if assignee and assignee.startswith("accountid:"): 

113 return assignee.replace("accountid:", "") 

114 

115 try: 

116 # First try direct user lookup 

117 account_id = self._lookup_user_directly(assignee) 

118 if account_id: 

119 return account_id 

120 

121 # Fall back to project permission based search 

122 account_id = self._lookup_user_by_permissions(assignee) 

123 if account_id: 

124 return account_id 

125 

126 # If we get here, we couldn't find a user 

127 logger.warning(f"No user found matching '{assignee}'") 

128 error_msg = f"No user found matching '{assignee}'" 

129 raise ValueError(error_msg) 

130 

131 except Exception as e: 

132 logger.error(f"Error finding user '{assignee}': {str(e)}") 

133 error_msg = f"Could not resolve account ID for '{assignee}'" 

134 raise ValueError(error_msg) from e 

135 

136 def _lookup_user_directly(self, username: str) -> str | None: 

137 """ 

138 Look up a user directly by username or email. 

139 

140 Args: 

141 username: The username or email to look up 

142 

143 Returns: 

144 The account ID as a string if found, None otherwise 

145 """ 

146 try: 

147 users = self.jira.user(username) 

148 if isinstance(users, dict): 

149 users = [users] 

150 

151 account_id = users[0].get("accountId") if users else None 

152 if account_id: 

153 return str(account_id) # Ensure we return a string 

154 else: 

155 logger.warning( 

156 f"Direct user lookup failed for '{username}': " 

157 "user found but no account ID present" 

158 ) 

159 return None 

160 

161 except IndexError: 

162 logger.warning( 

163 f"Direct user lookup failed for '{username}': " 

164 "user result has unexpected format" 

165 ) 

166 except KeyError: 

167 logger.warning( 

168 f"Direct user lookup failed for '{username}': " 

169 "missing accountId in response" 

170 ) 

171 except (ValueError, TypeError) as e: 

172 logger.warning( 

173 f"Direct user lookup failed for '{username}': " 

174 f"invalid data format: {str(e)}" 

175 ) 

176 except requests.RequestException as e: 

177 logger.warning( 

178 f"Direct user lookup failed for '{username}': API error: {str(e)}" 

179 ) 

180 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

181 logger.warning( 

182 f"Direct user lookup failed for '{username}': " 

183 f"unexpected error: {str(e)}" 

184 ) 

185 logger.debug( 

186 f"Full exception details for user lookup '{username}':", exc_info=True 

187 ) 

188 

189 return None 

190 

191 def _lookup_user_by_permissions(self, username: str) -> str | None: 

192 """ 

193 Look up a user by checking project permissions. 

194 

195 Args: 

196 username: The username or email to look up 

197 

198 Returns: 

199 The account ID as a string if found, None otherwise 

200 """ 

201 users = self.jira.get_users_with_browse_permission_to_a_project( 

202 username=username 

203 ) 

204 

205 if not users: 

206 return None 

207 

208 # Return the first matching user's account ID 

209 account_id = users[0].get("accountId") 

210 if not account_id or not isinstance(account_id, str): 

211 logger.warning( 

212 f"Permission-based user lookup failed for '{username}': " 

213 "invalid string format in response" 

214 ) 

215 return None 

216 

217 logger.info(f"Found account ID via browse permission lookup: {account_id}") 

218 return str(account_id) # Explicit str conversion 

219 

220 def create_issue( 

221 self, 

222 project_key: str, 

223 summary: str, 

224 issue_type: str, 

225 description: str = "", 

226 assignee: str | None = None, 

227 **kwargs: Any, # noqa: ANN401 - Dynamic field types are necessary for Jira API 

228 ) -> Document: 

229 """ 

230 Create a new issue in Jira and return it as a Document. 

231 

232 Args: 

233 project_key: The key of the project (e.g. 'PROJ') 

234 summary: Summary of the issue 

235 issue_type: Issue type (e.g. 'Task', 'Bug', 'Story') 

236 description: Issue description 

237 assignee: Email, full name, or account ID of the user to assign the issue to 

238 kwargs: Any other custom Jira fields 

239 

240 Returns: 

241 Document representing the newly created issue 

242 

243 Raises: 

244 ValueError: If required fields for the issue type cannot be determined 

245 """ 

246 # Prepare base fields 

247 fields = { 

248 "project": {"key": project_key}, 

249 "summary": summary, 

250 "issuetype": {"name": issue_type}, 

251 "description": self._markdown_to_jira(description), 

252 } 

253 

254 # Handle epic-specific fields if needed 

255 if issue_type.lower() == "epic": 

256 self._prepare_epic_fields(fields, summary, kwargs) 

257 

258 # Add assignee if provided 

259 if assignee: 

260 self._add_assignee_to_fields(fields, assignee) 

261 

262 # Add any remaining custom fields 

263 self._add_custom_fields(fields, kwargs) 

264 

265 # Create the issue 

266 try: 

267 response = self.jira.create_issue(fields=fields) 

268 issue_key = response["key"] 

269 logger.info(f"Created issue {issue_key}") 

270 return self.get_issue(issue_key) 

271 except Exception as e: 

272 self._handle_create_issue_error(e, issue_type) 

273 raise 

274 

275 def _prepare_epic_fields( 

276 self, fields: dict[str, Any], summary: str, kwargs: dict[str, Any] 

277 ) -> None: 

278 """ 

279 Prepare epic-specific fields for issue creation. 

280 

281 Args: 

282 fields: The fields dictionary being prepared for issue creation 

283 summary: The issue summary that can be used as a default epic name 

284 kwargs: Additional fields provided by the caller 

285 """ 

286 try: 

287 # Get the dynamic field IDs 

288 field_ids = self.get_jira_field_ids() 

289 logger.info(f"Discovered Jira field IDs for Epic creation: {field_ids}") 

290 

291 # Handle Epic Name - might be required in some instances, not in others 

292 if "epic_name" in field_ids: 

293 epic_name = kwargs.pop( 

294 "epic_name", summary 

295 ) # Use summary as default if not provided 

296 fields[field_ids["epic_name"]] = epic_name 

297 logger.info( 

298 f"Setting Epic Name field {field_ids['epic_name']} to: {epic_name}" 

299 ) 

300 

301 # Handle Epic Color if the field was discovered 

302 if "epic_color" in field_ids: 

303 epic_color = ( 

304 kwargs.pop("epic_color", None) 

305 or kwargs.pop("epic_colour", None) 

306 or "green" 

307 ) 

308 fields[field_ids["epic_color"]] = epic_color 

309 logger.info( 

310 f"Setting Epic Color field {field_ids['epic_color']} " 

311 f"to: {epic_color}" 

312 ) 

313 

314 # Pass through any explicitly provided custom fields 

315 # that might be instance-specific 

316 for field_key, field_value in list(kwargs.items()): 

317 if field_key.startswith("customfield_"): 

318 fields[field_key] = field_value 

319 kwargs.pop(field_key) 

320 logger.info( 

321 f"Using explicitly provided custom field {field_key}: " 

322 f"{field_value}" 

323 ) 

324 

325 # Warn if epic_name field is required but wasn't discovered 

326 if "epic_name" not in field_ids: 

327 logger.warning( 

328 "Epic Name field not found in Jira schema. " 

329 "If your Jira instance requires it, please provide " 

330 "the customfield_* ID directly." 

331 ) 

332 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

333 logger.error(f"Error preparing Epic-specific fields: {str(e)}") 

334 # Continue with creation anyway, as some instances might not 

335 # require special fields 

336 

337 def _add_assignee_to_fields(self, fields: dict[str, Any], assignee: str) -> None: 

338 """ 

339 Add assignee information to the fields dictionary. 

340 

341 Args: 

342 fields: The fields dictionary being prepared for issue creation 

343 assignee: The assignee value to process 

344 """ 

345 account_id = self._get_account_id(assignee) 

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

347 

348 def _add_custom_fields( 

349 self, fields: dict[str, Any], kwargs: dict[str, Any] 

350 ) -> None: 

351 """ 

352 Add any remaining custom fields to the fields dictionary. 

353 

354 Args: 

355 fields: The fields dictionary being prepared for issue creation 

356 kwargs: Additional fields provided by the caller 

357 """ 

358 # Remove assignee from additional_fields if present to avoid conflicts 

359 if "assignee" in kwargs: 

360 logger.warning( 

361 "Assignee found in additional_fields - this will be ignored. " 

362 "Please use the assignee parameter instead." 

363 ) 

364 kwargs.pop("assignee") 

365 

366 # Add remaining kwargs to fields 

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

368 fields[key] = value 

369 

370 # Ensure description is in Jira format 

371 if "description" in fields and fields["description"]: 

372 fields["description"] = self._markdown_to_jira(fields["description"]) 

373 

374 def _handle_create_issue_error(self, exception: Exception, issue_type: str) -> None: 

375 """ 

376 Handle errors that occur during issue creation with better error messages. 

377 

378 Args: 

379 exception: The exception that was raised 

380 issue_type: The type of issue being created 

381 """ 

382 error_msg = str(exception) 

383 

384 # Provide more helpful error messages for common issues 

385 if issue_type.lower() == "epic" and "customfield_" in error_msg: 

386 # Handle the case where a specific Epic field is required but missing 

387 missing_field_match = re.search( 

388 r"(?:Field '(customfield_\d+)'|'(customfield_\d+)' cannot be set)", 

389 error_msg, 

390 ) 

391 if missing_field_match: 

392 field_id = missing_field_match.group(1) or missing_field_match.group(2) 

393 logger.error( 

394 f"Failed to create Epic: Your Jira instance requires field " 

395 f"'{field_id}'. " 

396 "This is typically the Epic Name field. Try setting this field " 

397 "explicitly using " 

398 f"'{field_id}': 'Epic Name Value' in the " 

399 "additional_fields parameter." 

400 ) 

401 else: 

402 logger.error( 

403 "Failed to create Epic: Your Jira instance has custom field " 

404 "requirements. You may need to provide specific custom fields " 

405 f"for Epics in your instance. Original error: {error_msg}" 

406 ) 

407 else: 

408 logger.error(f"Error creating issue: {error_msg}") 

409 

410 def update_issue( 

411 self, 

412 issue_key: str, 

413 fields: dict[str, Any] | None = None, 

414 **kwargs: Any, # noqa: ANN401 - Dynamic field types are necessary for Jira API 

415 ) -> Document: 

416 """ 

417 Update an existing Jira issue. 

418 

419 Args: 

420 issue_key: The key of the issue to update 

421 fields: Fields to update in the Jira API format 

422 **kwargs: Additional fields to update 

423 

424 Returns: 

425 Document with updated issue info 

426 """ 

427 # Ensure we have a fields dictionary 

428 if fields is None: 

429 fields = {} 

430 

431 # Process any custom fields passed via kwargs 

432 if kwargs: 

433 # Combine any fields that might be in kwargs into our fields dict 

434 self._add_custom_fields(fields, kwargs) 

435 

436 # Check if status is being updated 

437 if "status" in fields: 

438 return self._update_issue_with_status(issue_key, fields) 

439 

440 # Regular update (no status change) 

441 try: 

442 logger.info(f"Updating issue {issue_key} with fields {fields}") 

443 self.jira.issue_update(issue_key, fields=fields) 

444 # Return the updated issue 

445 return self.get_issue(issue_key) 

446 except Exception as e: 

447 error_msg = f"Error updating issue {issue_key}: {str(e)}" 

448 logger.error(error_msg) 

449 raise 

450 

451 def _update_issue_with_status( 

452 self, issue_key: str, fields: dict[str, Any] 

453 ) -> Document: 

454 """ 

455 Update an issue that includes a status change, using transitions. 

456 

457 Args: 

458 issue_key: The key of the issue to update 

459 fields: Fields to update, including status 

460 

461 Returns: 

462 Document with updated issue info 

463 """ 

464 target_status = fields.pop("status") 

465 logger.info( 

466 f"Updating issue {issue_key} with status change to '{target_status}'" 

467 ) 

468 

469 # Get available transitions 

470 transitions = self.jira.get_issue_transitions(issue_key) 

471 

472 # Find the transition that matches the target status 

473 transition_id = None 

474 for transition in transitions.get("transitions", []): 

475 if ( 

476 transition.get("to", {}).get("name", "").lower() 

477 == target_status.lower() 

478 ): 

479 transition_id = transition["id"] 

480 break 

481 

482 if not transition_id: 

483 error_msg = ( 

484 f"No transition found for status '{target_status}' on issue {issue_key}" 

485 ) 

486 logger.error(error_msg) 

487 raise ValueError(error_msg) 

488 

489 # Create transition data 

490 transition_data = {"transition": {"id": transition_id}} 

491 

492 # Add remaining fields if any 

493 if fields: 

494 transition_data["fields"] = fields 

495 

496 # Execute the transition 

497 self.jira.issue_transition(issue_key, transition_data) 

498 

499 # Return the updated issue 

500 return self.get_issue(issue_key) 

501 

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

503 """ 

504 Dynamically discover Jira field IDs relevant to Epic linking. 

505 

506 This method queries the Jira API to find the correct custom field IDs 

507 for Epic-related fields, which can vary between different Jira instances. 

508 

509 Returns: 

510 Dictionary mapping field names to their IDs 

511 (e.g., {'epic_link': 'customfield_10014', 'epic_name': 'customfield_10011'}) 

512 """ 

513 try: 

514 # Check if we've already cached the field IDs 

515 cached_fields = self._get_cached_field_ids() 

516 if cached_fields: 

517 return cached_fields 

518 

519 # Fetch all fields from Jira API 

520 fields = self.jira.fields() 

521 field_ids: dict[str, str] = {} 

522 

523 # Log all fields for debugging 

524 self._log_available_fields(fields) 

525 

526 # Process each field to identify Epic-related fields 

527 for field in fields: 

528 self._process_field_for_epic_data(field, field_ids) 

529 

530 # Cache the results for future use 

531 self._field_ids_cache = field_ids 

532 

533 # If we couldn't find certain key fields, try alternative approaches 

534 if "epic_name" not in field_ids or "epic_link" not in field_ids: 

535 logger.warning( 

536 "Could not find all essential Epic fields through schema. " 

537 "This may cause issues with Epic operations." 

538 ) 

539 

540 # Try to find fields by looking at an existing Epic if possible 

541 self._try_discover_fields_from_existing_epic(field_ids) 

542 

543 return field_ids 

544 

545 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

546 logger.error(f"Error discovering Jira field IDs: {str(e)}") 

547 # Return an empty dict as fallback 

548 return {} 

549 

550 def _get_cached_field_ids(self) -> dict[str, str]: 

551 """ 

552 Retrieve cached field IDs if available. 

553 

554 Returns: 

555 Dictionary of cached field IDs or empty dict if no cache exists 

556 """ 

557 if hasattr(self, "_field_ids_cache"): 

558 return self._field_ids_cache 

559 return {} 

560 

561 def _log_available_fields(self, fields: list[dict]) -> None: 

562 """ 

563 Log all available Jira fields for debugging purposes. 

564 

565 Args: 

566 fields: List of field definitions from Jira API 

567 """ 

568 all_field_names = [ 

569 f"{field.get('name', '')} ({field.get('id', '')})" for field in fields 

570 ] 

571 logger.debug(f"All available Jira fields: {all_field_names}") 

572 

573 def _process_field_for_epic_data( 

574 self, field: dict, field_ids: dict[str, str] 

575 ) -> None: 

576 """ 

577 Process a single field to identify if it's an Epic-related field 

578 and add to field_ids. 

579 

580 Args: 

581 field: Field definition from Jira API 

582 field_ids: Dictionary to update with identified fields 

583 """ 

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

585 original_name = field.get("name", "") 

586 field_id = field.get("id", "") 

587 field_schema = field.get("schema", {}) 

588 field_type = field_schema.get("type", "") 

589 field_custom = field_schema.get("custom", "") 

590 

591 # Epic Link field - used to link issues to epics 

592 if ( 

593 "epic link" in field_name 

594 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-link" 

595 or field_type == "any" 

596 ) and field_id: 

597 self.epic_link_field_id = field_id 

598 field_ids["epic_link"] = field_id 

599 logger.info(f"Found Epic Link field: {original_name} ({field_id})") 

600 

601 # Epic Name field - used for the title of epics 

602 if ( 

603 "epic name" in field_name 

604 or "epic-name" in field_name 

605 or original_name == "Epic Name" 

606 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-label" 

607 ): 

608 field_ids["epic_name"] = field_id 

609 logger.info(f"Found Epic Name field: {original_name} ({field_id})") 

610 

611 # Parent field - sometimes used instead of Epic Link 

612 elif ( 

613 field_name == "parent" 

614 or field_name == "parent link" 

615 or original_name == "Parent Link" 

616 ): 

617 field_ids["parent"] = field_id 

618 logger.info(f"Found Parent field: {original_name} ({field_id})") 

619 

620 # Epic Status field 

621 elif "epic status" in field_name or original_name == "Epic Status": 

622 field_ids["epic_status"] = field_id 

623 logger.info(f"Found Epic Status field: {original_name} ({field_id})") 

624 

625 # Epic Color field 

626 elif ( 

627 "epic colour" in field_name 

628 or "epic color" in field_name 

629 or original_name == "Epic Colour" 

630 or original_name == "Epic Color" 

631 or field_custom == "com.pyxis.greenhopper.jira:gh-epic-color" 

632 ): 

633 field_ids["epic_color"] = field_id 

634 logger.info(f"Found Epic Color field: {original_name} ({field_id})") 

635 

636 # Try to detect any other fields that might be related to Epics 

637 elif ("epic" in field_name or "epic" in field_custom) and not any( 

638 k in field_ids.values() for k in [field_id] 

639 ): 

640 key = f"epic_{field_name.replace(' ', '_')}" 

641 field_ids[key] = field_id 

642 logger.info( 

643 f"Found additional Epic-related field: {original_name} ({field_id})" 

644 ) 

645 

646 def _try_discover_fields_from_existing_epic(self, field_ids: dict) -> None: 

647 """ 

648 Attempt to discover Epic fields by examining an existing Epic issue. 

649 This is a fallback method when we can't find fields through the schema. 

650 

651 Args: 

652 field_ids: Existing field_ids dictionary to update 

653 """ 

654 try: 

655 # Find an Epic in the system 

656 epics_jql = "issuetype = Epic ORDER BY created DESC" 

657 results = self.jira.jql(epics_jql, limit=1) 

658 

659 if not results.get("issues"): 

660 logger.warning("No existing Epics found to analyze field structure") 

661 return 

662 

663 epic = results["issues"][0] 

664 epic_key = epic.get("key") 

665 

666 logger.info( 

667 f"Analyzing existing Epic {epic_key} to discover field structure" 

668 ) 

669 

670 # Examine the fields of this Epic 

671 fields = epic.get("fields", {}) 

672 for field_id, field_value in fields.items(): 

673 if field_id.startswith("customfield_") and field_value is not None: 

674 # Look for fields with non-null values that might be Epic-related 

675 if ( 

676 "epic_name" not in field_ids 

677 and isinstance(field_value, str) 

678 and field_id not in field_ids.values() 

679 ): 

680 logger.info( 

681 f"Potential Epic Name field discovered: {field_id} " 

682 f"with value {field_value}" 

683 ) 

684 if len(field_value) < 100: # Epic names are typically short 

685 field_ids["epic_name"] = field_id 

686 

687 # Color values are often simple strings like "green", "blue", etc. 

688 if ( 

689 "epic_color" not in field_ids 

690 and isinstance(field_value, str) 

691 and field_id not in field_ids.values() 

692 ): 

693 colors = [ 

694 "green", 

695 "blue", 

696 "red", 

697 "yellow", 

698 "orange", 

699 "purple", 

700 "gray", 

701 "grey", 

702 "teal", 

703 ] 

704 if field_value.lower() in colors: 

705 logger.info( 

706 f"Potential Epic Color field discovered: {field_id} " 

707 f"with value {field_value}" 

708 ) 

709 field_ids["epic_color"] = field_id 

710 

711 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

712 logger.warning( 

713 f"Could not discover Epic fields from existing Epics: {str(e)}" 

714 ) 

715 

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

717 """ 

718 Link an issue to an epic. 

719 

720 Args: 

721 issue_key: The key of the issue to link 

722 epic_key: The key of the epic to link to 

723 

724 Returns: 

725 Document with updated issue info 

726 """ 

727 # Try to get the field IDs - if we haven't initialized them yet 

728 field_ids = self.get_jira_field_ids() 

729 

730 # Check if we've identified the epic link field 

731 if not field_ids.get("Epic Link"): 

732 logger.error("Cannot link issue to epic: Epic Link field not found") 

733 # Try to discover the fields by examining an existing epic 

734 self._try_discover_fields_from_existing_epic(field_ids) 

735 

736 # Multiple attempts to link the issue using different field names 

737 attempts = [ 

738 # Standard Jira Software method 

739 lambda: self.update_issue( 

740 issue_key, 

741 fields={ 

742 k: epic_key for k in [field_ids.get("Epic Link")] if k is not None 

743 }, 

744 ), 

745 # Advanced Roadmaps method using Epic Name 

746 lambda: self.update_issue( 

747 issue_key, 

748 fields={ 

749 k: epic_key for k in [field_ids.get("Epic Name")] if k is not None 

750 }, 

751 ), 

752 # Using the custom field directly 

753 lambda: self.update_issue( 

754 issue_key, fields={"customfield_10014": epic_key} 

755 ), 

756 ] 

757 

758 # Try each method 

759 for attempt_fn in attempts: 

760 try: 

761 return attempt_fn() 

762 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

763 logger.error( 

764 f"Failed to link issue {issue_key} to epic {epic_key}: {str(e)}" 

765 ) 

766 

767 # If we get here, none of our attempts worked 

768 error_msg = ( 

769 f"Couldn't link issue {issue_key} to epic {epic_key}. " 

770 "Your Jira instance might use a different field for epic links." 

771 ) 

772 raise ValueError(error_msg) 

773 

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

775 """ 

776 Delete an existing issue. 

777 

778 Args: 

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

780 

781 Returns: 

782 True if delete succeeded, otherwise raise an exception 

783 """ 

784 try: 

785 self.jira.delete_issue(issue_key) 

786 return True 

787 except Exception as e: 

788 logger.error(f"Error deleting issue {issue_key}: {str(e)}") 

789 raise 

790 

791 def _parse_date(self, date_str: str) -> str: 

792 """ 

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

794 

795 Args: 

796 date_str: The date string to parse 

797 

798 Returns: 

799 Formatted date string 

800 """ 

801 # Handle various formats of date strings from Jira 

802 try: 

803 date = datetime.fromisoformat(date_str.replace("Z", "+00:00")) 

804 return date.strftime("%Y-%m-%d") 

805 except ValueError as e: 

806 # This handles parsing errors in the date format 

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

808 return date_str 

809 except AttributeError as e: 

810 # This handles cases where date_str isn't a string 

811 logger.warning(f"Invalid date type {type(date_str)}: {e}") 

812 return str(date_str) 

813 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

814 logger.warning(f"Error parsing date {date_str}: {e}") 

815 logger.debug("Full exception details for date parsing:", exc_info=True) 

816 return date_str 

817 

818 def get_issue( 

819 self, 

820 issue_key: str, 

821 expand: str | None = None, 

822 comment_limit: int | str | None = 10, 

823 ) -> Document: 

824 """ 

825 Get a single issue with all its details. 

826 

827 Args: 

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

829 expand: Optional fields to expand 

830 comment_limit: Maximum number of comments to include 

831 (None for no comments, defaults to 10) 

832 Can be an integer or a string that can be converted 

833 to an integer. 

834 

835 Returns: 

836 Document containing issue content and metadata 

837 """ 

838 try: 

839 # Fetch the issue from Jira 

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

841 

842 # Process and normalize the comment limit 

843 comment_limit = self._normalize_comment_limit(comment_limit) 

844 

845 # Get the issue description and comments 

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

847 comments = self._get_issue_comments_if_needed(issue_key, comment_limit) 

848 

849 # Get Epic information if applicable 

850 epic_info = self._extract_epic_information(issue) 

851 

852 # Format the created date properly 

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

854 

855 # Generate the content for the document 

856 content = self._format_issue_content( 

857 issue_key, issue, description, comments, created_date, epic_info 

858 ) 

859 

860 # Create the metadata for the document 

861 metadata = self._create_issue_metadata( 

862 issue_key, issue, comments, created_date, epic_info 

863 ) 

864 

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

866 

867 except Exception as e: 

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

869 raise 

870 

871 def _normalize_comment_limit(self, comment_limit: int | str | None) -> int | None: 

872 """ 

873 Convert comment_limit to int if it's a string. 

874 

875 Args: 

876 comment_limit: The comment limit value to normalize 

877 

878 Returns: 

879 Normalized comment limit as int or None 

880 """ 

881 if comment_limit is not None and isinstance(comment_limit, str): 

882 try: 

883 return int(comment_limit) 

884 except ValueError: 

885 logger.warning( 

886 f"Invalid comment_limit value: {comment_limit}. " 

887 "Using default of 10." 

888 ) 

889 return 10 

890 return comment_limit 

891 

892 def _get_issue_comments_if_needed( 

893 self, issue_key: str, comment_limit: int | None 

894 ) -> list[dict]: 

895 """ 

896 Get comments for an issue if a valid limit is specified. 

897 

898 Args: 

899 issue_key: The issue key to get comments for 

900 comment_limit: Maximum number of comments to get 

901 

902 Returns: 

903 List of comment dictionaries or empty list if no comments needed 

904 """ 

905 if comment_limit is not None and comment_limit > 0: 

906 return self.get_issue_comments(issue_key, limit=comment_limit) 

907 return [] 

908 

909 def _extract_epic_information(self, issue: dict) -> dict[str, str | None]: 

910 """ 

911 Extract epic information from issue data. 

912 

913 Args: 

914 issue: Issue data from Jira API 

915 

916 Returns: 

917 Dictionary with epic_key and epic_name 

918 """ 

919 epic_info: dict[str, str | None] = {"epic_key": None, "epic_name": None} 

920 

921 # Try both "Epic Link" and "Parent" 

922 if "customfield_10014" in issue["fields"]: 

923 epic_info["epic_key"] = issue["fields"]["customfield_10014"] 

924 elif ( 

925 "parent" in issue["fields"] 

926 and issue["fields"]["parent"]["fields"]["issuetype"]["name"] == "Epic" 

927 ): 

928 epic_info["epic_key"] = issue["fields"]["parent"]["key"] 

929 epic_info["epic_name"] = issue["fields"]["parent"]["fields"]["summary"] 

930 

931 # Look for Epic Name if we have an Epic Key but no name yet 

932 if epic_info["epic_key"] and not epic_info["epic_name"]: 

933 try: 

934 epic = self.jira.issue(epic_info["epic_key"]) 

935 epic_info["epic_name"] = epic["fields"]["summary"] 

936 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

937 logger.warning(f"Error fetching epic details: {str(e)}") 

938 

939 return epic_info 

940 

941 def _format_issue_content( 

942 self, 

943 issue_key: str, 

944 issue: dict, 

945 description: str, 

946 comments: list[dict], 

947 created_date: str, 

948 epic_info: dict[str, str | None], 

949 ) -> str: 

950 """ 

951 Format the issue content for display. 

952 

953 Args: 

954 issue_key: The issue key 

955 issue: The issue data from Jira 

956 description: Processed description text 

957 comments: List of comment dictionaries 

958 created_date: Formatted created date 

959 epic_info: Dictionary with epic_key and epic_name 

960 

961 Returns: 

962 Formatted content string 

963 """ 

964 # Basic issue information 

965 content = f"""Issue: {issue_key} 

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

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

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

969Created: {created_date} 

970""" 

971 

972 # Add Epic information if available 

973 if epic_info["epic_key"]: 

974 content += f"Epic: {epic_info['epic_key']}" 

975 if epic_info["epic_name"]: 

976 content += f" - {epic_info['epic_name']}" 

977 content += "\n" 

978 

979 content += f""" 

980Description: 

981{description} 

982""" 

983 # Add comments if present 

984 if comments: 

985 content += "\nComments:\n" + "\n".join( 

986 [f"{c['created']} - {c['author']}: {c['body']}" for c in comments] 

987 ) 

988 

989 return content 

990 

991 def _create_issue_metadata( 

992 self, 

993 issue_key: str, 

994 issue: dict, 

995 comments: list[dict], 

996 created_date: str, 

997 epic_info: dict[str, str | None], 

998 ) -> dict[str, Any]: 

999 """ 

1000 Create metadata for the issue document. 

1001 

1002 Args: 

1003 issue_key: The issue key 

1004 issue: The issue data from Jira 

1005 comments: List of comment dictionaries 

1006 created_date: Formatted created date 

1007 epic_info: Dictionary with epic_key and epic_name 

1008 

1009 Returns: 

1010 Metadata dictionary 

1011 """ 

1012 # Basic metadata 

1013 metadata = { 

1014 "key": issue_key, 

1015 "title": issue["fields"].get("summary", ""), 

1016 "type": issue["fields"]["issuetype"]["name"], 

1017 "status": issue["fields"]["status"]["name"], 

1018 "created_date": created_date, 

1019 "priority": issue["fields"].get("priority", {}).get("name", "None"), 

1020 "link": f"{self.config.url.rstrip('/')}/browse/{issue_key}", 

1021 } 

1022 

1023 # Add Epic information to metadata if available 

1024 if epic_info["epic_key"]: 

1025 metadata["epic_key"] = epic_info["epic_key"] 

1026 if epic_info["epic_name"]: 

1027 metadata["epic_name"] = epic_info["epic_name"] 

1028 

1029 # Add comments to metadata if present 

1030 if comments: 

1031 metadata["comments"] = comments 

1032 

1033 return metadata 

1034 

1035 def search_issues( 

1036 self, 

1037 jql: str, 

1038 fields: str = "*all", 

1039 start: int = 0, 

1040 limit: int = 50, 

1041 expand: str | None = None, 

1042 ) -> list[Document]: 

1043 """ 

1044 Search for issues using JQL (Jira Query Language). 

1045 

1046 Args: 

1047 jql: JQL query string 

1048 fields: Fields to return (comma-separated string or "*all") 

1049 start: Starting index 

1050 limit: Maximum issues to return 

1051 expand: Optional items to expand (comma-separated) 

1052 

1053 Returns: 

1054 List of Documents representing the search results 

1055 """ 

1056 try: 

1057 issues = self.jira.jql( 

1058 jql, fields=fields, start=start, limit=limit, expand=expand 

1059 ) 

1060 documents = [] 

1061 

1062 for issue in issues.get("issues", []): 

1063 issue_key = issue["key"] 

1064 fields_data = issue.get("fields", {}) 

1065 

1066 # Safely handle fields that might not be included in the response 

1067 summary = fields_data.get("summary", "") 

1068 

1069 # Handle issuetype field with fallback to "Unknown" if missing 

1070 issue_type = "Unknown" 

1071 issuetype_data = fields_data.get("issuetype") 

1072 if issuetype_data is not None: 

1073 issue_type = issuetype_data.get("name", "Unknown") 

1074 

1075 # Handle status field with fallback to "Unknown" if missing 

1076 status = "Unknown" 

1077 status_data = fields_data.get("status") 

1078 if status_data is not None: 

1079 status = status_data.get("name", "Unknown") 

1080 

1081 # Process description field 

1082 description = fields_data.get("description") 

1083 desc = self._clean_text(description) if description is not None else "" 

1084 

1085 # Process created date field 

1086 created_date = "" 

1087 created = fields_data.get("created") 

1088 if created is not None: 

1089 created_date = self._parse_date(created) 

1090 

1091 # Process priority field 

1092 priority = "None" 

1093 priority_data = fields_data.get("priority") 

1094 if priority_data is not None: 

1095 priority = priority_data.get("name", "None") 

1096 

1097 # Add basic metadata 

1098 metadata = { 

1099 "key": issue_key, 

1100 "title": summary, 

1101 "type": issue_type, 

1102 "status": status, 

1103 "created_date": created_date, 

1104 "priority": priority, 

1105 "link": f"{self.config.url.rstrip('/')}/browse/{issue_key}", 

1106 } 

1107 

1108 # Prepare content 

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

1110 

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

1112 

1113 return documents 

1114 except Exception as e: 

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

1116 raise 

1117 

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

1119 """ 

1120 Get all issues linked to a specific epic. 

1121 

1122 Args: 

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

1124 limit: Maximum number of issues to return 

1125 

1126 Returns: 

1127 List of Documents representing the issues linked to the epic 

1128 """ 

1129 try: 

1130 # First, check if the issue is an Epic 

1131 epic = self.jira.issue(epic_key) 

1132 fields_data = epic.get("fields", {}) 

1133 

1134 # Safely check if the issue is an Epic 

1135 issue_type = None 

1136 issuetype_data = fields_data.get("issuetype") 

1137 if issuetype_data is not None: 

1138 issue_type = issuetype_data.get("name", "") 

1139 

1140 if issue_type != "Epic": 

1141 error_msg = ( 

1142 f"Issue {epic_key} is not an Epic, it is a " 

1143 f"{issue_type or 'unknown type'}" 

1144 ) 

1145 raise ValueError(error_msg) 

1146 

1147 # Get the dynamic field IDs for this Jira instance 

1148 field_ids = self.get_jira_field_ids() 

1149 

1150 # Build JQL queries based on discovered field IDs 

1151 jql_queries = [] 

1152 

1153 # Add queries based on discovered fields 

1154 if "parent" in field_ids: 

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

1156 

1157 if "epic_link" in field_ids: 

1158 field_name = field_ids["epic_link"] 

1159 jql_queries.append(f'"{field_name}" = {epic_key}') 

1160 jql_queries.append(f'"{field_name}" ~ {epic_key}') 

1161 

1162 # Add standard fallback queries 

1163 jql_queries.extend( 

1164 [ 

1165 f"parent = {epic_key}", # Common in most instances 

1166 f"'Epic Link' = {epic_key}", # Some instances 

1167 f"'Epic' = {epic_key}", # Some instances 

1168 f"issue in childIssuesOf('{epic_key}')", # Some instances 

1169 ] 

1170 ) 

1171 

1172 # Try each query until we get results or run out of options 

1173 documents = [] 

1174 for jql in jql_queries: 

1175 try: 

1176 logger.info(f"Trying to get epic issues with JQL: {jql}") 

1177 documents = self.search_issues(jql, limit=limit) 

1178 if documents: 

1179 return documents 

1180 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

1181 logger.info(f"Failed to get epic issues with JQL '{jql}': {str(e)}") 

1182 continue 

1183 

1184 # If we've tried all queries and got no results, return an empty list 

1185 # but also log a warning that we might be missing the right field 

1186 if not documents: 

1187 logger.warning( 

1188 f"Couldn't find issues linked to epic {epic_key}. " 

1189 "Your Jira instance might use a different field for epic links." 

1190 ) 

1191 

1192 return documents 

1193 

1194 except Exception as e: 

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

1196 raise 

1197 

1198 def get_project_issues( 

1199 self, project_key: str, start: int = 0, limit: int = 50 

1200 ) -> list[Document]: 

1201 """ 

1202 Get all issues for a project. 

1203 

1204 Args: 

1205 project_key: The project key 

1206 start: Starting index 

1207 limit: Maximum results to return 

1208 

1209 Returns: 

1210 List of Documents containing project issues 

1211 """ 

1212 jql = f"project = {project_key} ORDER BY created DESC" 

1213 return self.search_issues(jql, start=start, limit=limit) 

1214 

1215 def get_current_user_account_id(self) -> str: 

1216 """ 

1217 Get the account ID of the current user. 

1218 

1219 Returns: 

1220 String with the account ID 

1221 """ 

1222 try: 

1223 user = self.jira.myself() 

1224 account_id = user.get("accountId") 

1225 if not account_id: 

1226 error_msg = "No account ID found in user profile" 

1227 raise ValueError(error_msg) 

1228 return str(account_id) # Ensure we return a string 

1229 except Exception as e: 

1230 logger.error(f"Error getting current user account ID: {str(e)}") 

1231 error_msg = f"Failed to get current user account ID: {str(e)}" 

1232 raise ValueError(error_msg) from e 

1233 

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

1235 """ 

1236 Get comments for a specific issue. 

1237 

1238 Args: 

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

1240 limit: Maximum number of comments to return 

1241 

1242 Returns: 

1243 List of comments with author, creation date, and content 

1244 """ 

1245 try: 

1246 comments = self.jira.issue_get_comments(issue_key) 

1247 processed_comments = [] 

1248 

1249 for comment in comments.get("comments", [])[:limit]: 

1250 processed_comment = { 

1251 "id": comment.get("id"), 

1252 "body": self._clean_text(comment.get("body", "")), 

1253 "created": self._parse_date(comment.get("created")), 

1254 "updated": self._parse_date(comment.get("updated")), 

1255 "author": comment.get("author", {}).get("displayName", "Unknown"), 

1256 } 

1257 processed_comments.append(processed_comment) 

1258 

1259 return processed_comments 

1260 except Exception as e: 

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

1262 raise 

1263 

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

1265 """ 

1266 Add a comment to an issue. 

1267 

1268 Args: 

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

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

1271 

1272 Returns: 

1273 The created comment details 

1274 """ 

1275 try: 

1276 # Convert Markdown to Jira's markup format 

1277 jira_formatted_comment = self._markdown_to_jira(comment) 

1278 

1279 result = self.jira.issue_add_comment(issue_key, jira_formatted_comment) 

1280 return { 

1281 "id": result.get("id"), 

1282 "body": self._clean_text(result.get("body", "")), 

1283 "created": self._parse_date(result.get("created")), 

1284 "author": result.get("author", {}).get("displayName", "Unknown"), 

1285 } 

1286 except Exception as e: 

1287 logger.error(f"Error adding comment to issue {issue_key}: {str(e)}") 

1288 raise 

1289 

1290 def _parse_time_spent(self, time_spent: str) -> int: 

1291 """ 

1292 Parse time spent string into seconds. 

1293 

1294 Args: 

1295 time_spent: Time spent string (e.g. 1h 30m, 1d, etc.) 

1296 

1297 Returns: 

1298 Time spent in seconds 

1299 """ 

1300 # Base case for direct specification in seconds 

1301 if time_spent.endswith("s"): 

1302 try: 

1303 return int(time_spent[:-1]) 

1304 except ValueError: 

1305 pass 

1306 

1307 total_seconds = 0 

1308 time_units = { 

1309 "w": 7 * 24 * 60 * 60, # weeks to seconds 

1310 "d": 24 * 60 * 60, # days to seconds 

1311 "h": 60 * 60, # hours to seconds 

1312 "m": 60, # minutes to seconds 

1313 } 

1314 

1315 # Regular expression to find time components like 1w, 2d, 3h, 4m 

1316 pattern = r"(\d+)([wdhm])" 

1317 matches = re.findall(pattern, time_spent) 

1318 

1319 for value, unit in matches: 

1320 # Convert value to int and multiply by the unit in seconds 

1321 seconds = int(value) * time_units[unit] 

1322 total_seconds += seconds 

1323 

1324 if total_seconds == 0: 

1325 # If we couldn't parse anything, try using the raw value 

1326 try: 

1327 return int(float(time_spent)) # Convert to float first, then to int 

1328 except ValueError: 

1329 # If all else fails, default to 60 seconds (1 minute) 

1330 logger.warning( 

1331 f"Could not parse time: {time_spent}, defaulting to 60 seconds" 

1332 ) 

1333 return 60 

1334 

1335 return total_seconds 

1336 

1337 def add_worklog( 

1338 self, 

1339 issue_key: str, 

1340 time_spent: str, 

1341 comment: str | None = None, 

1342 started: str | None = None, 

1343 original_estimate: str | None = None, 

1344 remaining_estimate: str | None = None, 

1345 ) -> dict: 

1346 """ 

1347 Add a worklog to an issue with optional estimate updates. 

1348 

1349 Args: 

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

1351 time_spent: Time spent in Jira format (e.g., '1h 30m', '1d', '30m') 

1352 comment: Optional comment for the worklog (in Markdown format) 

1353 started: Optional start time in ISO format 

1354 (e.g. '2023-08-01T12:00:00.000+0000'). 

1355 If not provided, current time will be used. 

1356 original_estimate: Optional original estimate in Jira format 

1357 (e.g., '1h 30m', '1d') 

1358 This will update the original estimate for the issue. 

1359 remaining_estimate: Optional remaining estimate in Jira format 

1360 (e.g., '1h', '30m') 

1361 This will update the remaining estimate for the issue. 

1362 

1363 Returns: 

1364 The created worklog details 

1365 """ 

1366 try: 

1367 # Convert time_spent string to seconds 

1368 time_spent_seconds = self._parse_time_spent(time_spent) 

1369 

1370 # Convert Markdown comment to Jira format if provided 

1371 if comment: 

1372 comment = self._markdown_to_jira(comment) 

1373 

1374 # Step 1: Update original estimate if provided (separate API call) 

1375 original_estimate_updated = False 

1376 if original_estimate: 

1377 try: 

1378 fields = {"timetracking": {"originalEstimate": original_estimate}} 

1379 self.jira.edit_issue(issue_id_or_key=issue_key, fields=fields) 

1380 original_estimate_updated = True 

1381 logger.info(f"Updated original estimate for issue {issue_key}") 

1382 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

1383 logger.error( 

1384 f"Failed to update original estimate for issue {issue_key}: " 

1385 f"{str(e)}" 

1386 ) 

1387 # Continue with worklog creation even if estimate update fails 

1388 

1389 # Step 2: Prepare worklog data 

1390 worklog_data = {"timeSpentSeconds": time_spent_seconds} 

1391 if comment: 

1392 worklog_data["comment"] = comment 

1393 if started: 

1394 worklog_data["started"] = started 

1395 

1396 # Step 3: Prepare query parameters for remaining estimate 

1397 params = {} 

1398 remaining_estimate_updated = False 

1399 if remaining_estimate: 

1400 params["adjustEstimate"] = "new" 

1401 params["newEstimate"] = remaining_estimate 

1402 remaining_estimate_updated = True 

1403 

1404 # Step 4: Add the worklog with remaining estimate adjustment 

1405 base_url = self.jira.resource_url("issue") 

1406 url = f"{base_url}/{issue_key}/worklog" 

1407 result = self.jira.post(url, data=worklog_data, params=params) 

1408 

1409 # Format and return the result 

1410 return { 

1411 "id": result.get("id"), 

1412 "comment": self._clean_text(result.get("comment", "")), 

1413 "created": self._parse_date(result.get("created", "")), 

1414 "updated": self._parse_date(result.get("updated", "")), 

1415 "started": self._parse_date(result.get("started", "")), 

1416 "timeSpent": result.get("timeSpent", ""), 

1417 "timeSpentSeconds": result.get("timeSpentSeconds", 0), 

1418 "author": result.get("author", {}).get("displayName", "Unknown"), 

1419 "original_estimate_updated": original_estimate_updated, 

1420 "remaining_estimate_updated": remaining_estimate_updated, 

1421 } 

1422 except Exception as e: 

1423 logger.error(f"Error adding worklog to issue {issue_key}: {str(e)}") 

1424 raise 

1425 

1426 def get_worklogs(self, issue_key: str) -> list[dict]: 

1427 """ 

1428 Get worklogs for an issue. 

1429 

1430 Args: 

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

1432 

1433 Returns: 

1434 List of worklog entries 

1435 """ 

1436 try: 

1437 result = self.jira.issue_get_worklog(issue_key) 

1438 

1439 # Process the worklogs 

1440 worklogs = [] 

1441 for worklog in result.get("worklogs", []): 

1442 worklogs.append( 

1443 { 

1444 "id": worklog.get("id"), 

1445 "comment": self._clean_text(worklog.get("comment", "")), 

1446 "created": self._parse_date(worklog.get("created", "")), 

1447 "updated": self._parse_date(worklog.get("updated", "")), 

1448 "started": self._parse_date(worklog.get("started", "")), 

1449 "timeSpent": worklog.get("timeSpent", ""), 

1450 "timeSpentSeconds": worklog.get("timeSpentSeconds", 0), 

1451 "author": worklog.get("author", {}).get( 

1452 "displayName", "Unknown" 

1453 ), 

1454 } 

1455 ) 

1456 

1457 return worklogs 

1458 except Exception as e: 

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

1460 raise 

1461 

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

1463 """ 

1464 Convert Markdown syntax to Jira markup syntax. 

1465 

1466 This method uses the TextPreprocessor implementation for consistent 

1467 conversion between Markdown and Jira markup. 

1468 

1469 Args: 

1470 markdown_text: Text in Markdown format 

1471 

1472 Returns: 

1473 Text in Jira markup format 

1474 """ 

1475 if not markdown_text: 

1476 return "" 

1477 

1478 # Use the existing preprocessor 

1479 return self.preprocessor.markdown_to_jira(markdown_text) 

1480 

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

1482 """ 

1483 Get the available status transitions for an issue. 

1484 

1485 Args: 

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

1487 

1488 Returns: 

1489 List of available transitions with id, name, and to status details 

1490 """ 

1491 try: 

1492 transitions_data = self.jira.get_issue_transitions(issue_key) 

1493 result = [] 

1494 

1495 # Handle different response formats from the Jira API 

1496 transitions = [] 

1497 if isinstance(transitions_data, dict) and "transitions" in transitions_data: 

1498 # Handle the case where the response is a dict with a "transitions" key 

1499 transitions = transitions_data.get("transitions", []) 

1500 elif isinstance(transitions_data, list): 

1501 # Handle the case where the response is a list of transitions directly 

1502 transitions = transitions_data 

1503 else: 

1504 logger.warning( 

1505 f"Unexpected format for transitions data: {type(transitions_data)}" 

1506 ) 

1507 return [] 

1508 

1509 for transition in transitions: 

1510 if not isinstance(transition, dict): 

1511 continue 

1512 

1513 # Extract the transition information safely 

1514 transition_id = transition.get("id") 

1515 transition_name = transition.get("name") 

1516 

1517 # Handle different formats for the "to" status 

1518 to_status = None 

1519 if "to" in transition and isinstance(transition["to"], dict): 

1520 to_status = transition["to"].get("name") 

1521 elif "to_status" in transition: 

1522 to_status = transition["to_status"] 

1523 elif "status" in transition: 

1524 to_status = transition["status"] 

1525 

1526 result.append( 

1527 { 

1528 "id": transition_id, 

1529 "name": transition_name, 

1530 "to_status": to_status, 

1531 } 

1532 ) 

1533 

1534 return result 

1535 except Exception as e: 

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

1537 raise 

1538 

1539 def transition_issue( 

1540 self, 

1541 issue_key: str, 

1542 transition_id: str, 

1543 fields: dict | None = None, 

1544 comment: str | None = None, 

1545 ) -> Document: 

1546 """ 

1547 Transition a Jira issue to a new status. 

1548 

1549 Args: 

1550 issue_key: The key of the issue to transition 

1551 transition_id: The ID of the transition to perform 

1552 fields: Optional fields to set during the transition 

1553 comment: Optional comment to add during the transition 

1554 

1555 Returns: 

1556 Document representing the transitioned issue 

1557 """ 

1558 try: 

1559 # Ensure transition_id is a string 

1560 transition_id = self._normalize_transition_id(transition_id) 

1561 

1562 # Prepare transition data 

1563 transition_data = {"transition": {"id": transition_id}} 

1564 

1565 # Add fields if provided 

1566 if fields: 

1567 sanitized_fields = self._sanitize_transition_fields(fields) 

1568 if sanitized_fields: 

1569 transition_data["fields"] = sanitized_fields 

1570 

1571 # Add comment if provided 

1572 if comment: 

1573 self._add_comment_to_transition_data(transition_data, comment) 

1574 

1575 # Log the transition request for debugging 

1576 logger.info( 

1577 f"Transitioning issue {issue_key} with transition ID {transition_id}" 

1578 ) 

1579 logger.debug(f"Transition data: {transition_data}") 

1580 

1581 # Perform the transition 

1582 self.jira.issue_transition(issue_key, transition_data) 

1583 

1584 # Return the updated issue 

1585 return self.get_issue(issue_key) 

1586 except Exception as e: 

1587 error_msg = ( 

1588 f"Error transitioning issue {issue_key} with transition ID " 

1589 f"{transition_id}: {str(e)}" 

1590 ) 

1591 logger.error(error_msg) 

1592 raise ValueError(error_msg) from e 

1593 

1594 def _normalize_transition_id(self, transition_id: str | int) -> str: 

1595 """ 

1596 Normalize transition ID to a string. 

1597 

1598 Args: 

1599 transition_id: Transition ID as string or int 

1600 

1601 Returns: 

1602 String representation of transition ID 

1603 """ 

1604 return str(transition_id) 

1605 

1606 def _sanitize_transition_fields(self, fields: dict) -> dict: 

1607 """ 

1608 Sanitize fields to ensure they're valid for the Jira API. 

1609 

1610 Args: 

1611 fields: Dictionary of fields to sanitize 

1612 

1613 Returns: 

1614 Dictionary of sanitized fields 

1615 """ 

1616 sanitized_fields = {} 

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

1618 # Skip None values 

1619 if value is None: 

1620 continue 

1621 

1622 # Handle special case for assignee 

1623 if key == "assignee" and isinstance(value, str): 

1624 try: 

1625 account_id = self._get_account_id(value) 

1626 sanitized_fields[key] = {"accountId": account_id} 

1627 except Exception as e: # noqa: BLE001 - Intentional fallback with logging 

1628 error_msg = f"Could not resolve assignee '{value}': {str(e)}" 

1629 logger.warning(error_msg) 

1630 # Skip this field 

1631 continue 

1632 else: 

1633 sanitized_fields[key] = value 

1634 

1635 return sanitized_fields 

1636 

1637 def _add_comment_to_transition_data( 

1638 self, transition_data: dict[str, Any], comment: str | int 

1639 ) -> None: 

1640 """ 

1641 Add comment to transition data. 

1642 

1643 Args: 

1644 transition_data: The transition data dictionary to update 

1645 comment: The comment to add 

1646 """ 

1647 # Ensure comment is a string 

1648 if not isinstance(comment, str): 

1649 logger.warning( 

1650 f"Comment must be a string, converting from {type(comment)}: {comment}" 

1651 ) 

1652 comment = str(comment) 

1653 

1654 # Convert markdown to Jira format and add to transition data 

1655 jira_formatted_comment = self._markdown_to_jira(comment) 

1656 transition_data["update"] = { 

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

1658 }