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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/aitrackdown.py +507 -6
- mcp_ticketer/adapters/asana/adapter.py +229 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/adapter.py +2730 -139
- mcp_ticketer/adapters/linear/client.py +175 -3
- mcp_ticketer/adapters/linear/mappers.py +203 -8
- mcp_ticketer/adapters/linear/queries.py +280 -3
- mcp_ticketer/adapters/linear/types.py +120 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cli/adapter_diagnostics.py +3 -1
- mcp_ticketer/cli/auggie_configure.py +17 -5
- mcp_ticketer/cli/codex_configure.py +97 -61
- mcp_ticketer/cli/configure.py +1288 -105
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +13 -12
- mcp_ticketer/cli/discover.py +5 -0
- mcp_ticketer/cli/gemini_configure.py +17 -5
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +267 -3175
- mcp_ticketer/cli/mcp_configure.py +821 -119
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +795 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +705 -103
- mcp_ticketer/cli/utils.py +113 -0
- mcp_ticketer/core/__init__.py +56 -6
- mcp_ticketer/core/adapter.py +533 -2
- mcp_ticketer/core/config.py +21 -21
- mcp_ticketer/core/exceptions.py +7 -1
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +31 -19
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +480 -0
- mcp_ticketer/core/onepassword_secrets.py +1 -1
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +132 -14
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +625 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/mcp/server/__main__.py +2 -1
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +33 -11
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
- mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- mcp_ticketer/queue/queue.py +68 -0
- mcp_ticketer/queue/worker.py +1 -1
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1574
- mcp_ticketer/adapters/jira.py +0 -1258
- mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
- mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
- mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
- {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)
|