mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__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 +796 -46
- 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 +879 -129
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +973 -73
- mcp_ticketer/adapters/linear/__init__.py +24 -0
- mcp_ticketer/adapters/linear/adapter.py +2732 -0
- mcp_ticketer/adapters/linear/client.py +344 -0
- mcp_ticketer/adapters/linear/mappers.py +420 -0
- mcp_ticketer/adapters/linear/queries.py +479 -0
- mcp_ticketer/adapters/linear/types.py +360 -0
- mcp_ticketer/adapters/linear.py +10 -2315
- mcp_ticketer/analysis/__init__.py +23 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +421 -0
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +888 -151
- mcp_ticketer/cli/diagnostics.py +400 -157
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +616 -0
- mcp_ticketer/cli/main.py +203 -1165
- mcp_ticketer/cli/mcp_configure.py +474 -90
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +418 -0
- mcp_ticketer/cli/platform_installer.py +513 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +90 -65
- mcp_ticketer/cli/ticket_commands.py +1013 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +114 -66
- mcp_ticketer/core/__init__.py +24 -1
- mcp_ticketer/core/adapter.py +250 -16
- mcp_ticketer/core/config.py +145 -37
- mcp_ticketer/core/env_discovery.py +101 -22
- mcp_ticketer/core/env_loader.py +349 -0
- mcp_ticketer/core/exceptions.py +160 -0
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/models.py +280 -28
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +183 -49
- mcp_ticketer/core/registry.py +3 -3
- 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 +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +56 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +95 -25
- mcp_ticketer/queue/queue.py +40 -21
- mcp_ticketer/queue/run_worker.py +6 -1
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +109 -49
- mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
- mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
- mcp_ticketer/mcp/server.py +0 -1895
- mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
- mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1268 @@
|
|
|
1
|
+
"""Basic CRUD operations for tickets.
|
|
2
|
+
|
|
3
|
+
This module implements the core create, read, update, delete, and list
|
|
4
|
+
operations for tickets using the FastMCP SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ....core.adapter import BaseAdapter
|
|
12
|
+
from ....core.models import Priority, Task, TicketState
|
|
13
|
+
from ....core.project_config import ConfigResolver, TicketerConfig
|
|
14
|
+
from ....core.session_state import SessionStateManager
|
|
15
|
+
from ....core.url_parser import extract_id_from_url, is_url
|
|
16
|
+
from ..diagnostic_helper import (
|
|
17
|
+
build_diagnostic_suggestion,
|
|
18
|
+
get_quick_diagnostic_info,
|
|
19
|
+
should_suggest_diagnostics,
|
|
20
|
+
)
|
|
21
|
+
from ..server_sdk import get_adapter, get_router, has_router, mcp
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_adapter_metadata(
|
|
25
|
+
adapter: BaseAdapter,
|
|
26
|
+
ticket_id: str | None = None,
|
|
27
|
+
is_routed: bool = False,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""Build adapter metadata for MCP responses.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
adapter: The adapter that handled the operation
|
|
33
|
+
ticket_id: Optional ticket ID to include in metadata
|
|
34
|
+
is_routed: Whether this was routed via URL detection
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Dictionary with adapter metadata fields
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
metadata = {
|
|
41
|
+
"adapter": adapter.adapter_type,
|
|
42
|
+
"adapter_name": adapter.adapter_display_name,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if ticket_id:
|
|
46
|
+
metadata["ticket_id"] = ticket_id
|
|
47
|
+
|
|
48
|
+
if is_routed:
|
|
49
|
+
metadata["routed_from_url"] = True
|
|
50
|
+
|
|
51
|
+
return metadata
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def detect_and_apply_labels(
|
|
55
|
+
adapter: Any,
|
|
56
|
+
ticket_title: str,
|
|
57
|
+
ticket_description: str,
|
|
58
|
+
existing_labels: list[str] | None = None,
|
|
59
|
+
) -> list[str]:
|
|
60
|
+
"""Detect and suggest labels/tags based on ticket content.
|
|
61
|
+
|
|
62
|
+
This function analyzes the ticket title and description to automatically
|
|
63
|
+
detect relevant labels/tags from the adapter's available labels.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
adapter: The ticket adapter instance
|
|
67
|
+
ticket_title: Ticket title text
|
|
68
|
+
ticket_description: Ticket description text
|
|
69
|
+
existing_labels: Labels already specified by user (optional)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
List of label/tag identifiers to apply (combines auto-detected + user-specified)
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
# Get available labels from adapter
|
|
76
|
+
available_labels = []
|
|
77
|
+
try:
|
|
78
|
+
if hasattr(adapter, "list_labels"):
|
|
79
|
+
available_labels = await adapter.list_labels()
|
|
80
|
+
elif hasattr(adapter, "get_labels"):
|
|
81
|
+
available_labels = await adapter.get_labels()
|
|
82
|
+
except Exception:
|
|
83
|
+
# Adapter doesn't support labels or listing failed - return user labels only
|
|
84
|
+
return existing_labels or []
|
|
85
|
+
|
|
86
|
+
if not available_labels:
|
|
87
|
+
return existing_labels or []
|
|
88
|
+
|
|
89
|
+
# Combine title and description for matching (lowercase for case-insensitive matching)
|
|
90
|
+
content = f"{ticket_title} {ticket_description or ''}".lower()
|
|
91
|
+
|
|
92
|
+
# Common label keyword patterns
|
|
93
|
+
label_keywords = {
|
|
94
|
+
"bug": ["bug", "error", "broken", "crash", "fix", "issue", "defect"],
|
|
95
|
+
"feature": ["feature", "add", "new", "implement", "create", "enhancement"],
|
|
96
|
+
"improvement": [
|
|
97
|
+
"enhance",
|
|
98
|
+
"improve",
|
|
99
|
+
"update",
|
|
100
|
+
"upgrade",
|
|
101
|
+
"refactor",
|
|
102
|
+
"optimize",
|
|
103
|
+
],
|
|
104
|
+
"documentation": ["doc", "documentation", "readme", "guide", "manual"],
|
|
105
|
+
"test": ["test", "testing", "qa", "validation", "verify"],
|
|
106
|
+
"security": ["security", "vulnerability", "auth", "permission", "exploit"],
|
|
107
|
+
"performance": ["performance", "slow", "optimize", "speed", "latency"],
|
|
108
|
+
"ui": ["ui", "ux", "interface", "design", "layout", "frontend"],
|
|
109
|
+
"api": ["api", "endpoint", "rest", "graphql", "backend"],
|
|
110
|
+
"backend": ["backend", "server", "database", "storage"],
|
|
111
|
+
"frontend": ["frontend", "client", "web", "react", "vue"],
|
|
112
|
+
"critical": ["critical", "urgent", "emergency", "blocker"],
|
|
113
|
+
"high-priority": ["urgent", "asap", "important", "critical"],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# Match labels against content
|
|
117
|
+
matched_labels = []
|
|
118
|
+
|
|
119
|
+
for label in available_labels:
|
|
120
|
+
# Extract label name (handle both dict and string formats)
|
|
121
|
+
if isinstance(label, dict):
|
|
122
|
+
label_name = label.get("name", "")
|
|
123
|
+
else:
|
|
124
|
+
label_name = str(label)
|
|
125
|
+
|
|
126
|
+
label_name_lower = label_name.lower()
|
|
127
|
+
|
|
128
|
+
# Direct match: label name appears in content
|
|
129
|
+
if label_name_lower in content:
|
|
130
|
+
if label_name not in matched_labels:
|
|
131
|
+
matched_labels.append(label_name)
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Keyword match: check if label matches any keyword category
|
|
135
|
+
for keyword_category, keywords in label_keywords.items():
|
|
136
|
+
# Check if label name relates to the category
|
|
137
|
+
if (
|
|
138
|
+
keyword_category in label_name_lower
|
|
139
|
+
or label_name_lower in keyword_category
|
|
140
|
+
):
|
|
141
|
+
# Check if any keyword from this category appears in content
|
|
142
|
+
if any(kw in content for kw in keywords):
|
|
143
|
+
if label_name not in matched_labels:
|
|
144
|
+
matched_labels.append(label_name)
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
# Combine user-specified labels with auto-detected ones
|
|
148
|
+
final_labels = list(existing_labels or [])
|
|
149
|
+
for label in matched_labels:
|
|
150
|
+
if label not in final_labels:
|
|
151
|
+
final_labels.append(label)
|
|
152
|
+
|
|
153
|
+
return final_labels
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@mcp.tool()
|
|
157
|
+
async def ticket_create(
|
|
158
|
+
title: str,
|
|
159
|
+
description: str = "",
|
|
160
|
+
priority: str = "medium",
|
|
161
|
+
tags: list[str] | None = None,
|
|
162
|
+
assignee: str | None = None,
|
|
163
|
+
parent_epic: str | None = None,
|
|
164
|
+
auto_detect_labels: bool = True,
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
"""Create a new ticket with automatic label/tag detection.
|
|
167
|
+
|
|
168
|
+
This tool automatically scans available labels/tags and intelligently
|
|
169
|
+
applies relevant ones based on the ticket title and description.
|
|
170
|
+
|
|
171
|
+
Label Detection:
|
|
172
|
+
- Scans all available labels in the configured adapter
|
|
173
|
+
- Matches labels based on keywords in title/description
|
|
174
|
+
- Combines auto-detected labels with user-specified ones
|
|
175
|
+
- Can be disabled by setting auto_detect_labels=false
|
|
176
|
+
|
|
177
|
+
Common label patterns detected:
|
|
178
|
+
- bug, feature, improvement, documentation
|
|
179
|
+
- test, security, performance
|
|
180
|
+
- ui, api, backend, frontend
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
title: Ticket title (required)
|
|
184
|
+
description: Detailed description of the ticket
|
|
185
|
+
priority: Priority level - must be one of: low, medium, high, critical
|
|
186
|
+
tags: List of tags to categorize the ticket (auto-detection adds to these)
|
|
187
|
+
assignee: User ID or email to assign the ticket to
|
|
188
|
+
parent_epic: Parent epic/project ID to assign this ticket to (optional)
|
|
189
|
+
auto_detect_labels: Automatically detect and apply relevant labels (default: True)
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Created ticket details including ID and metadata, or error information
|
|
193
|
+
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
adapter = get_adapter()
|
|
197
|
+
|
|
198
|
+
# Validate and convert priority
|
|
199
|
+
try:
|
|
200
|
+
priority_enum = Priority(priority.lower())
|
|
201
|
+
except ValueError:
|
|
202
|
+
return {
|
|
203
|
+
"status": "error",
|
|
204
|
+
"error": f"Invalid priority '{priority}'. Must be one of: low, medium, high, critical",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Apply configuration defaults if values not provided
|
|
208
|
+
resolver = ConfigResolver(project_path=Path.cwd())
|
|
209
|
+
config = resolver.load_project_config() or TicketerConfig()
|
|
210
|
+
|
|
211
|
+
# Session ticket integration (NEW)
|
|
212
|
+
session_manager = SessionStateManager(project_path=Path.cwd())
|
|
213
|
+
session_state = session_manager.load_session()
|
|
214
|
+
|
|
215
|
+
# Check if we should prompt for ticket association
|
|
216
|
+
if parent_epic is None and not session_state.ticket_opted_out:
|
|
217
|
+
if session_state.current_ticket:
|
|
218
|
+
# Use session ticket as parent_epic
|
|
219
|
+
final_parent_epic = session_state.current_ticket
|
|
220
|
+
logging.info(
|
|
221
|
+
f"Using session ticket as parent_epic: {final_parent_epic}"
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
# No session ticket and user hasn't opted out - provide guidance
|
|
225
|
+
return {
|
|
226
|
+
"status": "error",
|
|
227
|
+
"requires_ticket_association": True,
|
|
228
|
+
"guidance": (
|
|
229
|
+
"⚠️ No ticket association found for this work session.\n\n"
|
|
230
|
+
"It's recommended to associate your work with a ticket for proper tracking.\n\n"
|
|
231
|
+
"**Options**:\n"
|
|
232
|
+
"1. Associate with a ticket: attach_ticket(action='set', ticket_id='PROJ-123')\n"
|
|
233
|
+
"2. Skip for this session: attach_ticket(action='none')\n"
|
|
234
|
+
"3. Provide parent_epic directly: ticket_create(..., parent_epic='PROJ-123')\n\n"
|
|
235
|
+
"After associating, run ticket_create again to create the ticket."
|
|
236
|
+
),
|
|
237
|
+
"session_id": session_state.session_id,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
# Default user/assignee
|
|
241
|
+
final_assignee = assignee
|
|
242
|
+
if final_assignee is None and config.default_user:
|
|
243
|
+
final_assignee = config.default_user
|
|
244
|
+
logging.debug(f"Using default assignee from config: {final_assignee}")
|
|
245
|
+
|
|
246
|
+
# Default project/epic (if not set by session)
|
|
247
|
+
if "final_parent_epic" not in locals():
|
|
248
|
+
final_parent_epic = parent_epic
|
|
249
|
+
if final_parent_epic is None:
|
|
250
|
+
# Try default_project first, fall back to default_epic
|
|
251
|
+
if config.default_project:
|
|
252
|
+
final_parent_epic = config.default_project
|
|
253
|
+
logging.debug(
|
|
254
|
+
f"Using default project from config: {final_parent_epic}"
|
|
255
|
+
)
|
|
256
|
+
elif config.default_epic:
|
|
257
|
+
final_parent_epic = config.default_epic
|
|
258
|
+
logging.debug(
|
|
259
|
+
f"Using default epic from config: {final_parent_epic}"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Default tags - merge with provided tags
|
|
263
|
+
final_tags = tags or []
|
|
264
|
+
if config.default_tags:
|
|
265
|
+
# Add default tags that aren't already in the provided tags
|
|
266
|
+
for default_tag in config.default_tags:
|
|
267
|
+
if default_tag not in final_tags:
|
|
268
|
+
final_tags.append(default_tag)
|
|
269
|
+
if final_tags != (tags or []):
|
|
270
|
+
logging.debug(f"Merged default tags from config: {config.default_tags}")
|
|
271
|
+
|
|
272
|
+
# Auto-detect labels if enabled (adds to existing tags)
|
|
273
|
+
if auto_detect_labels:
|
|
274
|
+
final_tags = await detect_and_apply_labels(
|
|
275
|
+
adapter, title, description or "", final_tags
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Create task object
|
|
279
|
+
task = Task(
|
|
280
|
+
title=title,
|
|
281
|
+
description=description or "",
|
|
282
|
+
priority=priority_enum,
|
|
283
|
+
tags=final_tags or [],
|
|
284
|
+
assignee=final_assignee,
|
|
285
|
+
parent_epic=final_parent_epic,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Create via adapter
|
|
289
|
+
created = await adapter.create(task)
|
|
290
|
+
|
|
291
|
+
# Build response with adapter metadata
|
|
292
|
+
response = {
|
|
293
|
+
"status": "completed",
|
|
294
|
+
**_build_adapter_metadata(adapter, created.id),
|
|
295
|
+
"ticket": created.model_dump(),
|
|
296
|
+
"labels_applied": created.tags or [],
|
|
297
|
+
"auto_detected": auto_detect_labels,
|
|
298
|
+
}
|
|
299
|
+
return response
|
|
300
|
+
except Exception as e:
|
|
301
|
+
error_response = {
|
|
302
|
+
"status": "error",
|
|
303
|
+
"error": f"Failed to create ticket: {str(e)}",
|
|
304
|
+
}
|
|
305
|
+
try:
|
|
306
|
+
adapter = get_adapter()
|
|
307
|
+
error_response.update(_build_adapter_metadata(adapter))
|
|
308
|
+
except Exception:
|
|
309
|
+
pass # If adapter not available, return error without metadata
|
|
310
|
+
|
|
311
|
+
# Add diagnostic suggestion for system-level errors
|
|
312
|
+
if should_suggest_diagnostics(e):
|
|
313
|
+
logging.debug(
|
|
314
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
315
|
+
)
|
|
316
|
+
try:
|
|
317
|
+
quick_info = await get_quick_diagnostic_info()
|
|
318
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
319
|
+
e, quick_info
|
|
320
|
+
)
|
|
321
|
+
except Exception as diag_error:
|
|
322
|
+
# Never block error response on diagnostic failure
|
|
323
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
324
|
+
|
|
325
|
+
return error_response
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@mcp.tool()
|
|
329
|
+
async def ticket_read(ticket_id: str) -> dict[str, Any]:
|
|
330
|
+
"""Read a ticket by its ID or URL.
|
|
331
|
+
|
|
332
|
+
This tool supports both plain ticket IDs and full URLs from multiple platforms:
|
|
333
|
+
- Plain IDs: Use the configured default adapter (e.g., "ABC-123", "456")
|
|
334
|
+
- Linear URLs: https://linear.app/team/issue/ABC-123
|
|
335
|
+
- GitHub URLs: https://github.com/owner/repo/issues/123
|
|
336
|
+
- JIRA URLs: https://company.atlassian.net/browse/PROJ-123
|
|
337
|
+
- Asana URLs: https://app.asana.com/0/1234567890/9876543210
|
|
338
|
+
|
|
339
|
+
The tool automatically detects the platform from URLs and routes to the
|
|
340
|
+
appropriate adapter. Multi-platform support must be configured for URL access.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
ticket_id: Ticket ID or URL to read
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Ticket details if found, or error information
|
|
347
|
+
|
|
348
|
+
"""
|
|
349
|
+
try:
|
|
350
|
+
is_routed = False
|
|
351
|
+
# Check if multi-platform routing is available
|
|
352
|
+
if is_url(ticket_id) and has_router():
|
|
353
|
+
# Use router for URL-based access
|
|
354
|
+
router = get_router()
|
|
355
|
+
logging.info(f"Routing ticket_read for URL: {ticket_id}")
|
|
356
|
+
ticket = await router.route_read(ticket_id)
|
|
357
|
+
is_routed = True
|
|
358
|
+
# Get adapter from router's cache to extract metadata
|
|
359
|
+
normalized_id, _, _ = router._normalize_ticket_id(ticket_id)
|
|
360
|
+
adapter = router._get_adapter(router._detect_adapter_from_url(ticket_id))
|
|
361
|
+
else:
|
|
362
|
+
# Use default adapter for plain IDs OR URLs (without multi-platform routing)
|
|
363
|
+
adapter = get_adapter()
|
|
364
|
+
|
|
365
|
+
# If URL provided, extract ID for the adapter
|
|
366
|
+
if is_url(ticket_id):
|
|
367
|
+
# Extract ID from URL for default adapter
|
|
368
|
+
adapter_type = type(adapter).__name__.lower().replace("adapter", "")
|
|
369
|
+
extracted_id, error = extract_id_from_url(
|
|
370
|
+
ticket_id, adapter_type=adapter_type
|
|
371
|
+
)
|
|
372
|
+
if error or not extracted_id:
|
|
373
|
+
return {
|
|
374
|
+
"status": "error",
|
|
375
|
+
"error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
|
|
376
|
+
}
|
|
377
|
+
ticket = await adapter.read(extracted_id)
|
|
378
|
+
else:
|
|
379
|
+
ticket = await adapter.read(ticket_id)
|
|
380
|
+
|
|
381
|
+
if ticket is None:
|
|
382
|
+
return {
|
|
383
|
+
"status": "error",
|
|
384
|
+
"error": f"Ticket {ticket_id} not found",
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
"status": "completed",
|
|
389
|
+
**_build_adapter_metadata(adapter, ticket.id, is_routed),
|
|
390
|
+
"ticket": ticket.model_dump(),
|
|
391
|
+
}
|
|
392
|
+
except ValueError as e:
|
|
393
|
+
# ValueError from adapters contains helpful user-facing messages
|
|
394
|
+
# (e.g., Linear view URL detection error)
|
|
395
|
+
# Return the error message directly without generic wrapper
|
|
396
|
+
return {
|
|
397
|
+
"status": "error",
|
|
398
|
+
"error": str(e),
|
|
399
|
+
}
|
|
400
|
+
except Exception as e:
|
|
401
|
+
error_response = {
|
|
402
|
+
"status": "error",
|
|
403
|
+
"error": f"Failed to read ticket: {str(e)}",
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
# Add diagnostic suggestion for system-level errors
|
|
407
|
+
if should_suggest_diagnostics(e):
|
|
408
|
+
logging.debug(
|
|
409
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
410
|
+
)
|
|
411
|
+
try:
|
|
412
|
+
quick_info = await get_quick_diagnostic_info()
|
|
413
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
414
|
+
e, quick_info
|
|
415
|
+
)
|
|
416
|
+
except Exception as diag_error:
|
|
417
|
+
# Never block error response on diagnostic failure
|
|
418
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
419
|
+
|
|
420
|
+
return error_response
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@mcp.tool()
|
|
424
|
+
async def ticket_update(
|
|
425
|
+
ticket_id: str,
|
|
426
|
+
title: str | None = None,
|
|
427
|
+
description: str | None = None,
|
|
428
|
+
priority: str | None = None,
|
|
429
|
+
state: str | None = None,
|
|
430
|
+
assignee: str | None = None,
|
|
431
|
+
tags: list[str] | None = None,
|
|
432
|
+
) -> dict[str, Any]:
|
|
433
|
+
"""Update an existing ticket using ID or URL.
|
|
434
|
+
|
|
435
|
+
Supports both plain ticket IDs and full URLs from multiple platforms.
|
|
436
|
+
See ticket_read for supported URL formats.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
ticket_id: Ticket ID or URL to update
|
|
440
|
+
title: New title for the ticket
|
|
441
|
+
description: New description for the ticket
|
|
442
|
+
priority: New priority - must be one of: low, medium, high, critical
|
|
443
|
+
state: New state - must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked
|
|
444
|
+
assignee: User ID or email to assign the ticket to
|
|
445
|
+
tags: New list of tags (replaces existing tags)
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Updated ticket details, or error information
|
|
449
|
+
|
|
450
|
+
"""
|
|
451
|
+
try:
|
|
452
|
+
# Build updates dictionary with only provided fields
|
|
453
|
+
updates: dict[str, Any] = {}
|
|
454
|
+
|
|
455
|
+
if title is not None:
|
|
456
|
+
updates["title"] = title
|
|
457
|
+
if description is not None:
|
|
458
|
+
updates["description"] = description
|
|
459
|
+
if assignee is not None:
|
|
460
|
+
updates["assignee"] = assignee
|
|
461
|
+
if tags is not None:
|
|
462
|
+
updates["tags"] = tags
|
|
463
|
+
|
|
464
|
+
# Validate and convert priority if provided
|
|
465
|
+
if priority is not None:
|
|
466
|
+
try:
|
|
467
|
+
updates["priority"] = Priority(priority.lower())
|
|
468
|
+
except ValueError:
|
|
469
|
+
return {
|
|
470
|
+
"status": "error",
|
|
471
|
+
"error": f"Invalid priority '{priority}'. Must be one of: low, medium, high, critical",
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
# Validate and convert state if provided
|
|
475
|
+
if state is not None:
|
|
476
|
+
try:
|
|
477
|
+
updates["state"] = TicketState(state.lower())
|
|
478
|
+
except ValueError:
|
|
479
|
+
return {
|
|
480
|
+
"status": "error",
|
|
481
|
+
"error": f"Invalid state '{state}'. Must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked",
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
# Route to appropriate adapter
|
|
485
|
+
is_routed = False
|
|
486
|
+
if is_url(ticket_id) and has_router():
|
|
487
|
+
router = get_router()
|
|
488
|
+
logging.info(f"Routing ticket_update for URL: {ticket_id}")
|
|
489
|
+
updated = await router.route_update(ticket_id, updates)
|
|
490
|
+
is_routed = True
|
|
491
|
+
normalized_id, _, _ = router._normalize_ticket_id(ticket_id)
|
|
492
|
+
adapter = router._get_adapter(router._detect_adapter_from_url(ticket_id))
|
|
493
|
+
else:
|
|
494
|
+
adapter = get_adapter()
|
|
495
|
+
|
|
496
|
+
# If URL provided, extract ID for the adapter
|
|
497
|
+
if is_url(ticket_id):
|
|
498
|
+
# Extract ID from URL for default adapter
|
|
499
|
+
adapter_type = type(adapter).__name__.lower().replace("adapter", "")
|
|
500
|
+
extracted_id, error = extract_id_from_url(
|
|
501
|
+
ticket_id, adapter_type=adapter_type
|
|
502
|
+
)
|
|
503
|
+
if error or not extracted_id:
|
|
504
|
+
return {
|
|
505
|
+
"status": "error",
|
|
506
|
+
"error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
|
|
507
|
+
}
|
|
508
|
+
updated = await adapter.update(extracted_id, updates)
|
|
509
|
+
else:
|
|
510
|
+
updated = await adapter.update(ticket_id, updates)
|
|
511
|
+
|
|
512
|
+
if updated is None:
|
|
513
|
+
return {
|
|
514
|
+
"status": "error",
|
|
515
|
+
"error": f"Ticket {ticket_id} not found or update failed",
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
"status": "completed",
|
|
520
|
+
**_build_adapter_metadata(adapter, updated.id, is_routed),
|
|
521
|
+
"ticket": updated.model_dump(),
|
|
522
|
+
}
|
|
523
|
+
except Exception as e:
|
|
524
|
+
error_response = {
|
|
525
|
+
"status": "error",
|
|
526
|
+
"error": f"Failed to update ticket: {str(e)}",
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
# Add diagnostic suggestion for system-level errors
|
|
530
|
+
if should_suggest_diagnostics(e):
|
|
531
|
+
logging.debug(
|
|
532
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
533
|
+
)
|
|
534
|
+
try:
|
|
535
|
+
quick_info = await get_quick_diagnostic_info()
|
|
536
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
537
|
+
e, quick_info
|
|
538
|
+
)
|
|
539
|
+
except Exception as diag_error:
|
|
540
|
+
# Never block error response on diagnostic failure
|
|
541
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
542
|
+
|
|
543
|
+
return error_response
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@mcp.tool()
|
|
547
|
+
async def ticket_delete(ticket_id: str) -> dict[str, Any]:
|
|
548
|
+
"""Delete a ticket by its ID or URL.
|
|
549
|
+
|
|
550
|
+
Supports both plain ticket IDs and full URLs from multiple platforms.
|
|
551
|
+
See ticket_read for supported URL formats.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
ticket_id: Ticket ID or URL to delete
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
Success confirmation or error information
|
|
558
|
+
|
|
559
|
+
"""
|
|
560
|
+
try:
|
|
561
|
+
# Route to appropriate adapter
|
|
562
|
+
is_routed = False
|
|
563
|
+
if is_url(ticket_id) and has_router():
|
|
564
|
+
router = get_router()
|
|
565
|
+
logging.info(f"Routing ticket_delete for URL: {ticket_id}")
|
|
566
|
+
success = await router.route_delete(ticket_id)
|
|
567
|
+
is_routed = True
|
|
568
|
+
normalized_id, _, _ = router._normalize_ticket_id(ticket_id)
|
|
569
|
+
adapter = router._get_adapter(router._detect_adapter_from_url(ticket_id))
|
|
570
|
+
else:
|
|
571
|
+
adapter = get_adapter()
|
|
572
|
+
|
|
573
|
+
# If URL provided, extract ID for the adapter
|
|
574
|
+
if is_url(ticket_id):
|
|
575
|
+
# Extract ID from URL for default adapter
|
|
576
|
+
adapter_type = type(adapter).__name__.lower().replace("adapter", "")
|
|
577
|
+
extracted_id, error = extract_id_from_url(
|
|
578
|
+
ticket_id, adapter_type=adapter_type
|
|
579
|
+
)
|
|
580
|
+
if error or not extracted_id:
|
|
581
|
+
return {
|
|
582
|
+
"status": "error",
|
|
583
|
+
"error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
|
|
584
|
+
}
|
|
585
|
+
success = await adapter.delete(extracted_id)
|
|
586
|
+
else:
|
|
587
|
+
success = await adapter.delete(ticket_id)
|
|
588
|
+
|
|
589
|
+
if not success:
|
|
590
|
+
return {
|
|
591
|
+
"status": "error",
|
|
592
|
+
"error": f"Ticket {ticket_id} not found or delete failed",
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
"status": "completed",
|
|
597
|
+
**_build_adapter_metadata(adapter, ticket_id, is_routed),
|
|
598
|
+
"message": f"Ticket {ticket_id} deleted successfully",
|
|
599
|
+
}
|
|
600
|
+
except Exception as e:
|
|
601
|
+
return {
|
|
602
|
+
"status": "error",
|
|
603
|
+
"error": f"Failed to delete ticket: {str(e)}",
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _compact_ticket(ticket_dict: dict[str, Any]) -> dict[str, Any]:
|
|
608
|
+
"""Extract compact representation of ticket for reduced token usage.
|
|
609
|
+
|
|
610
|
+
This helper function reduces ticket data from ~185 tokens to ~55 tokens by
|
|
611
|
+
including only essential fields. Use for listing operations where full
|
|
612
|
+
details are not needed.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
ticket_dict: Full ticket dictionary from model_dump()
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Compact ticket dictionary with only essential fields:
|
|
619
|
+
- id: Ticket identifier
|
|
620
|
+
- title: Ticket title
|
|
621
|
+
- state: Current state
|
|
622
|
+
- priority: Priority level
|
|
623
|
+
- assignee: Assigned user (if any)
|
|
624
|
+
- tags: List of tags/labels
|
|
625
|
+
- parent_epic: Parent epic/project ID (if any)
|
|
626
|
+
|
|
627
|
+
"""
|
|
628
|
+
return {
|
|
629
|
+
"id": ticket_dict.get("id"),
|
|
630
|
+
"title": ticket_dict.get("title"),
|
|
631
|
+
"state": ticket_dict.get("state"),
|
|
632
|
+
"priority": ticket_dict.get("priority"),
|
|
633
|
+
"assignee": ticket_dict.get("assignee"),
|
|
634
|
+
"tags": ticket_dict.get("tags", []),
|
|
635
|
+
"parent_epic": ticket_dict.get("parent_epic"),
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@mcp.tool()
|
|
640
|
+
async def ticket_list(
|
|
641
|
+
limit: int = 20,
|
|
642
|
+
offset: int = 0,
|
|
643
|
+
state: str | None = None,
|
|
644
|
+
priority: str | None = None,
|
|
645
|
+
assignee: str | None = None,
|
|
646
|
+
compact: bool = True,
|
|
647
|
+
) -> dict[str, Any]:
|
|
648
|
+
"""List tickets with pagination and optional filters.
|
|
649
|
+
|
|
650
|
+
Token Usage Optimization:
|
|
651
|
+
Default settings (limit=20, compact=True) return ~1.1k tokens per response.
|
|
652
|
+
For detailed information, use compact=False (returns ~185 tokens per ticket).
|
|
653
|
+
|
|
654
|
+
Token usage examples:
|
|
655
|
+
- 20 tickets, compact=True: ~1.1k tokens (~0.55% of context)
|
|
656
|
+
- 20 tickets, compact=False: ~3.7k tokens (~1.85% of context)
|
|
657
|
+
- 50 tickets, compact=True: ~2.75k tokens (~1.4% of context)
|
|
658
|
+
- 50 tickets, compact=False: ~9.25k tokens (~4.6% of context)
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
limit: Maximum number of tickets to return (default: 20, max: 100)
|
|
662
|
+
offset: Number of tickets to skip for pagination (default: 0)
|
|
663
|
+
state: Filter by state - must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked
|
|
664
|
+
priority: Filter by priority - must be one of: low, medium, high, critical
|
|
665
|
+
assignee: Filter by assigned user ID or email
|
|
666
|
+
compact: Return minimal fields for reduced token usage (default: True)
|
|
667
|
+
Set to False for full ticket details with description, metadata, etc.
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
List of tickets matching criteria, or error information
|
|
671
|
+
|
|
672
|
+
Examples:
|
|
673
|
+
# Default: 20 compact tickets (~1.1k tokens)
|
|
674
|
+
tickets = await ticket_list()
|
|
675
|
+
|
|
676
|
+
# Get full details for fewer tickets
|
|
677
|
+
tickets = await ticket_list(limit=10, compact=False)
|
|
678
|
+
|
|
679
|
+
# Large query with compact mode
|
|
680
|
+
tickets = await ticket_list(limit=50, compact=True)
|
|
681
|
+
|
|
682
|
+
"""
|
|
683
|
+
try:
|
|
684
|
+
adapter = get_adapter()
|
|
685
|
+
|
|
686
|
+
# Add warning for large non-compact queries
|
|
687
|
+
if limit > 30 and not compact:
|
|
688
|
+
logging.warning(
|
|
689
|
+
f"Large query requested: limit={limit}, compact={compact}. "
|
|
690
|
+
f"This may generate ~{limit * 185} tokens. "
|
|
691
|
+
f"Consider using compact=True to reduce token usage."
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
# Add warning for large unscoped queries
|
|
695
|
+
if limit > 50 and not (state or priority or assignee):
|
|
696
|
+
logging.warning(
|
|
697
|
+
f"Large unscoped query: limit={limit} with no filters. "
|
|
698
|
+
f"Consider using state, priority, or assignee filters to reduce result set. "
|
|
699
|
+
f"Tip: Configure default_team or default_project for automatic scoping."
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
# Build filters dictionary
|
|
703
|
+
filters: dict[str, Any] = {}
|
|
704
|
+
|
|
705
|
+
if state is not None:
|
|
706
|
+
try:
|
|
707
|
+
filters["state"] = TicketState(state.lower())
|
|
708
|
+
except ValueError:
|
|
709
|
+
return {
|
|
710
|
+
"status": "error",
|
|
711
|
+
"error": f"Invalid state '{state}'. Must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked",
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if priority is not None:
|
|
715
|
+
try:
|
|
716
|
+
filters["priority"] = Priority(priority.lower())
|
|
717
|
+
except ValueError:
|
|
718
|
+
return {
|
|
719
|
+
"status": "error",
|
|
720
|
+
"error": f"Invalid priority '{priority}'. Must be one of: low, medium, high, critical",
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if assignee is not None:
|
|
724
|
+
filters["assignee"] = assignee
|
|
725
|
+
|
|
726
|
+
# List tickets via adapter
|
|
727
|
+
tickets = await adapter.list(
|
|
728
|
+
limit=limit, offset=offset, filters=filters if filters else None
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
# Apply compact mode if requested
|
|
732
|
+
if compact:
|
|
733
|
+
ticket_data = [_compact_ticket(ticket.model_dump()) for ticket in tickets]
|
|
734
|
+
else:
|
|
735
|
+
ticket_data = [ticket.model_dump() for ticket in tickets]
|
|
736
|
+
|
|
737
|
+
return {
|
|
738
|
+
"status": "completed",
|
|
739
|
+
**_build_adapter_metadata(adapter),
|
|
740
|
+
"tickets": ticket_data,
|
|
741
|
+
"count": len(tickets),
|
|
742
|
+
"limit": limit,
|
|
743
|
+
"offset": offset,
|
|
744
|
+
"compact": compact,
|
|
745
|
+
}
|
|
746
|
+
except Exception as e:
|
|
747
|
+
error_response = {
|
|
748
|
+
"status": "error",
|
|
749
|
+
"error": f"Failed to list tickets: {str(e)}",
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
# Add diagnostic suggestion for system-level errors
|
|
753
|
+
if should_suggest_diagnostics(e):
|
|
754
|
+
logging.debug(
|
|
755
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
756
|
+
)
|
|
757
|
+
try:
|
|
758
|
+
quick_info = await get_quick_diagnostic_info()
|
|
759
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
760
|
+
e, quick_info
|
|
761
|
+
)
|
|
762
|
+
except Exception as diag_error:
|
|
763
|
+
# Never block error response on diagnostic failure
|
|
764
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
765
|
+
|
|
766
|
+
return error_response
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
@mcp.tool()
|
|
770
|
+
async def ticket_summary(ticket_id: str) -> dict[str, Any]:
|
|
771
|
+
"""Get ultra-compact ticket summary for minimal token usage.
|
|
772
|
+
|
|
773
|
+
This tool returns only the most essential ticket information for quick
|
|
774
|
+
status checks. It's optimized for minimal token usage (~20 tokens vs
|
|
775
|
+
~185 tokens for full ticket details).
|
|
776
|
+
|
|
777
|
+
Use Cases:
|
|
778
|
+
- Quick status check: "What's the current state of TICKET-123?"
|
|
779
|
+
- Batch status queries: Check multiple tickets without context overload
|
|
780
|
+
- Dashboard updates: Get high-level overview of many tickets
|
|
781
|
+
|
|
782
|
+
Fields Returned:
|
|
783
|
+
- id: Ticket identifier
|
|
784
|
+
- title: Ticket title
|
|
785
|
+
- state: Current workflow state
|
|
786
|
+
- priority: Priority level
|
|
787
|
+
- assignee: Assigned user (if any)
|
|
788
|
+
|
|
789
|
+
For full details including description, metadata, dates, etc., use ticket_read.
|
|
790
|
+
For list queries with filtering, use ticket_list with compact=True.
|
|
791
|
+
|
|
792
|
+
Args:
|
|
793
|
+
ticket_id: Ticket ID or URL to summarize
|
|
794
|
+
|
|
795
|
+
Returns:
|
|
796
|
+
Ultra-compact ticket summary with essential fields only, or error information
|
|
797
|
+
|
|
798
|
+
Examples:
|
|
799
|
+
# Quick status check
|
|
800
|
+
summary = await ticket_summary("PROJ-123")
|
|
801
|
+
# Returns: {"id": "PROJ-123", "title": "...", "state": "in_progress", "priority": "high", "assignee": "user@example.com"}
|
|
802
|
+
|
|
803
|
+
# Check using URL
|
|
804
|
+
summary = await ticket_summary("https://linear.app/team/issue/ABC-123")
|
|
805
|
+
|
|
806
|
+
"""
|
|
807
|
+
try:
|
|
808
|
+
# Use ticket_read to get full ticket
|
|
809
|
+
result = await ticket_read(ticket_id)
|
|
810
|
+
|
|
811
|
+
if result["status"] == "error":
|
|
812
|
+
return result
|
|
813
|
+
|
|
814
|
+
ticket = result["ticket"]
|
|
815
|
+
|
|
816
|
+
# Extract only ultra-essential fields
|
|
817
|
+
summary = {
|
|
818
|
+
"id": ticket.get("id"),
|
|
819
|
+
"title": ticket.get("title"),
|
|
820
|
+
"state": ticket.get("state"),
|
|
821
|
+
"priority": ticket.get("priority"),
|
|
822
|
+
"assignee": ticket.get("assignee"),
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
"status": "completed",
|
|
827
|
+
**_build_adapter_metadata(
|
|
828
|
+
get_adapter(), ticket.get("id"), result.get("routed_from_url", False)
|
|
829
|
+
),
|
|
830
|
+
"summary": summary,
|
|
831
|
+
"token_savings": "~90% smaller than full ticket_read",
|
|
832
|
+
}
|
|
833
|
+
except Exception as e:
|
|
834
|
+
return {
|
|
835
|
+
"status": "error",
|
|
836
|
+
"error": f"Failed to get ticket summary: {str(e)}",
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
@mcp.tool()
|
|
841
|
+
async def ticket_latest(ticket_id: str, limit: int = 5) -> dict[str, Any]:
|
|
842
|
+
"""Get recent activity and changes for a ticket (comments, state changes, updates).
|
|
843
|
+
|
|
844
|
+
This tool retrieves only recent activity without loading full ticket history,
|
|
845
|
+
optimizing for scenarios where you need to know "what changed recently" without
|
|
846
|
+
context overhead from full ticket details.
|
|
847
|
+
|
|
848
|
+
Use Cases:
|
|
849
|
+
- "What's the latest update on TICKET-123?"
|
|
850
|
+
- "Any recent comments or status changes?"
|
|
851
|
+
- "What happened since I last checked?"
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
- Recent comments (if adapter supports comment listing)
|
|
855
|
+
- State transition history (if available)
|
|
856
|
+
- Last update timestamp
|
|
857
|
+
- Summary of recent changes
|
|
858
|
+
|
|
859
|
+
Note: This tool's behavior varies by adapter based on available APIs:
|
|
860
|
+
- Adapters with comment API: Returns recent comments
|
|
861
|
+
- Adapters without comment API: Returns last update summary
|
|
862
|
+
- Some adapters may not support activity history
|
|
863
|
+
|
|
864
|
+
Args:
|
|
865
|
+
ticket_id: Ticket ID or URL to query
|
|
866
|
+
limit: Maximum number of recent activities to return (default: 5, max: 20)
|
|
867
|
+
|
|
868
|
+
Returns:
|
|
869
|
+
Recent activity list with timestamps and change descriptions, or error information
|
|
870
|
+
|
|
871
|
+
Examples:
|
|
872
|
+
# Get last 5 activities
|
|
873
|
+
activity = await ticket_latest("PROJ-123")
|
|
874
|
+
|
|
875
|
+
# Get last 10 activities
|
|
876
|
+
activity = await ticket_latest("PROJ-123", limit=10)
|
|
877
|
+
|
|
878
|
+
# Check using URL
|
|
879
|
+
activity = await ticket_latest("https://linear.app/team/issue/ABC-123")
|
|
880
|
+
|
|
881
|
+
"""
|
|
882
|
+
try:
|
|
883
|
+
# Validate limit
|
|
884
|
+
if limit < 1 or limit > 20:
|
|
885
|
+
return {
|
|
886
|
+
"status": "error",
|
|
887
|
+
"error": "Limit must be between 1 and 20",
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
# Route to appropriate adapter
|
|
891
|
+
is_routed = False
|
|
892
|
+
if is_url(ticket_id) and has_router():
|
|
893
|
+
router = get_router()
|
|
894
|
+
logging.info(f"Routing ticket_latest for URL: {ticket_id}")
|
|
895
|
+
# First get the ticket to verify it exists
|
|
896
|
+
ticket = await router.route_read(ticket_id)
|
|
897
|
+
is_routed = True
|
|
898
|
+
normalized_id, adapter_name, _ = router._normalize_ticket_id(ticket_id)
|
|
899
|
+
adapter = router._get_adapter(adapter_name)
|
|
900
|
+
actual_ticket_id = normalized_id
|
|
901
|
+
else:
|
|
902
|
+
adapter = get_adapter()
|
|
903
|
+
|
|
904
|
+
# If URL provided, extract ID for the adapter
|
|
905
|
+
actual_ticket_id = ticket_id
|
|
906
|
+
if is_url(ticket_id):
|
|
907
|
+
adapter_type = type(adapter).__name__.lower().replace("adapter", "")
|
|
908
|
+
extracted_id, error = extract_id_from_url(
|
|
909
|
+
ticket_id, adapter_type=adapter_type
|
|
910
|
+
)
|
|
911
|
+
if error or not extracted_id:
|
|
912
|
+
return {
|
|
913
|
+
"status": "error",
|
|
914
|
+
"error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
|
|
915
|
+
}
|
|
916
|
+
actual_ticket_id = extracted_id
|
|
917
|
+
|
|
918
|
+
# Get ticket to verify it exists
|
|
919
|
+
ticket = await adapter.read(actual_ticket_id)
|
|
920
|
+
|
|
921
|
+
if ticket is None:
|
|
922
|
+
return {
|
|
923
|
+
"status": "error",
|
|
924
|
+
"error": f"Ticket {ticket_id} not found",
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
# Try to get comments if adapter supports it
|
|
928
|
+
recent_activity = []
|
|
929
|
+
supports_comments = False
|
|
930
|
+
|
|
931
|
+
try:
|
|
932
|
+
# Check if adapter has list_comments method
|
|
933
|
+
if hasattr(adapter, "list_comments"):
|
|
934
|
+
comments = await adapter.list_comments(actual_ticket_id, limit=limit)
|
|
935
|
+
supports_comments = True
|
|
936
|
+
|
|
937
|
+
# Convert comments to activity format
|
|
938
|
+
for comment in comments[:limit]:
|
|
939
|
+
activity_item = {
|
|
940
|
+
"type": "comment",
|
|
941
|
+
"timestamp": (
|
|
942
|
+
comment.created_at
|
|
943
|
+
if hasattr(comment, "created_at")
|
|
944
|
+
else None
|
|
945
|
+
),
|
|
946
|
+
"author": (
|
|
947
|
+
comment.author if hasattr(comment, "author") else None
|
|
948
|
+
),
|
|
949
|
+
"content": comment.content[:200]
|
|
950
|
+
+ ("..." if len(comment.content) > 200 else ""),
|
|
951
|
+
}
|
|
952
|
+
recent_activity.append(activity_item)
|
|
953
|
+
except Exception as e:
|
|
954
|
+
logging.debug(f"Comment listing not supported or failed: {e}")
|
|
955
|
+
|
|
956
|
+
# If no comments available, provide last update info
|
|
957
|
+
if not recent_activity:
|
|
958
|
+
recent_activity.append(
|
|
959
|
+
{
|
|
960
|
+
"type": "last_update",
|
|
961
|
+
"timestamp": (
|
|
962
|
+
ticket.updated_at if hasattr(ticket, "updated_at") else None
|
|
963
|
+
),
|
|
964
|
+
"state": ticket.state,
|
|
965
|
+
"priority": ticket.priority,
|
|
966
|
+
"assignee": ticket.assignee,
|
|
967
|
+
}
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
return {
|
|
971
|
+
"status": "completed",
|
|
972
|
+
**_build_adapter_metadata(adapter, ticket.id, is_routed),
|
|
973
|
+
"ticket_id": ticket.id,
|
|
974
|
+
"ticket_title": ticket.title,
|
|
975
|
+
"recent_activity": recent_activity,
|
|
976
|
+
"activity_count": len(recent_activity),
|
|
977
|
+
"supports_full_history": supports_comments,
|
|
978
|
+
"limit": limit,
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
except Exception as e:
|
|
982
|
+
error_response = {
|
|
983
|
+
"status": "error",
|
|
984
|
+
"error": f"Failed to get recent activity: {str(e)}",
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
# Add diagnostic suggestion for system-level errors
|
|
988
|
+
if should_suggest_diagnostics(e):
|
|
989
|
+
logging.debug(
|
|
990
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
991
|
+
)
|
|
992
|
+
try:
|
|
993
|
+
quick_info = await get_quick_diagnostic_info()
|
|
994
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
995
|
+
e, quick_info
|
|
996
|
+
)
|
|
997
|
+
except Exception as diag_error:
|
|
998
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
999
|
+
|
|
1000
|
+
return error_response
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
@mcp.tool()
|
|
1004
|
+
async def ticket_assign(
|
|
1005
|
+
ticket_id: str,
|
|
1006
|
+
assignee: str | None,
|
|
1007
|
+
comment: str | None = None,
|
|
1008
|
+
auto_transition: bool = True,
|
|
1009
|
+
) -> dict[str, Any]:
|
|
1010
|
+
"""Assign or reassign a ticket to a user with automatic state transition.
|
|
1011
|
+
|
|
1012
|
+
This tool provides dedicated assignment functionality with audit trail support
|
|
1013
|
+
and automatic state transitions. It accepts both plain ticket IDs and full URLs
|
|
1014
|
+
from multiple platforms:
|
|
1015
|
+
- Plain IDs: Use the configured default adapter (e.g., "ABC-123", "456")
|
|
1016
|
+
- Linear URLs: https://linear.app/team/issue/ABC-123
|
|
1017
|
+
- GitHub URLs: https://github.com/owner/repo/issues/123
|
|
1018
|
+
- JIRA URLs: https://company.atlassian.net/browse/PROJ-123
|
|
1019
|
+
- Asana URLs: https://app.asana.com/0/1234567890/9876543210
|
|
1020
|
+
|
|
1021
|
+
The tool automatically detects the platform from URLs and routes to the
|
|
1022
|
+
appropriate adapter. Multi-platform support must be configured for URL access.
|
|
1023
|
+
|
|
1024
|
+
Auto-Transition Behavior:
|
|
1025
|
+
When a ticket is assigned (not unassigned), the tool automatically transitions
|
|
1026
|
+
the ticket to IN_PROGRESS if it's currently in one of these states:
|
|
1027
|
+
- OPEN → IN_PROGRESS: When starting work on new ticket
|
|
1028
|
+
- WAITING → IN_PROGRESS: When resuming after waiting period
|
|
1029
|
+
- BLOCKED → IN_PROGRESS: When resuming after block removed
|
|
1030
|
+
|
|
1031
|
+
States that do NOT auto-transition:
|
|
1032
|
+
- Already IN_PROGRESS: No change needed
|
|
1033
|
+
- READY, TESTED, DONE: Don't move backwards in workflow
|
|
1034
|
+
- CLOSED: Terminal state, should not be worked on
|
|
1035
|
+
- Unassignment (assignee=None): No state change
|
|
1036
|
+
- Can be disabled with auto_transition=False
|
|
1037
|
+
|
|
1038
|
+
User Resolution:
|
|
1039
|
+
- Accepts user IDs, emails, or names (adapter-dependent)
|
|
1040
|
+
- Each adapter handles user resolution according to its platform's API
|
|
1041
|
+
- Linear: User ID (UUID) or email
|
|
1042
|
+
- GitHub: Username
|
|
1043
|
+
- JIRA: Account ID or email
|
|
1044
|
+
- Asana: User GID or email
|
|
1045
|
+
|
|
1046
|
+
Unassignment:
|
|
1047
|
+
- Set assignee=None to unassign the ticket
|
|
1048
|
+
- The ticket will be moved to unassigned state
|
|
1049
|
+
- No automatic state change occurs during unassignment
|
|
1050
|
+
|
|
1051
|
+
Audit Trail:
|
|
1052
|
+
- Optional comment parameter adds a note to the ticket
|
|
1053
|
+
- Useful for explaining assignment/reassignment decisions
|
|
1054
|
+
- Automatic comment is added if state is auto-transitioned and no comment provided
|
|
1055
|
+
- Comment support is adapter-dependent
|
|
1056
|
+
|
|
1057
|
+
Args:
|
|
1058
|
+
ticket_id: Ticket ID or URL to assign
|
|
1059
|
+
assignee: User identifier (ID, email, or name) or None to unassign
|
|
1060
|
+
comment: Optional comment to add explaining the assignment
|
|
1061
|
+
auto_transition: Automatically transition to IN_PROGRESS when appropriate (default: True)
|
|
1062
|
+
|
|
1063
|
+
Returns:
|
|
1064
|
+
Dictionary containing:
|
|
1065
|
+
- status: "completed" or "error"
|
|
1066
|
+
- ticket: Full updated ticket object
|
|
1067
|
+
- previous_assignee: Who the ticket was assigned to before (if any)
|
|
1068
|
+
- new_assignee: Who the ticket is now assigned to (if any)
|
|
1069
|
+
- previous_state: State before assignment
|
|
1070
|
+
- new_state: State after assignment
|
|
1071
|
+
- state_auto_transitioned: Boolean indicating if state was automatically changed
|
|
1072
|
+
- comment_added: Boolean indicating if comment was added
|
|
1073
|
+
- adapter: Which adapter handled the operation
|
|
1074
|
+
- adapter_name: Human-readable adapter name
|
|
1075
|
+
- routed_from_url: True if ticket_id was a URL (optional)
|
|
1076
|
+
|
|
1077
|
+
Example:
|
|
1078
|
+
# Assign ticket to user by email (auto-transitions OPEN → IN_PROGRESS)
|
|
1079
|
+
>>> ticket_assign(
|
|
1080
|
+
... ticket_id="PROJ-123",
|
|
1081
|
+
... assignee="user@example.com",
|
|
1082
|
+
... comment="Taking ownership of this issue"
|
|
1083
|
+
... )
|
|
1084
|
+
|
|
1085
|
+
# Assign ticket using URL (with auto-transition)
|
|
1086
|
+
>>> ticket_assign(
|
|
1087
|
+
... ticket_id="https://linear.app/team/issue/ABC-123",
|
|
1088
|
+
... assignee="john.doe@example.com"
|
|
1089
|
+
... )
|
|
1090
|
+
|
|
1091
|
+
# Assign without auto-transition
|
|
1092
|
+
>>> ticket_assign(
|
|
1093
|
+
... ticket_id="PROJ-123",
|
|
1094
|
+
... assignee="user@example.com",
|
|
1095
|
+
... auto_transition=False
|
|
1096
|
+
... )
|
|
1097
|
+
|
|
1098
|
+
# Unassign ticket (no state change)
|
|
1099
|
+
>>> ticket_assign(ticket_id="PROJ-123", assignee=None)
|
|
1100
|
+
|
|
1101
|
+
# Reassign with explanation
|
|
1102
|
+
>>> ticket_assign(
|
|
1103
|
+
... ticket_id="PROJ-123",
|
|
1104
|
+
... assignee="jane.smith@example.com",
|
|
1105
|
+
... comment="Reassigning to Jane who has domain expertise"
|
|
1106
|
+
... )
|
|
1107
|
+
|
|
1108
|
+
"""
|
|
1109
|
+
try:
|
|
1110
|
+
# Read current ticket to get previous assignee
|
|
1111
|
+
is_routed = False
|
|
1112
|
+
if is_url(ticket_id) and has_router():
|
|
1113
|
+
router = get_router()
|
|
1114
|
+
logging.info(f"Routing ticket_assign for URL: {ticket_id}")
|
|
1115
|
+
ticket = await router.route_read(ticket_id)
|
|
1116
|
+
is_routed = True
|
|
1117
|
+
normalized_id, adapter_name, _ = router._normalize_ticket_id(ticket_id)
|
|
1118
|
+
adapter = router._get_adapter(adapter_name)
|
|
1119
|
+
else:
|
|
1120
|
+
adapter = get_adapter()
|
|
1121
|
+
|
|
1122
|
+
# If URL provided, extract ID for the adapter
|
|
1123
|
+
actual_ticket_id = ticket_id
|
|
1124
|
+
if is_url(ticket_id):
|
|
1125
|
+
# Extract ID from URL for default adapter
|
|
1126
|
+
adapter_type = type(adapter).__name__.lower().replace("adapter", "")
|
|
1127
|
+
extracted_id, error = extract_id_from_url(
|
|
1128
|
+
ticket_id, adapter_type=adapter_type
|
|
1129
|
+
)
|
|
1130
|
+
if error or not extracted_id:
|
|
1131
|
+
return {
|
|
1132
|
+
"status": "error",
|
|
1133
|
+
"error": f"Failed to extract ticket ID from URL: {ticket_id}. {error}",
|
|
1134
|
+
}
|
|
1135
|
+
actual_ticket_id = extracted_id
|
|
1136
|
+
|
|
1137
|
+
ticket = await adapter.read(actual_ticket_id)
|
|
1138
|
+
|
|
1139
|
+
if ticket is None:
|
|
1140
|
+
return {
|
|
1141
|
+
"status": "error",
|
|
1142
|
+
"error": f"Ticket {ticket_id} not found",
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
# Store previous assignee and state for response
|
|
1146
|
+
previous_assignee = ticket.assignee
|
|
1147
|
+
current_state = ticket.state
|
|
1148
|
+
|
|
1149
|
+
# Import TicketState for state transitions
|
|
1150
|
+
from ....core.models import TicketState
|
|
1151
|
+
|
|
1152
|
+
# Convert string state to enum if needed (Pydantic uses use_enum_values=True)
|
|
1153
|
+
if isinstance(current_state, str):
|
|
1154
|
+
current_state = TicketState(current_state)
|
|
1155
|
+
|
|
1156
|
+
# Build updates dictionary
|
|
1157
|
+
updates: dict[str, Any] = {"assignee": assignee}
|
|
1158
|
+
|
|
1159
|
+
# Auto-transition logic
|
|
1160
|
+
state_transitioned = False
|
|
1161
|
+
auto_comment = None
|
|
1162
|
+
|
|
1163
|
+
if (
|
|
1164
|
+
auto_transition and assignee is not None
|
|
1165
|
+
): # Only when assigning (not unassigning)
|
|
1166
|
+
# Check if current state should auto-transition to IN_PROGRESS
|
|
1167
|
+
if current_state in [
|
|
1168
|
+
TicketState.OPEN,
|
|
1169
|
+
TicketState.WAITING,
|
|
1170
|
+
TicketState.BLOCKED,
|
|
1171
|
+
]:
|
|
1172
|
+
# Validate workflow allows this transition
|
|
1173
|
+
if current_state.can_transition_to(TicketState.IN_PROGRESS):
|
|
1174
|
+
updates["state"] = TicketState.IN_PROGRESS
|
|
1175
|
+
state_transitioned = True
|
|
1176
|
+
|
|
1177
|
+
# Add automatic comment if no comment provided
|
|
1178
|
+
if comment is None:
|
|
1179
|
+
auto_comment = f"Automatically transitioned from {current_state.value} to in_progress when assigned to {assignee}"
|
|
1180
|
+
else:
|
|
1181
|
+
# Log warning if transition validation fails (shouldn't happen based on our rules)
|
|
1182
|
+
logging.warning(
|
|
1183
|
+
f"State transition from {current_state.value} to IN_PROGRESS failed validation"
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
if is_routed:
|
|
1187
|
+
updated = await router.route_update(ticket_id, updates)
|
|
1188
|
+
else:
|
|
1189
|
+
updated = await adapter.update(actual_ticket_id, updates)
|
|
1190
|
+
|
|
1191
|
+
if updated is None:
|
|
1192
|
+
return {
|
|
1193
|
+
"status": "error",
|
|
1194
|
+
"error": f"Failed to update assignment for ticket {ticket_id}",
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
# Add comment if provided or auto-generated, and adapter supports it
|
|
1198
|
+
comment_added = False
|
|
1199
|
+
comment_to_add = comment or auto_comment
|
|
1200
|
+
|
|
1201
|
+
if comment_to_add:
|
|
1202
|
+
try:
|
|
1203
|
+
from ....core.models import Comment as CommentModel
|
|
1204
|
+
|
|
1205
|
+
# Use actual_ticket_id for non-routed case, original ticket_id for routed
|
|
1206
|
+
comment_ticket_id = ticket_id if is_routed else actual_ticket_id
|
|
1207
|
+
|
|
1208
|
+
comment_obj = CommentModel(
|
|
1209
|
+
ticket_id=comment_ticket_id, content=comment_to_add, author=""
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
if is_routed:
|
|
1213
|
+
await router.route_add_comment(ticket_id, comment_obj)
|
|
1214
|
+
else:
|
|
1215
|
+
await adapter.add_comment(comment_obj)
|
|
1216
|
+
comment_added = True
|
|
1217
|
+
except Exception as e:
|
|
1218
|
+
# Comment failed but assignment succeeded - log and continue
|
|
1219
|
+
logging.warning(f"Assignment succeeded but comment failed: {str(e)}")
|
|
1220
|
+
|
|
1221
|
+
# Build response
|
|
1222
|
+
# Handle both string and enum state values
|
|
1223
|
+
previous_state_value = (
|
|
1224
|
+
current_state.value
|
|
1225
|
+
if hasattr(current_state, "value")
|
|
1226
|
+
else str(current_state)
|
|
1227
|
+
)
|
|
1228
|
+
new_state_value = (
|
|
1229
|
+
updated.state.value
|
|
1230
|
+
if hasattr(updated.state, "value")
|
|
1231
|
+
else str(updated.state)
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
response = {
|
|
1235
|
+
"status": "completed",
|
|
1236
|
+
**_build_adapter_metadata(adapter, updated.id, is_routed),
|
|
1237
|
+
"ticket": updated.model_dump(),
|
|
1238
|
+
"previous_assignee": previous_assignee,
|
|
1239
|
+
"new_assignee": assignee,
|
|
1240
|
+
"previous_state": previous_state_value,
|
|
1241
|
+
"new_state": new_state_value,
|
|
1242
|
+
"state_auto_transitioned": state_transitioned,
|
|
1243
|
+
"comment_added": comment_added,
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
return response
|
|
1247
|
+
|
|
1248
|
+
except Exception as e:
|
|
1249
|
+
error_response = {
|
|
1250
|
+
"status": "error",
|
|
1251
|
+
"error": f"Failed to assign ticket: {str(e)}",
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
# Add diagnostic suggestion for system-level errors
|
|
1255
|
+
if should_suggest_diagnostics(e):
|
|
1256
|
+
logging.debug(
|
|
1257
|
+
"Error classified as system-level, adding diagnostic suggestion"
|
|
1258
|
+
)
|
|
1259
|
+
try:
|
|
1260
|
+
quick_info = await get_quick_diagnostic_info()
|
|
1261
|
+
error_response["diagnostic_suggestion"] = build_diagnostic_suggestion(
|
|
1262
|
+
e, quick_info
|
|
1263
|
+
)
|
|
1264
|
+
except Exception as diag_error:
|
|
1265
|
+
# Never block error response on diagnostic failure
|
|
1266
|
+
logging.debug(f"Diagnostic suggestion generation failed: {diag_error}")
|
|
1267
|
+
|
|
1268
|
+
return error_response
|