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.

Files changed (109) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/adapters/__init__.py +2 -0
  4. mcp_ticketer/adapters/aitrackdown.py +796 -46
  5. mcp_ticketer/adapters/asana/__init__.py +15 -0
  6. mcp_ticketer/adapters/asana/adapter.py +1416 -0
  7. mcp_ticketer/adapters/asana/client.py +292 -0
  8. mcp_ticketer/adapters/asana/mappers.py +348 -0
  9. mcp_ticketer/adapters/asana/types.py +146 -0
  10. mcp_ticketer/adapters/github.py +879 -129
  11. mcp_ticketer/adapters/hybrid.py +11 -11
  12. mcp_ticketer/adapters/jira.py +973 -73
  13. mcp_ticketer/adapters/linear/__init__.py +24 -0
  14. mcp_ticketer/adapters/linear/adapter.py +2732 -0
  15. mcp_ticketer/adapters/linear/client.py +344 -0
  16. mcp_ticketer/adapters/linear/mappers.py +420 -0
  17. mcp_ticketer/adapters/linear/queries.py +479 -0
  18. mcp_ticketer/adapters/linear/types.py +360 -0
  19. mcp_ticketer/adapters/linear.py +10 -2315
  20. mcp_ticketer/analysis/__init__.py +23 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/similarity.py +224 -0
  23. mcp_ticketer/analysis/staleness.py +266 -0
  24. mcp_ticketer/cache/memory.py +9 -8
  25. mcp_ticketer/cli/adapter_diagnostics.py +421 -0
  26. mcp_ticketer/cli/auggie_configure.py +116 -15
  27. mcp_ticketer/cli/codex_configure.py +274 -82
  28. mcp_ticketer/cli/configure.py +888 -151
  29. mcp_ticketer/cli/diagnostics.py +400 -157
  30. mcp_ticketer/cli/discover.py +297 -26
  31. mcp_ticketer/cli/gemini_configure.py +119 -26
  32. mcp_ticketer/cli/init_command.py +880 -0
  33. mcp_ticketer/cli/instruction_commands.py +435 -0
  34. mcp_ticketer/cli/linear_commands.py +616 -0
  35. mcp_ticketer/cli/main.py +203 -1165
  36. mcp_ticketer/cli/mcp_configure.py +474 -90
  37. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  38. mcp_ticketer/cli/migrate_config.py +12 -8
  39. mcp_ticketer/cli/platform_commands.py +123 -0
  40. mcp_ticketer/cli/platform_detection.py +418 -0
  41. mcp_ticketer/cli/platform_installer.py +513 -0
  42. mcp_ticketer/cli/python_detection.py +126 -0
  43. mcp_ticketer/cli/queue_commands.py +15 -15
  44. mcp_ticketer/cli/setup_command.py +639 -0
  45. mcp_ticketer/cli/simple_health.py +90 -65
  46. mcp_ticketer/cli/ticket_commands.py +1013 -0
  47. mcp_ticketer/cli/update_checker.py +313 -0
  48. mcp_ticketer/cli/utils.py +114 -66
  49. mcp_ticketer/core/__init__.py +24 -1
  50. mcp_ticketer/core/adapter.py +250 -16
  51. mcp_ticketer/core/config.py +145 -37
  52. mcp_ticketer/core/env_discovery.py +101 -22
  53. mcp_ticketer/core/env_loader.py +349 -0
  54. mcp_ticketer/core/exceptions.py +160 -0
  55. mcp_ticketer/core/http_client.py +26 -26
  56. mcp_ticketer/core/instructions.py +405 -0
  57. mcp_ticketer/core/label_manager.py +732 -0
  58. mcp_ticketer/core/mappers.py +42 -30
  59. mcp_ticketer/core/models.py +280 -28
  60. mcp_ticketer/core/onepassword_secrets.py +379 -0
  61. mcp_ticketer/core/project_config.py +183 -49
  62. mcp_ticketer/core/registry.py +3 -3
  63. mcp_ticketer/core/session_state.py +171 -0
  64. mcp_ticketer/core/state_matcher.py +592 -0
  65. mcp_ticketer/core/url_parser.py +425 -0
  66. mcp_ticketer/core/validators.py +69 -0
  67. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  68. mcp_ticketer/mcp/__init__.py +29 -1
  69. mcp_ticketer/mcp/__main__.py +60 -0
  70. mcp_ticketer/mcp/server/__init__.py +25 -0
  71. mcp_ticketer/mcp/server/__main__.py +60 -0
  72. mcp_ticketer/mcp/server/constants.py +58 -0
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/dto.py +195 -0
  75. mcp_ticketer/mcp/server/main.py +1343 -0
  76. mcp_ticketer/mcp/server/response_builder.py +206 -0
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +56 -0
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
  90. mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
  91. mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
  92. mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
  93. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
  94. mcp_ticketer/queue/__init__.py +1 -0
  95. mcp_ticketer/queue/health_monitor.py +168 -136
  96. mcp_ticketer/queue/manager.py +95 -25
  97. mcp_ticketer/queue/queue.py +40 -21
  98. mcp_ticketer/queue/run_worker.py +6 -1
  99. mcp_ticketer/queue/ticket_registry.py +213 -155
  100. mcp_ticketer/queue/worker.py +109 -49
  101. mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
  102. mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
  103. mcp_ticketer/mcp/server.py +0 -1895
  104. mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
  105. mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
  106. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
  107. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
  108. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
  109. {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