mcp-ticketer 0.4.11__py3-none-any.whl → 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

Files changed (111) 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 +394 -9
  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 +836 -105
  11. mcp_ticketer/adapters/hybrid.py +47 -5
  12. mcp_ticketer/adapters/jira.py +772 -1
  13. mcp_ticketer/adapters/linear/adapter.py +2293 -108
  14. mcp_ticketer/adapters/linear/client.py +146 -12
  15. mcp_ticketer/adapters/linear/mappers.py +105 -11
  16. mcp_ticketer/adapters/linear/queries.py +168 -1
  17. mcp_ticketer/adapters/linear/types.py +80 -4
  18. mcp_ticketer/analysis/__init__.py +56 -0
  19. mcp_ticketer/analysis/dependency_graph.py +255 -0
  20. mcp_ticketer/analysis/health_assessment.py +304 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/project_status.py +594 -0
  23. mcp_ticketer/analysis/similarity.py +224 -0
  24. mcp_ticketer/analysis/staleness.py +266 -0
  25. mcp_ticketer/automation/__init__.py +11 -0
  26. mcp_ticketer/automation/project_updates.py +378 -0
  27. mcp_ticketer/cache/memory.py +3 -3
  28. mcp_ticketer/cli/adapter_diagnostics.py +4 -2
  29. mcp_ticketer/cli/auggie_configure.py +18 -6
  30. mcp_ticketer/cli/codex_configure.py +175 -60
  31. mcp_ticketer/cli/configure.py +884 -146
  32. mcp_ticketer/cli/cursor_configure.py +314 -0
  33. mcp_ticketer/cli/diagnostics.py +31 -28
  34. mcp_ticketer/cli/discover.py +293 -21
  35. mcp_ticketer/cli/gemini_configure.py +18 -6
  36. mcp_ticketer/cli/init_command.py +880 -0
  37. mcp_ticketer/cli/instruction_commands.py +435 -0
  38. mcp_ticketer/cli/linear_commands.py +99 -15
  39. mcp_ticketer/cli/main.py +109 -2055
  40. mcp_ticketer/cli/mcp_configure.py +673 -99
  41. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  42. mcp_ticketer/cli/migrate_config.py +12 -8
  43. mcp_ticketer/cli/platform_commands.py +6 -6
  44. mcp_ticketer/cli/platform_detection.py +477 -0
  45. mcp_ticketer/cli/platform_installer.py +536 -0
  46. mcp_ticketer/cli/project_update_commands.py +350 -0
  47. mcp_ticketer/cli/queue_commands.py +15 -15
  48. mcp_ticketer/cli/setup_command.py +639 -0
  49. mcp_ticketer/cli/simple_health.py +13 -11
  50. mcp_ticketer/cli/ticket_commands.py +277 -36
  51. mcp_ticketer/cli/update_checker.py +313 -0
  52. mcp_ticketer/cli/utils.py +45 -41
  53. mcp_ticketer/core/__init__.py +35 -1
  54. mcp_ticketer/core/adapter.py +170 -5
  55. mcp_ticketer/core/config.py +38 -31
  56. mcp_ticketer/core/env_discovery.py +33 -3
  57. mcp_ticketer/core/env_loader.py +7 -6
  58. mcp_ticketer/core/exceptions.py +10 -4
  59. mcp_ticketer/core/http_client.py +10 -10
  60. mcp_ticketer/core/instructions.py +405 -0
  61. mcp_ticketer/core/label_manager.py +732 -0
  62. mcp_ticketer/core/mappers.py +32 -20
  63. mcp_ticketer/core/models.py +136 -1
  64. mcp_ticketer/core/onepassword_secrets.py +379 -0
  65. mcp_ticketer/core/priority_matcher.py +463 -0
  66. mcp_ticketer/core/project_config.py +148 -14
  67. mcp_ticketer/core/registry.py +1 -1
  68. mcp_ticketer/core/session_state.py +171 -0
  69. mcp_ticketer/core/state_matcher.py +592 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  73. mcp_ticketer/mcp/__init__.py +2 -2
  74. mcp_ticketer/mcp/server/__init__.py +2 -2
  75. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  76. mcp_ticketer/mcp/server/main.py +187 -93
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +37 -9
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1429 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  90. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  91. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  92. mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
  93. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  94. mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
  95. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  96. mcp_ticketer/queue/health_monitor.py +1 -0
  97. mcp_ticketer/queue/manager.py +4 -4
  98. mcp_ticketer/queue/queue.py +3 -3
  99. mcp_ticketer/queue/run_worker.py +1 -1
  100. mcp_ticketer/queue/ticket_registry.py +2 -2
  101. mcp_ticketer/queue/worker.py +15 -13
  102. mcp_ticketer/utils/__init__.py +5 -0
  103. mcp_ticketer/utils/token_utils.py +246 -0
  104. mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
  105. mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
  106. mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
  107. mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
  108. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
  109. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
  110. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  111. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,854 @@
1
+ """MCP tools for ticket analysis and cleanup (v2.0.0).
2
+
3
+ This module provides a unified interface for all ticket analysis operations.
4
+
5
+ Unified Tool (v2.0.0):
6
+ - ticket_analyze: Single interface for all analysis operations
7
+ - find_similar: Find duplicate or related tickets
8
+ - find_stale: Identify old, inactive tickets
9
+ - find_orphaned: Find tickets without hierarchy
10
+ - cleanup_report: Generate comprehensive cleanup report
11
+
12
+ Deprecated Tools (removed in v3.0.0):
13
+ - ticket_find: Use ticket_analyze instead (deprecated v2.0.0)
14
+ - ticket_find_similar: Use ticket_analyze(action="find_similar") instead
15
+ - ticket_find_stale: Use ticket_analyze(action="find_stale") instead
16
+ - ticket_find_orphaned: Use ticket_analyze(action="find_orphaned") instead
17
+ - ticket_cleanup_report: Use ticket_analyze(action="cleanup_report") instead
18
+
19
+ These tools help product managers maintain development practices and
20
+ identify tickets that need attention.
21
+ """
22
+
23
+ import logging
24
+ import warnings
25
+ from datetime import datetime
26
+ from typing import Any
27
+
28
+ # Try to import analysis dependencies (optional)
29
+ try:
30
+ from ....analysis.orphaned import OrphanedTicketDetector
31
+ from ....analysis.similarity import TicketSimilarityAnalyzer
32
+ from ....analysis.staleness import StaleTicketDetector
33
+
34
+ ANALYSIS_AVAILABLE = True
35
+ except ImportError:
36
+ ANALYSIS_AVAILABLE = False
37
+ # Define placeholder classes for type hints
38
+ OrphanedTicketDetector = None # type: ignore
39
+ TicketSimilarityAnalyzer = None # type: ignore
40
+ StaleTicketDetector = None # type: ignore
41
+
42
+ from ....core.models import SearchQuery, TicketState
43
+ from ....utils.token_utils import estimate_json_tokens
44
+ from ..server_sdk import get_adapter, mcp
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ @mcp.tool()
50
+ async def ticket_analyze(
51
+ action: str,
52
+ # Find similar parameters
53
+ ticket_id: str | None = None,
54
+ threshold: float = 0.75,
55
+ limit: int = 10,
56
+ internal_limit: int = 100,
57
+ # Find stale parameters
58
+ age_threshold_days: int = 90,
59
+ activity_threshold_days: int = 30,
60
+ states: list[str] | None = None,
61
+ # Cleanup report parameters
62
+ include_similar: bool = True,
63
+ include_stale: bool = True,
64
+ include_orphaned: bool = True,
65
+ summary_only: bool = False,
66
+ format: str = "json",
67
+ ) -> dict[str, Any]:
68
+ """Unified ticket analysis tool for finding patterns and issues.
69
+
70
+ Handles ticket similarity analysis, staleness detection, orphan detection,
71
+ and comprehensive cleanup reporting through a single interface.
72
+
73
+ Args:
74
+ action: Analysis operation to perform. Valid values:
75
+ - "find_similar": Find duplicate or related tickets (TF-IDF similarity)
76
+ - "find_stale": Find old, inactive tickets that may need closing
77
+ - "find_orphaned": Find tickets without proper hierarchy
78
+ - "cleanup_report": Generate comprehensive cleanup report
79
+
80
+ ticket_id: [find_similar] Ticket to find similar matches for (optional)
81
+ threshold: [find_similar] Similarity threshold 0.0-1.0 (default: 0.75)
82
+ limit: Maximum number of results to return (default: 10, max: 50)
83
+ internal_limit: [find_similar] Max tickets to fetch for comparison (default: 100, max: 200)
84
+
85
+ age_threshold_days: [find_stale] Minimum age in days to consider (default: 90)
86
+ activity_threshold_days: [find_stale] Days without activity (default: 30)
87
+ states: [find_stale] Ticket states to check (default: ["open", "waiting", "blocked"])
88
+
89
+ include_similar: [cleanup_report] Include similarity analysis (default: True)
90
+ include_stale: [cleanup_report] Include staleness analysis (default: True)
91
+ include_orphaned: [cleanup_report] Include orphaned analysis (default: True)
92
+ summary_only: [cleanup_report] Return only summary statistics (default: False)
93
+ format: [cleanup_report] Output format: "json" or "markdown" (default: "json")
94
+
95
+ Returns:
96
+ Analysis results specific to action with status, count, and detailed findings
97
+
98
+ Examples:
99
+ # Find similar tickets
100
+ await ticket_analyze(action="find_similar", ticket_id="TICKET-123", threshold=0.8)
101
+
102
+ # Find stale tickets
103
+ await ticket_analyze(action="find_stale", age_threshold_days=180)
104
+
105
+ # Find orphaned tickets
106
+ await ticket_analyze(action="find_orphaned")
107
+
108
+ # Generate cleanup report (summary only)
109
+ await ticket_analyze(action="cleanup_report", summary_only=True)
110
+
111
+ # Full cleanup report (WARNING: high token usage)
112
+ await ticket_analyze(action="cleanup_report", summary_only=False)
113
+
114
+ Migration from deprecated tools:
115
+ - ticket_find_similar(...) → ticket_analyze(action="find_similar", ...)
116
+ - ticket_find_stale(...) → ticket_analyze(action="find_stale", ...)
117
+ - ticket_find_orphaned(...) → ticket_analyze(action="find_orphaned", ...)
118
+ - ticket_cleanup_report(...) → ticket_analyze(action="cleanup_report", ...)
119
+
120
+ Token Usage:
121
+ - find_similar: ~2,000-5,000 tokens (higher with large internal_limit)
122
+ - find_stale: ~1,000-2,000 tokens
123
+ - find_orphaned: ~500-1,000 tokens
124
+ - cleanup_report (summary): ~500-1,000 tokens
125
+ - cleanup_report (full): Up to 40,000+ tokens (EXCEEDS 20k limit!)
126
+
127
+ See: docs/mcp-api-reference.md for detailed response formats
128
+ """
129
+ action_lower = action.lower()
130
+
131
+ # Route to appropriate handler based on action
132
+ if action_lower == "find_similar":
133
+ return await ticket_find_similar(
134
+ ticket_id=ticket_id,
135
+ threshold=threshold,
136
+ limit=limit,
137
+ internal_limit=internal_limit,
138
+ )
139
+ elif action_lower == "find_stale":
140
+ return await ticket_find_stale(
141
+ age_threshold_days=age_threshold_days,
142
+ activity_threshold_days=activity_threshold_days,
143
+ states=states,
144
+ limit=limit,
145
+ )
146
+ elif action_lower == "find_orphaned":
147
+ return await ticket_find_orphaned(limit=limit)
148
+ elif action_lower == "cleanup_report":
149
+ return await ticket_cleanup_report(
150
+ include_similar=include_similar,
151
+ include_stale=include_stale,
152
+ include_orphaned=include_orphaned,
153
+ summary_only=summary_only,
154
+ format=format,
155
+ )
156
+ else:
157
+ valid_actions = [
158
+ "find_similar",
159
+ "find_stale",
160
+ "find_orphaned",
161
+ "cleanup_report",
162
+ ]
163
+ return {
164
+ "status": "error",
165
+ "error": f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}",
166
+ "valid_actions": valid_actions,
167
+ "hint": "Use ticket_analyze(action='find_similar'|'find_stale'|'find_orphaned'|'cleanup_report', ...)",
168
+ }
169
+
170
+
171
+ async def ticket_find(
172
+ find_type: str,
173
+ ticket_id: str | None = None,
174
+ threshold: float = 0.75,
175
+ limit: int = 10,
176
+ internal_limit: int = 100,
177
+ age_threshold_days: int = 90,
178
+ activity_threshold_days: int = 30,
179
+ states: list[str] | None = None,
180
+ ) -> dict[str, Any]:
181
+ """Find tickets by type (DEPRECATED - use ticket_analyze instead).
182
+
183
+ .. deprecated:: 2.0.0
184
+ Use ticket_analyze(action=...) instead.
185
+ This tool will be removed in v3.0.0.
186
+
187
+ This tool consolidates ticket_find_similar, ticket_find_stale, and
188
+ ticket_find_orphaned into a single interface.
189
+
190
+ Args:
191
+ find_type: Type of search to perform. Valid values:
192
+ - "similar": Find duplicate or related tickets (uses TF-IDF similarity)
193
+ - "stale": Find old, inactive tickets that may need closing
194
+ - "orphaned": Find tickets without proper hierarchy (no parent/epic/project)
195
+ ticket_id: For "similar" type: find tickets similar to this one (optional)
196
+ threshold: For "similar" type: similarity threshold 0.0-1.0 (default: 0.75)
197
+ limit: Maximum number of results to return (default: 10, max: 50)
198
+ internal_limit: For "similar" type: max tickets to fetch for comparison (default: 100, max: 200)
199
+ age_threshold_days: For "stale" type: minimum age in days to consider (default: 90)
200
+ activity_threshold_days: For "stale" type: days without activity (default: 30)
201
+ states: For "stale" type: ticket states to check (default: ["open", "waiting", "blocked"])
202
+
203
+ Returns:
204
+ Results specific to find_type with status, count, and detailed findings
205
+
206
+ Migration:
207
+ ticket_find(find_type="similar", ...) → ticket_analyze(action="find_similar", ...)
208
+ """
209
+ warnings.warn(
210
+ "ticket_find is deprecated. Use ticket_analyze(action=...) instead.",
211
+ DeprecationWarning,
212
+ stacklevel=2,
213
+ )
214
+
215
+ # Map find_type to action for ticket_analyze
216
+ find_type_lower = find_type.lower()
217
+ action_map = {
218
+ "similar": "find_similar",
219
+ "stale": "find_stale",
220
+ "orphaned": "find_orphaned",
221
+ }
222
+
223
+ if find_type_lower not in action_map:
224
+ valid_types = list(action_map.keys())
225
+ return {
226
+ "status": "error",
227
+ "error": f"Invalid find_type '{find_type}'. Must be one of: {', '.join(valid_types)}",
228
+ "valid_types": valid_types,
229
+ "hint": "Use ticket_analyze(action='find_similar'|'find_stale'|'find_orphaned', ...)",
230
+ }
231
+
232
+ # Forward to ticket_analyze
233
+ return await ticket_analyze(
234
+ action=action_map[find_type_lower],
235
+ ticket_id=ticket_id,
236
+ threshold=threshold,
237
+ limit=limit,
238
+ internal_limit=internal_limit,
239
+ age_threshold_days=age_threshold_days,
240
+ activity_threshold_days=activity_threshold_days,
241
+ states=states,
242
+ )
243
+
244
+
245
+ async def ticket_find_similar(
246
+ ticket_id: str | None = None,
247
+ threshold: float = 0.75,
248
+ limit: int = 10,
249
+ internal_limit: int = 100,
250
+ ) -> dict[str, Any]:
251
+ """Find similar tickets to detect duplicates.
252
+
253
+ .. deprecated:: 2.0.0
254
+ Use ticket_analyze(action="find_similar", ...) instead.
255
+ This tool will be removed in v3.0.0.
256
+
257
+ Uses TF-IDF and cosine similarity to find tickets with similar
258
+ titles and descriptions. Useful for identifying duplicate tickets
259
+ or related work that should be linked.
260
+
261
+ Token Usage:
262
+ - CRITICAL: This tool can generate significant tokens
263
+ - Default settings (limit=10, internal_limit=100): ~2,000-5,000 tokens
264
+ - With internal_limit=500: Up to 92,500 tokens (EXCEEDS 20k limit!)
265
+ - Recommendation: Keep internal_limit ≤ 100 for typical queries
266
+ - For large datasets: Run multiple queries with specific ticket_id
267
+
268
+ Args:
269
+ ticket_id: Find similar tickets to this one (if None, find all similar pairs)
270
+ threshold: Similarity threshold 0.0-1.0 (default: 0.75)
271
+ limit: Maximum number of similarity results to return (default: 10, max: 50)
272
+ internal_limit: Maximum tickets to fetch for comparison (default: 100, max: 200)
273
+ Higher values increase accuracy but exponentially increase tokens
274
+
275
+ Returns:
276
+ List of similar ticket pairs with similarity scores and recommended actions
277
+
278
+ Example:
279
+ # Find tickets similar to a specific ticket (most efficient)
280
+ result = await ticket_find_similar(ticket_id="TICKET-123", threshold=0.8)
281
+
282
+ # Find all similar pairs with controlled dataset size
283
+ result = await ticket_find_similar(limit=20, internal_limit=100)
284
+
285
+ # Large analysis (use cautiously - can exceed token limits)
286
+ result = await ticket_find_similar(limit=10, internal_limit=200)
287
+
288
+ """
289
+ warnings.warn(
290
+ "ticket_find_similar is deprecated. Use ticket_analyze(action='find_similar', ...) instead.",
291
+ DeprecationWarning,
292
+ stacklevel=2,
293
+ )
294
+ if not ANALYSIS_AVAILABLE:
295
+ return {
296
+ "status": "error",
297
+ "error": "Analysis features not available",
298
+ "message": "Install analysis dependencies with: pip install mcp-ticketer[analysis]",
299
+ "required_packages": [
300
+ "scikit-learn>=1.3.0",
301
+ "rapidfuzz>=3.0.0",
302
+ "numpy>=1.24.0",
303
+ ],
304
+ }
305
+
306
+ try:
307
+ adapter = get_adapter()
308
+
309
+ # Validate threshold
310
+ if threshold < 0.0 or threshold > 1.0:
311
+ return {
312
+ "status": "error",
313
+ "error": "threshold must be between 0.0 and 1.0",
314
+ }
315
+
316
+ # Validate and cap limits
317
+ if limit > 50:
318
+ logger.warning(f"Limit {limit} exceeds maximum 50, using 50")
319
+ limit = 50
320
+
321
+ if internal_limit > 200:
322
+ logger.warning(
323
+ f"Internal limit {internal_limit} exceeds maximum 200, using 200"
324
+ )
325
+ internal_limit = 200
326
+
327
+ # Warn about high token usage
328
+ if internal_limit > 150:
329
+ logger.warning(
330
+ f"Large internal_limit={internal_limit} may generate >15k tokens. "
331
+ f"Consider reducing to ≤100 or using specific ticket_id for targeted search."
332
+ )
333
+
334
+ # Fetch tickets
335
+ if ticket_id:
336
+ try:
337
+ target = await adapter.read(ticket_id)
338
+ if not target:
339
+ return {
340
+ "status": "error",
341
+ "error": f"Ticket {ticket_id} not found",
342
+ }
343
+ except Exception as e:
344
+ return {
345
+ "status": "error",
346
+ "error": f"Failed to read ticket {ticket_id}: {str(e)}",
347
+ }
348
+
349
+ # Fetch tickets for comparison (smaller dataset when targeting specific ticket)
350
+ tickets = await adapter.list(limit=min(internal_limit, 100))
351
+ else:
352
+ target = None
353
+ # Pairwise analysis - use full internal_limit
354
+ tickets = await adapter.list(limit=internal_limit)
355
+
356
+ if len(tickets) < 2:
357
+ return {
358
+ "status": "completed",
359
+ "similar_tickets": [],
360
+ "count": 0,
361
+ "message": "Not enough tickets to compare (need at least 2)",
362
+ }
363
+
364
+ # Analyze similarity
365
+ analyzer = TicketSimilarityAnalyzer(threshold=threshold)
366
+ results = analyzer.find_similar_tickets(tickets, target, limit)
367
+
368
+ # Build response
369
+ similar_tickets_data = [r.model_dump() for r in results]
370
+ response = {
371
+ "status": "completed",
372
+ "similar_tickets": similar_tickets_data,
373
+ "count": len(results),
374
+ "threshold": threshold,
375
+ "tickets_analyzed": len(tickets),
376
+ "internal_limit": internal_limit,
377
+ }
378
+
379
+ # Estimate token usage and warn if approaching limit
380
+ estimated_tokens = estimate_json_tokens(response)
381
+ response["estimated_tokens"] = estimated_tokens
382
+
383
+ if estimated_tokens > 15_000:
384
+ logger.warning(
385
+ f"Response contains ~{estimated_tokens} tokens (approaching 20k limit). "
386
+ f"Consider reducing internal_limit or result limit."
387
+ )
388
+ response["token_warning"] = (
389
+ f"Response approaching token limit ({estimated_tokens} tokens). "
390
+ f"Consider using ticket_id for targeted search or reducing internal_limit."
391
+ )
392
+
393
+ return response
394
+
395
+ except Exception as e:
396
+ logger.error(f"Failed to find similar tickets: {e}")
397
+ return {
398
+ "status": "error",
399
+ "error": f"Failed to find similar tickets: {str(e)}",
400
+ }
401
+
402
+
403
+ async def ticket_find_stale(
404
+ age_threshold_days: int = 90,
405
+ activity_threshold_days: int = 30,
406
+ states: list[str] | None = None,
407
+ limit: int = 50,
408
+ ) -> dict[str, Any]:
409
+ """Find stale tickets that may need closing.
410
+
411
+ .. deprecated:: 2.0.0
412
+ Use ticket_analyze(action="find_stale", ...) instead.
413
+ This tool will be removed in v3.0.0.
414
+
415
+ Identifies old tickets with no recent activity that might be
416
+ "won't do" or abandoned work. Uses age, inactivity, state, and
417
+ priority to calculate staleness score.
418
+
419
+ Args:
420
+ age_threshold_days: Minimum age to consider (default: 90)
421
+ activity_threshold_days: Days without activity (default: 30)
422
+ states: Ticket states to check (default: ["open", "waiting", "blocked"])
423
+ limit: Maximum results (default: 50)
424
+
425
+ Returns:
426
+ List of stale tickets with staleness scores and suggested actions
427
+
428
+ Example:
429
+ # Find very old, inactive tickets
430
+ result = await ticket_find_stale(
431
+ age_threshold_days=180,
432
+ activity_threshold_days=60
433
+ )
434
+
435
+ # Find stale open tickets only
436
+ result = await ticket_find_stale(states=["open"], limit=100)
437
+
438
+ """
439
+ warnings.warn(
440
+ "ticket_find_stale is deprecated. Use ticket_analyze(action='find_stale', ...) instead.",
441
+ DeprecationWarning,
442
+ stacklevel=2,
443
+ )
444
+ if not ANALYSIS_AVAILABLE:
445
+ return {
446
+ "status": "error",
447
+ "error": "Analysis features not available",
448
+ "message": "Install analysis dependencies with: pip install mcp-ticketer[analysis]",
449
+ "required_packages": [
450
+ "scikit-learn>=1.3.0",
451
+ "rapidfuzz>=3.0.0",
452
+ "numpy>=1.24.0",
453
+ ],
454
+ }
455
+
456
+ try:
457
+ adapter = get_adapter()
458
+
459
+ # Parse states
460
+ check_states = None
461
+ if states:
462
+ try:
463
+ check_states = [TicketState(s.lower()) for s in states]
464
+ except ValueError as e:
465
+ return {
466
+ "status": "error",
467
+ "error": f"Invalid state: {str(e)}. Must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked",
468
+ }
469
+ else:
470
+ check_states = [
471
+ TicketState.OPEN,
472
+ TicketState.WAITING,
473
+ TicketState.BLOCKED,
474
+ ]
475
+
476
+ # Fetch tickets - try to filter by state if adapter supports it
477
+ all_tickets = []
478
+ for state in check_states:
479
+ try:
480
+ query = SearchQuery(state=state, limit=100)
481
+ tickets = await adapter.search(query)
482
+ all_tickets.extend(tickets)
483
+ except Exception:
484
+ # If search with state fails, fall back to list all
485
+ all_tickets = await adapter.list(limit=500)
486
+ break
487
+
488
+ if not all_tickets:
489
+ return {
490
+ "status": "completed",
491
+ "stale_tickets": [],
492
+ "count": 0,
493
+ "message": "No tickets found to analyze",
494
+ }
495
+
496
+ # Detect stale tickets
497
+ detector = StaleTicketDetector(
498
+ age_threshold_days=age_threshold_days,
499
+ activity_threshold_days=activity_threshold_days,
500
+ check_states=check_states,
501
+ )
502
+ results = detector.find_stale_tickets(all_tickets, limit)
503
+
504
+ return {
505
+ "status": "completed",
506
+ "stale_tickets": [r.model_dump() for r in results],
507
+ "count": len(results),
508
+ "thresholds": {
509
+ "age_days": age_threshold_days,
510
+ "activity_days": activity_threshold_days,
511
+ },
512
+ "states_checked": [s.value for s in check_states],
513
+ "tickets_analyzed": len(all_tickets),
514
+ }
515
+
516
+ except Exception as e:
517
+ logger.error(f"Failed to find stale tickets: {e}")
518
+ return {
519
+ "status": "error",
520
+ "error": f"Failed to find stale tickets: {str(e)}",
521
+ }
522
+
523
+
524
+ async def ticket_find_orphaned(
525
+ limit: int = 100,
526
+ ) -> dict[str, Any]:
527
+ """Find orphaned tickets without parent epic or project.
528
+
529
+ .. deprecated:: 2.0.0
530
+ Use ticket_analyze(action="find_orphaned", ...) instead.
531
+ This tool will be removed in v3.0.0.
532
+
533
+ Identifies tickets that aren't properly organized in the hierarchy:
534
+ - Tickets without parent epic/milestone
535
+ - Tickets not assigned to any project/team
536
+ - Standalone issues that should be part of larger initiatives
537
+
538
+ Args:
539
+ limit: Maximum tickets to check (default: 100)
540
+
541
+ Returns:
542
+ List of orphaned tickets with orphan type and suggested actions
543
+
544
+ Example:
545
+ # Find all orphaned tickets
546
+ result = await ticket_find_orphaned(limit=200)
547
+
548
+ """
549
+ warnings.warn(
550
+ "ticket_find_orphaned is deprecated. Use ticket_analyze(action='find_orphaned', ...) instead.",
551
+ DeprecationWarning,
552
+ stacklevel=2,
553
+ )
554
+ if not ANALYSIS_AVAILABLE:
555
+ return {
556
+ "status": "error",
557
+ "error": "Analysis features not available",
558
+ "message": "Install analysis dependencies with: pip install mcp-ticketer[analysis]",
559
+ "required_packages": [
560
+ "scikit-learn>=1.3.0",
561
+ "rapidfuzz>=3.0.0",
562
+ "numpy>=1.24.0",
563
+ ],
564
+ }
565
+
566
+ try:
567
+ adapter = get_adapter()
568
+
569
+ # Fetch tickets
570
+ tickets = await adapter.list(limit=limit)
571
+
572
+ if not tickets:
573
+ return {
574
+ "status": "completed",
575
+ "orphaned_tickets": [],
576
+ "count": 0,
577
+ "message": "No tickets found to analyze",
578
+ }
579
+
580
+ # Detect orphaned tickets
581
+ detector = OrphanedTicketDetector()
582
+ results = detector.find_orphaned_tickets(tickets)
583
+
584
+ # Calculate statistics
585
+ orphan_stats = {
586
+ "no_parent": len([r for r in results if r.orphan_type == "no_parent"]),
587
+ "no_epic": len([r for r in results if r.orphan_type == "no_epic"]),
588
+ "no_project": len([r for r in results if r.orphan_type == "no_project"]),
589
+ }
590
+
591
+ return {
592
+ "status": "completed",
593
+ "orphaned_tickets": [r.model_dump() for r in results],
594
+ "count": len(results),
595
+ "orphan_types": orphan_stats,
596
+ "tickets_analyzed": len(tickets),
597
+ }
598
+
599
+ except Exception as e:
600
+ logger.error(f"Failed to find orphaned tickets: {e}")
601
+ return {
602
+ "status": "error",
603
+ "error": f"Failed to find orphaned tickets: {str(e)}",
604
+ }
605
+
606
+
607
+ async def ticket_cleanup_report(
608
+ include_similar: bool = True,
609
+ include_stale: bool = True,
610
+ include_orphaned: bool = True,
611
+ summary_only: bool = False,
612
+ format: str = "json",
613
+ ) -> dict[str, Any]:
614
+ """Generate comprehensive ticket cleanup report.
615
+
616
+ .. deprecated:: 2.0.0
617
+ Use ticket_analyze(action="cleanup_report", ...) instead.
618
+ This tool will be removed in v3.0.0.
619
+
620
+ Combines all cleanup analysis tools into a single report:
621
+ - Similar tickets (duplicates)
622
+ - Stale tickets (candidates for closing)
623
+ - Orphaned tickets (missing hierarchy)
624
+
625
+ Token Usage:
626
+ - CRITICAL: Full report can exceed 40,000 tokens
627
+ - Summary only (summary_only=True): ~500-1,000 tokens
628
+ - Full report with all sections: Up to 40,000+ tokens (EXCEEDS 20k limit!)
629
+ - Recommendation: Use summary_only=True for overview, then fetch specific sections
630
+
631
+ Args:
632
+ include_similar: Include similarity analysis (default: True)
633
+ include_stale: Include staleness analysis (default: True)
634
+ include_orphaned: Include orphaned ticket analysis (default: True)
635
+ summary_only: Return only summary statistics, not full details (default: False)
636
+ Set to True to stay under token limits
637
+ format: Output format: "json" or "markdown" (default: "json")
638
+
639
+ Returns:
640
+ Comprehensive cleanup report with all analyses and recommendations
641
+
642
+ Example:
643
+ # Summary only (recommended for initial overview)
644
+ result = await ticket_cleanup_report(summary_only=True)
645
+
646
+ # Get specific section details separately
647
+ similar = await ticket_find_similar(limit=10)
648
+ stale = await ticket_find_stale(limit=20)
649
+
650
+ # Full report (WARNING: Can exceed 20k tokens!)
651
+ result = await ticket_cleanup_report(summary_only=False)
652
+
653
+ """
654
+ warnings.warn(
655
+ "ticket_cleanup_report is deprecated. Use ticket_analyze(action='cleanup_report', ...) instead.",
656
+ DeprecationWarning,
657
+ stacklevel=2,
658
+ )
659
+ if not ANALYSIS_AVAILABLE:
660
+ return {
661
+ "status": "error",
662
+ "error": "Analysis features not available",
663
+ "message": "Install analysis dependencies with: pip install mcp-ticketer[analysis]",
664
+ "required_packages": [
665
+ "scikit-learn>=1.3.0",
666
+ "rapidfuzz>=3.0.0",
667
+ "numpy>=1.24.0",
668
+ ],
669
+ }
670
+
671
+ try:
672
+ report: dict[str, Any] = {
673
+ "status": "completed",
674
+ "generated_at": datetime.now().isoformat(),
675
+ "summary_only": summary_only,
676
+ }
677
+
678
+ # If summary_only, fetch smaller datasets and only extract counts
679
+ if summary_only:
680
+ similar_count = 0
681
+ stale_count = 0
682
+ orphaned_count = 0
683
+
684
+ if include_similar:
685
+ similar_result = await ticket_find_similar(limit=5, internal_limit=50)
686
+ similar_count = similar_result.get("count", 0)
687
+
688
+ if include_stale:
689
+ stale_result = await ticket_find_stale(limit=10)
690
+ stale_count = stale_result.get("count", 0)
691
+
692
+ if include_orphaned:
693
+ orphaned_result = await ticket_find_orphaned(limit=20)
694
+ orphaned_count = orphaned_result.get("count", 0)
695
+
696
+ report["summary"] = {
697
+ "total_issues_found": similar_count + stale_count + orphaned_count,
698
+ "similar_pairs": similar_count,
699
+ "stale_count": stale_count,
700
+ "orphaned_count": orphaned_count,
701
+ }
702
+
703
+ report["recommendation"] = (
704
+ "Use summary_only=False or fetch specific sections with "
705
+ "ticket_find_similar(), ticket_find_stale(), ticket_find_orphaned() "
706
+ "for full details."
707
+ )
708
+
709
+ else:
710
+ # Full report mode - WARNING: Can exceed token limits!
711
+ logger.warning(
712
+ "Generating full cleanup report. This may exceed 20k tokens. "
713
+ "Consider using summary_only=True for overview."
714
+ )
715
+
716
+ report["analyses"] = {}
717
+
718
+ # Similar tickets with reduced limits to control tokens
719
+ if include_similar:
720
+ similar_result = await ticket_find_similar(limit=10, internal_limit=100)
721
+ report["analyses"]["similar_tickets"] = similar_result
722
+
723
+ # Stale tickets
724
+ if include_stale:
725
+ stale_result = await ticket_find_stale(limit=20)
726
+ report["analyses"]["stale_tickets"] = stale_result
727
+
728
+ # Orphaned tickets
729
+ if include_orphaned:
730
+ orphaned_result = await ticket_find_orphaned(limit=30)
731
+ report["analyses"]["orphaned_tickets"] = orphaned_result
732
+
733
+ # Summary statistics
734
+ similar_count = (
735
+ report["analyses"].get("similar_tickets", {}).get("count", 0)
736
+ )
737
+ stale_count = report["analyses"].get("stale_tickets", {}).get("count", 0)
738
+ orphaned_count = (
739
+ report["analyses"].get("orphaned_tickets", {}).get("count", 0)
740
+ )
741
+
742
+ report["summary"] = {
743
+ "total_issues_found": similar_count + stale_count + orphaned_count,
744
+ "similar_pairs": similar_count,
745
+ "stale_count": stale_count,
746
+ "orphaned_count": orphaned_count,
747
+ }
748
+
749
+ # Format as markdown if requested
750
+ if format == "markdown":
751
+ report["markdown"] = _format_report_as_markdown(report)
752
+
753
+ # Estimate tokens and add warning if needed
754
+ estimated_tokens = estimate_json_tokens(report)
755
+ report["estimated_tokens"] = estimated_tokens
756
+
757
+ if estimated_tokens > 15_000:
758
+ logger.warning(
759
+ f"Cleanup report contains ~{estimated_tokens} tokens. "
760
+ f"Consider using summary_only=True or fetching sections separately."
761
+ )
762
+ report["token_warning"] = (
763
+ f"Response approaching token limit ({estimated_tokens} tokens). "
764
+ f"Use summary_only=True or fetch sections individually."
765
+ )
766
+
767
+ return report
768
+
769
+ except Exception as e:
770
+ logger.error(f"Failed to generate cleanup report: {e}")
771
+ return {
772
+ "status": "error",
773
+ "error": f"Failed to generate cleanup report: {str(e)}",
774
+ }
775
+
776
+
777
+ def _format_report_as_markdown(report: dict[str, Any]) -> str:
778
+ """Format cleanup report as markdown.
779
+
780
+ Args:
781
+ report: Report data dictionary
782
+
783
+ Returns:
784
+ Markdown-formatted report string
785
+
786
+ """
787
+ md = "# Ticket Cleanup Report\n\n"
788
+ md += f"**Generated:** {report['generated_at']}\n\n"
789
+
790
+ summary = report["summary"]
791
+ md += "## Summary\n\n"
792
+ md += f"- **Total Issues Found:** {summary['total_issues_found']}\n"
793
+ md += f"- **Similar Ticket Pairs:** {summary['similar_pairs']}\n"
794
+ md += f"- **Stale Tickets:** {summary['stale_count']}\n"
795
+ md += f"- **Orphaned Tickets:** {summary['orphaned_count']}\n\n"
796
+
797
+ # Similar tickets section
798
+ similar_data = report["analyses"].get("similar_tickets", {})
799
+ if similar_data.get("similar_tickets"):
800
+ md += "## Similar Tickets (Potential Duplicates)\n\n"
801
+ for result in similar_data["similar_tickets"][:10]: # Top 10
802
+ md += f"### {result['ticket1_title']} ↔ {result['ticket2_title']}\n"
803
+ md += f"- **Similarity:** {result['similarity_score']:.2%}\n"
804
+ md += f"- **Ticket 1:** `{result['ticket1_id']}`\n"
805
+ md += f"- **Ticket 2:** `{result['ticket2_id']}`\n"
806
+ md += f"- **Action:** {result['suggested_action'].upper()}\n"
807
+ md += f"- **Reasons:** {', '.join(result['similarity_reasons'])}\n\n"
808
+
809
+ # Stale tickets section
810
+ stale_data = report["analyses"].get("stale_tickets", {})
811
+ if stale_data.get("stale_tickets"):
812
+ md += "## Stale Tickets (Candidates for Closing)\n\n"
813
+ for result in stale_data["stale_tickets"][:15]: # Top 15
814
+ md += f"### {result['ticket_title']}\n"
815
+ md += f"- **ID:** `{result['ticket_id']}`\n"
816
+ md += f"- **State:** {result['ticket_state']}\n"
817
+ md += f"- **Age:** {result['age_days']} days\n"
818
+ md += f"- **Last Updated:** {result['days_since_update']} days ago\n"
819
+ md += f"- **Staleness Score:** {result['staleness_score']:.2%}\n"
820
+ md += f"- **Action:** {result['suggested_action'].upper()}\n"
821
+ md += f"- **Reason:** {result['reason']}\n\n"
822
+
823
+ # Orphaned tickets section
824
+ orphaned_data = report["analyses"].get("orphaned_tickets", {})
825
+ if orphaned_data.get("orphaned_tickets"):
826
+ md += "## Orphaned Tickets (Missing Hierarchy)\n\n"
827
+
828
+ # Group by orphan type
829
+ by_type: dict[str, list[Any]] = {}
830
+ for result in orphaned_data["orphaned_tickets"]:
831
+ orphan_type = result["orphan_type"]
832
+ if orphan_type not in by_type:
833
+ by_type[orphan_type] = []
834
+ by_type[orphan_type].append(result)
835
+
836
+ for orphan_type, tickets in by_type.items():
837
+ md += f"### {orphan_type.replace('_', ' ').title()} ({len(tickets)})\n\n"
838
+ for result in tickets[:10]: # Top 10 per type
839
+ md += f"- **{result['ticket_title']}** (`{result['ticket_id']}`)\n"
840
+ md += f" - Type: {result['ticket_type']}\n"
841
+ md += f" - Action: {result['suggested_action']}\n"
842
+ md += f" - Reason: {result['reason']}\n"
843
+ md += "\n"
844
+
845
+ # Recommendations section
846
+ md += "## Recommendations\n\n"
847
+ md += "1. **Review Similar Tickets:** Check pairs marked for 'merge' action\n"
848
+ md += "2. **Close Stale Tickets:** Review tickets marked for 'close' action\n"
849
+ md += (
850
+ "3. **Organize Orphaned Tickets:** Assign epics/projects to orphaned tickets\n"
851
+ )
852
+ md += "4. **Update Workflow:** Consider closing very old low-priority tickets\n\n"
853
+
854
+ return md