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

1 import logging

2 import os

3 import re

4 from datetime import datetime

5 from typing import Any

6

7 from atlassian import Jira

8

9 from .config import JiraConfig

10 from .document_types import Document

11 from .preprocessing import TextPreprocessor

12

13# Configure logging

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

15

16

17 class JiraFetcher:

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

19

20 def __init__(self):

21 url = os.getenv("JIRA_URL")

22 username = os.getenv("JIRA_USERNAME")

23 token = os.getenv("JIRA_API_TOKEN")

24

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

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

27

28 self.config = JiraConfig(url=url, username=username, api_token=token)

29 self.jira = Jira(

30 url=self.config.url,

31 username=self.config.username,

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

33 cloud=True,

34 )

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

36

37 # Field IDs cache

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

39

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

41 """

42 Clean text content by:

43 1. Processing user mentions and links

44 2. Converting HTML/wiki markup to markdown

45 """

46 if not text:

47 return ""

48

49 return self.preprocessor.clean_jira_text(text)

50

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

52 """

53 Get account ID from email or full name.

54

55 Args:

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

57

58 Returns:

59 Account ID of the user

60

61 Raises:

62 ValueError: If user cannot be found

63 """

64 # If it looks like an account ID (alphanumeric with hyphens), return as is

65 if assignee and assignee.replace("-", "").isalnum():

66 logger.info(f"Using '{assignee}' as account ID")

67 return assignee

68

69 try:

70 # First try direct user lookup

71 try:

72 users = self.jira.user_find_by_user_string(query=assignee)

73 if users:

74 if len(users) > 1:

75 # Log all found users for debugging

76 user_details = [f"{u.get('displayName')} ({u.get('emailAddress')})" for u in users]

77 logger.warning(

78 f"Multiple users found for '{assignee}', using first match. "

79 f"Found users: {', '.join(user_details)}"

80 )

81

82 user = users[0]

83 account_id = user.get("accountId")

84 if account_id and isinstance(account_id, str):

85 logger.info(

86 f"Found account ID via direct lookup: {account_id} "

87 f"({user.get('displayName')} - {user.get('emailAddress')})"

88 )

89 return str(account_id) # Explicit str conversion

90 logger.warning(f"Direct user lookup failed for '{assignee}': user found but no account ID present")

91 else:

92 logger.warning(f"Direct user lookup failed for '{assignee}': no users found")

93 except Exception as e:

94 logger.warning(f"Direct user lookup failed for '{assignee}': {str(e)}")

95

96 # Fall back to project permission based search

97 users = self.jira.get_users_with_browse_permission_to_a_project(username=assignee)

98 if not users:

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

100 raise ValueError(f"No user found matching '{assignee}'")

101

102 # Return the first matching user's account ID

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

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

105 logger.warning(f"Found user '{assignee}' but no account ID was returned")

106 raise ValueError(f"Found user '{assignee}' but no account ID was returned")

107

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

109 return str(account_id) # Explicit str conversion

110 except Exception as e:

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

112 raise ValueError(f"Could not resolve account ID for '{assignee}'") from e

113

114 def create_issue(

115 self,

116 project_key: str,

117 summary: str,

118 issue_type: str,

119 description: str = "",

120 assignee: str | None = None,

121 **kwargs: Any,

122 ) -> Document:

123 """

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

125

126 Args:

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

128 summary: Summary of the issue

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

130 description: Issue description

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

132 kwargs: Any other custom Jira fields

133

134 Returns:

135 Document representing the newly created issue

136 """

137 fields = {

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

139 "summary": summary,

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

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

142 }

143

144 # If we're creating an Epic, check for Epic-specific fields

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

146 # Get the dynamic field IDs

147 field_ids = self.get_jira_field_ids()

148

149 # Set the Epic Name field if available (required in many Jira instances)

150 if "epic_name" in field_ids and "epic_name" not in kwargs:

151 # Use the summary as the epic name if not provided

152 fields[field_ids["epic_name"]] = summary

153 elif "customfield_10011" not in kwargs: # Common default Epic Name field

154 # Fallback to common Epic Name field if not discovered

155 fields["customfield_10011"] = summary

156

157 # Check for other Epic fields from kwargs

158 epic_color = kwargs.pop("epic_color", None) or kwargs.pop("epic_colour", None)

159 if epic_color and "epic_color" in field_ids:

160 fields[field_ids["epic_color"]] = epic_color

161

162 # Add assignee if provided

163 if assignee:

164 account_id = self._get_account_id(assignee)

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

166

167 # Remove assignee from additional_fields if present to avoid conflicts

168 if "assignee" in kwargs:

169 logger.warning(

170 "Assignee found in additional_fields - this will be ignored. Please use the assignee parameter instead."

171 )

172 kwargs.pop("assignee")

173

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

175 fields[key] = value

176

177 # Convert description to Jira format if present

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

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

180

181 try:

182 created = self.jira.issue_create(fields=fields)

183 issue_key = created.get("key")

184 if not issue_key:

185 raise ValueError(f"Failed to create issue in project {project_key}")

186

187 return self.get_issue(issue_key)

188 except Exception as e:

189 logger.error(f"Error creating issue in project {project_key}: {str(e)}")

190 raise

191

192 def update_issue(self, issue_key: str, fields: dict[str, Any] = None, **kwargs: Any) -> Document:

193 """

194 Update an existing issue in Jira and return it as a Document.

195

196 Args:

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

198 fields: Fields to update

199 kwargs: Any other custom Jira fields

200

201 Returns:

202 Document representing the updated issue

203 """

204 if fields is None:

205 fields = {}

206

207 # Handle all kwargs

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

209 fields[key] = value

210

211 # Convert description to Jira format if present

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

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

214

215 # Check if status update is requested

216 if "status" in fields:

217 requested_status = fields.pop("status")

218 if not isinstance(requested_status, str):

219 logger.warning(f"Status must be a string, got {type(requested_status)}: {requested_status}")

220 # Try to convert to string if possible

221 requested_status = str(requested_status)

222

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

224

225 # Get available transitions

226 transitions = self.get_available_transitions(issue_key)

227

228 # Find matching transition

229 transition_id = None

230 for transition in transitions:

231 to_status = transition.get("to_status", "")

232 if isinstance(to_status, str) and to_status.lower() == requested_status.lower():

233 transition_id = transition["id"]

234 break

235

236 if transition_id:

237 # Use transition_issue method if we found a matching transition

238 logger.info(f"Found transition ID {transition_id} for status {requested_status}")

239 return self.transition_issue(issue_key, transition_id, fields)

240 else:

241 available_statuses = [t.get("to_status", "") for t in transitions]

242 logger.warning(

243 f"No transition found for status '{requested_status}'. Available transitions: {transitions}"

244 )

245 raise ValueError(

246 f"Cannot transition issue to status '{requested_status}'. Available status transitions: {available_statuses}"

247 )

248

249 try:

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

251 return self.get_issue(issue_key)

252 except Exception as e:

253 logger.error(f"Error updating issue {issue_key}: {str(e)}")

254 raise

255

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

257 """

258 Dynamically discover Jira field IDs relevant to Epic linking.

259

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

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

262

263 Returns:

264 Dictionary mapping field names to their IDs

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

266 """

267 try:

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

269 if hasattr(self, "_field_ids_cache"):

270 return self._field_ids_cache

271

272 # Fetch all fields from Jira API

273 fields = self.jira.fields()

274 field_ids = {}

275

276 # Look for Epic-related fields

277 for field in fields:

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

279

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

281 if "epic link" in field_name or "epic-link" in field_name:

282 field_ids["epic_link"] = field["id"]

283

284 # Epic Name field - used when creating epics

285 elif "epic name" in field_name or "epic-name" in field_name:

286 field_ids["epic_name"] = field["id"]

287

288 # Parent field - sometimes used instead of Epic Link

289 elif field_name == "parent" or field_name == "parent link":

290 field_ids["parent"] = field["id"]

291

292 # Epic Status field

293 elif "epic status" in field_name:

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

295

296 # Epic Color field

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

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

299

300 # Cache the results for future use

301 self._field_ids_cache = field_ids

302

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

304 return field_ids

305

306 except Exception as e:

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

308 # Return an empty dict as fallback

309 return {}

310

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

312 """

313 Link an existing issue to an epic.

314

315 Args:

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

317 epic_key: The key of the epic to link to (e.g. 'PROJ-456')

318

319 Returns:

320 Document representing the updated issue

321 """

322 try:

323 # First, check if the epic exists and is an Epic type

324 epic = self.jira.issue(epic_key)

325 if epic["fields"]["issuetype"]["name"] != "Epic":

326 raise ValueError(f"Issue {epic_key} is not an Epic, it is a {epic['fields']['issuetype']['name']}")

327

328 # Get the dynamic field IDs for this Jira instance

329 field_ids = self.get_jira_field_ids()

330

331 # Try the parent field first (if discovered or natively supported)

332 if "parent" in field_ids or "parent" not in field_ids:

333 try:

334 fields = {"parent": {"key": epic_key}}

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

336 return self.get_issue(issue_key)

337 except Exception as e:

338 logger.info(f"Couldn't link using parent field: {str(e)}. Trying discovered fields...")

339

340 # Try using the discovered Epic Link field

341 if "epic_link" in field_ids:

342 try:

343 epic_link_fields: dict[str, str] = {field_ids["epic_link"]: epic_key}

344 self.jira.issue_update(issue_key, fields=epic_link_fields)

345 return self.get_issue(issue_key)

346 except Exception as e:

347 logger.info(f"Couldn't link using discovered epic_link field: {str(e)}. Trying fallback methods...")

348

349 # Fallback to common custom fields if dynamic discovery didn't work

350 custom_field_attempts: list[dict[str, str]] = [

351 {"customfield_10014": epic_key}, # Common in Jira Cloud

352 {"customfield_10000": epic_key}, # Common in Jira Server

353 {"epic_link": epic_key}, # Sometimes used

354 ]

355

356 for fields in custom_field_attempts:

357 try:

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

359 return self.get_issue(issue_key)

360 except Exception as e:

361 logger.info(f"Couldn't link using fields {fields}: {str(e)}")

362 continue

363

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

365 raise ValueError(

366 f"Could not link issue {issue_key} to epic {epic_key}. Your Jira instance might use a different field for epic links."

367 )

368

369 except Exception as e:

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

371 raise

372

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

374 """

375 Delete an existing issue.

376

377 Args:

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

379

380 Returns:

381 True if delete succeeded, otherwise raise an exception

382 """

383 try:

384 self.jira.delete_issue(issue_key)

385 return True

386 except Exception as e:

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

388 raise

389

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

391 """Parse date string to handle various ISO formats."""

392 if not date_str:

393 return ""

394

395 # Handle various timezone formats

396 if "+0000" in date_str:

397 date_str = date_str.replace("+0000", "+00:00")

398 elif "-0000" in date_str:

399 date_str = date_str.replace("-0000", "+00:00")

400 # Handle other timezone formats like +0900, -0500, etc.

401 elif len(date_str) >= 5 and date_str[-5] in "+-" and date_str[-4:].isdigit():

402 # Insert colon between hours and minutes of timezone

403 date_str = date_str[:-2] + ":" + date_str[-2:]

404

405 try:

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

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

408 except Exception as e:

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

410 return date_str

411

412 def get_issue(self, issue_key: str, expand: str | None = None, comment_limit: int | None = None) -> Document:

413 """

414 Get a single issue with all its details.

415

416 Args:

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

418 expand: Optional fields to expand

419 comment_limit: Maximum number of comments to include (None for no comments)

420

421 Returns:

422 Document containing issue content and metadata

423 """

424 try:

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

426

427 # Process description and comments

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

429

430 # Get comments if limit is specified

431 comments = []

432 if comment_limit is not None and comment_limit > 0:

433 comments = self.get_issue_comments(issue_key, limit=comment_limit)

434

435 # Format created date using new parser

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

437

438 # Check for Epic information

439 epic_key = None

440 epic_name = None

441

442 # Most Jira instances use the "parent" field for Epic relationships

443 if "parent" in issue["fields"] and issue["fields"]["parent"]:

444 epic_key = issue["fields"]["parent"]["key"]

445 epic_name = issue["fields"]["parent"]["fields"]["summary"]

446

447 # Some Jira instances use custom fields for Epic links

448 # Common custom field names for Epic links

449 epic_field_names = ["customfield_10014", "customfield_10000", "epic_link"]

450 for field_name in epic_field_names:

451 if field_name in issue["fields"] and issue["fields"][field_name]:

452 # If it's a string, assume it's the epic key

453 if isinstance(issue["fields"][field_name], str):

454 epic_key = issue["fields"][field_name]

455 # If it's an object, extract the key

456 elif isinstance(issue["fields"][field_name], dict) and "key" in issue["fields"][field_name]:

457 epic_key = issue["fields"][field_name]["key"]

458

459 # Combine content in a more structured way

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

461 Title: {issue['fields'].get('summary', '')}

462 Type: {issue['fields']['issuetype']['name']}

463 Status: {issue['fields']['status']['name']}

464 Created: {created_date}

465"""

466

467 # Add Epic information if available

468 if epic_key:

469 content += f"Epic: {epic_key}"

470 if epic_name:

471 content += f" - {epic_name}"

472 content += "\n"

473

474 content += f"""

475 Description:

476{description}

477"""

478 if comments:

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

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

481 )

482

483 # Streamlined metadata with only essential information

484 metadata = {

485 "key": issue_key,

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

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

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

489 "created_date": created_date,

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

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

492 }

493

494 # Add Epic information to metadata

495 if epic_key:

496 metadata["epic_key"] = epic_key

497 if epic_name:

498 metadata["epic_name"] = epic_name

499

500 if comments:

501 metadata["comments"] = comments

502

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

504

505 except Exception as e:

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

507 raise

508

509 def search_issues(

510 self,

511 jql: str,

512 fields: str = "*all",

513 start: int = 0,

514 limit: int = 50,

515 expand: str | None = None,

516 ) -> list[Document]:

517 """

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

519

520 Args:

521 jql: JQL query string

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

523 start: Starting index

524 limit: Maximum issues to return

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

526

527 Returns:

528 List of Documents representing the search results

529 """

530 try:

531 issues = self.jira.jql(jql, fields=fields, start=start, limit=limit, expand=expand)

532 documents = []

533

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

535 issue_key = issue["key"]

536 summary = issue["fields"].get("summary", "")

537 issue_type = issue["fields"]["issuetype"]["name"]

538 status = issue["fields"]["status"]["name"]

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

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

541 priority = issue["fields"].get("priority", {}).get("name", "None")

542

543 # Add basic metadata

544 metadata = {

545 "key": issue_key,

546 "title": summary,

547 "type": issue_type,

548 "status": status,

549 "created_date": created_date,

550 "priority": priority,

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

552 }

553

554 # Prepare content

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

556

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

558

559 return documents

560 except Exception as e:

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

562 raise

563

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

565 """

566 Get all issues linked to a specific epic.

567

568 Args:

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

570 limit: Maximum number of issues to return

571

572 Returns:

573 List of Documents representing the issues linked to the epic

574 """

575 try:

576 # First, check if the issue is an Epic

577 epic = self.jira.issue(epic_key)

578 if epic["fields"]["issuetype"]["name"] != "Epic":

579 raise ValueError(f"Issue {epic_key} is not an Epic, it is a {epic['fields']['issuetype']['name']}")

580

581 # Get the dynamic field IDs for this Jira instance

582 field_ids = self.get_jira_field_ids()

583

584 # Build JQL queries based on discovered field IDs

585 jql_queries = []

586

587 # Add queries based on discovered fields

588 if "parent" in field_ids:

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

590

591 if "epic_link" in field_ids:

592 field_name = field_ids["epic_link"]

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

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

595

596 # Add standard fallback queries

597 jql_queries.extend(

598 [

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

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

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

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

603 ]

604 )

605

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

607 documents = []

608 for jql in jql_queries:

609 try:

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

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

612 if documents:

613 return documents

614 except Exception as e:

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

616 continue

617

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

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

620 if not documents:

621 logger.warning(

622 f"Couldn't find issues linked to epic {epic_key}. Your Jira instance might use a different field for epic links."

623 )

624

625 return documents

626

627 except Exception as e:

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

629 raise

630

631 def get_project_issues(self, project_key: str, start: int = 0, limit: int = 50) -> list[Document]:

632 """

633 Get all issues for a project.

634

635 Args:

636 project_key: The project key

637 start: Starting index

638 limit: Maximum results to return

639

640 Returns:

641 List of Documents containing project issues

642 """

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

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

645

646 def get_current_user_account_id(self) -> str:

647 """

648 Get the account ID of the current user.

649

650 Returns:

651 The account ID string of the current user

652

653 Raises:

654 ValueError: If unable to get the current user's account ID

655 """

656 try:

657 myself = self.jira.myself()

658 account_id: str | None = myself.get("accountId")

659 if not account_id:

660 raise ValueError("Unable to get account ID from user profile")

661 return account_id

662 except Exception as e:

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

664 raise ValueError(f"Failed to get current user account ID: {str(e)}")

665

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

667 """

668 Get comments for a specific issue.

669

670 Args:

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

672 limit: Maximum number of comments to return

673

674 Returns:

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

676 """

677 try:

678 comments = self.jira.issue_get_comments(issue_key)

679 processed_comments = []

680

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

682 processed_comment = {

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

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

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

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

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

688 }

689 processed_comments.append(processed_comment)

690

691 return processed_comments

692 except Exception as e:

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

694 raise

695

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

697 """

698 Add a comment to an issue.

699

700 Args:

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

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

703

704 Returns:

705 The created comment details

706 """

707 try:

708 # Convert Markdown to Jira's markup format

709 jira_formatted_comment = self._markdown_to_jira(comment)

710

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

712 return {

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

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

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

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

717 }

718 except Exception as e:

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

720 raise

721

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

723 """

724 Convert Markdown syntax to Jira markup syntax.

725

726 Supported Markdown syntax:

727 - Headers: # Heading 1, ## Heading 2, etc.

728 - Bold: **bold text** or __bold text__

729 - Italic: *italic text* or _italic text_

730 - Code blocks: ```code``` (triple backticks)

731 - Inline code: `code` (single backticks)

732 - Links: [link text](URL)

733 - Unordered lists: * item or - item

734 - Ordered lists: 1. item, 2. item, etc.

735 - Blockquotes: > quoted text

736 - Horizontal rules: --- or ****

737

738 Args:

739 markdown_text: Text in Markdown format

740

741 Returns:

742 Text in Jira markup format

743 """

744 if not markdown_text:

745 return ""

746

747 # Basic Markdown to Jira markup conversions

748 # Headers

749 jira_text = re.sub(r"^# (.+)$", r"h1. \1", markdown_text, flags=re.MULTILINE)

750 jira_text = re.sub(r"^## (.+)$", r"h2. \1", jira_text, flags=re.MULTILINE)

751 jira_text = re.sub(r"^### (.+)$", r"h3. \1", jira_text, flags=re.MULTILINE)

752 jira_text = re.sub(r"^#### (.+)$", r"h4. \1", jira_text, flags=re.MULTILINE)

753 jira_text = re.sub(r"^##### (.+)$", r"h5. \1", jira_text, flags=re.MULTILINE)

754 jira_text = re.sub(r"^###### (.+)$", r"h6. \1", jira_text, flags=re.MULTILINE)

755

756 # Bold and italic - handle both asterisks and underscores

757 # Note: Order matters here - process bold first, then italic

758 jira_text = re.sub(r"\*\*(.+?)\*\*", r"*\1*", jira_text) # Bold with **

759 jira_text = re.sub(r"__(.+?)__", r"*\1*", jira_text) # Bold with __

760

761 # Be careful with italic conversion to avoid over-replacing

762 # Look for single asterisks or underscores not preceded or followed by the same character

763 jira_text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"_\1_", jira_text) # Italic with *

764 jira_text = re.sub(r"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", r"_\1_", jira_text) # Italic with _ (keep as is)

765

766 # Code blocks with language support

767 # Match ```language\ncode\n``` pattern

768 jira_text = re.sub(

769 r"```(\w*)\n(.*?)\n```",

770 lambda m: "{code:" + (m.group(1) or "none") + "}\n" + m.group(2) + "\n{code}",

771 jira_text,

772 flags=re.DOTALL,

773 )

774

775 # Simple code blocks without language

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

777

778 # Inline code

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

780

781 # Links

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

783

784 # Unordered lists

785 jira_text = re.sub(r"^- (.+)$", r"* \1", jira_text, flags=re.MULTILINE)

786 jira_text = re.sub(r"^\* (.+)$", r"* \1", jira_text, flags=re.MULTILINE) # Keep as is

787

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

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

790

791 # Blockquotes

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

793

794 # Horizontal rules

795 jira_text = re.sub(r"^---+$", r"----", jira_text, flags=re.MULTILINE)

796 jira_text = re.sub(r"^\*\*\*+$", r"----", jira_text, flags=re.MULTILINE)

797

798 # Handle consecutive ordered list items to ensure they render properly

799 lines = jira_text.split("\n")

800 in_list = False

801 for i in range(len(lines)):

802 if lines[i].startswith("# "):

803 if not in_list:

804 # First item in a list

805 in_list = True

806 # No need to modify subsequent items as Jira continues the numbering

807 else:

808 in_list = False

809

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

811

812 return jira_text

813

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

815 """

816 Get the available status transitions for an issue.

817

818 Args:

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

820

821 Returns:

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

823 """

824 try:

825 transitions_data = self.jira.get_issue_transitions(issue_key)

826 result = []

827

828 # Handle different response formats from the Jira API

829 transitions = []

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

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

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

833 elif isinstance(transitions_data, list):

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

835 transitions = transitions_data

836 else:

837 logger.warning(f"Unexpected format for transitions data: {type(transitions_data)}")

838 return []

839

840 for transition in transitions:

841 if not isinstance(transition, dict):

842 continue

843

844 # Extract the transition information safely

845 transition_id = transition.get("id")

846 transition_name = transition.get("name")

847

848 # Handle different formats for the "to" status

849 to_status = None

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

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

852 elif "to_status" in transition:

853 to_status = transition["to_status"]

854 elif "status" in transition:

855 to_status = transition["status"]

856

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

858

859 return result

860 except Exception as e:

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

862 raise

863

864 def transition_issue(

865 self, issue_key: str, transition_id: str, fields: dict | None = None, comment: str | None = None

866 ) -> Document:

867 """

868 Transition an issue to a new status using the appropriate workflow transition.

869

870 Args:

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

872 transition_id: The ID of the transition to perform (get this from get_available_transitions)

873 fields: Additional fields to update during the transition

874 comment: Optional comment to add during the transition

875

876 Returns:

877 Document representing the updated issue

878 """

879 try:

880 # Ensure transition_id is a string

881 if not isinstance(transition_id, str):

882 logger.warning(

883 f"transition_id must be a string, converting from {type(transition_id)}: {transition_id}"

884 )

885 transition_id = str(transition_id)

886

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

888

889 # Add fields if provided

890 if fields:

891 # Sanitize fields to ensure they're valid for the API

892 sanitized_fields = {}

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

894 # Skip None values

895 if value is None:

896 continue

897

898 # Handle special case for assignee

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

900 try:

901 account_id = self._get_account_id(value)

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

903 except Exception as e:

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

905 logger.warning(error_msg)

906 # Skip this field

907 continue

908 else:

909 sanitized_fields[key] = value

910

911 if sanitized_fields:

912 transition_data["fields"] = sanitized_fields

913

914 # Add comment if provided

915 if comment:

916 if not isinstance(comment, str):

917 logger.warning(f"Comment must be a string, converting from {type(comment)}: {comment}")

918 comment = str(comment)

919

920 jira_formatted_comment = self._markdown_to_jira(comment)

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

922

923 # Log the transition request for debugging

924 logger.info(f"Transitioning issue {issue_key} with transition ID {transition_id}")

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

926

927 # Perform the transition

928 self.jira.issue_transition(issue_key, transition_data)

929

930 # Return the updated issue

931 return self.get_issue(issue_key)

932 except Exception as e:

933 error_msg = f"Error transitioning issue {issue_key} with transition ID {transition_id}: {str(e)}"

934 logger.error(error_msg)

935 raise ValueError(error_msg)