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
@@ -1,1258 +0,0 @@
1
- """JIRA adapter implementation using REST API v3."""
2
-
3
- import asyncio
4
- import builtins
5
- import logging
6
- import re
7
- from datetime import datetime
8
- from enum import Enum
9
- from typing import Any, Union
10
-
11
- import httpx
12
- from httpx import AsyncClient, HTTPStatusError, TimeoutException
13
-
14
- from ..core.adapter import BaseAdapter
15
- from ..core.env_loader import load_adapter_config, validate_adapter_config
16
- from ..core.models import (
17
- Attachment,
18
- Comment,
19
- Epic,
20
- Priority,
21
- SearchQuery,
22
- Task,
23
- TicketState,
24
- )
25
- from ..core.registry import AdapterRegistry
26
-
27
- logger = logging.getLogger(__name__)
28
-
29
-
30
- def parse_jira_datetime(date_str: str) -> datetime | None:
31
- """Parse JIRA datetime strings which can be in various formats.
32
-
33
- JIRA can return dates in formats like:
34
- - 2025-10-24T14:12:18.771-0400
35
- - 2025-10-24T14:12:18.771Z
36
- - 2025-10-24T14:12:18.771+00:00
37
- """
38
- if not date_str:
39
- return None
40
-
41
- try:
42
- # Handle Z timezone
43
- if date_str.endswith("Z"):
44
- return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
45
-
46
- # Handle timezone formats like -0400, +0500 (need to add colon)
47
- if re.match(r".*[+-]\d{4}$", date_str):
48
- # Insert colon in timezone: -0400 -> -04:00
49
- date_str = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", date_str)
50
-
51
- return datetime.fromisoformat(date_str)
52
-
53
- except (ValueError, TypeError) as e:
54
- logger.warning(f"Failed to parse JIRA datetime '{date_str}': {e}")
55
- return None
56
-
57
-
58
- def extract_text_from_adf(adf_content: str | dict[str, Any]) -> str:
59
- """Extract plain text from Atlassian Document Format (ADF).
60
-
61
- Args:
62
- adf_content: Either a string (already plain text) or ADF document dict
63
-
64
- Returns:
65
- Plain text string extracted from the ADF content
66
-
67
- """
68
- if isinstance(adf_content, str):
69
- return adf_content
70
-
71
- if not isinstance(adf_content, dict):
72
- return str(adf_content) if adf_content else ""
73
-
74
- def extract_text_recursive(node: dict[str, Any]) -> str:
75
- """Recursively extract text from ADF nodes."""
76
- if not isinstance(node, dict):
77
- return ""
78
-
79
- # If this is a text node, return its text
80
- if node.get("type") == "text":
81
- return node.get("text", "")
82
-
83
- # If this node has content, process it recursively
84
- content = node.get("content", [])
85
- if isinstance(content, list):
86
- return "".join(extract_text_recursive(child) for child in content)
87
-
88
- return ""
89
-
90
- try:
91
- return extract_text_recursive(adf_content)
92
- except Exception as e:
93
- logger.warning(f"Failed to extract text from ADF: {e}")
94
- return str(adf_content) if adf_content else ""
95
-
96
-
97
- class JiraIssueType(str, Enum):
98
- """Common JIRA issue types."""
99
-
100
- EPIC = "Epic"
101
- STORY = "Story"
102
- TASK = "Task"
103
- BUG = "Bug"
104
- SUBTASK = "Sub-task"
105
- IMPROVEMENT = "Improvement"
106
- NEW_FEATURE = "New Feature"
107
-
108
-
109
- class JiraPriority(str, Enum):
110
- """Standard JIRA priority levels."""
111
-
112
- HIGHEST = "Highest"
113
- HIGH = "High"
114
- MEDIUM = "Medium"
115
- LOW = "Low"
116
- LOWEST = "Lowest"
117
-
118
-
119
- class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
120
- """Adapter for JIRA using REST API v3."""
121
-
122
- def __init__(self, config: dict[str, Any]):
123
- """Initialize JIRA adapter.
124
-
125
- Args:
126
- config: Configuration with:
127
- - server: JIRA server URL (e.g., https://company.atlassian.net)
128
- - email: User email for authentication
129
- - api_token: API token for authentication
130
- - project_key: Default project key
131
- - cloud: Whether this is JIRA Cloud (default: True)
132
- - verify_ssl: Whether to verify SSL certificates (default: True)
133
- - timeout: Request timeout in seconds (default: 30)
134
- - max_retries: Maximum retry attempts (default: 3)
135
-
136
- """
137
- super().__init__(config)
138
-
139
- # Load configuration with environment variable resolution
140
- full_config = load_adapter_config("jira", config)
141
-
142
- # Validate required configuration
143
- missing_keys = validate_adapter_config("jira", full_config)
144
- if missing_keys:
145
- raise ValueError(
146
- f"JIRA adapter missing required configuration: {', '.join(missing_keys)}"
147
- )
148
-
149
- # Configuration
150
- self.server = full_config.get("server", "")
151
- self.email = full_config.get("email", "")
152
- self.api_token = full_config.get("api_token", "")
153
- self.project_key = full_config.get("project_key", "")
154
- self.is_cloud = full_config.get("cloud", True)
155
- self.verify_ssl = full_config.get("verify_ssl", True)
156
- self.timeout = full_config.get("timeout", 30)
157
- self.max_retries = full_config.get("max_retries", 3)
158
-
159
- # Clean up server URL
160
- self.server = self.server.rstrip("/")
161
-
162
- # API base URL
163
- self.api_base = (
164
- f"{self.server}/rest/api/3"
165
- if self.is_cloud
166
- else f"{self.server}/rest/api/2"
167
- )
168
-
169
- # HTTP client setup
170
- self.auth = httpx.BasicAuth(self.email, self.api_token)
171
- self.headers = {
172
- "Accept": "application/json",
173
- "Content-Type": "application/json",
174
- }
175
-
176
- # Cache for workflow states and transitions
177
- self._workflow_cache: dict[str, Any] = {}
178
- self._priority_cache: list[dict[str, Any]] = []
179
- self._issue_types_cache: dict[str, Any] = {}
180
- self._custom_fields_cache: dict[str, Any] = {}
181
-
182
- def validate_credentials(self) -> tuple[bool, str]:
183
- """Validate that required credentials are present.
184
-
185
- Returns:
186
- (is_valid, error_message) - Tuple of validation result and error message
187
-
188
- """
189
- if not self.server:
190
- return (
191
- False,
192
- "JIRA_SERVER is required but not found. Set it in .env.local or environment.",
193
- )
194
- if not self.email:
195
- return (
196
- False,
197
- "JIRA_EMAIL is required but not found. Set it in .env.local or environment.",
198
- )
199
- if not self.api_token:
200
- return (
201
- False,
202
- "JIRA_API_TOKEN is required but not found. Set it in .env.local or environment.",
203
- )
204
- return True, ""
205
-
206
- def _get_state_mapping(self) -> dict[TicketState, str]:
207
- """Map universal states to common JIRA workflow states."""
208
- return {
209
- TicketState.OPEN: "To Do",
210
- TicketState.IN_PROGRESS: "In Progress",
211
- TicketState.READY: "In Review",
212
- TicketState.TESTED: "Testing",
213
- TicketState.DONE: "Done",
214
- TicketState.WAITING: "Waiting",
215
- TicketState.BLOCKED: "Blocked",
216
- TicketState.CLOSED: "Closed",
217
- }
218
-
219
- async def _get_client(self) -> AsyncClient:
220
- """Get configured async HTTP client."""
221
- return AsyncClient(
222
- auth=self.auth,
223
- headers=self.headers,
224
- timeout=self.timeout,
225
- verify=self.verify_ssl,
226
- )
227
-
228
- async def _make_request(
229
- self,
230
- method: str,
231
- endpoint: str,
232
- data: dict[str, Any] | None = None,
233
- params: dict[str, Any] | None = None,
234
- retry_count: int = 0,
235
- ) -> dict[str, Any]:
236
- """Make HTTP request to JIRA API with retry logic.
237
-
238
- Args:
239
- method: HTTP method
240
- endpoint: API endpoint
241
- data: Request body data
242
- params: Query parameters
243
- retry_count: Current retry attempt
244
-
245
- Returns:
246
- Response data
247
-
248
- Raises:
249
- HTTPStatusError: On API errors
250
- TimeoutException: On timeout
251
-
252
- """
253
- url = f"{self.api_base}/{endpoint.lstrip('/')}"
254
-
255
- async with await self._get_client() as client:
256
- try:
257
- response = await client.request(
258
- method=method, url=url, json=data, params=params
259
- )
260
- response.raise_for_status()
261
-
262
- # Handle empty responses
263
- if response.status_code == 204:
264
- return {}
265
-
266
- return response.json()
267
-
268
- except TimeoutException as e:
269
- if retry_count < self.max_retries:
270
- await asyncio.sleep(2**retry_count) # Exponential backoff
271
- return await self._make_request(
272
- method, endpoint, data, params, retry_count + 1
273
- )
274
- raise e
275
-
276
- except HTTPStatusError as e:
277
- # Handle rate limiting
278
- if e.response.status_code == 429 and retry_count < self.max_retries:
279
- retry_after = int(e.response.headers.get("Retry-After", 5))
280
- await asyncio.sleep(retry_after)
281
- return await self._make_request(
282
- method, endpoint, data, params, retry_count + 1
283
- )
284
-
285
- # Log error details
286
- logger.error(
287
- f"JIRA API error: {e.response.status_code} - {e.response.text}"
288
- )
289
- raise e
290
-
291
- async def _get_priorities(self) -> list[dict[str, Any]]:
292
- """Get available priorities from JIRA."""
293
- if not self._priority_cache:
294
- self._priority_cache = await self._make_request("GET", "priority")
295
- return self._priority_cache
296
-
297
- async def _get_issue_types(
298
- self, project_key: str | None = None
299
- ) -> list[dict[str, Any]]:
300
- """Get available issue types for a project."""
301
- key = project_key or self.project_key
302
- if key not in self._issue_types_cache:
303
- data = await self._make_request("GET", f"project/{key}")
304
- self._issue_types_cache[key] = data.get("issueTypes", [])
305
- return self._issue_types_cache[key]
306
-
307
- async def _get_transitions(self, issue_key: str) -> list[dict[str, Any]]:
308
- """Get available transitions for an issue."""
309
- data = await self._make_request("GET", f"issue/{issue_key}/transitions")
310
- return data.get("transitions", [])
311
-
312
- async def _get_custom_fields(self) -> dict[str, str]:
313
- """Get custom field definitions."""
314
- if not self._custom_fields_cache:
315
- fields = await self._make_request("GET", "field")
316
- self._custom_fields_cache = {
317
- field["name"]: field["id"]
318
- for field in fields
319
- if field.get("custom", False)
320
- }
321
- return self._custom_fields_cache
322
-
323
- def _convert_from_adf(self, adf_content: Any) -> str:
324
- """Convert Atlassian Document Format (ADF) to plain text.
325
-
326
- This extracts text content from ADF structure for display.
327
- """
328
- if not adf_content:
329
- return ""
330
-
331
- # If it's already a string, return it (JIRA Server)
332
- if isinstance(adf_content, str):
333
- return adf_content
334
-
335
- # Handle ADF structure
336
- if not isinstance(adf_content, dict):
337
- return str(adf_content)
338
-
339
- content_nodes = adf_content.get("content", [])
340
- lines = []
341
-
342
- for node in content_nodes:
343
- if node.get("type") == "paragraph":
344
- paragraph_text = ""
345
- for content_item in node.get("content", []):
346
- if content_item.get("type") == "text":
347
- paragraph_text += content_item.get("text", "")
348
- lines.append(paragraph_text)
349
- elif node.get("type") == "heading":
350
- heading_text = ""
351
- for content_item in node.get("content", []):
352
- if content_item.get("type") == "text":
353
- heading_text += content_item.get("text", "")
354
- lines.append(heading_text)
355
-
356
- return "\n".join(lines)
357
-
358
- def _convert_to_adf(self, text: str) -> dict[str, Any]:
359
- """Convert plain text to Atlassian Document Format (ADF).
360
-
361
- ADF is required for JIRA Cloud description fields.
362
- This creates a simple document with paragraphs for each line.
363
- """
364
- if not text:
365
- return {"type": "doc", "version": 1, "content": []}
366
-
367
- # Split text into lines and create paragraphs
368
- lines = text.split("\n")
369
- content = []
370
-
371
- for line in lines:
372
- if line.strip(): # Non-empty line
373
- content.append(
374
- {"type": "paragraph", "content": [{"type": "text", "text": line}]}
375
- )
376
- else: # Empty line becomes empty paragraph
377
- content.append({"type": "paragraph", "content": []})
378
-
379
- return {"type": "doc", "version": 1, "content": content}
380
-
381
- def _map_priority_to_jira(self, priority: Priority) -> str:
382
- """Map universal priority to JIRA priority."""
383
- mapping = {
384
- Priority.CRITICAL: JiraPriority.HIGHEST,
385
- Priority.HIGH: JiraPriority.HIGH,
386
- Priority.MEDIUM: JiraPriority.MEDIUM,
387
- Priority.LOW: JiraPriority.LOW,
388
- }
389
- return mapping.get(priority, JiraPriority.MEDIUM)
390
-
391
- def _map_priority_from_jira(self, jira_priority: dict[str, Any] | None) -> Priority:
392
- """Map JIRA priority to universal priority."""
393
- if not jira_priority:
394
- return Priority.MEDIUM
395
-
396
- name = jira_priority.get("name", "").lower()
397
-
398
- if "highest" in name or "urgent" in name or "critical" in name:
399
- return Priority.CRITICAL
400
- elif "high" in name:
401
- return Priority.HIGH
402
- elif "low" in name:
403
- return Priority.LOW
404
- else:
405
- return Priority.MEDIUM
406
-
407
- def _map_state_from_jira(self, status: dict[str, Any]) -> TicketState:
408
- """Map JIRA status to universal state."""
409
- if not status:
410
- return TicketState.OPEN
411
-
412
- name = status.get("name", "").lower()
413
- category = status.get("statusCategory", {}).get("key", "").lower()
414
-
415
- # Try to match by category first (more reliable)
416
- if category == "new":
417
- return TicketState.OPEN
418
- elif category == "indeterminate":
419
- return TicketState.IN_PROGRESS
420
- elif category == "done":
421
- return TicketState.DONE
422
-
423
- # Fall back to name matching
424
- if "block" in name:
425
- return TicketState.BLOCKED
426
- elif "wait" in name:
427
- return TicketState.WAITING
428
- elif "progress" in name or "doing" in name:
429
- return TicketState.IN_PROGRESS
430
- elif "review" in name:
431
- return TicketState.READY
432
- elif "test" in name:
433
- return TicketState.TESTED
434
- elif "done" in name or "resolved" in name:
435
- return TicketState.DONE
436
- elif "closed" in name:
437
- return TicketState.CLOSED
438
- else:
439
- return TicketState.OPEN
440
-
441
- def _issue_to_ticket(self, issue: dict[str, Any]) -> Epic | Task:
442
- """Convert JIRA issue to universal ticket model."""
443
- fields = issue.get("fields", {})
444
-
445
- # Determine ticket type
446
- issue_type = fields.get("issuetype", {}).get("name", "").lower()
447
- is_epic = "epic" in issue_type
448
-
449
- # Extract common fields
450
- # Convert ADF description back to plain text if needed
451
- description = self._convert_from_adf(fields.get("description", ""))
452
-
453
- base_data = {
454
- "id": issue.get("key"),
455
- "title": fields.get("summary", ""),
456
- "description": description,
457
- "state": self._map_state_from_jira(fields.get("status", {})),
458
- "priority": self._map_priority_from_jira(fields.get("priority")),
459
- "tags": [
460
- label.get("name", "") if isinstance(label, dict) else str(label)
461
- for label in fields.get("labels", [])
462
- ],
463
- "created_at": parse_jira_datetime(fields.get("created")),
464
- "updated_at": parse_jira_datetime(fields.get("updated")),
465
- "metadata": {
466
- "jira": {
467
- "id": issue.get("id"),
468
- "key": issue.get("key"),
469
- "self": issue.get("self"),
470
- "url": f"{self.server}/browse/{issue.get('key')}",
471
- "issue_type": fields.get("issuetype", {}),
472
- "project": fields.get("project", {}),
473
- "components": fields.get("components", []),
474
- "fix_versions": fields.get("fixVersions", []),
475
- "resolution": fields.get("resolution"),
476
- }
477
- },
478
- }
479
-
480
- if is_epic:
481
- # Create Epic
482
- return Epic(
483
- **base_data,
484
- child_issues=[
485
- subtask.get("key") for subtask in fields.get("subtasks", [])
486
- ],
487
- )
488
- else:
489
- # Create Task
490
- parent = fields.get("parent", {})
491
- epic_link = fields.get("customfield_10014") # Common epic link field
492
-
493
- return Task(
494
- **base_data,
495
- parent_issue=parent.get("key") if parent else None,
496
- parent_epic=epic_link if epic_link else None,
497
- assignee=(
498
- fields.get("assignee", {}).get("displayName")
499
- if fields.get("assignee")
500
- else None
501
- ),
502
- estimated_hours=(
503
- fields.get("timetracking", {}).get("originalEstimateSeconds", 0)
504
- / 3600
505
- if fields.get("timetracking")
506
- else None
507
- ),
508
- actual_hours=(
509
- fields.get("timetracking", {}).get("timeSpentSeconds", 0) / 3600
510
- if fields.get("timetracking")
511
- else None
512
- ),
513
- )
514
-
515
- def _ticket_to_issue_fields(
516
- self, ticket: Epic | Task, issue_type: str | None = None
517
- ) -> dict[str, Any]:
518
- """Convert universal ticket to JIRA issue fields."""
519
- # Convert description to ADF format for JIRA Cloud
520
- description = (
521
- self._convert_to_adf(ticket.description or "")
522
- if self.is_cloud
523
- else (ticket.description or "")
524
- )
525
-
526
- fields = {
527
- "summary": ticket.title,
528
- "description": description,
529
- "labels": ticket.tags,
530
- }
531
-
532
- # Only add priority for Tasks, not Epics (some JIRA configurations don't allow priority on Epics)
533
- if isinstance(ticket, Task):
534
- fields["priority"] = {"name": self._map_priority_to_jira(ticket.priority)}
535
-
536
- # Add project if creating new issue
537
- if not ticket.id and self.project_key:
538
- fields["project"] = {"key": self.project_key}
539
-
540
- # Set issue type
541
- if issue_type:
542
- fields["issuetype"] = {"name": issue_type}
543
- elif isinstance(ticket, Epic):
544
- fields["issuetype"] = {"name": JiraIssueType.EPIC}
545
- else:
546
- fields["issuetype"] = {"name": JiraIssueType.TASK}
547
-
548
- # Add task-specific fields
549
- if isinstance(ticket, Task):
550
- if ticket.assignee:
551
- # Note: Need to resolve user account ID
552
- fields["assignee"] = {"accountId": ticket.assignee}
553
-
554
- if ticket.parent_issue:
555
- fields["parent"] = {"key": ticket.parent_issue}
556
-
557
- # Time tracking
558
- if ticket.estimated_hours:
559
- fields["timetracking"] = {
560
- "originalEstimate": f"{int(ticket.estimated_hours)}h"
561
- }
562
-
563
- return fields
564
-
565
- async def create(self, ticket: Epic | Task) -> Epic | Task:
566
- """Create a new JIRA issue."""
567
- # Validate credentials before attempting operation
568
- is_valid, error_message = self.validate_credentials()
569
- if not is_valid:
570
- raise ValueError(error_message)
571
-
572
- # Prepare issue fields
573
- fields = self._ticket_to_issue_fields(ticket)
574
-
575
- # Create issue
576
- data = await self._make_request("POST", "issue", data={"fields": fields})
577
-
578
- # Set the ID and fetch full issue data
579
- ticket.id = data.get("key")
580
-
581
- # Fetch complete issue data
582
- created_issue = await self._make_request("GET", f"issue/{ticket.id}")
583
- return self._issue_to_ticket(created_issue)
584
-
585
- async def read(self, ticket_id: str) -> Epic | Task | None:
586
- """Read a JIRA issue by key."""
587
- # Validate credentials before attempting operation
588
- is_valid, error_message = self.validate_credentials()
589
- if not is_valid:
590
- raise ValueError(error_message)
591
-
592
- try:
593
- issue = await self._make_request(
594
- "GET", f"issue/{ticket_id}", params={"expand": "renderedFields"}
595
- )
596
- return self._issue_to_ticket(issue)
597
- except HTTPStatusError as e:
598
- if e.response.status_code == 404:
599
- return None
600
- raise
601
-
602
- async def update(
603
- self, ticket_id: str, updates: dict[str, Any]
604
- ) -> Epic | Task | None:
605
- """Update a JIRA issue."""
606
- # Validate credentials before attempting operation
607
- is_valid, error_message = self.validate_credentials()
608
- if not is_valid:
609
- raise ValueError(error_message)
610
-
611
- # Read current issue
612
- current = await self.read(ticket_id)
613
- if not current:
614
- return None
615
-
616
- # Prepare update fields
617
- fields = {}
618
-
619
- if "title" in updates:
620
- fields["summary"] = updates["title"]
621
- if "description" in updates:
622
- fields["description"] = updates["description"]
623
- if "priority" in updates:
624
- fields["priority"] = {
625
- "name": self._map_priority_to_jira(updates["priority"])
626
- }
627
- if "tags" in updates:
628
- fields["labels"] = updates["tags"]
629
- if "assignee" in updates:
630
- fields["assignee"] = {"accountId": updates["assignee"]}
631
-
632
- # Apply update
633
- if fields:
634
- await self._make_request(
635
- "PUT", f"issue/{ticket_id}", data={"fields": fields}
636
- )
637
-
638
- # Handle state transitions separately
639
- if "state" in updates:
640
- await self.transition_state(ticket_id, updates["state"])
641
-
642
- # Return updated issue
643
- return await self.read(ticket_id)
644
-
645
- async def delete(self, ticket_id: str) -> bool:
646
- """Delete a JIRA issue."""
647
- # Validate credentials before attempting operation
648
- is_valid, error_message = self.validate_credentials()
649
- if not is_valid:
650
- raise ValueError(error_message)
651
-
652
- try:
653
- await self._make_request("DELETE", f"issue/{ticket_id}")
654
- return True
655
- except HTTPStatusError as e:
656
- if e.response.status_code == 404:
657
- return False
658
- raise
659
-
660
- async def list(
661
- self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
662
- ) -> list[Epic | Task]:
663
- """List JIRA issues with pagination."""
664
- # Build JQL query
665
- jql_parts = []
666
-
667
- if self.project_key:
668
- jql_parts.append(f"project = {self.project_key}")
669
-
670
- if filters:
671
- if "state" in filters:
672
- status = self.map_state_to_system(filters["state"])
673
- jql_parts.append(f'status = "{status}"')
674
- if "priority" in filters:
675
- priority = self._map_priority_to_jira(filters["priority"])
676
- jql_parts.append(f'priority = "{priority}"')
677
- if "assignee" in filters:
678
- jql_parts.append(f'assignee = "{filters["assignee"]}"')
679
- if "ticket_type" in filters:
680
- jql_parts.append(f'issuetype = "{filters["ticket_type"]}"')
681
-
682
- jql = " AND ".join(jql_parts) if jql_parts else "ORDER BY created DESC"
683
-
684
- # Search issues using the JIRA API endpoint
685
- data = await self._make_request(
686
- "GET",
687
- "search/jql", # JIRA search endpoint (new API v3)
688
- params={
689
- "jql": jql,
690
- "startAt": offset,
691
- "maxResults": limit,
692
- "fields": "*all",
693
- "expand": "renderedFields",
694
- },
695
- )
696
-
697
- # Convert issues
698
- issues = data.get("issues", [])
699
- return [self._issue_to_ticket(issue) for issue in issues]
700
-
701
- async def search(self, query: SearchQuery) -> builtins.list[Epic | Task]:
702
- """Search JIRA issues using JQL."""
703
- # Build JQL query
704
- jql_parts = []
705
-
706
- if self.project_key:
707
- jql_parts.append(f"project = {self.project_key}")
708
-
709
- # Text search
710
- if query.query:
711
- jql_parts.append(f'text ~ "{query.query}"')
712
-
713
- # State filter
714
- if query.state:
715
- status = self.map_state_to_system(query.state)
716
- jql_parts.append(f'status = "{status}"')
717
-
718
- # Priority filter
719
- if query.priority:
720
- priority = self._map_priority_to_jira(query.priority)
721
- jql_parts.append(f'priority = "{priority}"')
722
-
723
- # Assignee filter
724
- if query.assignee:
725
- jql_parts.append(f'assignee = "{query.assignee}"')
726
-
727
- # Tags/labels filter
728
- if query.tags:
729
- label_conditions = [f'labels = "{tag}"' for tag in query.tags]
730
- jql_parts.append(f"({' OR '.join(label_conditions)})")
731
-
732
- jql = " AND ".join(jql_parts) if jql_parts else "ORDER BY created DESC"
733
-
734
- # Execute search using the JIRA API endpoint
735
- data = await self._make_request(
736
- "GET",
737
- "search/jql", # JIRA search endpoint (new API v3)
738
- params={
739
- "jql": jql,
740
- "startAt": query.offset,
741
- "maxResults": query.limit,
742
- "fields": "*all",
743
- "expand": "renderedFields",
744
- },
745
- )
746
-
747
- # Convert and return results
748
- issues = data.get("issues", [])
749
- return [self._issue_to_ticket(issue) for issue in issues]
750
-
751
- async def transition_state(
752
- self, ticket_id: str, target_state: TicketState
753
- ) -> Epic | Task | None:
754
- """Transition JIRA issue to a new state."""
755
- # Get available transitions
756
- transitions = await self._get_transitions(ticket_id)
757
-
758
- # Find matching transition
759
- target_name = self.map_state_to_system(target_state).lower()
760
- transition = None
761
-
762
- for trans in transitions:
763
- trans_name = trans.get("to", {}).get("name", "").lower()
764
- if target_name in trans_name or trans_name in target_name:
765
- transition = trans
766
- break
767
-
768
- if not transition:
769
- # Try to find by status category
770
- for trans in transitions:
771
- category = (
772
- trans.get("to", {}).get("statusCategory", {}).get("key", "").lower()
773
- )
774
- if (
775
- (target_state == TicketState.DONE and category == "done")
776
- or (
777
- target_state == TicketState.IN_PROGRESS
778
- and category == "indeterminate"
779
- )
780
- or (target_state == TicketState.OPEN and category == "new")
781
- ):
782
- transition = trans
783
- break
784
-
785
- if not transition:
786
- logger.warning(
787
- f"No transition found to move {ticket_id} to {target_state}. "
788
- f"Available transitions: {[t.get('name') for t in transitions]}"
789
- )
790
- return None
791
-
792
- # Execute transition
793
- await self._make_request(
794
- "POST",
795
- f"issue/{ticket_id}/transitions",
796
- data={"transition": {"id": transition["id"]}},
797
- )
798
-
799
- # Return updated issue
800
- return await self.read(ticket_id)
801
-
802
- async def add_comment(self, comment: Comment) -> Comment:
803
- """Add a comment to a JIRA issue."""
804
- # Prepare comment data in Atlassian Document Format
805
- data = {
806
- "body": {
807
- "type": "doc",
808
- "version": 1,
809
- "content": [
810
- {
811
- "type": "paragraph",
812
- "content": [{"type": "text", "text": comment.content}],
813
- }
814
- ],
815
- }
816
- }
817
-
818
- # Add comment
819
- result = await self._make_request(
820
- "POST", f"issue/{comment.ticket_id}/comment", data=data
821
- )
822
-
823
- # Update comment with JIRA data
824
- comment.id = result.get("id")
825
- comment.created_at = (
826
- parse_jira_datetime(result.get("created")) or datetime.now()
827
- )
828
- comment.author = result.get("author", {}).get("displayName", comment.author)
829
- comment.metadata["jira"] = result
830
-
831
- return comment
832
-
833
- async def get_comments(
834
- self, ticket_id: str, limit: int = 10, offset: int = 0
835
- ) -> builtins.list[Comment]:
836
- """Get comments for a JIRA issue."""
837
- # Fetch issue with comments
838
- params = {"expand": "comments", "fields": "comment"}
839
-
840
- issue = await self._make_request("GET", f"issue/{ticket_id}", params=params)
841
-
842
- # Extract comments
843
- comments_data = issue.get("fields", {}).get("comment", {}).get("comments", [])
844
-
845
- # Apply pagination
846
- paginated = comments_data[offset : offset + limit]
847
-
848
- # Convert to Comment objects
849
- comments = []
850
- for comment_data in paginated:
851
- # Extract text content from ADF format
852
- body_content = comment_data.get("body", "")
853
- text_content = extract_text_from_adf(body_content)
854
-
855
- comment = Comment(
856
- id=comment_data.get("id"),
857
- ticket_id=ticket_id,
858
- author=comment_data.get("author", {}).get("displayName", "Unknown"),
859
- content=text_content,
860
- created_at=parse_jira_datetime(comment_data.get("created")),
861
- metadata={"jira": comment_data},
862
- )
863
- comments.append(comment)
864
-
865
- return comments
866
-
867
- async def get_project_info(self, project_key: str | None = None) -> dict[str, Any]:
868
- """Get JIRA project information including workflows and fields."""
869
- key = project_key or self.project_key
870
- if not key:
871
- raise ValueError("Project key is required")
872
-
873
- project = await self._make_request("GET", f"project/{key}")
874
-
875
- # Get additional project details
876
- issue_types = await self._get_issue_types(key)
877
- priorities = await self._get_priorities()
878
- custom_fields = await self._get_custom_fields()
879
-
880
- return {
881
- "project": project,
882
- "issue_types": issue_types,
883
- "priorities": priorities,
884
- "custom_fields": custom_fields,
885
- }
886
-
887
- async def execute_jql(
888
- self, jql: str, limit: int = 50
889
- ) -> builtins.list[Epic | Task]:
890
- """Execute a raw JQL query.
891
-
892
- Args:
893
- jql: JIRA Query Language string
894
- limit: Maximum number of results
895
-
896
- Returns:
897
- List of matching tickets
898
-
899
- """
900
- data = await self._make_request(
901
- "POST",
902
- "search",
903
- data={
904
- "jql": jql,
905
- "startAt": 0,
906
- "maxResults": limit,
907
- "fields": ["*all"],
908
- },
909
- )
910
-
911
- issues = data.get("issues", [])
912
- return [self._issue_to_ticket(issue) for issue in issues]
913
-
914
- async def get_sprints(
915
- self, board_id: int | None = None
916
- ) -> builtins.list[dict[str, Any]]:
917
- """Get active sprints for a board (requires JIRA Software).
918
-
919
- Args:
920
- board_id: Agile board ID
921
-
922
- Returns:
923
- List of sprint information
924
-
925
- """
926
- if not board_id:
927
- # Try to find a board for the project
928
- boards_data = await self._make_request(
929
- "GET",
930
- "/rest/agile/1.0/board",
931
- params={"projectKeyOrId": self.project_key},
932
- )
933
- boards = boards_data.get("values", [])
934
- if not boards:
935
- return []
936
- board_id = boards[0]["id"]
937
-
938
- # Get sprints for the board
939
- sprints_data = await self._make_request(
940
- "GET",
941
- f"/rest/agile/1.0/board/{board_id}/sprint",
942
- params={"state": "active,future"},
943
- )
944
-
945
- return sprints_data.get("values", [])
946
-
947
- async def get_project_users(self) -> builtins.list[dict[str, Any]]:
948
- """Get users who have access to the project."""
949
- if not self.project_key:
950
- return []
951
-
952
- try:
953
- # Get project role users
954
- project_data = await self._make_request(
955
- "GET", f"project/{self.project_key}"
956
- )
957
-
958
- # Get users from project roles
959
- users = []
960
- if "roles" in project_data:
961
- for _role_name, role_url in project_data["roles"].items():
962
- # Extract role ID from URL
963
- role_id = role_url.split("/")[-1]
964
- try:
965
- role_data = await self._make_request(
966
- "GET", f"project/{self.project_key}/role/{role_id}"
967
- )
968
- if "actors" in role_data:
969
- for actor in role_data["actors"]:
970
- if actor.get("type") == "atlassian-user-role-actor":
971
- users.append(actor.get("actorUser", {}))
972
- except Exception:
973
- # Skip if role access fails
974
- continue
975
-
976
- # Remove duplicates based on accountId
977
- seen_ids = set()
978
- unique_users = []
979
- for user in users:
980
- account_id = user.get("accountId")
981
- if account_id and account_id not in seen_ids:
982
- seen_ids.add(account_id)
983
- unique_users.append(user)
984
-
985
- return unique_users
986
-
987
- except Exception:
988
- # Fallback: try to get assignable users for the project
989
- try:
990
- users_data = await self._make_request(
991
- "GET",
992
- "user/assignable/search",
993
- params={"project": self.project_key, "maxResults": 50},
994
- )
995
- return users_data if isinstance(users_data, list) else []
996
- except Exception:
997
- return []
998
-
999
- async def get_current_user(self) -> dict[str, Any] | None:
1000
- """Get current authenticated user information."""
1001
- try:
1002
- return await self._make_request("GET", "myself")
1003
- except Exception:
1004
- return None
1005
-
1006
- async def list_labels(self) -> builtins.list[dict[str, Any]]:
1007
- """List all labels used in the project.
1008
-
1009
- JIRA doesn't have a direct "list all labels" endpoint, so we query
1010
- recent issues and extract unique labels from them.
1011
-
1012
- Returns:
1013
- List of label dictionaries with 'id' and 'name' fields
1014
-
1015
- """
1016
- try:
1017
- # Query recent issues to get labels in use
1018
- jql = f"project = {self.project_key} ORDER BY updated DESC"
1019
- data = await self._make_request(
1020
- "GET",
1021
- "search/jql",
1022
- params={
1023
- "jql": jql,
1024
- "maxResults": 100, # Sample from recent 100 issues
1025
- "fields": "labels",
1026
- },
1027
- )
1028
-
1029
- # Collect unique labels
1030
- unique_labels = set()
1031
- for issue in data.get("issues", []):
1032
- labels = issue.get("fields", {}).get("labels", [])
1033
- for label in labels:
1034
- if isinstance(label, dict):
1035
- unique_labels.add(label.get("name", ""))
1036
- else:
1037
- unique_labels.add(str(label))
1038
-
1039
- # Transform to standardized format
1040
- return [
1041
- {"id": label, "name": label} for label in sorted(unique_labels) if label
1042
- ]
1043
-
1044
- except Exception:
1045
- # Fallback: return empty list if query fails
1046
- return []
1047
-
1048
- async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
1049
- """Update a JIRA Epic with epic-specific field handling.
1050
-
1051
- Args:
1052
- epic_id: Epic identifier (key like PROJ-123 or ID)
1053
- updates: Dictionary with fields to update:
1054
- - title: Epic title (maps to summary)
1055
- - description: Epic description (auto-converted to ADF)
1056
- - state: TicketState value (transitions via workflow)
1057
- - tags: List of labels
1058
- - priority: Priority level
1059
-
1060
- Returns:
1061
- Updated Epic object or None if not found
1062
-
1063
- Raises:
1064
- ValueError: If no fields provided for update
1065
- HTTPStatusError: If update fails
1066
-
1067
- """
1068
- fields = {}
1069
-
1070
- # Map title to summary
1071
- if "title" in updates:
1072
- fields["summary"] = updates["title"]
1073
-
1074
- # Convert description to ADF format
1075
- if "description" in updates:
1076
- fields["description"] = self._convert_to_adf(updates["description"])
1077
-
1078
- # Map tags to labels
1079
- if "tags" in updates:
1080
- fields["labels"] = updates["tags"]
1081
-
1082
- # Map priority (some JIRA configs allow priority on Epics)
1083
- if "priority" in updates:
1084
- priority_value = updates["priority"]
1085
- if isinstance(priority_value, Priority):
1086
- fields["priority"] = {
1087
- "name": self._map_priority_to_jira(priority_value)
1088
- }
1089
- else:
1090
- # String priority passed directly
1091
- fields["priority"] = {"name": priority_value}
1092
-
1093
- if not fields and "state" not in updates:
1094
- raise ValueError("At least one field must be updated")
1095
-
1096
- # Apply field updates if any
1097
- if fields:
1098
- await self._make_request("PUT", f"issue/{epic_id}", data={"fields": fields})
1099
-
1100
- # Handle state transitions separately (JIRA uses workflow transitions)
1101
- if "state" in updates:
1102
- await self.transition_state(epic_id, updates["state"])
1103
-
1104
- # Fetch and return updated epic
1105
- return await self.read(epic_id)
1106
-
1107
- async def add_attachment(
1108
- self, ticket_id: str, file_path: str, description: str | None = None
1109
- ) -> Attachment:
1110
- """Attach file to JIRA issue (including Epic).
1111
-
1112
- Args:
1113
- ticket_id: Issue key (e.g., PROJ-123) or ID
1114
- file_path: Path to file to attach
1115
- description: Optional description (stored in metadata, not used by JIRA directly)
1116
-
1117
- Returns:
1118
- Attachment object with metadata
1119
-
1120
- Raises:
1121
- FileNotFoundError: If file doesn't exist
1122
- ValueError: If credentials invalid
1123
- HTTPStatusError: If upload fails
1124
-
1125
- """
1126
- from pathlib import Path
1127
-
1128
- # Validate credentials before attempting operation
1129
- is_valid, error_message = self.validate_credentials()
1130
- if not is_valid:
1131
- raise ValueError(error_message)
1132
-
1133
- file_path_obj = Path(file_path)
1134
- if not file_path_obj.exists():
1135
- raise FileNotFoundError(f"File not found: {file_path}")
1136
-
1137
- # JIRA requires special header for attachment upload
1138
- headers = {
1139
- "X-Atlassian-Token": "no-check",
1140
- # Don't set Content-Type - let httpx handle multipart
1141
- }
1142
-
1143
- # Prepare multipart file upload
1144
- with open(file_path_obj, "rb") as f:
1145
- files = {"file": (file_path_obj.name, f, "application/octet-stream")}
1146
-
1147
- url = f"{self.api_base}/issue/{ticket_id}/attachments"
1148
-
1149
- # Use existing client infrastructure
1150
- async with await self._get_client() as client:
1151
- response = await client.post(
1152
- url, files=files, headers={**self.headers, **headers}
1153
- )
1154
- response.raise_for_status()
1155
-
1156
- # JIRA returns array with single attachment
1157
- attachment_data = response.json()[0]
1158
-
1159
- return Attachment(
1160
- id=attachment_data["id"],
1161
- ticket_id=ticket_id,
1162
- filename=attachment_data["filename"],
1163
- url=attachment_data["content"],
1164
- content_type=attachment_data["mimeType"],
1165
- size_bytes=attachment_data["size"],
1166
- created_at=parse_jira_datetime(attachment_data["created"]),
1167
- created_by=attachment_data["author"]["displayName"],
1168
- description=description,
1169
- metadata={"jira": attachment_data},
1170
- )
1171
-
1172
- async def get_attachments(self, ticket_id: str) -> builtins.list[Attachment]:
1173
- """Get all attachments for a JIRA issue.
1174
-
1175
- Args:
1176
- ticket_id: Issue key or ID
1177
-
1178
- Returns:
1179
- List of Attachment objects
1180
-
1181
- Raises:
1182
- ValueError: If credentials invalid
1183
- HTTPStatusError: If request fails
1184
-
1185
- """
1186
- # Validate credentials before attempting operation
1187
- is_valid, error_message = self.validate_credentials()
1188
- if not is_valid:
1189
- raise ValueError(error_message)
1190
-
1191
- # Fetch issue with attachment field
1192
- issue = await self._make_request(
1193
- "GET", f"issue/{ticket_id}", params={"fields": "attachment"}
1194
- )
1195
-
1196
- attachments = []
1197
- for att_data in issue.get("fields", {}).get("attachment", []):
1198
- attachments.append(
1199
- Attachment(
1200
- id=att_data["id"],
1201
- ticket_id=ticket_id,
1202
- filename=att_data["filename"],
1203
- url=att_data["content"],
1204
- content_type=att_data["mimeType"],
1205
- size_bytes=att_data["size"],
1206
- created_at=parse_jira_datetime(att_data["created"]),
1207
- created_by=att_data["author"]["displayName"],
1208
- metadata={"jira": att_data},
1209
- )
1210
- )
1211
-
1212
- return attachments
1213
-
1214
- async def delete_attachment(self, ticket_id: str, attachment_id: str) -> bool:
1215
- """Delete an attachment from a JIRA issue.
1216
-
1217
- Args:
1218
- ticket_id: Issue key or ID (for validation/context)
1219
- attachment_id: Attachment ID to delete
1220
-
1221
- Returns:
1222
- True if deleted successfully, False otherwise
1223
-
1224
- Raises:
1225
- ValueError: If credentials invalid
1226
-
1227
- """
1228
- # Validate credentials before attempting operation
1229
- is_valid, error_message = self.validate_credentials()
1230
- if not is_valid:
1231
- raise ValueError(error_message)
1232
-
1233
- try:
1234
- await self._make_request("DELETE", f"attachment/{attachment_id}")
1235
- return True
1236
- except HTTPStatusError as e:
1237
- if e.response.status_code == 404:
1238
- logger.warning(f"Attachment {attachment_id} not found")
1239
- return False
1240
- logger.error(
1241
- f"Failed to delete attachment {attachment_id}: {e.response.status_code} - {e.response.text}"
1242
- )
1243
- return False
1244
- except Exception as e:
1245
- logger.error(f"Unexpected error deleting attachment {attachment_id}: {e}")
1246
- return False
1247
-
1248
- async def close(self) -> None:
1249
- """Close the adapter and cleanup resources."""
1250
- # Clear caches
1251
- self._workflow_cache.clear()
1252
- self._priority_cache.clear()
1253
- self._issue_types_cache.clear()
1254
- self._custom_fields_cache.clear()
1255
-
1256
-
1257
- # Register the adapter
1258
- AdapterRegistry.register("jira", JiraAdapter)