mcp-ticketer 0.4.11__py3-none-any.whl → 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mcp-ticketer might be problematic. Click here for more details.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +394 -9
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +836 -105
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +772 -1
- mcp_ticketer/adapters/linear/adapter.py +2293 -108
- mcp_ticketer/adapters/linear/client.py +146 -12
- mcp_ticketer/adapters/linear/mappers.py +105 -11
- mcp_ticketer/adapters/linear/queries.py +168 -1
- mcp_ticketer/adapters/linear/types.py +80 -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/cache/memory.py +3 -3
- mcp_ticketer/cli/adapter_diagnostics.py +4 -2
- mcp_ticketer/cli/auggie_configure.py +18 -6
- mcp_ticketer/cli/codex_configure.py +175 -60
- mcp_ticketer/cli/configure.py +884 -146
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +31 -28
- mcp_ticketer/cli/discover.py +293 -21
- mcp_ticketer/cli/gemini_configure.py +18 -6
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +99 -15
- mcp_ticketer/cli/main.py +109 -2055
- mcp_ticketer/cli/mcp_configure.py +673 -99
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +6 -6
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +13 -11
- mcp_ticketer/cli/ticket_commands.py +277 -36
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +45 -41
- mcp_ticketer/core/__init__.py +35 -1
- mcp_ticketer/core/adapter.py +170 -5
- mcp_ticketer/core/config.py +38 -31
- mcp_ticketer/core/env_discovery.py +33 -3
- mcp_ticketer/core/env_loader.py +7 -6
- mcp_ticketer/core/exceptions.py +10 -4
- mcp_ticketer/core/http_client.py +10 -10
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +32 -20
- mcp_ticketer/core/models.py +136 -1
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +148 -14
- mcp_ticketer/core/registry.py +1 -1
- mcp_ticketer/core/session_state.py +171 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +2 -2
- mcp_ticketer/mcp/server/__init__.py +2 -2
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +187 -93
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +37 -9
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
- 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 +1429 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -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 +180 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/health_monitor.py +1 -0
- mcp_ticketer/queue/manager.py +4 -4
- mcp_ticketer/queue/queue.py +3 -3
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +2 -2
- mcp_ticketer/queue/worker.py +15 -13
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
- mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
- mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
- mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -1,85 +1,604 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Unified ticket CRUD operations (v2.0.0).
|
|
2
2
|
|
|
3
|
-
This module implements
|
|
4
|
-
|
|
3
|
+
This module implements ticket management through a single unified `ticket()` interface.
|
|
4
|
+
|
|
5
|
+
Version 2.0.0 changes:
|
|
6
|
+
- Removed @mcp.tool() decorators from individual operations (converted to private helpers)
|
|
7
|
+
- Single `ticket()` function is the only exposed MCP tool
|
|
8
|
+
- All operations accessible via ticket(action="create"|"get"|"update"|"delete"|"list"|"summary"|"get_activity"|"assign")
|
|
9
|
+
- Individual functions retained as internal helpers for code organization
|
|
5
10
|
"""
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
import logging
|
|
13
|
+
import warnings
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal
|
|
8
16
|
|
|
17
|
+
from ....core.adapter import BaseAdapter
|
|
9
18
|
from ....core.models import Priority, Task, TicketState
|
|
10
|
-
from
|
|
19
|
+
from ....core.priority_matcher import get_priority_matcher
|
|
20
|
+
from ....core.project_config import ConfigResolver, TicketerConfig
|
|
21
|
+
from ....core.session_state import SessionStateManager
|
|
22
|
+
from ....core.url_parser import extract_id_from_url, is_url
|
|
23
|
+
from ..diagnostic_helper import (
|
|
24
|
+
build_diagnostic_suggestion,
|
|
25
|
+
get_quick_diagnostic_info,
|
|
26
|
+
should_suggest_diagnostics,
|
|
27
|
+
)
|
|
28
|
+
from ..server_sdk import get_adapter, get_router, has_router, mcp
|
|
29
|
+
|
|
30
|
+
# Sentinel value to distinguish between "parameter not provided" and "explicitly None"
|
|
31
|
+
_UNSET = object()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_adapter_metadata(
|
|
35
|
+
adapter: BaseAdapter,
|
|
36
|
+
ticket_id: str | None = None,
|
|
37
|
+
is_routed: bool = False,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Build adapter metadata for MCP responses.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
adapter: The adapter that handled the operation
|
|
43
|
+
ticket_id: Optional ticket ID to include in metadata
|
|
44
|
+
is_routed: Whether this was routed via URL detection
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Dictionary with adapter metadata fields
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
metadata = {
|
|
51
|
+
"adapter": adapter.adapter_type,
|
|
52
|
+
"adapter_name": adapter.adapter_display_name,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if ticket_id:
|
|
56
|
+
metadata["ticket_id"] = ticket_id
|
|
57
|
+
|
|
58
|
+
if is_routed:
|
|
59
|
+
metadata["routed_from_url"] = True
|
|
60
|
+
|
|
61
|
+
return metadata
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def detect_and_apply_labels(
|
|
65
|
+
adapter: Any,
|
|
66
|
+
ticket_title: str,
|
|
67
|
+
ticket_description: str,
|
|
68
|
+
existing_labels: list[str] | None = None,
|
|
69
|
+
) -> list[str]:
|
|
70
|
+
"""Detect and suggest labels/tags based on ticket content.
|
|
71
|
+
|
|
72
|
+
This function analyzes the ticket title and description to automatically
|
|
73
|
+
detect relevant labels/tags from the adapter's available labels.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
adapter: The ticket adapter instance
|
|
77
|
+
ticket_title: Ticket title text
|
|
78
|
+
ticket_description: Ticket description text
|
|
79
|
+
existing_labels: Labels already specified by user (optional)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List of label/tag identifiers to apply (combines auto-detected + user-specified)
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
# Get available labels from adapter
|
|
86
|
+
available_labels = []
|
|
87
|
+
try:
|
|
88
|
+
if hasattr(adapter, "list_labels"):
|
|
89
|
+
available_labels = await adapter.list_labels()
|
|
90
|
+
elif hasattr(adapter, "get_labels"):
|
|
91
|
+
available_labels = await adapter.get_labels()
|
|
92
|
+
except Exception:
|
|
93
|
+
# Adapter doesn't support labels or listing failed - return user labels only
|
|
94
|
+
return existing_labels or []
|
|
95
|
+
|
|
96
|
+
if not available_labels:
|
|
97
|
+
return existing_labels or []
|
|
98
|
+
|
|
99
|
+
# Combine title and description for matching (lowercase for case-insensitive matching)
|
|
100
|
+
content = f"{ticket_title} {ticket_description or ''}".lower()
|
|
101
|
+
|
|
102
|
+
# Common label keyword patterns
|
|
103
|
+
label_keywords = {
|
|
104
|
+
"bug": ["bug", "error", "broken", "crash", "fix", "issue", "defect"],
|
|
105
|
+
"feature": ["feature", "add", "new", "implement", "create", "enhancement"],
|
|
106
|
+
"improvement": [
|
|
107
|
+
"enhance",
|
|
108
|
+
"improve",
|
|
109
|
+
"update",
|
|
110
|
+
"upgrade",
|
|
111
|
+
"refactor",
|
|
112
|
+
"optimize",
|
|
113
|
+
],
|
|
114
|
+
"documentation": ["doc", "documentation", "readme", "guide", "manual"],
|
|
115
|
+
"test": ["test", "testing", "qa", "validation", "verify"],
|
|
116
|
+
"security": ["security", "vulnerability", "auth", "permission", "exploit"],
|
|
117
|
+
"performance": ["performance", "slow", "optimize", "speed", "latency"],
|
|
118
|
+
"ui": ["ui", "ux", "interface", "design", "layout", "frontend"],
|
|
119
|
+
"api": ["api", "endpoint", "rest", "graphql", "backend"],
|
|
120
|
+
"backend": ["backend", "server", "database", "storage"],
|
|
121
|
+
"frontend": ["frontend", "client", "web", "react", "vue"],
|
|
122
|
+
"critical": ["critical", "urgent", "emergency", "blocker"],
|
|
123
|
+
"high-priority": ["urgent", "asap", "important", "critical"],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Match labels against content
|
|
127
|
+
matched_labels = []
|
|
128
|
+
|
|
129
|
+
for label in available_labels:
|
|
130
|
+
# Extract label name (handle both dict and string formats)
|
|
131
|
+
if isinstance(label, dict):
|
|
132
|
+
label_name = label.get("name", "")
|
|
133
|
+
else:
|
|
134
|
+
label_name = str(label)
|
|
135
|
+
|
|
136
|
+
label_name_lower = label_name.lower()
|
|
137
|
+
|
|
138
|
+
# Direct match: label name appears in content
|
|
139
|
+
if label_name_lower in content:
|
|
140
|
+
if label_name not in matched_labels:
|
|
141
|
+
matched_labels.append(label_name)
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
# Keyword match: check if label matches any keyword category
|
|
145
|
+
for keyword_category, keywords in label_keywords.items():
|
|
146
|
+
# Check if label name relates to the category
|
|
147
|
+
if (
|
|
148
|
+
keyword_category in label_name_lower
|
|
149
|
+
or label_name_lower in keyword_category
|
|
150
|
+
):
|
|
151
|
+
# Check if any keyword from this category appears in content
|
|
152
|
+
if any(kw in content for kw in keywords):
|
|
153
|
+
if label_name not in matched_labels:
|
|
154
|
+
matched_labels.append(label_name)
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
# Combine user-specified labels with auto-detected ones
|
|
158
|
+
final_labels = list(existing_labels or [])
|
|
159
|
+
for label in matched_labels:
|
|
160
|
+
if label not in final_labels:
|
|
161
|
+
final_labels.append(label)
|
|
162
|
+
|
|
163
|
+
return final_labels
|
|
11
164
|
|
|
12
165
|
|
|
13
166
|
@mcp.tool()
|
|
14
|
-
async def
|
|
15
|
-
|
|
167
|
+
async def ticket(
|
|
168
|
+
action: Literal[
|
|
169
|
+
"create", "get", "update", "delete", "list", "summary", "get_activity", "assign"
|
|
170
|
+
],
|
|
171
|
+
# Ticket identification
|
|
172
|
+
ticket_id: str | None = None,
|
|
173
|
+
# Create parameters
|
|
174
|
+
title: str | None = None,
|
|
16
175
|
description: str = "",
|
|
17
176
|
priority: str = "medium",
|
|
18
177
|
tags: list[str] | None = None,
|
|
19
178
|
assignee: str | None = None,
|
|
179
|
+
parent_epic: str | None = _UNSET,
|
|
180
|
+
auto_detect_labels: bool = True,
|
|
181
|
+
# Update parameters
|
|
182
|
+
state: str | None = None,
|
|
183
|
+
# List parameters
|
|
184
|
+
limit: int = 20,
|
|
185
|
+
offset: int = 0,
|
|
186
|
+
project_id: str | None = None,
|
|
187
|
+
compact: bool = True,
|
|
188
|
+
# Assign parameters
|
|
189
|
+
comment: str | None = None,
|
|
190
|
+
auto_transition: bool = True,
|
|
20
191
|
) -> dict[str, Any]:
|
|
21
|
-
"""
|
|
192
|
+
"""Unified ticket management tool for all CRUD operations.
|
|
193
|
+
|
|
194
|
+
Handles ticket creation, reading, updating, deletion, listing,
|
|
195
|
+
summarization, activity tracking, and assignment in a single interface.
|
|
22
196
|
|
|
23
197
|
Args:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
198
|
+
action: Operation to perform (create, get, update, delete, list, summary, get_activity, assign)
|
|
199
|
+
ticket_id: Ticket ID for get/update/delete/summary/assign operations
|
|
200
|
+
title: Ticket title (required for create)
|
|
201
|
+
description: Ticket description
|
|
202
|
+
priority: Ticket priority (low, medium, high, critical)
|
|
203
|
+
tags: List of tags/labels
|
|
204
|
+
assignee: User ID or email to assign ticket
|
|
205
|
+
parent_epic: Parent epic/project ID
|
|
206
|
+
auto_detect_labels: Auto-detect labels from content
|
|
207
|
+
state: Ticket state for updates
|
|
208
|
+
limit: Maximum results for list/get_activity operations
|
|
209
|
+
offset: Pagination offset for list
|
|
210
|
+
project_id: Project filter for list operations
|
|
211
|
+
compact: Return compact format for list (saves tokens)
|
|
212
|
+
comment: Comment when assigning ticket
|
|
213
|
+
auto_transition: Auto-transition state when assigning
|
|
29
214
|
|
|
30
215
|
Returns:
|
|
31
|
-
|
|
216
|
+
dict: Operation results
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
ValueError: If action is invalid or required parameters missing
|
|
220
|
+
|
|
221
|
+
Examples:
|
|
222
|
+
# Create ticket
|
|
223
|
+
await ticket(
|
|
224
|
+
action="create",
|
|
225
|
+
title="Fix login bug",
|
|
226
|
+
priority="high",
|
|
227
|
+
tags=["bug", "security"]
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Get ticket details
|
|
231
|
+
await ticket(
|
|
232
|
+
action="get",
|
|
233
|
+
ticket_id="PROJ-123"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Update ticket
|
|
237
|
+
await ticket(
|
|
238
|
+
action="update",
|
|
239
|
+
ticket_id="PROJ-123",
|
|
240
|
+
state="in_progress",
|
|
241
|
+
priority="critical"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# List tickets
|
|
245
|
+
await ticket(
|
|
246
|
+
action="list",
|
|
247
|
+
project_id="PROJ",
|
|
248
|
+
state="open",
|
|
249
|
+
limit=50
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Get compact summary
|
|
253
|
+
await ticket(
|
|
254
|
+
action="summary",
|
|
255
|
+
ticket_id="PROJ-123"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Get activity/comments
|
|
259
|
+
await ticket(
|
|
260
|
+
action="get_activity",
|
|
261
|
+
ticket_id="PROJ-123",
|
|
262
|
+
limit=5
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Assign ticket
|
|
266
|
+
await ticket(
|
|
267
|
+
action="assign",
|
|
268
|
+
ticket_id="PROJ-123",
|
|
269
|
+
assignee="user@example.com",
|
|
270
|
+
comment="Taking this one"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Delete ticket
|
|
274
|
+
await ticket(
|
|
275
|
+
action="delete",
|
|
276
|
+
ticket_id="PROJ-123"
|
|
277
|
+
)
|
|
278
|
+
"""
|
|
279
|
+
# Normalize action to lowercase for case-insensitive matching
|
|
280
|
+
action_lower = action.lower()
|
|
281
|
+
|
|
282
|
+
if action_lower == "create":
|
|
283
|
+
if not title:
|
|
284
|
+
return {
|
|
285
|
+
"status": "error",
|
|
286
|
+
"error": "title parameter required for action='create'",
|
|
287
|
+
"hint": "Example: ticket(action='create', title='Fix bug', priority='high')",
|
|
288
|
+
}
|
|
289
|
+
return await ticket_create(
|
|
290
|
+
title,
|
|
291
|
+
description,
|
|
292
|
+
priority,
|
|
293
|
+
tags,
|
|
294
|
+
assignee,
|
|
295
|
+
parent_epic,
|
|
296
|
+
auto_detect_labels,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
elif action_lower == "get":
|
|
300
|
+
if not ticket_id:
|
|
301
|
+
return {
|
|
302
|
+
"status": "error",
|
|
303
|
+
"error": "ticket_id parameter required for action='get'",
|
|
304
|
+
"hint": "Example: ticket(action='get', ticket_id='PROJ-123')",
|
|
305
|
+
}
|
|
306
|
+
return await ticket_read(ticket_id)
|
|
307
|
+
|
|
308
|
+
elif action_lower == "update":
|
|
309
|
+
if not ticket_id:
|
|
310
|
+
return {
|
|
311
|
+
"status": "error",
|
|
312
|
+
"error": "ticket_id parameter required for action='update'",
|
|
313
|
+
"hint": "Example: ticket(action='update', ticket_id='PROJ-123', state='done')",
|
|
314
|
+
}
|
|
315
|
+
return await ticket_update(
|
|
316
|
+
ticket_id, title, description, priority, state, assignee, tags
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
elif action_lower == "delete":
|
|
320
|
+
if not ticket_id:
|
|
321
|
+
return {
|
|
322
|
+
"status": "error",
|
|
323
|
+
"error": "ticket_id parameter required for action='delete'",
|
|
324
|
+
"hint": "Example: ticket(action='delete', ticket_id='PROJ-123')",
|
|
325
|
+
}
|
|
326
|
+
return await ticket_delete(ticket_id)
|
|
327
|
+
|
|
328
|
+
elif action_lower == "list":
|
|
329
|
+
return await ticket_list(
|
|
330
|
+
limit, offset, state, priority, assignee, project_id, compact
|
|
331
|
+
)
|
|
32
332
|
|
|
333
|
+
elif action_lower == "summary":
|
|
334
|
+
if not ticket_id:
|
|
335
|
+
return {
|
|
336
|
+
"status": "error",
|
|
337
|
+
"error": "ticket_id parameter required for action='summary'",
|
|
338
|
+
"hint": "Example: ticket(action='summary', ticket_id='PROJ-123')",
|
|
339
|
+
}
|
|
340
|
+
return await ticket_summary(ticket_id)
|
|
341
|
+
|
|
342
|
+
elif action_lower == "get_activity":
|
|
343
|
+
if not ticket_id:
|
|
344
|
+
return {
|
|
345
|
+
"status": "error",
|
|
346
|
+
"error": "ticket_id parameter required for action='get_activity'",
|
|
347
|
+
"hint": "Example: ticket(action='get_activity', ticket_id='PROJ-123', limit=5)",
|
|
348
|
+
}
|
|
349
|
+
return await ticket_latest(ticket_id, limit)
|
|
350
|
+
|
|
351
|
+
elif action_lower == "assign":
|
|
352
|
+
if not ticket_id:
|
|
353
|
+
return {
|
|
354
|
+
"status": "error",
|
|
355
|
+
"error": "ticket_id parameter required for action='assign'",
|
|
356
|
+
"hint": "Example: ticket(action='assign', ticket_id='PROJ-123', assignee='user@example.com')",
|
|
357
|
+
}
|
|
358
|
+
return await ticket_assign(ticket_id, assignee, comment, auto_transition)
|
|
359
|
+
|
|
360
|
+
else:
|
|
361
|
+
return {
|
|
362
|
+
"status": "error",
|
|
363
|
+
"error": f"Invalid action: {action}",
|
|
364
|
+
"valid_actions": [
|
|
365
|
+
"create",
|
|
366
|
+
"get",
|
|
367
|
+
"update",
|
|
368
|
+
"delete",
|
|
369
|
+
"list",
|
|
370
|
+
"summary",
|
|
371
|
+
"get_activity",
|
|
372
|
+
"assign",
|
|
373
|
+
],
|
|
374
|
+
"hint": "Use one of the valid actions listed above",
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def ticket_create(
|
|
379
|
+
title: str,
|
|
380
|
+
description: str = "",
|
|
381
|
+
priority: str = "medium",
|
|
382
|
+
tags: list[str] | None = None,
|
|
383
|
+
assignee: str | None = None,
|
|
384
|
+
parent_epic: str | None = _UNSET,
|
|
385
|
+
auto_detect_labels: bool = True,
|
|
386
|
+
) -> dict[str, Any]:
|
|
387
|
+
"""Create ticket with auto-label detection and semantic priority matching.
|
|
388
|
+
|
|
389
|
+
.. deprecated:: 1.5.0
|
|
390
|
+
Use :func:`ticket` with ``action='create'`` instead.
|
|
391
|
+
This function will be removed in version 2.0.0.
|
|
392
|
+
|
|
393
|
+
Args: title (required), description, priority (supports natural language), tags, assignee, parent_epic (optional), auto_detect_labels (default: True)
|
|
394
|
+
Returns: TicketResponse with created ticket, ID, metadata
|
|
395
|
+
See: docs/mcp-api-reference.md#ticket-response-format, docs/mcp-api-reference.md#semantic-priority-matching
|
|
33
396
|
"""
|
|
397
|
+
warnings.warn(
|
|
398
|
+
"ticket_create is deprecated. Use ticket(action='create', ...) instead. "
|
|
399
|
+
"This function will be removed in version 2.0.0.",
|
|
400
|
+
DeprecationWarning,
|
|
401
|
+
stacklevel=2,
|
|
402
|
+
)
|
|
34
403
|
try:
|
|
35
404
|
adapter = get_adapter()
|
|
36
405
|
|
|
37
|
-
# Validate and convert priority
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
406
|
+
# Validate and convert priority using semantic matcher (ISS-0002)
|
|
407
|
+
priority_matcher = get_priority_matcher()
|
|
408
|
+
match_result = priority_matcher.match_priority(priority)
|
|
409
|
+
|
|
410
|
+
# Handle low confidence matches - provide suggestions
|
|
411
|
+
if match_result.is_low_confidence():
|
|
412
|
+
suggestions = priority_matcher.suggest_priorities(priority, top_n=3)
|
|
41
413
|
return {
|
|
42
|
-
"status": "
|
|
43
|
-
"
|
|
414
|
+
"status": "ambiguous",
|
|
415
|
+
"message": f"Priority input '{priority}' is ambiguous. Please choose from suggestions or use exact values.",
|
|
416
|
+
"original_input": priority,
|
|
417
|
+
"suggestions": [
|
|
418
|
+
{
|
|
419
|
+
"priority": s.priority.value,
|
|
420
|
+
"confidence": round(s.confidence, 2),
|
|
421
|
+
}
|
|
422
|
+
for s in suggestions
|
|
423
|
+
],
|
|
424
|
+
"exact_values": ["low", "medium", "high", "critical"],
|
|
44
425
|
}
|
|
45
426
|
|
|
427
|
+
priority_enum = match_result.priority
|
|
428
|
+
|
|
429
|
+
# Apply configuration defaults if values not provided
|
|
430
|
+
resolver = ConfigResolver(project_path=Path.cwd())
|
|
431
|
+
config = resolver.load_project_config() or TicketerConfig()
|
|
432
|
+
|
|
433
|
+
# Determine final_parent_epic based on priority order:
|
|
434
|
+
# Priority 1: Explicit parent_epic argument (including explicit None for opt-out)
|
|
435
|
+
# Priority 2: Config default (default_epic or default_project)
|
|
436
|
+
# Priority 3: Session-attached ticket
|
|
437
|
+
# Priority 4: Prompt user (last resort only if nothing configured)
|
|
438
|
+
|
|
439
|
+
final_parent_epic: str | None = None
|
|
440
|
+
|
|
441
|
+
if parent_epic is not _UNSET:
|
|
442
|
+
# Priority 1: Explicit value provided (including None for opt-out)
|
|
443
|
+
final_parent_epic = parent_epic
|
|
444
|
+
if parent_epic is not None:
|
|
445
|
+
logging.debug(f"Using explicit parent_epic: {parent_epic}")
|
|
446
|
+
else:
|
|
447
|
+
logging.debug("Explicitly opted out of parent_epic (parent_epic=None)")
|
|
448
|
+
elif config.default_project or config.default_epic:
|
|
449
|
+
# Priority 2: Use configured default
|
|
450
|
+
final_parent_epic = config.default_project or config.default_epic
|
|
451
|
+
logging.debug(f"Using default epic from config: {final_parent_epic}")
|
|
452
|
+
else:
|
|
453
|
+
# Priority 3 & 4: Check session, then prompt
|
|
454
|
+
session_manager = SessionStateManager(project_path=Path.cwd())
|
|
455
|
+
session_state = session_manager.load_session()
|
|
456
|
+
|
|
457
|
+
if session_state.current_ticket:
|
|
458
|
+
# Priority 3: Use session ticket as parent_epic
|
|
459
|
+
final_parent_epic = session_state.current_ticket
|
|
460
|
+
logging.info(
|
|
461
|
+
f"Using session ticket as parent_epic: {final_parent_epic}"
|
|
462
|
+
)
|
|
463
|
+
elif not session_state.ticket_opted_out:
|
|
464
|
+
# Priority 4: No default, no session, no opt-out - provide guidance
|
|
465
|
+
return {
|
|
466
|
+
"status": "error",
|
|
467
|
+
"requires_ticket_association": True,
|
|
468
|
+
"guidance": (
|
|
469
|
+
"⚠️ No ticket association found for this work session.\n\n"
|
|
470
|
+
"It's recommended to associate your work with a ticket for proper tracking.\n\n"
|
|
471
|
+
"**Options**:\n"
|
|
472
|
+
"1. Associate with a ticket: attach_ticket(action='set', ticket_id='PROJ-123')\n"
|
|
473
|
+
"2. Skip for this session: attach_ticket(action='none')\n"
|
|
474
|
+
"3. Provide parent_epic directly: ticket_create(..., parent_epic='PROJ-123')\n"
|
|
475
|
+
"4. Set a default: config_set_default_project(project_id='PROJ-123')\n\n"
|
|
476
|
+
"After associating, run ticket_create again to create the ticket."
|
|
477
|
+
),
|
|
478
|
+
"session_id": session_state.session_id,
|
|
479
|
+
}
|
|
480
|
+
# else: session opted out, final_parent_epic stays None
|
|
481
|
+
|
|
482
|
+
# Default user/assignee
|
|
483
|
+
final_assignee = assignee
|
|
484
|
+
if final_assignee is None and config.default_user:
|
|
485
|
+
final_assignee = config.default_user
|
|
486
|
+
logging.debug(f"Using default assignee from config: {final_assignee}")
|
|
487
|
+
|
|
488
|
+
# Default tags - merge with provided tags
|
|
489
|
+
final_tags = tags or []
|
|
490
|
+
if config.default_tags:
|
|
491
|
+
# Add default tags that aren't already in the provided tags
|
|
492
|
+
for default_tag in config.default_tags:
|
|
493
|
+
if default_tag not in final_tags:
|
|
494
|
+
final_tags.append(default_tag)
|
|
495
|
+
if final_tags != (tags or []):
|
|
496
|
+
logging.debug(f"Merged default tags from config: {config.default_tags}")
|
|
497
|
+
|
|
498
|
+
# Auto-detect labels if enabled (adds to existing tags)
|
|
499
|
+
if auto_detect_labels:
|
|
500
|
+
final_tags = await detect_and_apply_labels(
|
|
501
|
+
adapter, title, description or "", final_tags
|
|
502
|
+
)
|
|
503
|
+
|
|
46
504
|
# Create task object
|
|
47
505
|
task = Task(
|
|
48
506
|
title=title,
|
|
49
507
|
description=description or "",
|
|
50
508
|
priority=priority_enum,
|
|
51
|
-
tags=
|
|
52
|
-
assignee=
|
|
509
|
+
tags=final_tags or [],
|
|
510
|
+
assignee=final_assignee,
|
|
511
|
+
parent_epic=final_parent_epic,
|
|
53
512
|
)
|
|
54
513
|
|
|
55
514
|
# Create via adapter
|
|
56
515
|
created = await adapter.create(task)
|
|
57
516
|
|
|
58
|
-
|
|
517
|
+
# Build response with adapter metadata
|
|
518
|
+
response = {
|
|
59
519
|
"status": "completed",
|
|
520
|
+
**_build_adapter_metadata(adapter, created.id),
|
|
60
521
|
"ticket": created.model_dump(),
|
|
522
|
+
"labels_applied": created.tags or [],
|
|
523
|
+
"auto_detected": auto_detect_labels,
|
|
61
524
|
}
|
|
525
|
+
return response
|
|
62
526
|
except Exception as e:
|
|
63
|
-
|
|
527
|
+
error_response = {
|
|
64
528
|
"status": "error",
|
|
65
529
|
"error": f"Failed to create ticket: {str(e)}",
|
|
66
530
|
}
|
|
531
|
+
try:
|
|
532
|
+
adapter = get_adapter()
|
|
533
|
+
error_response.update(_build_adapter_metadata(adapter))
|
|
534
|
+
except Exception:
|
|
535
|
+
pass # If adapter not available, return error without metadata
|
|
67
536
|
|
|
537
|
+
# Add diagnostic suggestion for system-level errors
|
|
538
|
+
if should_suggest_diagnostics(e):
|
|
539
|
+
logging.debug(
|
|
540
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
541
|
+
)
|
|
542
|
+
try:
|
|
543
|
+
quick_info = await get_quick_diagnostic_info()
|
|
544
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
545
|
+
e, quick_info
|
|
546
|
+
)
|
|
547
|
+
except Exception as diag_error:
|
|
548
|
+
# Never block error response on diagnostic failure
|
|
549
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
68
550
|
|
|
69
|
-
|
|
70
|
-
async def ticket_read(ticket_id: str) -> dict[str, Any]:
|
|
71
|
-
"""Read a ticket by its ID.
|
|
551
|
+
return error_response
|
|
72
552
|
|
|
73
|
-
Args:
|
|
74
|
-
ticket_id: Unique identifier of the ticket to retrieve
|
|
75
553
|
|
|
76
|
-
|
|
77
|
-
|
|
554
|
+
async def ticket_read(ticket_id: str) -> dict[str, Any]:
|
|
555
|
+
"""Read ticket by ID or URL (supports Linear, GitHub, JIRA, Asana URLs with multi-platform routing).
|
|
78
556
|
|
|
557
|
+
.. deprecated:: 1.5.0
|
|
558
|
+
Use :func:`ticket` with ``action='get'`` instead.
|
|
559
|
+
This function will be removed in version 2.0.0.
|
|
560
|
+
|
|
561
|
+
Args: ticket_id (ID or full URL)
|
|
562
|
+
Returns: TicketResponse with ticket details
|
|
563
|
+
See: docs/mcp-api-reference.md#ticket-response-format, docs/mcp-api-reference.md#url-routing
|
|
79
564
|
"""
|
|
565
|
+
warnings.warn(
|
|
566
|
+
"ticket_read is deprecated. Use ticket(action='get', ticket_id=...) instead. "
|
|
567
|
+
"This function will be removed in version 2.0.0.",
|
|
568
|
+
DeprecationWarning,
|
|
569
|
+
stacklevel=2,
|
|
570
|
+
)
|
|
80
571
|
try:
|
|
81
|
-
|
|
82
|
-
|
|
572
|
+
is_routed = False
|
|
573
|
+
# Check if multi-platform routing is available
|
|
574
|
+
if is_url(ticket_id) and has_router():
|
|
575
|
+
# Use router for URL-based access
|
|
576
|
+
router = get_router()
|
|
577
|
+
logging.info(f"Routing ticket_read for URL: {ticket_id}")
|
|
578
|
+
ticket = await router.route_read(ticket_id)
|
|
579
|
+
is_routed = True
|
|
580
|
+
# Get adapter from router's cache to extract metadata
|
|
581
|
+
normalized_id, _, _ = router._normalize_ticket_id(ticket_id)
|
|
582
|
+
adapter = router._get_adapter(router._detect_adapter_from_url(ticket_id))
|
|
583
|
+
else:
|
|
584
|
+
# Use default adapter for plain IDs OR URLs (without multi-platform routing)
|
|
585
|
+
adapter = get_adapter()
|
|
586
|
+
|
|
587
|
+
# If URL provided, extract ID for the adapter
|
|
588
|
+
if is_url(ticket_id):
|
|
589
|
+
# Extract ID from URL for default adapter
|
|
590
|
+
adapter_type = type(adapter).__name__.lower().replace("adapter", "")
|
|
591
|
+
extracted_id, error = extract_id_from_url(
|
|
592
|
+
ticket_id, adapter_type=adapter_type
|
|
593
|
+
)
|
|
594
|
+
if error or not extracted_id:
|
|
595
|
+
return {
|
|
596
|
+
"status": "error",
|
|
597
|
+
"error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
|
|
598
|
+
}
|
|
599
|
+
ticket = await adapter.read(extracted_id)
|
|
600
|
+
else:
|
|
601
|
+
ticket = await adapter.read(ticket_id)
|
|
83
602
|
|
|
84
603
|
if ticket is None:
|
|
85
604
|
return {
|
|
@@ -89,16 +608,40 @@ async def ticket_read(ticket_id: str) -> dict[str, Any]:
|
|
|
89
608
|
|
|
90
609
|
return {
|
|
91
610
|
"status": "completed",
|
|
611
|
+
**_build_adapter_metadata(adapter, ticket.id, is_routed),
|
|
92
612
|
"ticket": ticket.model_dump(),
|
|
93
613
|
}
|
|
94
|
-
except
|
|
614
|
+
except ValueError as e:
|
|
615
|
+
# ValueError from adapters contains helpful user-facing messages
|
|
616
|
+
# (e.g., Linear view URL detection error)
|
|
617
|
+
# Return the error message directly without generic wrapper
|
|
95
618
|
return {
|
|
619
|
+
"status": "error",
|
|
620
|
+
"error": str(e),
|
|
621
|
+
}
|
|
622
|
+
except Exception as e:
|
|
623
|
+
error_response = {
|
|
96
624
|
"status": "error",
|
|
97
625
|
"error": f"Failed to read ticket: {str(e)}",
|
|
98
626
|
}
|
|
99
627
|
|
|
628
|
+
# Add diagnostic suggestion for system-level errors
|
|
629
|
+
if should_suggest_diagnostics(e):
|
|
630
|
+
logging.debug(
|
|
631
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
632
|
+
)
|
|
633
|
+
try:
|
|
634
|
+
quick_info = await get_quick_diagnostic_info()
|
|
635
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
636
|
+
e, quick_info
|
|
637
|
+
)
|
|
638
|
+
except Exception as diag_error:
|
|
639
|
+
# Never block error response on diagnostic failure
|
|
640
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
641
|
+
|
|
642
|
+
return error_response
|
|
643
|
+
|
|
100
644
|
|
|
101
|
-
@mcp.tool()
|
|
102
645
|
async def ticket_update(
|
|
103
646
|
ticket_id: str,
|
|
104
647
|
title: str | None = None,
|
|
@@ -108,24 +651,23 @@ async def ticket_update(
|
|
|
108
651
|
assignee: str | None = None,
|
|
109
652
|
tags: list[str] | None = None,
|
|
110
653
|
) -> dict[str, Any]:
|
|
111
|
-
"""Update
|
|
654
|
+
"""Update ticket using ID or URL (semantic priority matching, workflow states).
|
|
112
655
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
description: New description for the ticket
|
|
117
|
-
priority: New priority - must be one of: low, medium, high, critical
|
|
118
|
-
state: New state - must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked
|
|
119
|
-
assignee: User ID or email to assign the ticket to
|
|
120
|
-
tags: New list of tags (replaces existing tags)
|
|
121
|
-
|
|
122
|
-
Returns:
|
|
123
|
-
Updated ticket details, or error information
|
|
656
|
+
.. deprecated:: 1.5.0
|
|
657
|
+
Use :func:`ticket` with ``action='update'`` instead.
|
|
658
|
+
This function will be removed in version 2.0.0.
|
|
124
659
|
|
|
660
|
+
Args: ticket_id (ID or URL), title, description, priority (natural language), state (workflow), assignee, tags
|
|
661
|
+
Returns: TicketResponse with updated ticket
|
|
662
|
+
See: docs/mcp-api-reference.md#ticket-response-format, docs/mcp-api-reference.md#semantic-priority-matching
|
|
125
663
|
"""
|
|
664
|
+
warnings.warn(
|
|
665
|
+
"ticket_update is deprecated. Use ticket(action='update', ticket_id=...) instead. "
|
|
666
|
+
"This function will be removed in version 2.0.0.",
|
|
667
|
+
DeprecationWarning,
|
|
668
|
+
stacklevel=2,
|
|
669
|
+
)
|
|
126
670
|
try:
|
|
127
|
-
adapter = get_adapter()
|
|
128
|
-
|
|
129
671
|
# Build updates dictionary with only provided fields
|
|
130
672
|
updates: dict[str, Any] = {}
|
|
131
673
|
|
|
@@ -138,16 +680,30 @@ async def ticket_update(
|
|
|
138
680
|
if tags is not None:
|
|
139
681
|
updates["tags"] = tags
|
|
140
682
|
|
|
141
|
-
# Validate and convert priority if provided
|
|
683
|
+
# Validate and convert priority if provided (ISS-0002)
|
|
142
684
|
if priority is not None:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
685
|
+
priority_matcher = get_priority_matcher()
|
|
686
|
+
match_result = priority_matcher.match_priority(priority)
|
|
687
|
+
|
|
688
|
+
# Handle low confidence matches - provide suggestions
|
|
689
|
+
if match_result.is_low_confidence():
|
|
690
|
+
suggestions = priority_matcher.suggest_priorities(priority, top_n=3)
|
|
146
691
|
return {
|
|
147
|
-
"status": "
|
|
148
|
-
"
|
|
692
|
+
"status": "ambiguous",
|
|
693
|
+
"message": f"Priority input '{priority}' is ambiguous. Please choose from suggestions or use exact values.",
|
|
694
|
+
"original_input": priority,
|
|
695
|
+
"suggestions": [
|
|
696
|
+
{
|
|
697
|
+
"priority": s.priority.value,
|
|
698
|
+
"confidence": round(s.confidence, 2),
|
|
699
|
+
}
|
|
700
|
+
for s in suggestions
|
|
701
|
+
],
|
|
702
|
+
"exact_values": ["low", "medium", "high", "critical"],
|
|
149
703
|
}
|
|
150
704
|
|
|
705
|
+
updates["priority"] = match_result.priority
|
|
706
|
+
|
|
151
707
|
# Validate and convert state if provided
|
|
152
708
|
if state is not None:
|
|
153
709
|
try:
|
|
@@ -158,8 +714,33 @@ async def ticket_update(
|
|
|
158
714
|
"error": f"Invalid state '{state}'. Must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked",
|
|
159
715
|
}
|
|
160
716
|
|
|
161
|
-
#
|
|
162
|
-
|
|
717
|
+
# Route to appropriate adapter
|
|
718
|
+
is_routed = False
|
|
719
|
+
if is_url(ticket_id) and has_router():
|
|
720
|
+
router = get_router()
|
|
721
|
+
logging.info(f"Routing ticket_update for URL: {ticket_id}")
|
|
722
|
+
updated = await router.route_update(ticket_id, updates)
|
|
723
|
+
is_routed = True
|
|
724
|
+
normalized_id, _, _ = router._normalize_ticket_id(ticket_id)
|
|
725
|
+
adapter = router._get_adapter(router._detect_adapter_from_url(ticket_id))
|
|
726
|
+
else:
|
|
727
|
+
adapter = get_adapter()
|
|
728
|
+
|
|
729
|
+
# If URL provided, extract ID for the adapter
|
|
730
|
+
if is_url(ticket_id):
|
|
731
|
+
# Extract ID from URL for default adapter
|
|
732
|
+
adapter_type = type(adapter).__name__.lower().replace("adapter", "")
|
|
733
|
+
extracted_id, error = extract_id_from_url(
|
|
734
|
+
ticket_id, adapter_type=adapter_type
|
|
735
|
+
)
|
|
736
|
+
if error or not extracted_id:
|
|
737
|
+
return {
|
|
738
|
+
"status": "error",
|
|
739
|
+
"error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
|
|
740
|
+
}
|
|
741
|
+
updated = await adapter.update(extracted_id, updates)
|
|
742
|
+
else:
|
|
743
|
+
updated = await adapter.update(ticket_id, updates)
|
|
163
744
|
|
|
164
745
|
if updated is None:
|
|
165
746
|
return {
|
|
@@ -169,29 +750,77 @@ async def ticket_update(
|
|
|
169
750
|
|
|
170
751
|
return {
|
|
171
752
|
"status": "completed",
|
|
753
|
+
**_build_adapter_metadata(adapter, updated.id, is_routed),
|
|
172
754
|
"ticket": updated.model_dump(),
|
|
173
755
|
}
|
|
174
756
|
except Exception as e:
|
|
175
|
-
|
|
757
|
+
error_response = {
|
|
176
758
|
"status": "error",
|
|
177
759
|
"error": f"Failed to update ticket: {str(e)}",
|
|
178
760
|
}
|
|
179
761
|
|
|
762
|
+
# Add diagnostic suggestion for system-level errors
|
|
763
|
+
if should_suggest_diagnostics(e):
|
|
764
|
+
logging.debug(
|
|
765
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
766
|
+
)
|
|
767
|
+
try:
|
|
768
|
+
quick_info = await get_quick_diagnostic_info()
|
|
769
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
770
|
+
e, quick_info
|
|
771
|
+
)
|
|
772
|
+
except Exception as diag_error:
|
|
773
|
+
# Never block error response on diagnostic failure
|
|
774
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
180
775
|
|
|
181
|
-
|
|
182
|
-
async def ticket_delete(ticket_id: str) -> dict[str, Any]:
|
|
183
|
-
"""Delete a ticket by its ID.
|
|
776
|
+
return error_response
|
|
184
777
|
|
|
185
|
-
Args:
|
|
186
|
-
ticket_id: Unique identifier of the ticket to delete
|
|
187
778
|
|
|
188
|
-
|
|
189
|
-
|
|
779
|
+
async def ticket_delete(ticket_id: str) -> dict[str, Any]:
|
|
780
|
+
"""Delete ticket by ID or URL.
|
|
781
|
+
|
|
782
|
+
.. deprecated:: 1.5.0
|
|
783
|
+
Use :func:`ticket` with ``action='delete'`` instead.
|
|
784
|
+
This function will be removed in version 2.0.0.
|
|
190
785
|
|
|
786
|
+
Args: ticket_id (ID or URL)
|
|
787
|
+
Returns: DeleteResponse with status confirmation
|
|
788
|
+
See: docs/mcp-api-reference.md#delete-response
|
|
191
789
|
"""
|
|
790
|
+
warnings.warn(
|
|
791
|
+
"ticket_delete is deprecated. Use ticket(action='delete', ticket_id=...) instead. "
|
|
792
|
+
"This function will be removed in version 2.0.0.",
|
|
793
|
+
DeprecationWarning,
|
|
794
|
+
stacklevel=2,
|
|
795
|
+
)
|
|
192
796
|
try:
|
|
193
|
-
|
|
194
|
-
|
|
797
|
+
# Route to appropriate adapter
|
|
798
|
+
is_routed = False
|
|
799
|
+
if is_url(ticket_id) and has_router():
|
|
800
|
+
router = get_router()
|
|
801
|
+
logging.info(f"Routing ticket_delete for URL: {ticket_id}")
|
|
802
|
+
success = await router.route_delete(ticket_id)
|
|
803
|
+
is_routed = True
|
|
804
|
+
normalized_id, _, _ = router._normalize_ticket_id(ticket_id)
|
|
805
|
+
adapter = router._get_adapter(router._detect_adapter_from_url(ticket_id))
|
|
806
|
+
else:
|
|
807
|
+
adapter = get_adapter()
|
|
808
|
+
|
|
809
|
+
# If URL provided, extract ID for the adapter
|
|
810
|
+
if is_url(ticket_id):
|
|
811
|
+
# Extract ID from URL for default adapter
|
|
812
|
+
adapter_type = type(adapter).__name__.lower().replace("adapter", "")
|
|
813
|
+
extracted_id, error = extract_id_from_url(
|
|
814
|
+
ticket_id, adapter_type=adapter_type
|
|
815
|
+
)
|
|
816
|
+
if error or not extracted_id:
|
|
817
|
+
return {
|
|
818
|
+
"status": "error",
|
|
819
|
+
"error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
|
|
820
|
+
}
|
|
821
|
+
success = await adapter.delete(extracted_id)
|
|
822
|
+
else:
|
|
823
|
+
success = await adapter.delete(ticket_id)
|
|
195
824
|
|
|
196
825
|
if not success:
|
|
197
826
|
return {
|
|
@@ -201,6 +830,7 @@ async def ticket_delete(ticket_id: str) -> dict[str, Any]:
|
|
|
201
830
|
|
|
202
831
|
return {
|
|
203
832
|
"status": "completed",
|
|
833
|
+
**_build_adapter_metadata(adapter, ticket_id, is_routed),
|
|
204
834
|
"message": f"Ticket {ticket_id} deleted successfully",
|
|
205
835
|
}
|
|
206
836
|
except Exception as e:
|
|
@@ -210,32 +840,106 @@ async def ticket_delete(ticket_id: str) -> dict[str, Any]:
|
|
|
210
840
|
}
|
|
211
841
|
|
|
212
842
|
|
|
213
|
-
|
|
843
|
+
def _compact_ticket(ticket_dict: dict[str, Any]) -> dict[str, Any]:
|
|
844
|
+
"""Extract compact representation of ticket for reduced token usage.
|
|
845
|
+
|
|
846
|
+
This helper function reduces ticket data from ~185 tokens to ~50 tokens by
|
|
847
|
+
including only the most essential fields. Use for listing operations where full
|
|
848
|
+
details are not needed.
|
|
849
|
+
|
|
850
|
+
Args:
|
|
851
|
+
ticket_dict: Full ticket dictionary from model_dump()
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
Compact ticket dictionary with essential fields:
|
|
855
|
+
- id: Ticket identifier
|
|
856
|
+
- title: Ticket title
|
|
857
|
+
- state: Current state (for quick status check)
|
|
858
|
+
- priority: Priority level
|
|
859
|
+
- assignee: Assigned user (if any)
|
|
860
|
+
- tags: List of tags/labels (if any)
|
|
861
|
+
- parent_epic: Parent epic ID (if any)
|
|
862
|
+
|
|
863
|
+
"""
|
|
864
|
+
return {
|
|
865
|
+
"id": ticket_dict.get("id"),
|
|
866
|
+
"title": ticket_dict.get("title"),
|
|
867
|
+
"state": ticket_dict.get("state"),
|
|
868
|
+
"priority": ticket_dict.get("priority"),
|
|
869
|
+
"assignee": ticket_dict.get("assignee"),
|
|
870
|
+
"tags": ticket_dict.get("tags") or [],
|
|
871
|
+
"parent_epic": ticket_dict.get("parent_epic"),
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
|
|
214
875
|
async def ticket_list(
|
|
215
|
-
limit: int =
|
|
876
|
+
limit: int = 20,
|
|
216
877
|
offset: int = 0,
|
|
217
878
|
state: str | None = None,
|
|
218
879
|
priority: str | None = None,
|
|
219
880
|
assignee: str | None = None,
|
|
881
|
+
project_id: str | None = None,
|
|
882
|
+
compact: bool = True,
|
|
220
883
|
) -> dict[str, Any]:
|
|
221
|
-
"""List tickets with pagination and
|
|
884
|
+
"""List tickets with pagination and filters (compact mode default, project scoping required).
|
|
222
885
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
state: Filter by state - must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked
|
|
227
|
-
priority: Filter by priority - must be one of: low, medium, high, critical
|
|
228
|
-
assignee: Filter by assigned user ID or email
|
|
886
|
+
.. deprecated:: 1.5.0
|
|
887
|
+
Use :func:`ticket` with ``action='list'`` instead.
|
|
888
|
+
This function will be removed in version 2.0.0.
|
|
229
889
|
|
|
230
|
-
|
|
231
|
-
|
|
890
|
+
⚠️ Project Filtering Required:
|
|
891
|
+
This tool requires project_id parameter OR default_project configuration.
|
|
892
|
+
To set default project: config_set_default_project(project_id="YOUR-PROJECT")
|
|
893
|
+
To check current config: config_get()
|
|
232
894
|
|
|
895
|
+
Args: limit (max: 100, default: 20), offset (pagination), state, priority, assignee, project_id (required), compact (default: True, ~50 tokens/ticket vs ~185 full)
|
|
896
|
+
Returns: ListResponse with tickets array, count, pagination
|
|
897
|
+
See: docs/mcp-api-reference.md#list-response-format, docs/mcp-api-reference.md#token-usage-optimization
|
|
233
898
|
"""
|
|
899
|
+
warnings.warn(
|
|
900
|
+
"ticket_list is deprecated. Use ticket(action='list', ...) instead. "
|
|
901
|
+
"This function will be removed in version 2.0.0.",
|
|
902
|
+
DeprecationWarning,
|
|
903
|
+
stacklevel=2,
|
|
904
|
+
)
|
|
234
905
|
try:
|
|
906
|
+
# Validate project context (NEW: Required for list operations)
|
|
907
|
+
from pathlib import Path
|
|
908
|
+
|
|
909
|
+
from ....core.project_config import ConfigResolver
|
|
910
|
+
|
|
911
|
+
resolver = ConfigResolver(project_path=Path.cwd())
|
|
912
|
+
config = resolver.load_project_config()
|
|
913
|
+
final_project = project_id or (config.default_project if config else None)
|
|
914
|
+
|
|
915
|
+
if not final_project:
|
|
916
|
+
return {
|
|
917
|
+
"status": "error",
|
|
918
|
+
"error": "project_id required. Provide project_id parameter or configure default_project.",
|
|
919
|
+
"help": "Use config_set_default_project(project_id='YOUR-PROJECT') to set default project",
|
|
920
|
+
"check_config": "Use config_get() to view current configuration",
|
|
921
|
+
}
|
|
922
|
+
|
|
235
923
|
adapter = get_adapter()
|
|
236
924
|
|
|
237
|
-
#
|
|
238
|
-
|
|
925
|
+
# Add warning for large non-compact queries
|
|
926
|
+
if limit > 30 and not compact:
|
|
927
|
+
logging.warning(
|
|
928
|
+
f"Large query requested: limit={limit}, compact={compact}. "
|
|
929
|
+
f"This may generate ~{limit * 185} tokens. "
|
|
930
|
+
f"Consider using compact=True to reduce token usage."
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
# Add warning for large unscoped queries
|
|
934
|
+
if limit > 50 and not (state or priority or assignee):
|
|
935
|
+
logging.warning(
|
|
936
|
+
f"Large unscoped query: limit={limit} with no filters. "
|
|
937
|
+
f"Consider using state, priority, or assignee filters to reduce result set. "
|
|
938
|
+
f"Tip: Configure default_team or default_project for automatic scoping."
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
# Build filters dictionary with required project scoping
|
|
942
|
+
filters: dict[str, Any] = {"project": final_project}
|
|
239
943
|
|
|
240
944
|
if state is not None:
|
|
241
945
|
try:
|
|
@@ -263,15 +967,411 @@ async def ticket_list(
|
|
|
263
967
|
limit=limit, offset=offset, filters=filters if filters else None
|
|
264
968
|
)
|
|
265
969
|
|
|
970
|
+
# Apply compact mode if requested
|
|
971
|
+
if compact:
|
|
972
|
+
ticket_data = [_compact_ticket(ticket.model_dump()) for ticket in tickets]
|
|
973
|
+
else:
|
|
974
|
+
ticket_data = [ticket.model_dump() for ticket in tickets]
|
|
975
|
+
|
|
266
976
|
return {
|
|
267
977
|
"status": "completed",
|
|
268
|
-
|
|
978
|
+
**_build_adapter_metadata(adapter),
|
|
979
|
+
"tickets": ticket_data,
|
|
269
980
|
"count": len(tickets),
|
|
270
981
|
"limit": limit,
|
|
271
982
|
"offset": offset,
|
|
983
|
+
"compact": compact,
|
|
272
984
|
}
|
|
273
985
|
except Exception as e:
|
|
274
|
-
|
|
986
|
+
error_response = {
|
|
275
987
|
"status": "error",
|
|
276
988
|
"error": f"Failed to list tickets: {str(e)}",
|
|
277
989
|
}
|
|
990
|
+
|
|
991
|
+
# Add diagnostic suggestion for system-level errors
|
|
992
|
+
if should_suggest_diagnostics(e):
|
|
993
|
+
logging.debug(
|
|
994
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
995
|
+
)
|
|
996
|
+
try:
|
|
997
|
+
quick_info = await get_quick_diagnostic_info()
|
|
998
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
999
|
+
e, quick_info
|
|
1000
|
+
)
|
|
1001
|
+
except Exception as diag_error:
|
|
1002
|
+
# Never block error response on diagnostic failure
|
|
1003
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
1004
|
+
|
|
1005
|
+
return error_response
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
async def ticket_summary(ticket_id: str) -> dict[str, Any]:
|
|
1009
|
+
"""Get ultra-compact summary (id, title, state, priority, assignee only - ~20 tokens vs ~185 full).
|
|
1010
|
+
|
|
1011
|
+
.. deprecated:: 1.5.0
|
|
1012
|
+
Use :func:`ticket` with ``action='summary'`` instead.
|
|
1013
|
+
This function will be removed in version 2.0.0.
|
|
1014
|
+
|
|
1015
|
+
Args: ticket_id (ID or URL)
|
|
1016
|
+
Returns: SummaryResponse with minimal fields (90% token savings)
|
|
1017
|
+
See: docs/mcp-api-reference.md#compact-ticket-format
|
|
1018
|
+
"""
|
|
1019
|
+
warnings.warn(
|
|
1020
|
+
"ticket_summary is deprecated. Use ticket(action='summary', ticket_id=...) instead. "
|
|
1021
|
+
"This function will be removed in version 2.0.0.",
|
|
1022
|
+
DeprecationWarning,
|
|
1023
|
+
stacklevel=2,
|
|
1024
|
+
)
|
|
1025
|
+
try:
|
|
1026
|
+
# Use ticket_read to get full ticket
|
|
1027
|
+
result = await ticket_read(ticket_id)
|
|
1028
|
+
|
|
1029
|
+
if result["status"] == "error":
|
|
1030
|
+
return result
|
|
1031
|
+
|
|
1032
|
+
ticket = result["ticket"]
|
|
1033
|
+
|
|
1034
|
+
# Extract only ultra-essential fields
|
|
1035
|
+
summary = {
|
|
1036
|
+
"id": ticket.get("id"),
|
|
1037
|
+
"title": ticket.get("title"),
|
|
1038
|
+
"state": ticket.get("state"),
|
|
1039
|
+
"priority": ticket.get("priority"),
|
|
1040
|
+
"assignee": ticket.get("assignee"),
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return {
|
|
1044
|
+
"status": "completed",
|
|
1045
|
+
**_build_adapter_metadata(
|
|
1046
|
+
get_adapter(), ticket.get("id"), result.get("routed_from_url", False)
|
|
1047
|
+
),
|
|
1048
|
+
"summary": summary,
|
|
1049
|
+
"token_savings": "~90% smaller than full ticket_read",
|
|
1050
|
+
}
|
|
1051
|
+
except Exception as e:
|
|
1052
|
+
return {
|
|
1053
|
+
"status": "error",
|
|
1054
|
+
"error": f"Failed to get ticket summary: {str(e)}",
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
async def ticket_latest(ticket_id: str, limit: int = 5) -> dict[str, Any]:
|
|
1059
|
+
"""Get recent activity (comments, state changes, updates - adapter-dependent behavior).
|
|
1060
|
+
|
|
1061
|
+
.. deprecated:: 1.5.0
|
|
1062
|
+
Use :func:`ticket` with ``action='get_activity'`` instead.
|
|
1063
|
+
This function will be removed in version 2.0.0.
|
|
1064
|
+
|
|
1065
|
+
Args: ticket_id (ID or URL), limit (max: 20, default: 5)
|
|
1066
|
+
Returns: ActivityResponse with recent activities, timestamps, change descriptions
|
|
1067
|
+
See: docs/mcp-api-reference.md#activity-response-format
|
|
1068
|
+
"""
|
|
1069
|
+
warnings.warn(
|
|
1070
|
+
"ticket_latest is deprecated. Use ticket(action='get_activity', ticket_id=...) instead. "
|
|
1071
|
+
"This function will be removed in version 2.0.0.",
|
|
1072
|
+
DeprecationWarning,
|
|
1073
|
+
stacklevel=2,
|
|
1074
|
+
)
|
|
1075
|
+
try:
|
|
1076
|
+
# Validate limit
|
|
1077
|
+
if limit < 1 or limit > 20:
|
|
1078
|
+
return {
|
|
1079
|
+
"status": "error",
|
|
1080
|
+
"error": "Limit must be between 1 and 20",
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
# Route to appropriate adapter
|
|
1084
|
+
is_routed = False
|
|
1085
|
+
if is_url(ticket_id) and has_router():
|
|
1086
|
+
router = get_router()
|
|
1087
|
+
logging.info(f"Routing ticket_latest for URL: {ticket_id}")
|
|
1088
|
+
# First get the ticket to verify it exists
|
|
1089
|
+
ticket = await router.route_read(ticket_id)
|
|
1090
|
+
is_routed = True
|
|
1091
|
+
normalized_id, adapter_name, _ = router._normalize_ticket_id(ticket_id)
|
|
1092
|
+
adapter = router._get_adapter(adapter_name)
|
|
1093
|
+
actual_ticket_id = normalized_id
|
|
1094
|
+
else:
|
|
1095
|
+
adapter = get_adapter()
|
|
1096
|
+
|
|
1097
|
+
# If URL provided, extract ID for the adapter
|
|
1098
|
+
actual_ticket_id = ticket_id
|
|
1099
|
+
if is_url(ticket_id):
|
|
1100
|
+
adapter_type = type(adapter).__name__.lower().replace("adapter", "")
|
|
1101
|
+
extracted_id, error = extract_id_from_url(
|
|
1102
|
+
ticket_id, adapter_type=adapter_type
|
|
1103
|
+
)
|
|
1104
|
+
if error or not extracted_id:
|
|
1105
|
+
return {
|
|
1106
|
+
"status": "error",
|
|
1107
|
+
"error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
|
|
1108
|
+
}
|
|
1109
|
+
actual_ticket_id = extracted_id
|
|
1110
|
+
|
|
1111
|
+
# Get ticket to verify it exists
|
|
1112
|
+
ticket = await adapter.read(actual_ticket_id)
|
|
1113
|
+
|
|
1114
|
+
if ticket is None:
|
|
1115
|
+
return {
|
|
1116
|
+
"status": "error",
|
|
1117
|
+
"error": f"Ticket {ticket_id} not found",
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
# Try to get comments if adapter supports it
|
|
1121
|
+
recent_activity = []
|
|
1122
|
+
supports_comments = False
|
|
1123
|
+
|
|
1124
|
+
try:
|
|
1125
|
+
# Check if adapter has list_comments method
|
|
1126
|
+
if hasattr(adapter, "list_comments"):
|
|
1127
|
+
comments = await adapter.list_comments(actual_ticket_id, limit=limit)
|
|
1128
|
+
supports_comments = True
|
|
1129
|
+
|
|
1130
|
+
# Convert comments to activity format
|
|
1131
|
+
for comment in comments[:limit]:
|
|
1132
|
+
activity_item = {
|
|
1133
|
+
"type": "comment",
|
|
1134
|
+
"timestamp": (
|
|
1135
|
+
comment.created_at
|
|
1136
|
+
if hasattr(comment, "created_at")
|
|
1137
|
+
else None
|
|
1138
|
+
),
|
|
1139
|
+
"author": (
|
|
1140
|
+
comment.author if hasattr(comment, "author") else None
|
|
1141
|
+
),
|
|
1142
|
+
"content": comment.content[:200]
|
|
1143
|
+
+ ("..." if len(comment.content) > 200 else ""),
|
|
1144
|
+
}
|
|
1145
|
+
recent_activity.append(activity_item)
|
|
1146
|
+
except Exception as e:
|
|
1147
|
+
logging.debug(f"Comment listing not supported or failed: {e}")
|
|
1148
|
+
|
|
1149
|
+
# If no comments available, provide last update info
|
|
1150
|
+
if not recent_activity:
|
|
1151
|
+
recent_activity.append(
|
|
1152
|
+
{
|
|
1153
|
+
"type": "last_update",
|
|
1154
|
+
"timestamp": (
|
|
1155
|
+
ticket.updated_at if hasattr(ticket, "updated_at") else None
|
|
1156
|
+
),
|
|
1157
|
+
"state": ticket.state,
|
|
1158
|
+
"priority": ticket.priority,
|
|
1159
|
+
"assignee": ticket.assignee,
|
|
1160
|
+
}
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
return {
|
|
1164
|
+
"status": "completed",
|
|
1165
|
+
**_build_adapter_metadata(adapter, ticket.id, is_routed),
|
|
1166
|
+
"ticket_id": ticket.id,
|
|
1167
|
+
"ticket_title": ticket.title,
|
|
1168
|
+
"recent_activity": recent_activity,
|
|
1169
|
+
"activity_count": len(recent_activity),
|
|
1170
|
+
"supports_full_history": supports_comments,
|
|
1171
|
+
"limit": limit,
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
except Exception as e:
|
|
1175
|
+
error_response = {
|
|
1176
|
+
"status": "error",
|
|
1177
|
+
"error": f"Failed to get recent activity: {str(e)}",
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
# Add diagnostic suggestion for system-level errors
|
|
1181
|
+
if should_suggest_diagnostics(e):
|
|
1182
|
+
logging.debug(
|
|
1183
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
1184
|
+
)
|
|
1185
|
+
try:
|
|
1186
|
+
quick_info = await get_quick_diagnostic_info()
|
|
1187
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
1188
|
+
e, quick_info
|
|
1189
|
+
)
|
|
1190
|
+
except Exception as diag_error:
|
|
1191
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
1192
|
+
|
|
1193
|
+
return error_response
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
async def ticket_assign(
|
|
1197
|
+
ticket_id: str,
|
|
1198
|
+
assignee: str | None,
|
|
1199
|
+
comment: str | None = None,
|
|
1200
|
+
auto_transition: bool = True,
|
|
1201
|
+
) -> dict[str, Any]:
|
|
1202
|
+
"""Assign/unassign ticket with auto-transition to IN_PROGRESS (OPEN/WAITING/BLOCKED → IN_PROGRESS when assigned).
|
|
1203
|
+
|
|
1204
|
+
.. deprecated:: 1.5.0
|
|
1205
|
+
Use :func:`ticket` with ``action='assign'`` instead.
|
|
1206
|
+
This function will be removed in version 2.0.0.
|
|
1207
|
+
|
|
1208
|
+
Args: ticket_id (ID or URL), assignee (user ID/email or None to unassign), comment (optional audit trail), auto_transition (default: True)
|
|
1209
|
+
Returns: AssignmentResponse with ticket, previous/new assignee, previous/new state, state_auto_transitioned, comment_added
|
|
1210
|
+
See: docs/ticket-workflows.md#auto-transitions, docs/mcp-api-reference.md#user-identifiers
|
|
1211
|
+
"""
|
|
1212
|
+
warnings.warn(
|
|
1213
|
+
"ticket_assign is deprecated. Use ticket(action='assign', ticket_id=...) instead. "
|
|
1214
|
+
"This function will be removed in version 2.0.0.",
|
|
1215
|
+
DeprecationWarning,
|
|
1216
|
+
stacklevel=2,
|
|
1217
|
+
)
|
|
1218
|
+
try:
|
|
1219
|
+
# Read current ticket to get previous assignee
|
|
1220
|
+
is_routed = False
|
|
1221
|
+
if is_url(ticket_id) and has_router():
|
|
1222
|
+
router = get_router()
|
|
1223
|
+
logging.info(f"Routing ticket_assign for URL: {ticket_id}")
|
|
1224
|
+
ticket = await router.route_read(ticket_id)
|
|
1225
|
+
is_routed = True
|
|
1226
|
+
normalized_id, adapter_name, _ = router._normalize_ticket_id(ticket_id)
|
|
1227
|
+
adapter = router._get_adapter(adapter_name)
|
|
1228
|
+
else:
|
|
1229
|
+
adapter = get_adapter()
|
|
1230
|
+
|
|
1231
|
+
# If URL provided, extract ID for the adapter
|
|
1232
|
+
actual_ticket_id = ticket_id
|
|
1233
|
+
if is_url(ticket_id):
|
|
1234
|
+
# Extract ID from URL for default adapter
|
|
1235
|
+
adapter_type = type(adapter).__name__.lower().replace("adapter", "")
|
|
1236
|
+
extracted_id, error = extract_id_from_url(
|
|
1237
|
+
ticket_id, adapter_type=adapter_type
|
|
1238
|
+
)
|
|
1239
|
+
if error or not extracted_id:
|
|
1240
|
+
return {
|
|
1241
|
+
"status": "error",
|
|
1242
|
+
"error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
|
|
1243
|
+
}
|
|
1244
|
+
actual_ticket_id = extracted_id
|
|
1245
|
+
|
|
1246
|
+
ticket = await adapter.read(actual_ticket_id)
|
|
1247
|
+
|
|
1248
|
+
if ticket is None:
|
|
1249
|
+
return {
|
|
1250
|
+
"status": "error",
|
|
1251
|
+
"error": f"Ticket {ticket_id} not found",
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
# Store previous assignee and state for response
|
|
1255
|
+
previous_assignee = ticket.assignee
|
|
1256
|
+
current_state = ticket.state
|
|
1257
|
+
|
|
1258
|
+
# Import TicketState for state transitions
|
|
1259
|
+
from ....core.models import TicketState
|
|
1260
|
+
|
|
1261
|
+
# Convert string state to enum if needed (Pydantic uses use_enum_values=True)
|
|
1262
|
+
if isinstance(current_state, str):
|
|
1263
|
+
current_state = TicketState(current_state)
|
|
1264
|
+
|
|
1265
|
+
# Build updates dictionary
|
|
1266
|
+
updates: dict[str, Any] = {"assignee": assignee}
|
|
1267
|
+
|
|
1268
|
+
# Auto-transition logic
|
|
1269
|
+
state_transitioned = False
|
|
1270
|
+
auto_comment = None
|
|
1271
|
+
|
|
1272
|
+
if (
|
|
1273
|
+
auto_transition and assignee is not None
|
|
1274
|
+
): # Only when assigning (not unassigning)
|
|
1275
|
+
# Check if current state should auto-transition to IN_PROGRESS
|
|
1276
|
+
if current_state in [
|
|
1277
|
+
TicketState.OPEN,
|
|
1278
|
+
TicketState.WAITING,
|
|
1279
|
+
TicketState.BLOCKED,
|
|
1280
|
+
]:
|
|
1281
|
+
# Validate workflow allows this transition
|
|
1282
|
+
if current_state.can_transition_to(TicketState.IN_PROGRESS):
|
|
1283
|
+
updates["state"] = TicketState.IN_PROGRESS
|
|
1284
|
+
state_transitioned = True
|
|
1285
|
+
|
|
1286
|
+
# Add automatic comment if no comment provided
|
|
1287
|
+
if comment is None:
|
|
1288
|
+
auto_comment = f"Automatically transitioned from {current_state.value} to in_progress when assigned to {assignee}"
|
|
1289
|
+
else:
|
|
1290
|
+
# Log warning if transition validation fails (shouldn't happen based on our rules)
|
|
1291
|
+
logging.warning(
|
|
1292
|
+
f"State transition from {current_state.value} to IN_PROGRESS failed validation"
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
if is_routed:
|
|
1296
|
+
updated = await router.route_update(ticket_id, updates)
|
|
1297
|
+
else:
|
|
1298
|
+
updated = await adapter.update(actual_ticket_id, updates)
|
|
1299
|
+
|
|
1300
|
+
if updated is None:
|
|
1301
|
+
return {
|
|
1302
|
+
"status": "error",
|
|
1303
|
+
"error": f"Failed to update assignment for ticket {ticket_id}",
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
# Add comment if provided or auto-generated, and adapter supports it
|
|
1307
|
+
comment_added = False
|
|
1308
|
+
comment_to_add = comment or auto_comment
|
|
1309
|
+
|
|
1310
|
+
if comment_to_add:
|
|
1311
|
+
try:
|
|
1312
|
+
from ....core.models import Comment as CommentModel
|
|
1313
|
+
|
|
1314
|
+
# Use actual_ticket_id for non-routed case, original ticket_id for routed
|
|
1315
|
+
comment_ticket_id = ticket_id if is_routed else actual_ticket_id
|
|
1316
|
+
|
|
1317
|
+
comment_obj = CommentModel(
|
|
1318
|
+
ticket_id=comment_ticket_id, content=comment_to_add, author=""
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
if is_routed:
|
|
1322
|
+
await router.route_add_comment(ticket_id, comment_obj)
|
|
1323
|
+
else:
|
|
1324
|
+
await adapter.add_comment(comment_obj)
|
|
1325
|
+
comment_added = True
|
|
1326
|
+
except Exception as e:
|
|
1327
|
+
# Comment failed but assignment succeeded - log and continue
|
|
1328
|
+
logging.warning(f"Assignment succeeded but comment failed: {str(e)}")
|
|
1329
|
+
|
|
1330
|
+
# Build response
|
|
1331
|
+
# Handle both string and enum state values
|
|
1332
|
+
previous_state_value = (
|
|
1333
|
+
current_state.value
|
|
1334
|
+
if hasattr(current_state, "value")
|
|
1335
|
+
else str(current_state)
|
|
1336
|
+
)
|
|
1337
|
+
new_state_value = (
|
|
1338
|
+
updated.state.value
|
|
1339
|
+
if hasattr(updated.state, "value")
|
|
1340
|
+
else str(updated.state)
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
response = {
|
|
1344
|
+
"status": "completed",
|
|
1345
|
+
**_build_adapter_metadata(adapter, updated.id, is_routed),
|
|
1346
|
+
"ticket": updated.model_dump(),
|
|
1347
|
+
"previous_assignee": previous_assignee,
|
|
1348
|
+
"new_assignee": assignee,
|
|
1349
|
+
"previous_state": previous_state_value,
|
|
1350
|
+
"new_state": new_state_value,
|
|
1351
|
+
"state_auto_transitioned": state_transitioned,
|
|
1352
|
+
"comment_added": comment_added,
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
return response
|
|
1356
|
+
|
|
1357
|
+
except Exception as e:
|
|
1358
|
+
error_response = {
|
|
1359
|
+
"status": "error",
|
|
1360
|
+
"error": f"Failed to assign ticket: {str(e)}",
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
# Add diagnostic suggestion for system-level errors
|
|
1364
|
+
if should_suggest_diagnostics(e):
|
|
1365
|
+
logging.debug(
|
|
1366
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
1367
|
+
)
|
|
1368
|
+
try:
|
|
1369
|
+
quick_info = await get_quick_diagnostic_info()
|
|
1370
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
1371
|
+
e, quick_info
|
|
1372
|
+
)
|
|
1373
|
+
except Exception as diag_error:
|
|
1374
|
+
# Never block error response on diagnostic failure
|
|
1375
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
1376
|
+
|
|
1377
|
+
return error_response
|