mcp-ticketer 0.12.0__py3-none-any.whl → 2.2.13__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 (129) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/aitrackdown.py +507 -6
  5. mcp_ticketer/adapters/asana/adapter.py +229 -0
  6. mcp_ticketer/adapters/asana/mappers.py +14 -0
  7. mcp_ticketer/adapters/github/__init__.py +26 -0
  8. mcp_ticketer/adapters/github/adapter.py +3229 -0
  9. mcp_ticketer/adapters/github/client.py +335 -0
  10. mcp_ticketer/adapters/github/mappers.py +797 -0
  11. mcp_ticketer/adapters/github/queries.py +692 -0
  12. mcp_ticketer/adapters/github/types.py +460 -0
  13. mcp_ticketer/adapters/hybrid.py +47 -5
  14. mcp_ticketer/adapters/jira/__init__.py +35 -0
  15. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  16. mcp_ticketer/adapters/jira/client.py +271 -0
  17. mcp_ticketer/adapters/jira/mappers.py +246 -0
  18. mcp_ticketer/adapters/jira/queries.py +216 -0
  19. mcp_ticketer/adapters/jira/types.py +304 -0
  20. mcp_ticketer/adapters/linear/adapter.py +2730 -139
  21. mcp_ticketer/adapters/linear/client.py +175 -3
  22. mcp_ticketer/adapters/linear/mappers.py +203 -8
  23. mcp_ticketer/adapters/linear/queries.py +280 -3
  24. mcp_ticketer/adapters/linear/types.py +120 -4
  25. mcp_ticketer/analysis/__init__.py +56 -0
  26. mcp_ticketer/analysis/dependency_graph.py +255 -0
  27. mcp_ticketer/analysis/health_assessment.py +304 -0
  28. mcp_ticketer/analysis/orphaned.py +218 -0
  29. mcp_ticketer/analysis/project_status.py +594 -0
  30. mcp_ticketer/analysis/similarity.py +224 -0
  31. mcp_ticketer/analysis/staleness.py +266 -0
  32. mcp_ticketer/automation/__init__.py +11 -0
  33. mcp_ticketer/automation/project_updates.py +378 -0
  34. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  35. mcp_ticketer/cli/auggie_configure.py +17 -5
  36. mcp_ticketer/cli/codex_configure.py +97 -61
  37. mcp_ticketer/cli/configure.py +1288 -105
  38. mcp_ticketer/cli/cursor_configure.py +314 -0
  39. mcp_ticketer/cli/diagnostics.py +13 -12
  40. mcp_ticketer/cli/discover.py +5 -0
  41. mcp_ticketer/cli/gemini_configure.py +17 -5
  42. mcp_ticketer/cli/init_command.py +880 -0
  43. mcp_ticketer/cli/install_mcp_server.py +418 -0
  44. mcp_ticketer/cli/instruction_commands.py +6 -0
  45. mcp_ticketer/cli/main.py +267 -3175
  46. mcp_ticketer/cli/mcp_configure.py +821 -119
  47. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  48. mcp_ticketer/cli/platform_detection.py +77 -12
  49. mcp_ticketer/cli/platform_installer.py +545 -0
  50. mcp_ticketer/cli/project_update_commands.py +350 -0
  51. mcp_ticketer/cli/setup_command.py +795 -0
  52. mcp_ticketer/cli/simple_health.py +12 -10
  53. mcp_ticketer/cli/ticket_commands.py +705 -103
  54. mcp_ticketer/cli/utils.py +113 -0
  55. mcp_ticketer/core/__init__.py +56 -6
  56. mcp_ticketer/core/adapter.py +533 -2
  57. mcp_ticketer/core/config.py +21 -21
  58. mcp_ticketer/core/exceptions.py +7 -1
  59. mcp_ticketer/core/label_manager.py +732 -0
  60. mcp_ticketer/core/mappers.py +31 -19
  61. mcp_ticketer/core/milestone_manager.py +252 -0
  62. mcp_ticketer/core/models.py +480 -0
  63. mcp_ticketer/core/onepassword_secrets.py +1 -1
  64. mcp_ticketer/core/priority_matcher.py +463 -0
  65. mcp_ticketer/core/project_config.py +132 -14
  66. mcp_ticketer/core/project_utils.py +281 -0
  67. mcp_ticketer/core/project_validator.py +376 -0
  68. mcp_ticketer/core/session_state.py +176 -0
  69. mcp_ticketer/core/state_matcher.py +625 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/mcp/server/__main__.py +2 -1
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/main.py +106 -25
  75. mcp_ticketer/mcp/server/routing.py +723 -0
  76. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  77. mcp_ticketer/mcp/server/tools/__init__.py +33 -11
  78. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  79. mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
  80. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  81. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  82. mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
  83. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  84. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  85. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  86. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  87. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  88. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  89. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  90. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  91. mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
  92. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  93. mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
  94. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  95. mcp_ticketer/queue/queue.py +68 -0
  96. mcp_ticketer/queue/worker.py +1 -1
  97. mcp_ticketer/utils/__init__.py +5 -0
  98. mcp_ticketer/utils/token_utils.py +246 -0
  99. mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
  100. mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
  101. mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
  102. py_mcp_installer/examples/phase3_demo.py +178 -0
  103. py_mcp_installer/scripts/manage_version.py +54 -0
  104. py_mcp_installer/setup.py +6 -0
  105. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  106. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  107. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  108. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  109. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  110. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  111. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  112. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  113. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  114. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  115. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  116. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  117. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  118. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  119. py_mcp_installer/tests/__init__.py +0 -0
  120. py_mcp_installer/tests/platforms/__init__.py +0 -0
  121. py_mcp_installer/tests/test_platform_detector.py +17 -0
  122. mcp_ticketer/adapters/github.py +0 -1574
  123. mcp_ticketer/adapters/jira.py +0 -1258
  124. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  125. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  126. mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
  127. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
  128. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
  129. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1351 @@
1
+ """JIRA adapter implementation using REST API v3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import logging
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any, Union
10
+
11
+ from httpx import HTTPStatusError
12
+
13
+ from ...core.adapter import BaseAdapter
14
+ from ...core.env_loader import load_adapter_config, validate_adapter_config
15
+ from ...core.models import (
16
+ Attachment,
17
+ Comment,
18
+ Epic,
19
+ Priority,
20
+ SearchQuery,
21
+ Task,
22
+ TicketState,
23
+ )
24
+ from ...core.registry import AdapterRegistry
25
+ from .client import JiraClient
26
+ from .mappers import (
27
+ issue_to_ticket,
28
+ map_epic_update_fields,
29
+ map_update_fields,
30
+ ticket_to_issue_fields,
31
+ )
32
+ from .queries import (
33
+ build_epic_list_jql,
34
+ build_list_jql,
35
+ build_project_labels_jql,
36
+ build_search_jql,
37
+ get_labels_search_params,
38
+ get_search_params,
39
+ )
40
+ from .types import (
41
+ extract_text_from_adf,
42
+ get_state_mapping,
43
+ parse_jira_datetime,
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
50
+ """Adapter for JIRA using REST API v3."""
51
+
52
+ def __init__(self, config: dict[str, Any]):
53
+ """Initialize JIRA adapter.
54
+
55
+ Args:
56
+ ----
57
+ config: Configuration with:
58
+ - server: JIRA server URL (e.g., https://company.atlassian.net)
59
+ - email: User email for authentication
60
+ - api_token: API token for authentication
61
+ - project_key: Default project key
62
+ - cloud: Whether this is JIRA Cloud (default: True)
63
+ - verify_ssl: Whether to verify SSL certificates (default: True)
64
+ - timeout: Request timeout in seconds (default: 30)
65
+ - max_retries: Maximum retry attempts (default: 3)
66
+
67
+ """
68
+ super().__init__(config)
69
+
70
+ # Load configuration with environment variable resolution
71
+ full_config = load_adapter_config("jira", config)
72
+
73
+ # Validate required configuration
74
+ missing_keys = validate_adapter_config("jira", full_config)
75
+ if missing_keys:
76
+ raise ValueError(
77
+ f"JIRA adapter missing required configuration: {', '.join(missing_keys)}"
78
+ )
79
+
80
+ # Configuration
81
+ self.server = full_config.get("server", "").rstrip("/")
82
+ self.email = full_config.get("email", "")
83
+ self.api_token = full_config.get("api_token", "")
84
+ self.project_key = full_config.get("project_key", "")
85
+ self.is_cloud = full_config.get("cloud", True)
86
+ self.verify_ssl = full_config.get("verify_ssl", True)
87
+ self.timeout = full_config.get("timeout", 30)
88
+ self.max_retries = full_config.get("max_retries", 3)
89
+
90
+ # Initialize HTTP client
91
+ self.client = JiraClient(
92
+ server=self.server,
93
+ email=self.email,
94
+ api_token=self.api_token,
95
+ is_cloud=self.is_cloud,
96
+ verify_ssl=self.verify_ssl,
97
+ timeout=self.timeout,
98
+ max_retries=self.max_retries,
99
+ )
100
+
101
+ # Cache for workflow states and transitions
102
+ self._workflow_cache: dict[str, Any] = {}
103
+ self._priority_cache: list[dict[str, Any]] = []
104
+ self._issue_types_cache: dict[str, Any] = {}
105
+ self._custom_fields_cache: dict[str, Any] = {}
106
+
107
+ def validate_credentials(self) -> tuple[bool, str]:
108
+ """Validate that required credentials are present.
109
+
110
+ Returns:
111
+ -------
112
+ (is_valid, error_message) - Tuple of validation result and error message
113
+
114
+ """
115
+ if not self.server:
116
+ return (
117
+ False,
118
+ "JIRA_SERVER is required but not found. Set it in .env.local or environment.",
119
+ )
120
+ if not self.email:
121
+ return (
122
+ False,
123
+ "JIRA_EMAIL is required but not found. Set it in .env.local or environment.",
124
+ )
125
+ if not self.api_token:
126
+ return (
127
+ False,
128
+ "JIRA_API_TOKEN is required but not found. Set it in .env.local or environment.",
129
+ )
130
+ return True, ""
131
+
132
+ def _get_state_mapping(self) -> dict[TicketState, str]:
133
+ """Map universal states to common JIRA workflow states."""
134
+ return get_state_mapping()
135
+
136
+ async def _get_priorities(self) -> list[dict[str, Any]]:
137
+ """Get available priorities from JIRA."""
138
+ if not self._priority_cache:
139
+ self._priority_cache = await self.client.get("priority")
140
+ return self._priority_cache
141
+
142
+ async def _get_issue_types(
143
+ self, project_key: str | None = None
144
+ ) -> list[dict[str, Any]]:
145
+ """Get available issue types for a project."""
146
+ key = project_key or self.project_key
147
+ if key not in self._issue_types_cache:
148
+ data = await self.client.get(f"project/{key}")
149
+ self._issue_types_cache[key] = data.get("issueTypes", [])
150
+ return self._issue_types_cache[key]
151
+
152
+ async def _get_transitions(self, issue_key: str) -> list[dict[str, Any]]:
153
+ """Get available transitions for an issue."""
154
+ data = await self.client.get(f"issue/{issue_key}/transitions")
155
+ return data.get("transitions", [])
156
+
157
+ async def _get_custom_fields(self) -> dict[str, str]:
158
+ """Get custom field definitions."""
159
+ if not self._custom_fields_cache:
160
+ fields = await self.client.get("field")
161
+ self._custom_fields_cache = {
162
+ field["name"]: field["id"]
163
+ for field in fields
164
+ if field.get("custom", False)
165
+ }
166
+ return self._custom_fields_cache
167
+
168
+ async def create(self, ticket: Epic | Task) -> Epic | Task:
169
+ """Create a new JIRA issue."""
170
+ # Validate credentials before attempting operation
171
+ is_valid, error_message = self.validate_credentials()
172
+ if not is_valid:
173
+ raise ValueError(error_message)
174
+
175
+ # Prepare issue fields
176
+ fields = ticket_to_issue_fields(
177
+ ticket,
178
+ is_cloud=self.is_cloud,
179
+ project_key=self.project_key,
180
+ )
181
+
182
+ # Create issue
183
+ data = await self.client.post("issue", data={"fields": fields})
184
+
185
+ # Set the ID and fetch full issue data
186
+ ticket.id = data.get("key")
187
+
188
+ # Fetch complete issue data
189
+ created_issue = await self.client.get(f"issue/{ticket.id}")
190
+ return issue_to_ticket(created_issue, self.server)
191
+
192
+ async def read(self, ticket_id: str) -> Epic | Task | None:
193
+ """Read a JIRA issue by key."""
194
+ # Validate credentials before attempting operation
195
+ is_valid, error_message = self.validate_credentials()
196
+ if not is_valid:
197
+ raise ValueError(error_message)
198
+
199
+ try:
200
+ issue = await self.client.get(
201
+ f"issue/{ticket_id}", params={"expand": "renderedFields"}
202
+ )
203
+ return issue_to_ticket(issue, self.server)
204
+ except HTTPStatusError as e:
205
+ if e.response.status_code == 404:
206
+ return None
207
+ raise
208
+
209
+ async def update(
210
+ self, ticket_id: str, updates: dict[str, Any]
211
+ ) -> Epic | Task | None:
212
+ """Update a JIRA issue."""
213
+ # Validate credentials before attempting operation
214
+ is_valid, error_message = self.validate_credentials()
215
+ if not is_valid:
216
+ raise ValueError(error_message)
217
+
218
+ # Read current issue
219
+ current = await self.read(ticket_id)
220
+ if not current:
221
+ return None
222
+
223
+ # Prepare update fields
224
+ fields = map_update_fields(updates, is_cloud=self.is_cloud)
225
+
226
+ # Apply update
227
+ if fields:
228
+ await self.client.put(f"issue/{ticket_id}", data={"fields": fields})
229
+
230
+ # Handle state transitions separately
231
+ if "state" in updates:
232
+ await self.transition_state(ticket_id, updates["state"])
233
+
234
+ # Return updated issue
235
+ return await self.read(ticket_id)
236
+
237
+ async def delete(self, ticket_id: str) -> bool:
238
+ """Delete a JIRA issue."""
239
+ # Validate credentials before attempting operation
240
+ is_valid, error_message = self.validate_credentials()
241
+ if not is_valid:
242
+ raise ValueError(error_message)
243
+
244
+ try:
245
+ await self.client.delete(f"issue/{ticket_id}")
246
+ return True
247
+ except HTTPStatusError as e:
248
+ if e.response.status_code == 404:
249
+ return False
250
+ raise
251
+
252
+ async def list(
253
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
254
+ ) -> list[Epic | Task]:
255
+ """List JIRA issues with pagination."""
256
+ # Build JQL query
257
+ jql = build_list_jql(
258
+ self.project_key,
259
+ filters=filters,
260
+ state_mapper=self.map_state_to_system,
261
+ )
262
+
263
+ # Search issues using the JIRA API endpoint
264
+ params = get_search_params(jql, start_at=offset, max_results=limit)
265
+ data = await self.client.get("search/jql", params=params)
266
+
267
+ # Convert issues
268
+ issues = data.get("issues", [])
269
+ return [issue_to_ticket(issue, self.server) for issue in issues]
270
+
271
+ async def search(self, query: SearchQuery) -> builtins.list[Epic | Task]:
272
+ """Search JIRA issues using JQL."""
273
+ # Build JQL query
274
+ jql = build_search_jql(
275
+ self.project_key,
276
+ query,
277
+ state_mapper=self.map_state_to_system,
278
+ )
279
+
280
+ # Execute search using the JIRA API endpoint
281
+ params = get_search_params(
282
+ jql,
283
+ start_at=query.offset,
284
+ max_results=query.limit,
285
+ )
286
+ data = await self.client.get("search/jql", params=params)
287
+
288
+ # Convert and return results
289
+ issues = data.get("issues", [])
290
+ return [issue_to_ticket(issue, self.server) for issue in issues]
291
+
292
+ async def transition_state(
293
+ self, ticket_id: str, target_state: TicketState
294
+ ) -> Epic | Task | None:
295
+ """Transition JIRA issue to a new state."""
296
+ # Get available transitions
297
+ transitions = await self._get_transitions(ticket_id)
298
+
299
+ # Find matching transition
300
+ target_name = self.map_state_to_system(target_state).lower()
301
+ transition = None
302
+
303
+ for trans in transitions:
304
+ trans_name = trans.get("to", {}).get("name", "").lower()
305
+ if target_name in trans_name or trans_name in target_name:
306
+ transition = trans
307
+ break
308
+
309
+ if not transition:
310
+ # Try to find by status category
311
+ for trans in transitions:
312
+ category = (
313
+ trans.get("to", {}).get("statusCategory", {}).get("key", "").lower()
314
+ )
315
+ if (
316
+ (target_state == TicketState.DONE and category == "done")
317
+ or (
318
+ target_state == TicketState.IN_PROGRESS
319
+ and category == "indeterminate"
320
+ )
321
+ or (target_state == TicketState.OPEN and category == "new")
322
+ ):
323
+ transition = trans
324
+ break
325
+
326
+ if not transition:
327
+ logger.warning(
328
+ f"No transition found to move {ticket_id} to {target_state}. "
329
+ f"Available transitions: {[t.get('name') for t in transitions]}"
330
+ )
331
+ return None
332
+
333
+ # Execute transition
334
+ await self.client.post(
335
+ f"issue/{ticket_id}/transitions",
336
+ data={"transition": {"id": transition["id"]}},
337
+ )
338
+
339
+ # Return updated issue
340
+ return await self.read(ticket_id)
341
+
342
+ async def add_comment(self, comment: Comment) -> Comment:
343
+ """Add a comment to a JIRA issue."""
344
+ # Prepare comment data in Atlassian Document Format
345
+ data = {
346
+ "body": {
347
+ "type": "doc",
348
+ "version": 1,
349
+ "content": [
350
+ {
351
+ "type": "paragraph",
352
+ "content": [{"type": "text", "text": comment.content}],
353
+ }
354
+ ],
355
+ }
356
+ }
357
+
358
+ # Add comment
359
+ result = await self.client.post(f"issue/{comment.ticket_id}/comment", data=data)
360
+
361
+ # Update comment with JIRA data
362
+ comment.id = result.get("id")
363
+ comment.created_at = (
364
+ parse_jira_datetime(result.get("created")) or datetime.now()
365
+ )
366
+ comment.author = result.get("author", {}).get("displayName", comment.author)
367
+ comment.metadata["jira"] = result
368
+
369
+ return comment
370
+
371
+ async def get_comments(
372
+ self, ticket_id: str, limit: int = 10, offset: int = 0
373
+ ) -> builtins.list[Comment]:
374
+ """Get comments for a JIRA issue."""
375
+ # Fetch issue with comments
376
+ params = {"expand": "comments", "fields": "comment"}
377
+
378
+ issue = await self.client.get(f"issue/{ticket_id}", params=params)
379
+
380
+ # Extract comments
381
+ comments_data = issue.get("fields", {}).get("comment", {}).get("comments", [])
382
+
383
+ # Apply pagination
384
+ paginated = comments_data[offset : offset + limit]
385
+
386
+ # Convert to Comment objects
387
+ comments = []
388
+ for comment_data in paginated:
389
+ # Extract text content from ADF format
390
+ body_content = comment_data.get("body", "")
391
+ text_content = extract_text_from_adf(body_content)
392
+
393
+ comment = Comment(
394
+ id=comment_data.get("id"),
395
+ ticket_id=ticket_id,
396
+ author=comment_data.get("author", {}).get("displayName", "Unknown"),
397
+ content=text_content,
398
+ created_at=parse_jira_datetime(comment_data.get("created")),
399
+ metadata={"jira": comment_data},
400
+ )
401
+ comments.append(comment)
402
+
403
+ return comments
404
+
405
+ async def get_project_info(self, project_key: str | None = None) -> dict[str, Any]:
406
+ """Get JIRA project information including workflows and fields."""
407
+ key = project_key or self.project_key
408
+ if not key:
409
+ raise ValueError("Project key is required")
410
+
411
+ project = await self.client.get(f"project/{key}")
412
+
413
+ # Get additional project details
414
+ issue_types = await self._get_issue_types(key)
415
+ priorities = await self._get_priorities()
416
+ custom_fields = await self._get_custom_fields()
417
+
418
+ return {
419
+ "project": project,
420
+ "issue_types": issue_types,
421
+ "priorities": priorities,
422
+ "custom_fields": custom_fields,
423
+ }
424
+
425
+ async def execute_jql(
426
+ self, jql: str, limit: int = 50
427
+ ) -> builtins.list[Epic | Task]:
428
+ """Execute a raw JQL query.
429
+
430
+ Args:
431
+ ----
432
+ jql: JIRA Query Language string
433
+ limit: Maximum number of results
434
+
435
+ Returns:
436
+ -------
437
+ List of matching tickets
438
+
439
+ """
440
+ data = await self.client.post(
441
+ "search",
442
+ data={
443
+ "jql": jql,
444
+ "startAt": 0,
445
+ "maxResults": limit,
446
+ "fields": ["*all"],
447
+ },
448
+ )
449
+
450
+ issues = data.get("issues", [])
451
+ return [issue_to_ticket(issue, self.server) for issue in issues]
452
+
453
+ async def get_sprints(
454
+ self, board_id: int | None = None
455
+ ) -> builtins.list[dict[str, Any]]:
456
+ """Get active sprints for a board (requires JIRA Software).
457
+
458
+ Args:
459
+ ----
460
+ board_id: Agile board ID
461
+
462
+ Returns:
463
+ -------
464
+ List of sprint information
465
+
466
+ """
467
+ if not board_id:
468
+ # Try to find a board for the project
469
+ boards_data = await self.client.get(
470
+ "/rest/agile/1.0/board",
471
+ params={"projectKeyOrId": self.project_key},
472
+ )
473
+ boards = boards_data.get("values", [])
474
+ if not boards:
475
+ return []
476
+ board_id = boards[0]["id"]
477
+
478
+ # Get sprints for the board
479
+ sprints_data = await self.client.get(
480
+ f"/rest/agile/1.0/board/{board_id}/sprint",
481
+ params={"state": "active,future"},
482
+ )
483
+
484
+ return sprints_data.get("values", [])
485
+
486
+ async def get_project_users(self) -> builtins.list[dict[str, Any]]:
487
+ """Get users who have access to the project."""
488
+ if not self.project_key:
489
+ return []
490
+
491
+ try:
492
+ # Get project role users
493
+ project_data = await self.client.get(f"project/{self.project_key}")
494
+
495
+ # Get users from project roles
496
+ users = []
497
+ if "roles" in project_data:
498
+ for _role_name, role_url in project_data["roles"].items():
499
+ # Extract role ID from URL
500
+ role_id = role_url.split("/")[-1]
501
+ try:
502
+ role_data = await self.client.get(
503
+ f"project/{self.project_key}/role/{role_id}"
504
+ )
505
+ if "actors" in role_data:
506
+ for actor in role_data["actors"]:
507
+ if actor.get("type") == "atlassian-user-role-actor":
508
+ users.append(actor.get("actorUser", {}))
509
+ except Exception:
510
+ # Skip if role access fails
511
+ continue
512
+
513
+ # Remove duplicates based on accountId
514
+ seen_ids = set()
515
+ unique_users = []
516
+ for user in users:
517
+ account_id = user.get("accountId")
518
+ if account_id and account_id not in seen_ids:
519
+ seen_ids.add(account_id)
520
+ unique_users.append(user)
521
+
522
+ return unique_users
523
+
524
+ except Exception:
525
+ # Fallback: try to get assignable users for the project
526
+ try:
527
+ users_data = await self.client.get(
528
+ "user/assignable/search",
529
+ params={"project": self.project_key, "maxResults": 50},
530
+ )
531
+ return users_data if isinstance(users_data, list) else []
532
+ except Exception:
533
+ return []
534
+
535
+ async def get_current_user(self) -> dict[str, Any] | None:
536
+ """Get current authenticated user information."""
537
+ try:
538
+ return await self.client.get("myself")
539
+ except Exception:
540
+ return None
541
+
542
+ async def list_labels(self) -> builtins.list[dict[str, Any]]:
543
+ """List all labels used in the project.
544
+
545
+ JIRA doesn't have a direct "list all labels" endpoint, so we query
546
+ recent issues and extract unique labels from them.
547
+
548
+ Returns:
549
+ -------
550
+ List of label dictionaries with 'id' and 'name' fields
551
+
552
+ """
553
+ try:
554
+ # Query recent issues to get labels in use
555
+ jql = f"project = {self.project_key} ORDER BY updated DESC"
556
+ params = get_labels_search_params(jql, max_results=100)
557
+ data = await self.client.get("search/jql", params=params)
558
+
559
+ # Collect unique labels
560
+ unique_labels = set()
561
+ for issue in data.get("issues", []):
562
+ labels = issue.get("fields", {}).get("labels", [])
563
+ for label in labels:
564
+ if isinstance(label, dict):
565
+ unique_labels.add(label.get("name", ""))
566
+ else:
567
+ unique_labels.add(str(label))
568
+
569
+ # Transform to standardized format
570
+ return [
571
+ {"id": label, "name": label} for label in sorted(unique_labels) if label
572
+ ]
573
+
574
+ except Exception:
575
+ # Fallback: return empty list if query fails
576
+ return []
577
+
578
+ async def create_issue_label(
579
+ self, name: str, color: str | None = None
580
+ ) -> dict[str, Any]:
581
+ """Create a new issue label in JIRA.
582
+
583
+ Note: JIRA doesn't have a dedicated label creation API. Labels are
584
+ created automatically when first used on an issue. This method
585
+ validates the label name and returns a success response.
586
+
587
+ Args:
588
+ ----
589
+ name: Label name to create
590
+ color: Optional color (JIRA doesn't support colors natively, ignored)
591
+
592
+ Returns:
593
+ -------
594
+ Dict with label details:
595
+ - id: Label name (same as name in JIRA)
596
+ - name: Label name
597
+ - status: "ready" indicating the label can be used
598
+
599
+ Raises:
600
+ ------
601
+ ValueError: If credentials are invalid or label name is invalid
602
+
603
+ """
604
+ # Validate credentials before attempting operation
605
+ is_valid, error_message = self.validate_credentials()
606
+ if not is_valid:
607
+ raise ValueError(error_message)
608
+
609
+ # Validate label name
610
+ if not name or not name.strip():
611
+ raise ValueError("Label name cannot be empty")
612
+
613
+ # JIRA label names must not contain spaces
614
+ if " " in name:
615
+ raise ValueError(
616
+ "JIRA label names cannot contain spaces. Use underscores or hyphens instead."
617
+ )
618
+
619
+ # Return success response
620
+ # The label will be created automatically when first used on an issue
621
+ return {"id": name, "name": name, "status": "ready"}
622
+
623
+ async def list_project_labels(
624
+ self, project_key: str | None = None, limit: int = 100
625
+ ) -> builtins.list[dict[str, Any]]:
626
+ """List all labels used in a JIRA project.
627
+
628
+ JIRA doesn't have a dedicated endpoint for listing project labels.
629
+ This method queries recent issues and extracts unique labels.
630
+
631
+ Args:
632
+ ----
633
+ project_key: JIRA project key (e.g., 'PROJ'). If None, uses configured project.
634
+ limit: Maximum number of labels to return (default: 100)
635
+
636
+ Returns:
637
+ -------
638
+ List of label dictionaries with 'id', 'name', and 'usage_count' fields
639
+
640
+ Raises:
641
+ ------
642
+ ValueError: If credentials are invalid or project key not available
643
+
644
+ """
645
+ # Validate credentials before attempting operation
646
+ is_valid, error_message = self.validate_credentials()
647
+ if not is_valid:
648
+ raise ValueError(error_message)
649
+
650
+ # Use configured project if not specified
651
+ key = project_key or self.project_key
652
+ if not key:
653
+ raise ValueError("Project key is required")
654
+
655
+ try:
656
+ # Query recent issues to get labels in use
657
+ jql = build_project_labels_jql(key, max_results=500)
658
+ params = get_labels_search_params(jql, max_results=500)
659
+ data = await self.client.get("search/jql", params=params)
660
+
661
+ # Collect labels with usage count
662
+ label_counts: dict[str, int] = {}
663
+ for issue in data.get("issues", []):
664
+ labels = issue.get("fields", {}).get("labels", [])
665
+ for label in labels:
666
+ label_name = (
667
+ label.get("name", "") if isinstance(label, dict) else str(label)
668
+ )
669
+ if label_name:
670
+ label_counts[label_name] = label_counts.get(label_name, 0) + 1
671
+
672
+ # Transform to standardized format with usage counts
673
+ result = [
674
+ {"id": label, "name": label, "usage_count": count}
675
+ for label, count in sorted(
676
+ label_counts.items(), key=lambda x: x[1], reverse=True
677
+ )
678
+ ]
679
+
680
+ return result[:limit]
681
+
682
+ except Exception as e:
683
+ logger.error(f"Failed to list project labels: {e}")
684
+ raise ValueError(f"Failed to list project labels: {e}") from e
685
+
686
+ async def list_cycles(
687
+ self, board_id: str | None = None, state: str | None = None, limit: int = 50
688
+ ) -> builtins.list[dict[str, Any]]:
689
+ """List JIRA sprints (cycles) for a board.
690
+
691
+ Requires JIRA Agile/Software. Falls back to empty list if not available.
692
+
693
+ Args:
694
+ ----
695
+ board_id: JIRA Agile board ID. If None, finds first board for project.
696
+ state: Filter by state ('active', 'closed', 'future'). If None, returns all.
697
+ limit: Maximum number of sprints to return (default: 50)
698
+
699
+ Returns:
700
+ -------
701
+ List of sprint dictionaries with fields:
702
+ - id: Sprint ID
703
+ - name: Sprint name
704
+ - state: Sprint state (active, closed, future)
705
+ - startDate: Start date (ISO format)
706
+ - endDate: End date (ISO format)
707
+ - completeDate: Completion date (ISO format, None if not completed)
708
+ - goal: Sprint goal
709
+
710
+ Raises:
711
+ ------
712
+ ValueError: If credentials are invalid
713
+
714
+ """
715
+ # Validate credentials before attempting operation
716
+ is_valid, error_message = self.validate_credentials()
717
+ if not is_valid:
718
+ raise ValueError(error_message)
719
+
720
+ try:
721
+ # If no board_id provided, try to find a board for the project
722
+ if not board_id:
723
+ boards_data = await self.client.get(
724
+ "/rest/agile/1.0/board",
725
+ params={"projectKeyOrId": self.project_key, "maxResults": 1},
726
+ )
727
+ boards = boards_data.get("values", [])
728
+ if not boards:
729
+ logger.warning(
730
+ f"No Agile boards found for project {self.project_key}"
731
+ )
732
+ return []
733
+ board_id = str(boards[0]["id"])
734
+
735
+ # Get sprints for the board
736
+ params = {"maxResults": limit}
737
+ if state:
738
+ params["state"] = state
739
+
740
+ sprints_data = await self.client.get(
741
+ f"/rest/agile/1.0/board/{board_id}/sprint", params=params
742
+ )
743
+
744
+ sprints = sprints_data.get("values", [])
745
+
746
+ # Transform to standardized format
747
+ return [
748
+ {
749
+ "id": sprint.get("id"),
750
+ "name": sprint.get("name"),
751
+ "state": sprint.get("state"),
752
+ "startDate": sprint.get("startDate"),
753
+ "endDate": sprint.get("endDate"),
754
+ "completeDate": sprint.get("completeDate"),
755
+ "goal": sprint.get("goal", ""),
756
+ }
757
+ for sprint in sprints
758
+ ]
759
+
760
+ except HTTPStatusError as e:
761
+ if e.response.status_code == 404:
762
+ logger.warning("JIRA Agile API not available (404)")
763
+ return []
764
+ logger.error(f"Failed to list sprints: {e}")
765
+ raise ValueError(f"Failed to list sprints: {e}") from e
766
+ except Exception as e:
767
+ logger.warning(f"JIRA Agile may not be available: {e}")
768
+ return []
769
+
770
+ async def list_issue_statuses(
771
+ self, project_key: str | None = None
772
+ ) -> builtins.list[dict[str, Any]]:
773
+ """List all workflow statuses in JIRA.
774
+
775
+ Args:
776
+ ----
777
+ project_key: Optional project key to filter statuses.
778
+ If None, returns all statuses.
779
+
780
+ Returns:
781
+ -------
782
+ List of status dictionaries with fields:
783
+ - id: Status ID
784
+ - name: Status name (e.g., "To Do", "In Progress", "Done")
785
+ - category: Status category key (e.g., "new", "indeterminate", "done")
786
+ - categoryName: Human-readable category name
787
+ - description: Status description
788
+
789
+ Raises:
790
+ ------
791
+ ValueError: If credentials are invalid
792
+
793
+ """
794
+ # Validate credentials before attempting operation
795
+ is_valid, error_message = self.validate_credentials()
796
+ if not is_valid:
797
+ raise ValueError(error_message)
798
+
799
+ try:
800
+ # Use project-specific statuses if project key provided
801
+ if project_key:
802
+ # Get statuses for the project
803
+ data = await self.client.get(f"project/{project_key}/statuses")
804
+
805
+ # Extract unique statuses from all issue types
806
+ status_map: dict[str, dict[str, Any]] = {}
807
+ for issue_type_data in data:
808
+ for status in issue_type_data.get("statuses", []):
809
+ status_id = status.get("id")
810
+ if status_id not in status_map:
811
+ status_map[status_id] = status
812
+
813
+ statuses = list(status_map.values())
814
+ else:
815
+ # Get all statuses
816
+ statuses = await self.client.get("status")
817
+
818
+ # Transform to standardized format
819
+ return [
820
+ {
821
+ "id": status.get("id"),
822
+ "name": status.get("name"),
823
+ "category": status.get("statusCategory", {}).get("key", ""),
824
+ "categoryName": status.get("statusCategory", {}).get("name", ""),
825
+ "description": status.get("description", ""),
826
+ }
827
+ for status in statuses
828
+ ]
829
+
830
+ except Exception as e:
831
+ logger.error(f"Failed to list issue statuses: {e}")
832
+ raise ValueError(f"Failed to list issue statuses: {e}") from e
833
+
834
+ async def get_issue_status(self, issue_key: str) -> dict[str, Any] | None:
835
+ """Get rich status information for an issue.
836
+
837
+ Args:
838
+ ----
839
+ issue_key: JIRA issue key (e.g., 'PROJ-123')
840
+
841
+ Returns:
842
+ -------
843
+ Dict with status details and available transitions:
844
+ - id: Status ID
845
+ - name: Status name
846
+ - category: Status category key
847
+ - categoryName: Human-readable category name
848
+ - description: Status description
849
+ - transitions: List of available transitions with:
850
+ - id: Transition ID
851
+ - name: Transition name
852
+ - to: Target status info (id, name, category)
853
+ Returns None if issue not found.
854
+
855
+ Raises:
856
+ ------
857
+ ValueError: If credentials are invalid
858
+
859
+ """
860
+ # Validate credentials before attempting operation
861
+ is_valid, error_message = self.validate_credentials()
862
+ if not is_valid:
863
+ raise ValueError(error_message)
864
+
865
+ try:
866
+ # Get issue with status field
867
+ issue = await self.client.get(
868
+ f"issue/{issue_key}", params={"fields": "status"}
869
+ )
870
+
871
+ if not issue:
872
+ return None
873
+
874
+ status = issue.get("fields", {}).get("status", {})
875
+
876
+ # Get available transitions
877
+ transitions_data = await self.client.get(f"issue/{issue_key}/transitions")
878
+ transitions = transitions_data.get("transitions", [])
879
+
880
+ # Transform transitions to simplified format
881
+ transition_list = [
882
+ {
883
+ "id": trans.get("id"),
884
+ "name": trans.get("name"),
885
+ "to": {
886
+ "id": trans.get("to", {}).get("id"),
887
+ "name": trans.get("to", {}).get("name"),
888
+ "category": trans.get("to", {})
889
+ .get("statusCategory", {})
890
+ .get("key", ""),
891
+ },
892
+ }
893
+ for trans in transitions
894
+ ]
895
+
896
+ return {
897
+ "id": status.get("id"),
898
+ "name": status.get("name"),
899
+ "category": status.get("statusCategory", {}).get("key", ""),
900
+ "categoryName": status.get("statusCategory", {}).get("name", ""),
901
+ "description": status.get("description", ""),
902
+ "transitions": transition_list,
903
+ }
904
+
905
+ except HTTPStatusError as e:
906
+ if e.response.status_code == 404:
907
+ return None
908
+ logger.error(f"Failed to get issue status: {e}")
909
+ raise ValueError(f"Failed to get issue status: {e}") from e
910
+ except Exception as e:
911
+ logger.error(f"Failed to get issue status: {e}")
912
+ raise ValueError(f"Failed to get issue status: {e}") from e
913
+
914
+ async def create_epic(
915
+ self,
916
+ title: str,
917
+ description: str = "",
918
+ priority: Priority = Priority.MEDIUM,
919
+ tags: list[str] | None = None,
920
+ **kwargs: Any,
921
+ ) -> Epic:
922
+ """Create a new JIRA Epic.
923
+
924
+ Args:
925
+ ----
926
+ title: Epic title
927
+ description: Epic description
928
+ priority: Priority level
929
+ tags: List of labels
930
+ **kwargs: Additional fields (reserved for future use)
931
+
932
+ Returns:
933
+ -------
934
+ Created Epic with ID populated
935
+
936
+ Raises:
937
+ ------
938
+ ValueError: If credentials are invalid or creation fails
939
+
940
+ """
941
+ # Validate credentials
942
+ is_valid, error_message = self.validate_credentials()
943
+ if not is_valid:
944
+ raise ValueError(error_message)
945
+
946
+ # Build epic input
947
+ epic = Epic(
948
+ id="", # Will be populated by JIRA
949
+ title=title,
950
+ description=description,
951
+ priority=priority,
952
+ tags=tags or [],
953
+ state=TicketState.OPEN,
954
+ )
955
+
956
+ # Create using base create method with Epic type
957
+ created_epic = await self.create(epic)
958
+
959
+ if not isinstance(created_epic, Epic):
960
+ raise ValueError("Created ticket is not an Epic")
961
+
962
+ return created_epic
963
+
964
+ async def get_epic(self, epic_id: str) -> Epic | None:
965
+ """Get a JIRA Epic by key or ID.
966
+
967
+ Args:
968
+ ----
969
+ epic_id: Epic identifier (key like PROJ-123)
970
+
971
+ Returns:
972
+ -------
973
+ Epic object if found and is an Epic type, None otherwise
974
+
975
+ Raises:
976
+ ------
977
+ ValueError: If credentials are invalid
978
+
979
+ """
980
+ # Validate credentials
981
+ is_valid, error_message = self.validate_credentials()
982
+ if not is_valid:
983
+ raise ValueError(error_message)
984
+
985
+ # Read issue
986
+ ticket = await self.read(epic_id)
987
+
988
+ if not ticket:
989
+ return None
990
+
991
+ # Verify it's an Epic
992
+ if not isinstance(ticket, Epic):
993
+ return None
994
+
995
+ return ticket
996
+
997
+ async def list_epics(
998
+ self, limit: int = 50, offset: int = 0, state: str | None = None, **kwargs: Any
999
+ ) -> builtins.list[Epic]:
1000
+ """List JIRA Epics with pagination.
1001
+
1002
+ Args:
1003
+ ----
1004
+ limit: Maximum number of epics to return (default: 50)
1005
+ offset: Number of epics to skip for pagination (default: 0)
1006
+ state: Filter by state/status name (e.g., "To Do", "In Progress", "Done")
1007
+ **kwargs: Additional filter parameters (reserved for future use)
1008
+
1009
+ Returns:
1010
+ -------
1011
+ List of Epic objects
1012
+
1013
+ Raises:
1014
+ ------
1015
+ ValueError: If credentials are invalid or query fails
1016
+
1017
+ """
1018
+ # Validate credentials
1019
+ is_valid, error_message = self.validate_credentials()
1020
+ if not is_valid:
1021
+ raise ValueError(error_message)
1022
+
1023
+ # Build JQL query for epics
1024
+ jql = build_epic_list_jql(self.project_key, state=state)
1025
+
1026
+ try:
1027
+ # Execute search
1028
+ params = get_search_params(jql, start_at=offset, max_results=limit)
1029
+ data = await self.client.get("search/jql", params=params)
1030
+
1031
+ # Convert issues to tickets
1032
+ issues = data.get("issues", [])
1033
+ epics = []
1034
+
1035
+ for issue in issues:
1036
+ ticket = issue_to_ticket(issue, self.server)
1037
+ # Only include if it's actually an Epic
1038
+ if isinstance(ticket, Epic):
1039
+ epics.append(ticket)
1040
+
1041
+ return epics
1042
+
1043
+ except Exception as e:
1044
+ raise ValueError(f"Failed to list JIRA epics: {e}") from e
1045
+
1046
+ async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
1047
+ """Update a JIRA Epic with epic-specific field handling.
1048
+
1049
+ Args:
1050
+ ----
1051
+ epic_id: Epic identifier (key like PROJ-123 or ID)
1052
+ updates: Dictionary with fields to update:
1053
+ - title: Epic title (maps to summary)
1054
+ - description: Epic description (auto-converted to ADF)
1055
+ - state: TicketState value (transitions via workflow)
1056
+ - tags: List of labels
1057
+ - priority: Priority level
1058
+
1059
+ Returns:
1060
+ -------
1061
+ Updated Epic object or None if not found
1062
+
1063
+ Raises:
1064
+ ------
1065
+ ValueError: If no fields provided for update
1066
+ HTTPStatusError: If update fails
1067
+
1068
+ """
1069
+ fields = map_epic_update_fields(updates)
1070
+
1071
+ if not fields and "state" not in updates:
1072
+ raise ValueError("At least one field must be updated")
1073
+
1074
+ # Apply field updates if any
1075
+ if fields:
1076
+ await self.client.put(f"issue/{epic_id}", data={"fields": fields})
1077
+
1078
+ # Handle state transitions separately (JIRA uses workflow transitions)
1079
+ if "state" in updates:
1080
+ await self.transition_state(epic_id, updates["state"])
1081
+
1082
+ # Fetch and return updated epic
1083
+ return await self.read(epic_id)
1084
+
1085
+ async def add_attachment(
1086
+ self, ticket_id: str, file_path: str, description: str | None = None
1087
+ ) -> Attachment:
1088
+ """Attach file to JIRA issue (including Epic).
1089
+
1090
+ Args:
1091
+ ----
1092
+ ticket_id: Issue key (e.g., PROJ-123) or ID
1093
+ file_path: Path to file to attach
1094
+ description: Optional description (stored in metadata, not used by JIRA directly)
1095
+
1096
+ Returns:
1097
+ -------
1098
+ Attachment object with metadata
1099
+
1100
+ Raises:
1101
+ ------
1102
+ FileNotFoundError: If file doesn't exist
1103
+ ValueError: If credentials invalid
1104
+ HTTPStatusError: If upload fails
1105
+
1106
+ """
1107
+ # Validate credentials before attempting operation
1108
+ is_valid, error_message = self.validate_credentials()
1109
+ if not is_valid:
1110
+ raise ValueError(error_message)
1111
+
1112
+ file_path_obj = Path(file_path)
1113
+ if not file_path_obj.exists():
1114
+ raise FileNotFoundError(f"File not found: {file_path}")
1115
+
1116
+ # Upload file
1117
+ result = await self.client.upload_file(
1118
+ f"issue/{ticket_id}/attachments",
1119
+ str(file_path_obj),
1120
+ file_path_obj.name,
1121
+ )
1122
+
1123
+ # JIRA returns array with single attachment
1124
+ attachment_data = result[0]
1125
+
1126
+ return Attachment(
1127
+ id=attachment_data["id"],
1128
+ ticket_id=ticket_id,
1129
+ filename=attachment_data["filename"],
1130
+ url=attachment_data["content"],
1131
+ content_type=attachment_data["mimeType"],
1132
+ size_bytes=attachment_data["size"],
1133
+ created_at=parse_jira_datetime(attachment_data["created"]),
1134
+ created_by=attachment_data["author"]["displayName"],
1135
+ description=description,
1136
+ metadata={"jira": attachment_data},
1137
+ )
1138
+
1139
+ async def get_attachments(self, ticket_id: str) -> builtins.list[Attachment]:
1140
+ """Get all attachments for a JIRA issue.
1141
+
1142
+ Args:
1143
+ ----
1144
+ ticket_id: Issue key or ID
1145
+
1146
+ Returns:
1147
+ -------
1148
+ List of Attachment objects
1149
+
1150
+ Raises:
1151
+ ------
1152
+ ValueError: If credentials invalid
1153
+ HTTPStatusError: If request fails
1154
+
1155
+ """
1156
+ # Validate credentials before attempting operation
1157
+ is_valid, error_message = self.validate_credentials()
1158
+ if not is_valid:
1159
+ raise ValueError(error_message)
1160
+
1161
+ # Fetch issue with attachment field
1162
+ issue = await self.client.get(
1163
+ f"issue/{ticket_id}", params={"fields": "attachment"}
1164
+ )
1165
+
1166
+ attachments = []
1167
+ for att_data in issue.get("fields", {}).get("attachment", []):
1168
+ attachments.append(
1169
+ Attachment(
1170
+ id=att_data["id"],
1171
+ ticket_id=ticket_id,
1172
+ filename=att_data["filename"],
1173
+ url=att_data["content"],
1174
+ content_type=att_data["mimeType"],
1175
+ size_bytes=att_data["size"],
1176
+ created_at=parse_jira_datetime(att_data["created"]),
1177
+ created_by=att_data["author"]["displayName"],
1178
+ metadata={"jira": att_data},
1179
+ )
1180
+ )
1181
+
1182
+ return attachments
1183
+
1184
+ async def delete_attachment(self, ticket_id: str, attachment_id: str) -> bool:
1185
+ """Delete an attachment from a JIRA issue.
1186
+
1187
+ Args:
1188
+ ----
1189
+ ticket_id: Issue key or ID (for validation/context)
1190
+ attachment_id: Attachment ID to delete
1191
+
1192
+ Returns:
1193
+ -------
1194
+ True if deleted successfully, False otherwise
1195
+
1196
+ Raises:
1197
+ ------
1198
+ ValueError: If credentials invalid
1199
+
1200
+ """
1201
+ # Validate credentials before attempting operation
1202
+ is_valid, error_message = self.validate_credentials()
1203
+ if not is_valid:
1204
+ raise ValueError(error_message)
1205
+
1206
+ try:
1207
+ await self.client.delete(f"attachment/{attachment_id}")
1208
+ return True
1209
+ except HTTPStatusError as e:
1210
+ if e.response.status_code == 404:
1211
+ logger.warning(f"Attachment {attachment_id} not found")
1212
+ return False
1213
+ logger.error(
1214
+ f"Failed to delete attachment {attachment_id}: {e.response.status_code} - {e.response.text}"
1215
+ )
1216
+ return False
1217
+ except Exception as e:
1218
+ logger.error(f"Unexpected error deleting attachment {attachment_id}: {e}")
1219
+ return False
1220
+
1221
+ async def close(self) -> None:
1222
+ """Close the adapter and cleanup resources."""
1223
+ # Clear caches
1224
+ self._workflow_cache.clear()
1225
+ self._priority_cache.clear()
1226
+ self._issue_types_cache.clear()
1227
+ self._custom_fields_cache.clear()
1228
+
1229
+ # Milestone Methods (Not yet implemented)
1230
+
1231
+ async def milestone_create(
1232
+ self,
1233
+ name: str,
1234
+ target_date: datetime | None = None,
1235
+ labels: list[str] | None = None,
1236
+ description: str = "",
1237
+ project_id: str | None = None,
1238
+ ) -> Any:
1239
+ """Create milestone - not yet implemented for Jira.
1240
+
1241
+ Args:
1242
+ ----
1243
+ name: Milestone name
1244
+ target_date: Target completion date
1245
+ labels: Labels that define this milestone
1246
+ description: Milestone description
1247
+ project_id: Associated project ID
1248
+
1249
+ Raises:
1250
+ ------
1251
+ NotImplementedError: Milestone support coming in v2.1.0
1252
+
1253
+ """
1254
+ raise NotImplementedError("Milestone support for Jira coming in v2.1.0")
1255
+
1256
+ async def milestone_get(self, milestone_id: str) -> Any:
1257
+ """Get milestone - not yet implemented for Jira.
1258
+
1259
+ Args:
1260
+ ----
1261
+ milestone_id: Milestone identifier
1262
+
1263
+ Raises:
1264
+ ------
1265
+ NotImplementedError: Milestone support coming in v2.1.0
1266
+
1267
+ """
1268
+ raise NotImplementedError("Milestone support for Jira coming in v2.1.0")
1269
+
1270
+ async def milestone_list(
1271
+ self,
1272
+ project_id: str | None = None,
1273
+ state: str | None = None,
1274
+ ) -> list[Any]:
1275
+ """List milestones - not yet implemented for Jira.
1276
+
1277
+ Args:
1278
+ ----
1279
+ project_id: Filter by project
1280
+ state: Filter by state
1281
+
1282
+ Raises:
1283
+ ------
1284
+ NotImplementedError: Milestone support coming in v2.1.0
1285
+
1286
+ """
1287
+ raise NotImplementedError("Milestone support for Jira coming in v2.1.0")
1288
+
1289
+ async def milestone_update(
1290
+ self,
1291
+ milestone_id: str,
1292
+ name: str | None = None,
1293
+ target_date: datetime | None = None,
1294
+ state: str | None = None,
1295
+ labels: list[str] | None = None,
1296
+ description: str | None = None,
1297
+ ) -> Any:
1298
+ """Update milestone - not yet implemented for Jira.
1299
+
1300
+ Args:
1301
+ ----
1302
+ milestone_id: Milestone identifier
1303
+ name: New name
1304
+ target_date: New target date
1305
+ state: New state
1306
+ labels: New labels
1307
+ description: New description
1308
+
1309
+ Raises:
1310
+ ------
1311
+ NotImplementedError: Milestone support coming in v2.1.0
1312
+
1313
+ """
1314
+ raise NotImplementedError("Milestone support for Jira coming in v2.1.0")
1315
+
1316
+ async def milestone_delete(self, milestone_id: str) -> bool:
1317
+ """Delete milestone - not yet implemented for Jira.
1318
+
1319
+ Args:
1320
+ ----
1321
+ milestone_id: Milestone identifier
1322
+
1323
+ Raises:
1324
+ ------
1325
+ NotImplementedError: Milestone support coming in v2.1.0
1326
+
1327
+ """
1328
+ raise NotImplementedError("Milestone support for Jira coming in v2.1.0")
1329
+
1330
+ async def milestone_get_issues(
1331
+ self,
1332
+ milestone_id: str,
1333
+ state: str | None = None,
1334
+ ) -> list[Any]:
1335
+ """Get milestone issues - not yet implemented for Jira.
1336
+
1337
+ Args:
1338
+ ----
1339
+ milestone_id: Milestone identifier
1340
+ state: Filter by issue state
1341
+
1342
+ Raises:
1343
+ ------
1344
+ NotImplementedError: Milestone support coming in v2.1.0
1345
+
1346
+ """
1347
+ raise NotImplementedError("Milestone support for Jira coming in v2.1.0")
1348
+
1349
+
1350
+ # Register the adapter
1351
+ AdapterRegistry.register("jira", JiraAdapter)