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

1 import logging

2 import os

3 import re

4 from datetime import datetime

5 from typing import Any

6

7 import requests

8 from atlassian import Jira

9

10 from .config import JiraConfig

11 from .document_types import Document

12 from .preprocessing import TextPreprocessor

13

14# Configure logging

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

16

17

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

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

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

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

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

980 Description:

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 }