Coverage for src/mcp_atlassian/jira/epics.py: 79%

1"""Module for Jira epic operations."""

2

3 import logging

4 from typing import Any, Dict, List, Optional, cast

5

6 import requests

7

8 from ..document_types import Document

9 from .client import JiraClient

10

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

12

13

14 class EpicsMixin(JiraClient):

15 """Mixin for Jira epic operations."""

16

17 def get_jira_field_ids(self) -> Dict[str, str]:

18 """

19 Get mappings of field names to IDs.

20

21 This method discovers and caches various Jira field IDs, with a focus

22 on Epic-related fields, which can vary between different Jira instances.

23

24 Returns:

25 Dictionary mapping field names to their IDs

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

27 """

28 # Use cached field IDs if available

29 if hasattr(self, "_field_ids_cache") and self._field_ids_cache:

30 return self._field_ids_cache

31

32 # Get cached field IDs or fetch from server

33 return self._get_cached_field_ids()

34

35 def _get_cached_field_ids(self) -> Dict[str, str]:

36 """

37 Get cached field IDs or fetch from server.

38

39 Returns:

40 Dictionary mapping field names to their IDs

41 """

42 # Initialize cache if needed

43 if not hasattr(self, "_field_ids_cache"):

44 self._field_ids_cache = {}

45

46 # Return cache if not empty

47 if self._field_ids_cache:

48 return self._field_ids_cache

49

50 # Fetch field IDs from server

51 try:

52 fields = self.jira.get_all_fields()

53 field_ids = {}

54

55 # Log available fields to help with debugging

56 self._log_available_fields(fields)

57

58 # Process each field to identify Epic-related fields

59 for field in fields:

60 self._process_field_for_epic_data(field, field_ids)

61

62 # If we couldn't find all essential fields, try other discovery methods

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

64 logger.warning(

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

66 "This may cause issues with Epic operations."

67 )

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

69 self._try_discover_fields_from_existing_epic(field_ids)

70

71 # Cache the results

72 self._field_ids_cache = field_ids

73 return field_ids

74

75 except Exception as e:

76 logger.warning(f"Error getting field IDs: {str(e)}")

77 return {}

78

79 def _log_available_fields(self, fields: List[Dict]) -> None:

80 """

81 Log available fields for debugging.

82

83 Args:

84 fields: List of field definitions

85 """

86 logger.debug("Available Jira fields:")

87 for field in fields:

88 logger.debug(f"{field.get('id')}: {field.get('name')} ({field.get('schema', {}).get('type')})")

89

90 def _process_field_for_epic_data(

91 self, field: Dict, field_ids: Dict[str, str]

92 ) -> None:

93 """

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

95

96 Args:

97 field: The field definition

98 field_ids: Dictionary of field IDs to update

99 """

100 try:

101 field_id = field.get("id")

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

103 field_name = original_name.lower() if original_name else ""

104

105 # Skip if no field ID or name

106 if not field_id or not field_name:

107 return

108

109 # Get the custom schema type if available

110 field_custom = ""

111 schema = field.get("schema", {})

112 if schema:

113 field_custom = schema.get("custom", "").lower()

114

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

116 if (

117 "epic link" in field_name

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

119 ):

120 self.epic_link_field_id = field_id

121 field_ids["epic_link"] = field_id

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

123

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

125 elif (

126 "epic name" in field_name

127 or "epic-name" in field_name

128 or original_name == "Epic Name"

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

130 ):

131 field_ids["epic_name"] = field_id

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

133

134 # Parent field - sometimes used instead of Epic Link

135 elif original_name == "Parent" or field_name == "parent" or field_name == "parent link":

136 field_ids["parent"] = field_id

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

138

139 # Epic Status field

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

141 field_ids["epic_status"] = field_id

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

143

144 # Epic Color field

145 elif (

146 "epic colour" in field_name

147 or "epic color" in field_name

148 or original_name == "Epic Colour"

149 or original_name == "Epic Color"

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

151 ):

152 field_ids["epic_color"] = field_id

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

154

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

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

157 key in field_ids for key in ["epic_link", "epic_name", "epic_status", "epic_color"]

158 ):

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

160 field_ids[key] = field_id

161 logger.info(

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

163 )

164 except Exception as e:

165 logger.warning(f"Error processing field for Epic data: {str(e)}")

166

167 def _try_discover_fields_from_existing_epic(self, field_ids: Dict[str, str]) -> None:

168 """

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

170

171 This is a fallback method that attempts to find Epic fields by looking

172 at actual Epic issues already in the system.

173

174 Args:

175 field_ids: Dictionary of field IDs to update

176 """

177 # If we already have both epic fields, no need to search

178 if "epic_link" in field_ids and "epic_name" in field_ids:

179 return

180

181 try:

182 # Find an Epic in the system

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

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

185

186 # If no epics found, we can't use this method

187 if not results or not results.get("issues"):

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

189 return

190

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

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

193

194 # Inspect every custom field for values that look like epic fields

195 for field_id, value in fields.items():

196 if not field_id.startswith("customfield_"):

197 continue

198

199 # If it's a string value for a customfield, it might be the Epic Name

200 if "epic_name" not in field_ids and isinstance(value, str) and value:

201 field_ids["epic_name"] = field_id

202 logger.info(f"Discovered Epic Name field from existing epic: {field_id}")

203

204 # Now try to find issues linked to this Epic to discover the Epic Link field

205 if "epic_link" not in field_ids:

206 epic_key = epic.get("key")

207 if not epic_key:

208 return

209

210 # Try several query formats to find linked issues

211 link_queries = [

212 f"'Epic Link' = {epic_key}",

213 f"'Epic' = {epic_key}",

214 f"parent = {epic_key}",

215 ]

216

217 for query in link_queries:

218 try:

219 link_results = self.jira.jql(query, limit=1)

220 if link_results and link_results.get("issues"):

221 # Found an issue linked to our epic, now inspect its fields

222 linked_issue = link_results["issues"][0]

223 linked_fields = linked_issue.get("fields", {})

224

225 # Check each field to see if it contains our epic key

226 for field_id, value in linked_fields.items():

227 if (

228 field_id.startswith("customfield_")

229 and isinstance(value, str)

230 and value == epic_key

231 ):

232 field_ids["epic_link"] = field_id

233 logger.info(

234 f"Discovered Epic Link field from linked issue: {field_id}"

235 )

236 break

237

238 # If we found the epic link field, we can stop

239 if "epic_link" in field_ids:

240 break

241 except Exception: # noqa: BLE001 - Intentional fallback with logging

242 continue

243

244 except Exception as e:

245 logger.warning(f"Error discovering fields from existing Epics: {str(e)}")

246

247 def prepare_epic_fields(

248 self, fields: Dict[str, Any], summary: str, kwargs: Dict[str, Any]

249 ) -> None:

250 """

251 Prepare epic-specific fields for issue creation.

252

253 Args:

254 fields: The fields dictionary to update

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

256 kwargs: Additional fields from the user

257 """

258 try:

259 # Get all field IDs

260 field_ids = self.get_jira_field_ids()

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

262

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

264 if "epic_name" in field_ids:

265 epic_name = kwargs.pop(

266 "epic_name", summary

267 ) # Use summary as default if epic_name not provided

268 fields[field_ids["epic_name"]] = epic_name

269 logger.info(

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

271 )

272

273 # Handle Epic Color if the field was discovered

274 if "epic_color" in field_ids:

275 epic_color = (

276 kwargs.pop("epic_color", None)

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

278 or "green" # Default color

279 )

280 fields[field_ids["epic_color"]] = epic_color

281 logger.info(

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

283 f"to: {epic_color}"

284 )

285

286 # Add any other epic-related fields provided

287 for key, value in list(kwargs.items()):

288 if key.startswith("epic_") and key != "epic_name" and key != "epic_color":

289 field_key = key.replace("epic_", "")

290 if f"epic_{field_key}" in field_ids:

291 fields[field_ids[f"epic_{field_key}"]] = value

292 kwargs.pop(key)

293

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

295 if "epic_name" not in field_ids:

296 logger.warning(

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

298 "Epic creation may fail if this field is required."

299 )

300

301 except Exception as e:

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

303

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

305 """

306 Link an issue to an epic.

307

308 Args:

309 issue_key: The key of the issue to link

310 epic_key: The key of the epic to link to

311

312 Returns:

313 Document with the updated issue

314

315 Raises:

316 Exception: If there is an error linking the issue

317 """

318 try:

319 # Verify both keys exist

320 self.jira.get_issue(issue_key)

321 epic = self.jira.get_issue(epic_key)

322

323 # Verify epic_key is actually an epic

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

325 issue_type = fields.get("issuetype", {}).get("name", "").lower()

326

327 if issue_type != "epic":

328 error_msg = f"{epic_key} is not an Epic"

329 raise ValueError(error_msg)

330

331 # Get the epic link field ID

332 field_ids = self.get_jira_field_ids()

333 epic_link_field = field_ids.get("epic_link")

334

335 if not epic_link_field:

336 error_msg = "Could not determine Epic Link field"

337 raise ValueError(error_msg)

338

339 # Update the issue to link it to the epic

340 update_fields = {epic_link_field: epic_key}

341 self.jira.update_issue(issue_key, fields=update_fields)

342

343 # Return the updated issue

344 if hasattr(self, 'get_issue') and callable(self.get_issue):

345 return self.get_issue(issue_key)

346 else:

347 # Fallback if get_issue is not available

348 logger.warning("get_issue method not available, returning empty Document")

349 return Document(page_content="", metadata={"key": issue_key})

350

351 except Exception as e:

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

353 raise Exception(f"Error linking issue to epic: {str(e)}") from e

354

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

356 """

357 Get all issues linked to a specific epic.

358

359 Args:

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

361 limit: Maximum number of issues to return

362

363 Returns:

364 List of Documents representing the issues linked to the epic

365

366 Raises:

367 ValueError: If the issue is not an Epic

368 Exception: If there is an error getting epic issues

369 """

370 try:

371 # First, check if the issue is an Epic

372 epic = self.jira.issue(epic_key)

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

374

375 # Safely check if the issue is an Epic

376 issue_type = None

377 issuetype_data = fields_data.get("issuetype")

378 if issuetype_data is not None:

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

380

381 if issue_type != "Epic":

382 error_msg = (

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

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

385 )

386 raise ValueError(error_msg)

387

388 # Get the dynamic field IDs for this Jira instance

389 field_ids = self.get_jira_field_ids()

390

391 # Build JQL queries based on discovered field IDs

392 jql_queries = []

393

394 # Add queries based on discovered fields

395 if "parent" in field_ids:

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

397

398 if "epic_link" in field_ids:

399 field_name = field_ids["epic_link"]

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

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

402

403 # Add standard fallback queries

404 jql_queries.extend(

405 [

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

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

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

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

410 ]

411 )

412

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

414 documents = []

415 for jql in jql_queries:

416 try:

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

418 if hasattr(self, 'search_issues') and callable(self.search_issues):

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

420 else:

421 # Fallback if search_issues is not available

422 results = self.jira.jql(jql, limit=limit)

423 documents = []

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

425 key = issue.get("key", "")

426 summary = issue.get("fields", {}).get("summary", "")

427 documents.append(

428 Document(

429 page_content=summary,

430 metadata={"key": key, "type": "issue"}

431 )

432 )

433

434 if documents:

435 return documents

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

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

438 continue

439

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

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

442 if not documents:

443 logger.warning(

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

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

446 )

447

448 return documents

449

450 except ValueError as e:

451 # Re-raise ValueError for non-epic issues

452 raise

453

454 except Exception as e:

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

456 raise Exception(f"Error getting epic issues: {str(e)}") from e