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,151 @@
1
+ """FastMCP-based MCP server implementation.
2
+
3
+ This module implements the MCP server using the official FastMCP SDK,
4
+ replacing the custom JSON-RPC implementation. It provides a cleaner,
5
+ more maintainable approach with automatic schema generation and
6
+ better error handling.
7
+
8
+ The server manages a global adapter instance that is configured at
9
+ startup and used by all tool implementations.
10
+ """
11
+
12
+ import logging
13
+ from typing import Any
14
+
15
+ from mcp.server.fastmcp import FastMCP
16
+
17
+ from ...core.adapter import BaseAdapter
18
+ from ...core.registry import AdapterRegistry
19
+
20
+ # Initialize FastMCP server
21
+ mcp = FastMCP("mcp-ticketer")
22
+
23
+ # Global adapter instance
24
+ _adapter: BaseAdapter | None = None
25
+
26
+ # Global router instance (optional, for multi-platform support)
27
+ _router: Any | None = None
28
+
29
+ # Configure logging
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def configure_adapter(adapter_type: str, config: dict[str, Any]) -> None:
34
+ """Configure the global adapter instance.
35
+
36
+ This must be called before starting the server to initialize the
37
+ adapter that will handle all ticket operations.
38
+
39
+ Args:
40
+ adapter_type: Type of adapter to create (e.g., "linear", "jira", "github")
41
+ config: Configuration dictionary for the adapter
42
+
43
+ Raises:
44
+ ValueError: If adapter type is not registered
45
+ RuntimeError: If adapter configuration fails
46
+
47
+ """
48
+ global _adapter
49
+
50
+ try:
51
+ # Get adapter from registry
52
+ _adapter = AdapterRegistry.get_adapter(adapter_type, config)
53
+ logger.info(f"Configured {adapter_type} adapter for MCP server")
54
+ except Exception as e:
55
+ logger.error(f"Failed to configure adapter: {e}")
56
+ raise RuntimeError(f"Adapter configuration failed: {e}") from e
57
+
58
+
59
+ def get_adapter() -> BaseAdapter:
60
+ """Get the configured adapter instance.
61
+
62
+ Returns:
63
+ The global adapter instance
64
+
65
+ Raises:
66
+ RuntimeError: If adapter has not been configured
67
+
68
+ """
69
+ if _adapter is None:
70
+ raise RuntimeError(
71
+ "Adapter not configured. Call configure_adapter() before starting server."
72
+ )
73
+ return _adapter
74
+
75
+
76
+ def configure_router(
77
+ default_adapter: str, adapter_configs: dict[str, dict[str, Any]]
78
+ ) -> None:
79
+ """Configure multi-platform routing support (optional).
80
+
81
+ This enables URL-based ticket access across multiple platforms in a
82
+ single MCP session. When configured, tools will use the router to
83
+ automatically detect the platform from URLs.
84
+
85
+ Args:
86
+ default_adapter: Default adapter for plain IDs (e.g., "linear")
87
+ adapter_configs: Configuration for each adapter
88
+ Example: {
89
+ "linear": {"api_key": "...", "team_id": "..."},
90
+ "github": {"token": "...", "owner": "...", "repo": "..."}
91
+ }
92
+
93
+ Raises:
94
+ RuntimeError: If router configuration fails
95
+
96
+ """
97
+ global _router
98
+
99
+ try:
100
+ from .routing import TicketRouter
101
+
102
+ _router = TicketRouter(
103
+ default_adapter=default_adapter, adapter_configs=adapter_configs
104
+ )
105
+ logger.info(f"Configured multi-platform router with default: {default_adapter}")
106
+ except Exception as e:
107
+ logger.error(f"Failed to configure router: {e}")
108
+ raise RuntimeError(f"Router configuration failed: {e}") from e
109
+
110
+
111
+ def get_router() -> Any:
112
+ """Get the configured router instance (if available).
113
+
114
+ Returns:
115
+ The global router instance, or None if not configured
116
+
117
+ """
118
+ return _router
119
+
120
+
121
+ def has_router() -> bool:
122
+ """Check if multi-platform router is configured.
123
+
124
+ Returns:
125
+ True if router is available, False otherwise
126
+
127
+ """
128
+ return _router is not None
129
+
130
+
131
+ # Import all tool modules to register them with FastMCP
132
+ # These imports must come after mcp is initialized but before main()
133
+ from . import tools # noqa: E402, F401
134
+
135
+
136
+ def main() -> None:
137
+ """Run the FastMCP server.
138
+
139
+ This function starts the server using stdio transport for
140
+ JSON-RPC communication with Claude Desktop/Code.
141
+
142
+ The adapter must be configured via configure_adapter() before
143
+ calling this function.
144
+
145
+ """
146
+ # Run the server with stdio transport
147
+ mcp.run(transport="stdio")
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -0,0 +1,56 @@
1
+ """MCP tool modules for ticket operations.
2
+
3
+ This package contains all FastMCP tool implementations organized by
4
+ functional area. Tools are automatically registered with the FastMCP
5
+ server when imported.
6
+
7
+ Modules:
8
+ ticket_tools: Basic CRUD operations for tickets
9
+ hierarchy_tools: Epic/Issue/Task hierarchy management
10
+ search_tools: Search and query operations
11
+ bulk_tools: Bulk create and update operations
12
+ comment_tools: Comment management
13
+ pr_tools: Pull request integration
14
+ attachment_tools: File attachment handling
15
+ instruction_tools: Ticket instructions management
16
+ config_tools: Configuration management (adapter, project, user settings)
17
+ session_tools: Session tracking and ticket association management
18
+ user_ticket_tools: User-specific ticket operations (my tickets, transitions)
19
+ analysis_tools: Ticket analysis and cleanup tools (similar, stale, orphaned)
20
+ label_tools: Label management, normalization, deduplication, and cleanup
21
+
22
+ """
23
+
24
+ # Import all tool modules to register them with FastMCP
25
+ # Order matters - import core functionality first
26
+ from . import (
27
+ analysis_tools, # noqa: F401
28
+ attachment_tools, # noqa: F401
29
+ bulk_tools, # noqa: F401
30
+ comment_tools, # noqa: F401
31
+ config_tools, # noqa: F401
32
+ hierarchy_tools, # noqa: F401
33
+ instruction_tools, # noqa: F401
34
+ label_tools, # noqa: F401
35
+ pr_tools, # noqa: F401
36
+ search_tools, # noqa: F401
37
+ session_tools, # noqa: F401
38
+ ticket_tools, # noqa: F401
39
+ user_ticket_tools, # noqa: F401
40
+ )
41
+
42
+ __all__ = [
43
+ "analysis_tools",
44
+ "attachment_tools",
45
+ "bulk_tools",
46
+ "comment_tools",
47
+ "config_tools",
48
+ "hierarchy_tools",
49
+ "instruction_tools",
50
+ "label_tools",
51
+ "pr_tools",
52
+ "search_tools",
53
+ "session_tools",
54
+ "ticket_tools",
55
+ "user_ticket_tools",
56
+ ]
@@ -0,0 +1,495 @@
1
+ """MCP tools for ticket analysis and cleanup.
2
+
3
+ This module provides PM-focused tools to help maintain ticket health:
4
+ - ticket_find_similar: Find duplicate or related tickets
5
+ - ticket_find_stale: Identify old, inactive tickets
6
+ - ticket_find_orphaned: Find tickets without hierarchy
7
+ - ticket_cleanup_report: Generate comprehensive cleanup report
8
+
9
+ These tools help product managers maintain development practices and
10
+ identify tickets that need attention.
11
+ """
12
+
13
+ import logging
14
+ from datetime import datetime
15
+ from typing import Any
16
+
17
+ # Try to import analysis dependencies (optional)
18
+ try:
19
+ from ....analysis.orphaned import OrphanedTicketDetector
20
+ from ....analysis.similarity import TicketSimilarityAnalyzer
21
+ from ....analysis.staleness import StaleTicketDetector
22
+
23
+ ANALYSIS_AVAILABLE = True
24
+ except ImportError:
25
+ ANALYSIS_AVAILABLE = False
26
+ # Define placeholder classes for type hints
27
+ OrphanedTicketDetector = None # type: ignore
28
+ TicketSimilarityAnalyzer = None # type: ignore
29
+ StaleTicketDetector = None # type: ignore
30
+
31
+ from ....core.models import SearchQuery, TicketState
32
+ from ..server_sdk import get_adapter, mcp
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ @mcp.tool()
38
+ async def ticket_find_similar(
39
+ ticket_id: str | None = None,
40
+ threshold: float = 0.75,
41
+ limit: int = 10,
42
+ ) -> dict[str, Any]:
43
+ """Find similar tickets to detect duplicates.
44
+
45
+ Uses TF-IDF and cosine similarity to find tickets with similar
46
+ titles and descriptions. Useful for identifying duplicate tickets
47
+ or related work that should be linked.
48
+
49
+ Args:
50
+ ticket_id: Find similar tickets to this one (if None, find all similar pairs)
51
+ threshold: Similarity threshold 0.0-1.0 (default: 0.75)
52
+ limit: Maximum number of results (default: 10)
53
+
54
+ Returns:
55
+ List of similar ticket pairs with similarity scores and recommended actions
56
+
57
+ Example:
58
+ # Find tickets similar to a specific ticket
59
+ result = await ticket_find_similar(ticket_id="TICKET-123", threshold=0.8)
60
+
61
+ # Find all similar pairs in the system
62
+ result = await ticket_find_similar(limit=20)
63
+
64
+ """
65
+ if not ANALYSIS_AVAILABLE:
66
+ return {
67
+ "status": "error",
68
+ "error": "Analysis features not available",
69
+ "message": "Install analysis dependencies with: pip install mcp-ticketer[analysis]",
70
+ "required_packages": [
71
+ "scikit-learn>=1.3.0",
72
+ "rapidfuzz>=3.0.0",
73
+ "numpy>=1.24.0",
74
+ ],
75
+ }
76
+
77
+ try:
78
+ adapter = get_adapter()
79
+
80
+ # Validate threshold
81
+ if threshold < 0.0 or threshold > 1.0:
82
+ return {
83
+ "status": "error",
84
+ "error": "threshold must be between 0.0 and 1.0",
85
+ }
86
+
87
+ # Fetch tickets
88
+ if ticket_id:
89
+ try:
90
+ target = await adapter.read(ticket_id)
91
+ if not target:
92
+ return {
93
+ "status": "error",
94
+ "error": f"Ticket {ticket_id} not found",
95
+ }
96
+ except Exception as e:
97
+ return {
98
+ "status": "error",
99
+ "error": f"Failed to read ticket {ticket_id}: {str(e)}",
100
+ }
101
+
102
+ # Fetch more tickets for comparison
103
+ tickets = await adapter.list(limit=100)
104
+ else:
105
+ target = None
106
+ tickets = await adapter.list(limit=500) # Larger for pairwise analysis
107
+
108
+ if len(tickets) < 2:
109
+ return {
110
+ "status": "completed",
111
+ "similar_tickets": [],
112
+ "count": 0,
113
+ "message": "Not enough tickets to compare (need at least 2)",
114
+ }
115
+
116
+ # Analyze similarity
117
+ analyzer = TicketSimilarityAnalyzer(threshold=threshold)
118
+ results = analyzer.find_similar_tickets(tickets, target, limit)
119
+
120
+ return {
121
+ "status": "completed",
122
+ "similar_tickets": [r.model_dump() for r in results],
123
+ "count": len(results),
124
+ "threshold": threshold,
125
+ "tickets_analyzed": len(tickets),
126
+ }
127
+
128
+ except Exception as e:
129
+ logger.error(f"Failed to find similar tickets: {e}")
130
+ return {
131
+ "status": "error",
132
+ "error": f"Failed to find similar tickets: {str(e)}",
133
+ }
134
+
135
+
136
+ @mcp.tool()
137
+ async def ticket_find_stale(
138
+ age_threshold_days: int = 90,
139
+ activity_threshold_days: int = 30,
140
+ states: list[str] | None = None,
141
+ limit: int = 50,
142
+ ) -> dict[str, Any]:
143
+ """Find stale tickets that may need closing.
144
+
145
+ Identifies old tickets with no recent activity that might be
146
+ "won't do" or abandoned work. Uses age, inactivity, state, and
147
+ priority to calculate staleness score.
148
+
149
+ Args:
150
+ age_threshold_days: Minimum age to consider (default: 90)
151
+ activity_threshold_days: Days without activity (default: 30)
152
+ states: Ticket states to check (default: ["open", "waiting", "blocked"])
153
+ limit: Maximum results (default: 50)
154
+
155
+ Returns:
156
+ List of stale tickets with staleness scores and suggested actions
157
+
158
+ Example:
159
+ # Find very old, inactive tickets
160
+ result = await ticket_find_stale(
161
+ age_threshold_days=180,
162
+ activity_threshold_days=60
163
+ )
164
+
165
+ # Find stale open tickets only
166
+ result = await ticket_find_stale(states=["open"], limit=100)
167
+
168
+ """
169
+ if not ANALYSIS_AVAILABLE:
170
+ return {
171
+ "status": "error",
172
+ "error": "Analysis features not available",
173
+ "message": "Install analysis dependencies with: pip install mcp-ticketer[analysis]",
174
+ "required_packages": [
175
+ "scikit-learn>=1.3.0",
176
+ "rapidfuzz>=3.0.0",
177
+ "numpy>=1.24.0",
178
+ ],
179
+ }
180
+
181
+ try:
182
+ adapter = get_adapter()
183
+
184
+ # Parse states
185
+ check_states = None
186
+ if states:
187
+ try:
188
+ check_states = [TicketState(s.lower()) for s in states]
189
+ except ValueError as e:
190
+ return {
191
+ "status": "error",
192
+ "error": f"Invalid state: {str(e)}. Must be one of: open, in_progress, ready, tested, done, closed, waiting, blocked",
193
+ }
194
+ else:
195
+ check_states = [
196
+ TicketState.OPEN,
197
+ TicketState.WAITING,
198
+ TicketState.BLOCKED,
199
+ ]
200
+
201
+ # Fetch tickets - try to filter by state if adapter supports it
202
+ all_tickets = []
203
+ for state in check_states:
204
+ try:
205
+ query = SearchQuery(state=state, limit=100)
206
+ tickets = await adapter.search(query)
207
+ all_tickets.extend(tickets)
208
+ except Exception:
209
+ # If search with state fails, fall back to list all
210
+ all_tickets = await adapter.list(limit=500)
211
+ break
212
+
213
+ if not all_tickets:
214
+ return {
215
+ "status": "completed",
216
+ "stale_tickets": [],
217
+ "count": 0,
218
+ "message": "No tickets found to analyze",
219
+ }
220
+
221
+ # Detect stale tickets
222
+ detector = StaleTicketDetector(
223
+ age_threshold_days=age_threshold_days,
224
+ activity_threshold_days=activity_threshold_days,
225
+ check_states=check_states,
226
+ )
227
+ results = detector.find_stale_tickets(all_tickets, limit)
228
+
229
+ return {
230
+ "status": "completed",
231
+ "stale_tickets": [r.model_dump() for r in results],
232
+ "count": len(results),
233
+ "thresholds": {
234
+ "age_days": age_threshold_days,
235
+ "activity_days": activity_threshold_days,
236
+ },
237
+ "states_checked": [s.value for s in check_states],
238
+ "tickets_analyzed": len(all_tickets),
239
+ }
240
+
241
+ except Exception as e:
242
+ logger.error(f"Failed to find stale tickets: {e}")
243
+ return {
244
+ "status": "error",
245
+ "error": f"Failed to find stale tickets: {str(e)}",
246
+ }
247
+
248
+
249
+ @mcp.tool()
250
+ async def ticket_find_orphaned(
251
+ limit: int = 100,
252
+ ) -> dict[str, Any]:
253
+ """Find orphaned tickets without parent epic or project.
254
+
255
+ Identifies tickets that aren't properly organized in the hierarchy:
256
+ - Tickets without parent epic/milestone
257
+ - Tickets not assigned to any project/team
258
+ - Standalone issues that should be part of larger initiatives
259
+
260
+ Args:
261
+ limit: Maximum tickets to check (default: 100)
262
+
263
+ Returns:
264
+ List of orphaned tickets with orphan type and suggested actions
265
+
266
+ Example:
267
+ # Find all orphaned tickets
268
+ result = await ticket_find_orphaned(limit=200)
269
+
270
+ """
271
+ if not ANALYSIS_AVAILABLE:
272
+ return {
273
+ "status": "error",
274
+ "error": "Analysis features not available",
275
+ "message": "Install analysis dependencies with: pip install mcp-ticketer[analysis]",
276
+ "required_packages": [
277
+ "scikit-learn>=1.3.0",
278
+ "rapidfuzz>=3.0.0",
279
+ "numpy>=1.24.0",
280
+ ],
281
+ }
282
+
283
+ try:
284
+ adapter = get_adapter()
285
+
286
+ # Fetch tickets
287
+ tickets = await adapter.list(limit=limit)
288
+
289
+ if not tickets:
290
+ return {
291
+ "status": "completed",
292
+ "orphaned_tickets": [],
293
+ "count": 0,
294
+ "message": "No tickets found to analyze",
295
+ }
296
+
297
+ # Detect orphaned tickets
298
+ detector = OrphanedTicketDetector()
299
+ results = detector.find_orphaned_tickets(tickets)
300
+
301
+ # Calculate statistics
302
+ orphan_stats = {
303
+ "no_parent": len([r for r in results if r.orphan_type == "no_parent"]),
304
+ "no_epic": len([r for r in results if r.orphan_type == "no_epic"]),
305
+ "no_project": len([r for r in results if r.orphan_type == "no_project"]),
306
+ }
307
+
308
+ return {
309
+ "status": "completed",
310
+ "orphaned_tickets": [r.model_dump() for r in results],
311
+ "count": len(results),
312
+ "orphan_types": orphan_stats,
313
+ "tickets_analyzed": len(tickets),
314
+ }
315
+
316
+ except Exception as e:
317
+ logger.error(f"Failed to find orphaned tickets: {e}")
318
+ return {
319
+ "status": "error",
320
+ "error": f"Failed to find orphaned tickets: {str(e)}",
321
+ }
322
+
323
+
324
+ @mcp.tool()
325
+ async def ticket_cleanup_report(
326
+ include_similar: bool = True,
327
+ include_stale: bool = True,
328
+ include_orphaned: bool = True,
329
+ format: str = "json",
330
+ ) -> dict[str, Any]:
331
+ """Generate comprehensive ticket cleanup report.
332
+
333
+ Combines all cleanup analysis tools into a single report:
334
+ - Similar tickets (duplicates)
335
+ - Stale tickets (candidates for closing)
336
+ - Orphaned tickets (missing hierarchy)
337
+
338
+ Args:
339
+ include_similar: Include similarity analysis (default: True)
340
+ include_stale: Include staleness analysis (default: True)
341
+ include_orphaned: Include orphaned ticket analysis (default: True)
342
+ format: Output format: "json" or "markdown" (default: "json")
343
+
344
+ Returns:
345
+ Comprehensive cleanup report with all analyses and recommendations
346
+
347
+ Example:
348
+ # Full cleanup report
349
+ result = await ticket_cleanup_report()
350
+
351
+ # Only stale and orphaned analysis
352
+ result = await ticket_cleanup_report(include_similar=False)
353
+
354
+ # Generate markdown report
355
+ result = await ticket_cleanup_report(format="markdown")
356
+
357
+ """
358
+ if not ANALYSIS_AVAILABLE:
359
+ return {
360
+ "status": "error",
361
+ "error": "Analysis features not available",
362
+ "message": "Install analysis dependencies with: pip install mcp-ticketer[analysis]",
363
+ "required_packages": [
364
+ "scikit-learn>=1.3.0",
365
+ "rapidfuzz>=3.0.0",
366
+ "numpy>=1.24.0",
367
+ ],
368
+ }
369
+
370
+ try:
371
+ report: dict[str, Any] = {
372
+ "status": "completed",
373
+ "generated_at": datetime.now().isoformat(),
374
+ "analyses": {},
375
+ }
376
+
377
+ # Similar tickets
378
+ if include_similar:
379
+ similar_result = await ticket_find_similar(limit=20)
380
+ report["analyses"]["similar_tickets"] = similar_result
381
+
382
+ # Stale tickets
383
+ if include_stale:
384
+ stale_result = await ticket_find_stale(limit=50)
385
+ report["analyses"]["stale_tickets"] = stale_result
386
+
387
+ # Orphaned tickets
388
+ if include_orphaned:
389
+ orphaned_result = await ticket_find_orphaned(limit=100)
390
+ report["analyses"]["orphaned_tickets"] = orphaned_result
391
+
392
+ # Summary statistics
393
+ similar_count = report["analyses"].get("similar_tickets", {}).get("count", 0)
394
+ stale_count = report["analyses"].get("stale_tickets", {}).get("count", 0)
395
+ orphaned_count = report["analyses"].get("orphaned_tickets", {}).get("count", 0)
396
+
397
+ report["summary"] = {
398
+ "total_issues_found": similar_count + stale_count + orphaned_count,
399
+ "similar_pairs": similar_count,
400
+ "stale_count": stale_count,
401
+ "orphaned_count": orphaned_count,
402
+ }
403
+
404
+ # Format as markdown if requested
405
+ if format == "markdown":
406
+ report["markdown"] = _format_report_as_markdown(report)
407
+
408
+ return report
409
+
410
+ except Exception as e:
411
+ logger.error(f"Failed to generate cleanup report: {e}")
412
+ return {
413
+ "status": "error",
414
+ "error": f"Failed to generate cleanup report: {str(e)}",
415
+ }
416
+
417
+
418
+ def _format_report_as_markdown(report: dict[str, Any]) -> str:
419
+ """Format cleanup report as markdown.
420
+
421
+ Args:
422
+ report: Report data dictionary
423
+
424
+ Returns:
425
+ Markdown-formatted report string
426
+
427
+ """
428
+ md = "# Ticket Cleanup Report\n\n"
429
+ md += f"**Generated:** {report['generated_at']}\n\n"
430
+
431
+ summary = report["summary"]
432
+ md += "## Summary\n\n"
433
+ md += f"- **Total Issues Found:** {summary['total_issues_found']}\n"
434
+ md += f"- **Similar Ticket Pairs:** {summary['similar_pairs']}\n"
435
+ md += f"- **Stale Tickets:** {summary['stale_count']}\n"
436
+ md += f"- **Orphaned Tickets:** {summary['orphaned_count']}\n\n"
437
+
438
+ # Similar tickets section
439
+ similar_data = report["analyses"].get("similar_tickets", {})
440
+ if similar_data.get("similar_tickets"):
441
+ md += "## Similar Tickets (Potential Duplicates)\n\n"
442
+ for result in similar_data["similar_tickets"][:10]: # Top 10
443
+ md += f"### {result['ticket1_title']} ↔ {result['ticket2_title']}\n"
444
+ md += f"- **Similarity:** {result['similarity_score']:.2%}\n"
445
+ md += f"- **Ticket 1:** `{result['ticket1_id']}`\n"
446
+ md += f"- **Ticket 2:** `{result['ticket2_id']}`\n"
447
+ md += f"- **Action:** {result['suggested_action'].upper()}\n"
448
+ md += f"- **Reasons:** {', '.join(result['similarity_reasons'])}\n\n"
449
+
450
+ # Stale tickets section
451
+ stale_data = report["analyses"].get("stale_tickets", {})
452
+ if stale_data.get("stale_tickets"):
453
+ md += "## Stale Tickets (Candidates for Closing)\n\n"
454
+ for result in stale_data["stale_tickets"][:15]: # Top 15
455
+ md += f"### {result['ticket_title']}\n"
456
+ md += f"- **ID:** `{result['ticket_id']}`\n"
457
+ md += f"- **State:** {result['ticket_state']}\n"
458
+ md += f"- **Age:** {result['age_days']} days\n"
459
+ md += f"- **Last Updated:** {result['days_since_update']} days ago\n"
460
+ md += f"- **Staleness Score:** {result['staleness_score']:.2%}\n"
461
+ md += f"- **Action:** {result['suggested_action'].upper()}\n"
462
+ md += f"- **Reason:** {result['reason']}\n\n"
463
+
464
+ # Orphaned tickets section
465
+ orphaned_data = report["analyses"].get("orphaned_tickets", {})
466
+ if orphaned_data.get("orphaned_tickets"):
467
+ md += "## Orphaned Tickets (Missing Hierarchy)\n\n"
468
+
469
+ # Group by orphan type
470
+ by_type: dict[str, list[Any]] = {}
471
+ for result in orphaned_data["orphaned_tickets"]:
472
+ orphan_type = result["orphan_type"]
473
+ if orphan_type not in by_type:
474
+ by_type[orphan_type] = []
475
+ by_type[orphan_type].append(result)
476
+
477
+ for orphan_type, tickets in by_type.items():
478
+ md += f"### {orphan_type.replace('_', ' ').title()} ({len(tickets)})\n\n"
479
+ for result in tickets[:10]: # Top 10 per type
480
+ md += f"- **{result['ticket_title']}** (`{result['ticket_id']}`)\n"
481
+ md += f" - Type: {result['ticket_type']}\n"
482
+ md += f" - Action: {result['suggested_action']}\n"
483
+ md += f" - Reason: {result['reason']}\n"
484
+ md += "\n"
485
+
486
+ # Recommendations section
487
+ md += "## Recommendations\n\n"
488
+ md += "1. **Review Similar Tickets:** Check pairs marked for 'merge' action\n"
489
+ md += "2. **Close Stale Tickets:** Review tickets marked for 'close' action\n"
490
+ md += (
491
+ "3. **Organize Orphaned Tickets:** Assign epics/projects to orphaned tickets\n"
492
+ )
493
+ md += "4. **Update Workflow:** Consider closing very old low-priority tickets\n\n"
494
+
495
+ return md