mcp-ticketer 0.12.0__py3-none-any.whl → 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

Files changed (87) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/adapters/aitrackdown.py +385 -6
  4. mcp_ticketer/adapters/asana/adapter.py +108 -0
  5. mcp_ticketer/adapters/asana/mappers.py +14 -0
  6. mcp_ticketer/adapters/github.py +525 -11
  7. mcp_ticketer/adapters/hybrid.py +47 -5
  8. mcp_ticketer/adapters/jira.py +521 -0
  9. mcp_ticketer/adapters/linear/adapter.py +1784 -101
  10. mcp_ticketer/adapters/linear/client.py +85 -3
  11. mcp_ticketer/adapters/linear/mappers.py +96 -8
  12. mcp_ticketer/adapters/linear/queries.py +168 -1
  13. mcp_ticketer/adapters/linear/types.py +80 -4
  14. mcp_ticketer/analysis/__init__.py +56 -0
  15. mcp_ticketer/analysis/dependency_graph.py +255 -0
  16. mcp_ticketer/analysis/health_assessment.py +304 -0
  17. mcp_ticketer/analysis/orphaned.py +218 -0
  18. mcp_ticketer/analysis/project_status.py +594 -0
  19. mcp_ticketer/analysis/similarity.py +224 -0
  20. mcp_ticketer/analysis/staleness.py +266 -0
  21. mcp_ticketer/automation/__init__.py +11 -0
  22. mcp_ticketer/automation/project_updates.py +378 -0
  23. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  24. mcp_ticketer/cli/auggie_configure.py +17 -5
  25. mcp_ticketer/cli/codex_configure.py +97 -61
  26. mcp_ticketer/cli/configure.py +851 -103
  27. mcp_ticketer/cli/cursor_configure.py +314 -0
  28. mcp_ticketer/cli/diagnostics.py +13 -12
  29. mcp_ticketer/cli/discover.py +5 -0
  30. mcp_ticketer/cli/gemini_configure.py +17 -5
  31. mcp_ticketer/cli/init_command.py +880 -0
  32. mcp_ticketer/cli/instruction_commands.py +6 -0
  33. mcp_ticketer/cli/main.py +233 -3151
  34. mcp_ticketer/cli/mcp_configure.py +672 -98
  35. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  36. mcp_ticketer/cli/platform_detection.py +77 -12
  37. mcp_ticketer/cli/platform_installer.py +536 -0
  38. mcp_ticketer/cli/project_update_commands.py +350 -0
  39. mcp_ticketer/cli/setup_command.py +639 -0
  40. mcp_ticketer/cli/simple_health.py +12 -10
  41. mcp_ticketer/cli/ticket_commands.py +264 -24
  42. mcp_ticketer/core/__init__.py +28 -6
  43. mcp_ticketer/core/adapter.py +166 -1
  44. mcp_ticketer/core/config.py +21 -21
  45. mcp_ticketer/core/exceptions.py +7 -1
  46. mcp_ticketer/core/label_manager.py +732 -0
  47. mcp_ticketer/core/mappers.py +31 -19
  48. mcp_ticketer/core/models.py +135 -0
  49. mcp_ticketer/core/onepassword_secrets.py +1 -1
  50. mcp_ticketer/core/priority_matcher.py +463 -0
  51. mcp_ticketer/core/project_config.py +132 -14
  52. mcp_ticketer/core/session_state.py +171 -0
  53. mcp_ticketer/core/state_matcher.py +592 -0
  54. mcp_ticketer/core/url_parser.py +425 -0
  55. mcp_ticketer/core/validators.py +69 -0
  56. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  57. mcp_ticketer/mcp/server/main.py +106 -25
  58. mcp_ticketer/mcp/server/routing.py +655 -0
  59. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  60. mcp_ticketer/mcp/server/tools/__init__.py +31 -12
  61. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  62. mcp_ticketer/mcp/server/tools/attachment_tools.py +6 -8
  63. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  64. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  65. mcp_ticketer/mcp/server/tools/config_tools.py +1184 -136
  66. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  67. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  68. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  69. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  70. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  71. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  72. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  73. mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
  74. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  75. mcp_ticketer/mcp/server/tools/ticket_tools.py +1070 -123
  76. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  77. mcp_ticketer/queue/worker.py +1 -1
  78. mcp_ticketer/utils/__init__.py +5 -0
  79. mcp_ticketer/utils/token_utils.py +246 -0
  80. mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
  81. mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
  82. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  83. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  84. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
  85. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
  86. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  87. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,7 @@
1
1
  """JIRA adapter implementation using REST API v3."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import asyncio
4
6
  import builtins
5
7
  import logging
@@ -59,9 +61,11 @@ def extract_text_from_adf(adf_content: str | dict[str, Any]) -> str:
59
61
  """Extract plain text from Atlassian Document Format (ADF).
60
62
 
61
63
  Args:
64
+ ----
62
65
  adf_content: Either a string (already plain text) or ADF document dict
63
66
 
64
67
  Returns:
68
+ -------
65
69
  Plain text string extracted from the ADF content
66
70
 
67
71
  """
@@ -123,6 +127,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
123
127
  """Initialize JIRA adapter.
124
128
 
125
129
  Args:
130
+ ----
126
131
  config: Configuration with:
127
132
  - server: JIRA server URL (e.g., https://company.atlassian.net)
128
133
  - email: User email for authentication
@@ -183,6 +188,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
183
188
  """Validate that required credentials are present.
184
189
 
185
190
  Returns:
191
+ -------
186
192
  (is_valid, error_message) - Tuple of validation result and error message
187
193
 
188
194
  """
@@ -236,6 +242,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
236
242
  """Make HTTP request to JIRA API with retry logic.
237
243
 
238
244
  Args:
245
+ ----
239
246
  method: HTTP method
240
247
  endpoint: API endpoint
241
248
  data: Request body data
@@ -243,9 +250,11 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
243
250
  retry_count: Current retry attempt
244
251
 
245
252
  Returns:
253
+ -------
246
254
  Response data
247
255
 
248
256
  Raises:
257
+ ------
249
258
  HTTPStatusError: On API errors
250
259
  TimeoutException: On timeout
251
260
 
@@ -890,10 +899,12 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
890
899
  """Execute a raw JQL query.
891
900
 
892
901
  Args:
902
+ ----
893
903
  jql: JIRA Query Language string
894
904
  limit: Maximum number of results
895
905
 
896
906
  Returns:
907
+ -------
897
908
  List of matching tickets
898
909
 
899
910
  """
@@ -917,9 +928,11 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
917
928
  """Get active sprints for a board (requires JIRA Software).
918
929
 
919
930
  Args:
931
+ ----
920
932
  board_id: Agile board ID
921
933
 
922
934
  Returns:
935
+ -------
923
936
  List of sprint information
924
937
 
925
938
  """
@@ -1010,6 +1023,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
1010
1023
  recent issues and extract unique labels from them.
1011
1024
 
1012
1025
  Returns:
1026
+ -------
1013
1027
  List of label dictionaries with 'id' and 'name' fields
1014
1028
 
1015
1029
  """
@@ -1045,10 +1059,506 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
1045
1059
  # Fallback: return empty list if query fails
1046
1060
  return []
1047
1061
 
1062
+ async def create_issue_label(
1063
+ self, name: str, color: str | None = None
1064
+ ) -> dict[str, Any]:
1065
+ """Create a new issue label in JIRA.
1066
+
1067
+ Note: JIRA doesn't have a dedicated label creation API. Labels are
1068
+ created automatically when first used on an issue. This method
1069
+ validates the label name and returns a success response.
1070
+
1071
+ Args:
1072
+ ----
1073
+ name: Label name to create
1074
+ color: Optional color (JIRA doesn't support colors natively, ignored)
1075
+
1076
+ Returns:
1077
+ -------
1078
+ Dict with label details:
1079
+ - id: Label name (same as name in JIRA)
1080
+ - name: Label name
1081
+ - status: "ready" indicating the label can be used
1082
+
1083
+ Raises:
1084
+ ------
1085
+ ValueError: If credentials are invalid or label name is invalid
1086
+
1087
+ """
1088
+ # Validate credentials before attempting operation
1089
+ is_valid, error_message = self.validate_credentials()
1090
+ if not is_valid:
1091
+ raise ValueError(error_message)
1092
+
1093
+ # Validate label name
1094
+ if not name or not name.strip():
1095
+ raise ValueError("Label name cannot be empty")
1096
+
1097
+ # JIRA label names must not contain spaces
1098
+ if " " in name:
1099
+ raise ValueError(
1100
+ "JIRA label names cannot contain spaces. Use underscores or hyphens instead."
1101
+ )
1102
+
1103
+ # Return success response
1104
+ # The label will be created automatically when first used on an issue
1105
+ return {"id": name, "name": name, "status": "ready"}
1106
+
1107
+ async def list_project_labels(
1108
+ self, project_key: str | None = None, limit: int = 100
1109
+ ) -> builtins.list[dict[str, Any]]:
1110
+ """List all labels used in a JIRA project.
1111
+
1112
+ JIRA doesn't have a dedicated endpoint for listing project labels.
1113
+ This method queries recent issues and extracts unique labels.
1114
+
1115
+ Args:
1116
+ ----
1117
+ project_key: JIRA project key (e.g., 'PROJ'). If None, uses configured project.
1118
+ limit: Maximum number of labels to return (default: 100)
1119
+
1120
+ Returns:
1121
+ -------
1122
+ List of label dictionaries with 'id', 'name', and 'usage_count' fields
1123
+
1124
+ Raises:
1125
+ ------
1126
+ ValueError: If credentials are invalid or project key not available
1127
+
1128
+ """
1129
+ # Validate credentials before attempting operation
1130
+ is_valid, error_message = self.validate_credentials()
1131
+ if not is_valid:
1132
+ raise ValueError(error_message)
1133
+
1134
+ # Use configured project if not specified
1135
+ key = project_key or self.project_key
1136
+ if not key:
1137
+ raise ValueError("Project key is required")
1138
+
1139
+ try:
1140
+ # Query recent issues to get labels in use
1141
+ jql = f"project = {key} ORDER BY updated DESC"
1142
+ data = await self._make_request(
1143
+ "GET",
1144
+ "search/jql",
1145
+ params={
1146
+ "jql": jql,
1147
+ "maxResults": 500, # Sample from more issues for better coverage
1148
+ "fields": "labels",
1149
+ },
1150
+ )
1151
+
1152
+ # Collect labels with usage count
1153
+ label_counts: dict[str, int] = {}
1154
+ for issue in data.get("issues", []):
1155
+ labels = issue.get("fields", {}).get("labels", [])
1156
+ for label in labels:
1157
+ label_name = (
1158
+ label.get("name", "") if isinstance(label, dict) else str(label)
1159
+ )
1160
+ if label_name:
1161
+ label_counts[label_name] = label_counts.get(label_name, 0) + 1
1162
+
1163
+ # Transform to standardized format with usage counts
1164
+ result = [
1165
+ {"id": label, "name": label, "usage_count": count}
1166
+ for label, count in sorted(
1167
+ label_counts.items(), key=lambda x: x[1], reverse=True
1168
+ )
1169
+ ]
1170
+
1171
+ return result[:limit]
1172
+
1173
+ except Exception as e:
1174
+ logger.error(f"Failed to list project labels: {e}")
1175
+ raise ValueError(f"Failed to list project labels: {e}") from e
1176
+
1177
+ async def list_cycles(
1178
+ self, board_id: str | None = None, state: str | None = None, limit: int = 50
1179
+ ) -> builtins.list[dict[str, Any]]:
1180
+ """List JIRA sprints (cycles) for a board.
1181
+
1182
+ Requires JIRA Agile/Software. Falls back to empty list if not available.
1183
+
1184
+ Args:
1185
+ ----
1186
+ board_id: JIRA Agile board ID. If None, finds first board for project.
1187
+ state: Filter by state ('active', 'closed', 'future'). If None, returns all.
1188
+ limit: Maximum number of sprints to return (default: 50)
1189
+
1190
+ Returns:
1191
+ -------
1192
+ List of sprint dictionaries with fields:
1193
+ - id: Sprint ID
1194
+ - name: Sprint name
1195
+ - state: Sprint state (active, closed, future)
1196
+ - startDate: Start date (ISO format)
1197
+ - endDate: End date (ISO format)
1198
+ - completeDate: Completion date (ISO format, None if not completed)
1199
+ - goal: Sprint goal
1200
+
1201
+ Raises:
1202
+ ------
1203
+ ValueError: If credentials are invalid
1204
+
1205
+ """
1206
+ # Validate credentials before attempting operation
1207
+ is_valid, error_message = self.validate_credentials()
1208
+ if not is_valid:
1209
+ raise ValueError(error_message)
1210
+
1211
+ try:
1212
+ # If no board_id provided, try to find a board for the project
1213
+ if not board_id:
1214
+ boards_data = await self._make_request(
1215
+ "GET",
1216
+ "/rest/agile/1.0/board",
1217
+ params={"projectKeyOrId": self.project_key, "maxResults": 1},
1218
+ )
1219
+ boards = boards_data.get("values", [])
1220
+ if not boards:
1221
+ logger.warning(
1222
+ f"No Agile boards found for project {self.project_key}"
1223
+ )
1224
+ return []
1225
+ board_id = str(boards[0]["id"])
1226
+
1227
+ # Get sprints for the board
1228
+ params = {"maxResults": limit}
1229
+ if state:
1230
+ params["state"] = state
1231
+
1232
+ sprints_data = await self._make_request(
1233
+ "GET", f"/rest/agile/1.0/board/{board_id}/sprint", params=params
1234
+ )
1235
+
1236
+ sprints = sprints_data.get("values", [])
1237
+
1238
+ # Transform to standardized format
1239
+ return [
1240
+ {
1241
+ "id": sprint.get("id"),
1242
+ "name": sprint.get("name"),
1243
+ "state": sprint.get("state"),
1244
+ "startDate": sprint.get("startDate"),
1245
+ "endDate": sprint.get("endDate"),
1246
+ "completeDate": sprint.get("completeDate"),
1247
+ "goal": sprint.get("goal", ""),
1248
+ }
1249
+ for sprint in sprints
1250
+ ]
1251
+
1252
+ except HTTPStatusError as e:
1253
+ if e.response.status_code == 404:
1254
+ logger.warning("JIRA Agile API not available (404)")
1255
+ return []
1256
+ logger.error(f"Failed to list sprints: {e}")
1257
+ raise ValueError(f"Failed to list sprints: {e}") from e
1258
+ except Exception as e:
1259
+ logger.warning(f"JIRA Agile may not be available: {e}")
1260
+ return []
1261
+
1262
+ async def list_issue_statuses(
1263
+ self, project_key: str | None = None
1264
+ ) -> builtins.list[dict[str, Any]]:
1265
+ """List all workflow statuses in JIRA.
1266
+
1267
+ Args:
1268
+ ----
1269
+ project_key: Optional project key to filter statuses.
1270
+ If None, returns all statuses.
1271
+
1272
+ Returns:
1273
+ -------
1274
+ List of status dictionaries with fields:
1275
+ - id: Status ID
1276
+ - name: Status name (e.g., "To Do", "In Progress", "Done")
1277
+ - category: Status category key (e.g., "new", "indeterminate", "done")
1278
+ - categoryName: Human-readable category name
1279
+ - description: Status description
1280
+
1281
+ Raises:
1282
+ ------
1283
+ ValueError: If credentials are invalid
1284
+
1285
+ """
1286
+ # Validate credentials before attempting operation
1287
+ is_valid, error_message = self.validate_credentials()
1288
+ if not is_valid:
1289
+ raise ValueError(error_message)
1290
+
1291
+ try:
1292
+ # Use project-specific statuses if project key provided
1293
+ if project_key:
1294
+ # Get statuses for the project
1295
+ data = await self._make_request(
1296
+ "GET", f"project/{project_key}/statuses"
1297
+ )
1298
+
1299
+ # Extract unique statuses from all issue types
1300
+ status_map: dict[str, dict[str, Any]] = {}
1301
+ for issue_type_data in data:
1302
+ for status in issue_type_data.get("statuses", []):
1303
+ status_id = status.get("id")
1304
+ if status_id not in status_map:
1305
+ status_map[status_id] = status
1306
+
1307
+ statuses = list(status_map.values())
1308
+ else:
1309
+ # Get all statuses
1310
+ statuses = await self._make_request("GET", "status")
1311
+
1312
+ # Transform to standardized format
1313
+ return [
1314
+ {
1315
+ "id": status.get("id"),
1316
+ "name": status.get("name"),
1317
+ "category": status.get("statusCategory", {}).get("key", ""),
1318
+ "categoryName": status.get("statusCategory", {}).get("name", ""),
1319
+ "description": status.get("description", ""),
1320
+ }
1321
+ for status in statuses
1322
+ ]
1323
+
1324
+ except Exception as e:
1325
+ logger.error(f"Failed to list issue statuses: {e}")
1326
+ raise ValueError(f"Failed to list issue statuses: {e}") from e
1327
+
1328
+ async def get_issue_status(self, issue_key: str) -> dict[str, Any] | None:
1329
+ """Get rich status information for an issue.
1330
+
1331
+ Args:
1332
+ ----
1333
+ issue_key: JIRA issue key (e.g., 'PROJ-123')
1334
+
1335
+ Returns:
1336
+ -------
1337
+ Dict with status details and available transitions:
1338
+ - id: Status ID
1339
+ - name: Status name
1340
+ - category: Status category key
1341
+ - categoryName: Human-readable category name
1342
+ - description: Status description
1343
+ - transitions: List of available transitions with:
1344
+ - id: Transition ID
1345
+ - name: Transition name
1346
+ - to: Target status info (id, name, category)
1347
+ Returns None if issue not found.
1348
+
1349
+ Raises:
1350
+ ------
1351
+ ValueError: If credentials are invalid
1352
+
1353
+ """
1354
+ # Validate credentials before attempting operation
1355
+ is_valid, error_message = self.validate_credentials()
1356
+ if not is_valid:
1357
+ raise ValueError(error_message)
1358
+
1359
+ try:
1360
+ # Get issue with status field
1361
+ issue = await self._make_request(
1362
+ "GET", f"issue/{issue_key}", params={"fields": "status"}
1363
+ )
1364
+
1365
+ if not issue:
1366
+ return None
1367
+
1368
+ status = issue.get("fields", {}).get("status", {})
1369
+
1370
+ # Get available transitions
1371
+ transitions_data = await self._make_request(
1372
+ "GET", f"issue/{issue_key}/transitions"
1373
+ )
1374
+ transitions = transitions_data.get("transitions", [])
1375
+
1376
+ # Transform transitions to simplified format
1377
+ transition_list = [
1378
+ {
1379
+ "id": trans.get("id"),
1380
+ "name": trans.get("name"),
1381
+ "to": {
1382
+ "id": trans.get("to", {}).get("id"),
1383
+ "name": trans.get("to", {}).get("name"),
1384
+ "category": trans.get("to", {})
1385
+ .get("statusCategory", {})
1386
+ .get("key", ""),
1387
+ },
1388
+ }
1389
+ for trans in transitions
1390
+ ]
1391
+
1392
+ return {
1393
+ "id": status.get("id"),
1394
+ "name": status.get("name"),
1395
+ "category": status.get("statusCategory", {}).get("key", ""),
1396
+ "categoryName": status.get("statusCategory", {}).get("name", ""),
1397
+ "description": status.get("description", ""),
1398
+ "transitions": transition_list,
1399
+ }
1400
+
1401
+ except HTTPStatusError as e:
1402
+ if e.response.status_code == 404:
1403
+ return None
1404
+ logger.error(f"Failed to get issue status: {e}")
1405
+ raise ValueError(f"Failed to get issue status: {e}") from e
1406
+ except Exception as e:
1407
+ logger.error(f"Failed to get issue status: {e}")
1408
+ raise ValueError(f"Failed to get issue status: {e}") from e
1409
+
1410
+ async def create_epic(
1411
+ self,
1412
+ title: str,
1413
+ description: str = "",
1414
+ priority: Priority = Priority.MEDIUM,
1415
+ tags: list[str] | None = None,
1416
+ **kwargs: Any,
1417
+ ) -> Epic:
1418
+ """Create a new JIRA Epic.
1419
+
1420
+ Args:
1421
+ ----
1422
+ title: Epic title
1423
+ description: Epic description
1424
+ priority: Priority level
1425
+ tags: List of labels
1426
+ **kwargs: Additional fields (reserved for future use)
1427
+
1428
+ Returns:
1429
+ -------
1430
+ Created Epic with ID populated
1431
+
1432
+ Raises:
1433
+ ------
1434
+ ValueError: If credentials are invalid or creation fails
1435
+
1436
+ """
1437
+ # Validate credentials
1438
+ is_valid, error_message = self.validate_credentials()
1439
+ if not is_valid:
1440
+ raise ValueError(error_message)
1441
+
1442
+ # Build epic input
1443
+ epic = Epic(
1444
+ id="", # Will be populated by JIRA
1445
+ title=title,
1446
+ description=description,
1447
+ priority=priority,
1448
+ tags=tags or [],
1449
+ state=TicketState.OPEN,
1450
+ )
1451
+
1452
+ # Create using base create method with Epic type
1453
+ created_epic = await self.create(epic)
1454
+
1455
+ if not isinstance(created_epic, Epic):
1456
+ raise ValueError("Created ticket is not an Epic")
1457
+
1458
+ return created_epic
1459
+
1460
+ async def get_epic(self, epic_id: str) -> Epic | None:
1461
+ """Get a JIRA Epic by key or ID.
1462
+
1463
+ Args:
1464
+ ----
1465
+ epic_id: Epic identifier (key like PROJ-123)
1466
+
1467
+ Returns:
1468
+ -------
1469
+ Epic object if found and is an Epic type, None otherwise
1470
+
1471
+ Raises:
1472
+ ------
1473
+ ValueError: If credentials are invalid
1474
+
1475
+ """
1476
+ # Validate credentials
1477
+ is_valid, error_message = self.validate_credentials()
1478
+ if not is_valid:
1479
+ raise ValueError(error_message)
1480
+
1481
+ # Read issue
1482
+ ticket = await self.read(epic_id)
1483
+
1484
+ if not ticket:
1485
+ return None
1486
+
1487
+ # Verify it's an Epic
1488
+ if not isinstance(ticket, Epic):
1489
+ return None
1490
+
1491
+ return ticket
1492
+
1493
+ async def list_epics(
1494
+ self, limit: int = 50, offset: int = 0, state: str | None = None, **kwargs: Any
1495
+ ) -> builtins.list[Epic]:
1496
+ """List JIRA Epics with pagination.
1497
+
1498
+ Args:
1499
+ ----
1500
+ limit: Maximum number of epics to return (default: 50)
1501
+ offset: Number of epics to skip for pagination (default: 0)
1502
+ state: Filter by state/status name (e.g., "To Do", "In Progress", "Done")
1503
+ **kwargs: Additional filter parameters (reserved for future use)
1504
+
1505
+ Returns:
1506
+ -------
1507
+ List of Epic objects
1508
+
1509
+ Raises:
1510
+ ------
1511
+ ValueError: If credentials are invalid or query fails
1512
+
1513
+ """
1514
+ # Validate credentials
1515
+ is_valid, error_message = self.validate_credentials()
1516
+ if not is_valid:
1517
+ raise ValueError(error_message)
1518
+
1519
+ # Build JQL query for epics
1520
+ jql_parts = [f"project = {self.project_key}", 'issuetype = "Epic"']
1521
+
1522
+ # Add state filter if provided
1523
+ if state:
1524
+ jql_parts.append(f'status = "{state}"')
1525
+
1526
+ jql = " AND ".join(jql_parts) + " ORDER BY updated DESC"
1527
+
1528
+ try:
1529
+ # Execute search
1530
+ data = await self._make_request(
1531
+ "GET",
1532
+ "search/jql",
1533
+ params={
1534
+ "jql": jql,
1535
+ "startAt": offset,
1536
+ "maxResults": limit,
1537
+ "fields": "*all",
1538
+ "expand": "renderedFields",
1539
+ },
1540
+ )
1541
+
1542
+ # Convert issues to tickets
1543
+ issues = data.get("issues", [])
1544
+ epics = []
1545
+
1546
+ for issue in issues:
1547
+ ticket = self._issue_to_ticket(issue)
1548
+ # Only include if it's actually an Epic
1549
+ if isinstance(ticket, Epic):
1550
+ epics.append(ticket)
1551
+
1552
+ return epics
1553
+
1554
+ except Exception as e:
1555
+ raise ValueError(f"Failed to list JIRA epics: {e}") from e
1556
+
1048
1557
  async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
1049
1558
  """Update a JIRA Epic with epic-specific field handling.
1050
1559
 
1051
1560
  Args:
1561
+ ----
1052
1562
  epic_id: Epic identifier (key like PROJ-123 or ID)
1053
1563
  updates: Dictionary with fields to update:
1054
1564
  - title: Epic title (maps to summary)
@@ -1058,9 +1568,11 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
1058
1568
  - priority: Priority level
1059
1569
 
1060
1570
  Returns:
1571
+ -------
1061
1572
  Updated Epic object or None if not found
1062
1573
 
1063
1574
  Raises:
1575
+ ------
1064
1576
  ValueError: If no fields provided for update
1065
1577
  HTTPStatusError: If update fails
1066
1578
 
@@ -1110,14 +1622,17 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
1110
1622
  """Attach file to JIRA issue (including Epic).
1111
1623
 
1112
1624
  Args:
1625
+ ----
1113
1626
  ticket_id: Issue key (e.g., PROJ-123) or ID
1114
1627
  file_path: Path to file to attach
1115
1628
  description: Optional description (stored in metadata, not used by JIRA directly)
1116
1629
 
1117
1630
  Returns:
1631
+ -------
1118
1632
  Attachment object with metadata
1119
1633
 
1120
1634
  Raises:
1635
+ ------
1121
1636
  FileNotFoundError: If file doesn't exist
1122
1637
  ValueError: If credentials invalid
1123
1638
  HTTPStatusError: If upload fails
@@ -1173,12 +1688,15 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
1173
1688
  """Get all attachments for a JIRA issue.
1174
1689
 
1175
1690
  Args:
1691
+ ----
1176
1692
  ticket_id: Issue key or ID
1177
1693
 
1178
1694
  Returns:
1695
+ -------
1179
1696
  List of Attachment objects
1180
1697
 
1181
1698
  Raises:
1699
+ ------
1182
1700
  ValueError: If credentials invalid
1183
1701
  HTTPStatusError: If request fails
1184
1702
 
@@ -1215,13 +1733,16 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
1215
1733
  """Delete an attachment from a JIRA issue.
1216
1734
 
1217
1735
  Args:
1736
+ ----
1218
1737
  ticket_id: Issue key or ID (for validation/context)
1219
1738
  attachment_id: Attachment ID to delete
1220
1739
 
1221
1740
  Returns:
1741
+ -------
1222
1742
  True if deleted successfully, False otherwise
1223
1743
 
1224
1744
  Raises:
1745
+ ------
1225
1746
  ValueError: If credentials invalid
1226
1747
 
1227
1748
  """