mcp-ticketer 0.3.0__py3-none-any.whl → 2.2.9__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.
Files changed (160) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/__init__.py +2 -0
  5. mcp_ticketer/adapters/aitrackdown.py +930 -52
  6. mcp_ticketer/adapters/asana/__init__.py +15 -0
  7. mcp_ticketer/adapters/asana/adapter.py +1537 -0
  8. mcp_ticketer/adapters/asana/client.py +292 -0
  9. mcp_ticketer/adapters/asana/mappers.py +348 -0
  10. mcp_ticketer/adapters/asana/types.py +146 -0
  11. mcp_ticketer/adapters/github/__init__.py +26 -0
  12. mcp_ticketer/adapters/github/adapter.py +3229 -0
  13. mcp_ticketer/adapters/github/client.py +335 -0
  14. mcp_ticketer/adapters/github/mappers.py +797 -0
  15. mcp_ticketer/adapters/github/queries.py +692 -0
  16. mcp_ticketer/adapters/github/types.py +460 -0
  17. mcp_ticketer/adapters/hybrid.py +58 -16
  18. mcp_ticketer/adapters/jira/__init__.py +35 -0
  19. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  20. mcp_ticketer/adapters/jira/client.py +271 -0
  21. mcp_ticketer/adapters/jira/mappers.py +246 -0
  22. mcp_ticketer/adapters/jira/queries.py +216 -0
  23. mcp_ticketer/adapters/jira/types.py +304 -0
  24. mcp_ticketer/adapters/linear/__init__.py +1 -1
  25. mcp_ticketer/adapters/linear/adapter.py +3810 -462
  26. mcp_ticketer/adapters/linear/client.py +312 -69
  27. mcp_ticketer/adapters/linear/mappers.py +305 -85
  28. mcp_ticketer/adapters/linear/queries.py +317 -17
  29. mcp_ticketer/adapters/linear/types.py +187 -64
  30. mcp_ticketer/adapters/linear.py +2 -2
  31. mcp_ticketer/analysis/__init__.py +56 -0
  32. mcp_ticketer/analysis/dependency_graph.py +255 -0
  33. mcp_ticketer/analysis/health_assessment.py +304 -0
  34. mcp_ticketer/analysis/orphaned.py +218 -0
  35. mcp_ticketer/analysis/project_status.py +594 -0
  36. mcp_ticketer/analysis/similarity.py +224 -0
  37. mcp_ticketer/analysis/staleness.py +266 -0
  38. mcp_ticketer/automation/__init__.py +11 -0
  39. mcp_ticketer/automation/project_updates.py +378 -0
  40. mcp_ticketer/cache/memory.py +9 -8
  41. mcp_ticketer/cli/adapter_diagnostics.py +91 -54
  42. mcp_ticketer/cli/auggie_configure.py +116 -15
  43. mcp_ticketer/cli/codex_configure.py +274 -82
  44. mcp_ticketer/cli/configure.py +1323 -151
  45. mcp_ticketer/cli/cursor_configure.py +314 -0
  46. mcp_ticketer/cli/diagnostics.py +209 -114
  47. mcp_ticketer/cli/discover.py +297 -26
  48. mcp_ticketer/cli/gemini_configure.py +119 -26
  49. mcp_ticketer/cli/init_command.py +880 -0
  50. mcp_ticketer/cli/install_mcp_server.py +418 -0
  51. mcp_ticketer/cli/instruction_commands.py +435 -0
  52. mcp_ticketer/cli/linear_commands.py +256 -130
  53. mcp_ticketer/cli/main.py +140 -1544
  54. mcp_ticketer/cli/mcp_configure.py +1013 -100
  55. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  56. mcp_ticketer/cli/migrate_config.py +12 -8
  57. mcp_ticketer/cli/platform_commands.py +123 -0
  58. mcp_ticketer/cli/platform_detection.py +477 -0
  59. mcp_ticketer/cli/platform_installer.py +545 -0
  60. mcp_ticketer/cli/project_update_commands.py +350 -0
  61. mcp_ticketer/cli/python_detection.py +126 -0
  62. mcp_ticketer/cli/queue_commands.py +15 -15
  63. mcp_ticketer/cli/setup_command.py +794 -0
  64. mcp_ticketer/cli/simple_health.py +84 -59
  65. mcp_ticketer/cli/ticket_commands.py +1375 -0
  66. mcp_ticketer/cli/update_checker.py +313 -0
  67. mcp_ticketer/cli/utils.py +195 -72
  68. mcp_ticketer/core/__init__.py +64 -1
  69. mcp_ticketer/core/adapter.py +618 -18
  70. mcp_ticketer/core/config.py +77 -68
  71. mcp_ticketer/core/env_discovery.py +75 -16
  72. mcp_ticketer/core/env_loader.py +121 -97
  73. mcp_ticketer/core/exceptions.py +32 -24
  74. mcp_ticketer/core/http_client.py +26 -26
  75. mcp_ticketer/core/instructions.py +405 -0
  76. mcp_ticketer/core/label_manager.py +732 -0
  77. mcp_ticketer/core/mappers.py +42 -30
  78. mcp_ticketer/core/milestone_manager.py +252 -0
  79. mcp_ticketer/core/models.py +566 -19
  80. mcp_ticketer/core/onepassword_secrets.py +379 -0
  81. mcp_ticketer/core/priority_matcher.py +463 -0
  82. mcp_ticketer/core/project_config.py +189 -49
  83. mcp_ticketer/core/project_utils.py +281 -0
  84. mcp_ticketer/core/project_validator.py +376 -0
  85. mcp_ticketer/core/registry.py +3 -3
  86. mcp_ticketer/core/session_state.py +176 -0
  87. mcp_ticketer/core/state_matcher.py +592 -0
  88. mcp_ticketer/core/url_parser.py +425 -0
  89. mcp_ticketer/core/validators.py +69 -0
  90. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  91. mcp_ticketer/mcp/__init__.py +29 -1
  92. mcp_ticketer/mcp/__main__.py +60 -0
  93. mcp_ticketer/mcp/server/__init__.py +25 -0
  94. mcp_ticketer/mcp/server/__main__.py +60 -0
  95. mcp_ticketer/mcp/server/constants.py +58 -0
  96. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  97. mcp_ticketer/mcp/server/dto.py +195 -0
  98. mcp_ticketer/mcp/server/main.py +1343 -0
  99. mcp_ticketer/mcp/server/response_builder.py +206 -0
  100. mcp_ticketer/mcp/server/routing.py +723 -0
  101. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  102. mcp_ticketer/mcp/server/tools/__init__.py +69 -0
  103. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  104. mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
  105. mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
  106. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  107. mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
  108. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  109. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
  110. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  111. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  112. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  113. mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
  114. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  115. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  116. mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
  117. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  118. mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
  119. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  120. mcp_ticketer/queue/__init__.py +1 -0
  121. mcp_ticketer/queue/health_monitor.py +168 -136
  122. mcp_ticketer/queue/manager.py +78 -63
  123. mcp_ticketer/queue/queue.py +108 -21
  124. mcp_ticketer/queue/run_worker.py +2 -2
  125. mcp_ticketer/queue/ticket_registry.py +213 -155
  126. mcp_ticketer/queue/worker.py +96 -58
  127. mcp_ticketer/utils/__init__.py +5 -0
  128. mcp_ticketer/utils/token_utils.py +246 -0
  129. mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
  130. mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
  131. mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
  132. py_mcp_installer/examples/phase3_demo.py +178 -0
  133. py_mcp_installer/scripts/manage_version.py +54 -0
  134. py_mcp_installer/setup.py +6 -0
  135. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  136. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  137. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  138. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  139. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  140. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  141. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  142. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  143. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  144. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  145. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  146. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  147. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  148. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  149. py_mcp_installer/tests/__init__.py +0 -0
  150. py_mcp_installer/tests/platforms/__init__.py +0 -0
  151. py_mcp_installer/tests/test_platform_detector.py +17 -0
  152. mcp_ticketer/adapters/github.py +0 -1354
  153. mcp_ticketer/adapters/jira.py +0 -1011
  154. mcp_ticketer/mcp/server.py +0 -2030
  155. mcp_ticketer-0.3.0.dist-info/METADATA +0 -414
  156. mcp_ticketer-0.3.0.dist-info/RECORD +0 -59
  157. mcp_ticketer-0.3.0.dist-info/top_level.txt +0 -1
  158. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
  159. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
  160. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1375 @@
1
+ """Ticket management commands."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from ..core import AdapterRegistry, Priority, TicketState
15
+ from ..core.models import Comment, SearchQuery
16
+ from ..queue import Queue, QueueStatus, WorkerManager
17
+ from ..queue.health_monitor import HealthStatus, QueueHealthMonitor
18
+ from ..queue.ticket_registry import TicketRegistry
19
+
20
+
21
+ # Moved from main.py to avoid circular import
22
+ class AdapterType(str, Enum):
23
+ """Available adapter types."""
24
+
25
+ AITRACKDOWN = "aitrackdown"
26
+ LINEAR = "linear"
27
+ JIRA = "jira"
28
+ GITHUB = "github"
29
+
30
+
31
+ app = typer.Typer(
32
+ name="ticket",
33
+ help="Ticket management operations (create, list, update, search, etc.)",
34
+ )
35
+ console = Console()
36
+
37
+
38
+ # Configuration functions (moved from main.py to avoid circular import)
39
+ def load_config(project_dir: Path | None = None) -> dict:
40
+ """Load configuration from project-local config file."""
41
+ import logging
42
+
43
+ logger = logging.getLogger(__name__)
44
+ base_dir = project_dir or Path.cwd()
45
+ project_config = base_dir / ".mcp-ticketer" / "config.json"
46
+
47
+ if project_config.exists():
48
+ try:
49
+ with open(project_config) as f:
50
+ config = json.load(f)
51
+ logger.info(f"Loaded configuration from: {project_config}")
52
+ return config
53
+ except (OSError, json.JSONDecodeError) as e:
54
+ logger.warning(f"Could not load project config: {e}, using defaults")
55
+ console.print(
56
+ f"[yellow]Warning: Could not load project config: {e}[/yellow]"
57
+ )
58
+
59
+ logger.info("No project-local config found, defaulting to aitrackdown adapter")
60
+ return {"adapter": "aitrackdown", "config": {"base_path": ".aitrackdown"}}
61
+
62
+
63
+ def save_config(config: dict) -> None:
64
+ """Save configuration to project-local config file."""
65
+ import logging
66
+
67
+ logger = logging.getLogger(__name__)
68
+ project_config = Path.cwd() / ".mcp-ticketer" / "config.json"
69
+ project_config.parent.mkdir(parents=True, exist_ok=True)
70
+ with open(project_config, "w") as f:
71
+ json.dump(config, f, indent=2)
72
+ logger.info(f"Saved configuration to: {project_config}")
73
+
74
+
75
+ def get_adapter(
76
+ override_adapter: str | None = None, override_config: dict | None = None
77
+ ) -> Any:
78
+ """Get configured adapter instance."""
79
+ config = load_config()
80
+
81
+ if override_adapter:
82
+ adapter_type = override_adapter
83
+ adapters_config = config.get("adapters", {})
84
+ adapter_config = adapters_config.get(adapter_type, {})
85
+ if override_config:
86
+ adapter_config.update(override_config)
87
+ else:
88
+ adapter_type = config.get("default_adapter", "aitrackdown")
89
+ adapters_config = config.get("adapters", {})
90
+ adapter_config = adapters_config.get(adapter_type, {})
91
+
92
+ if not adapter_config and "config" in config:
93
+ adapter_config = config["config"]
94
+
95
+ # Add environment variables for authentication
96
+ if adapter_type == "linear":
97
+ if not adapter_config.get("api_key"):
98
+ adapter_config["api_key"] = os.getenv("LINEAR_API_KEY")
99
+ elif adapter_type == "github":
100
+ if not adapter_config.get("api_key") and not adapter_config.get("token"):
101
+ adapter_config["api_key"] = os.getenv("GITHUB_TOKEN")
102
+ elif adapter_type == "jira":
103
+ if not adapter_config.get("api_token"):
104
+ adapter_config["api_token"] = os.getenv("JIRA_ACCESS_TOKEN")
105
+ if not adapter_config.get("email"):
106
+ adapter_config["email"] = os.getenv("JIRA_ACCESS_USER")
107
+
108
+ return AdapterRegistry.get_adapter(adapter_type, adapter_config)
109
+
110
+
111
+ def _discover_from_env_files() -> str | None:
112
+ """Discover adapter configuration from .env or .env.local files.
113
+
114
+ Returns:
115
+ Adapter name if discovered, None otherwise
116
+
117
+ """
118
+ import logging
119
+ from pathlib import Path
120
+
121
+ logger = logging.getLogger(__name__)
122
+
123
+ # Check .env.local first, then .env
124
+ env_files = [".env.local", ".env"]
125
+
126
+ for env_file in env_files:
127
+ env_path = Path.cwd() / env_file
128
+ if env_path.exists():
129
+ try:
130
+ # Simple .env parsing (key=value format)
131
+ env_vars = {}
132
+ with open(env_path) as f:
133
+ for line in f:
134
+ line = line.strip()
135
+ if line and not line.startswith("#") and "=" in line:
136
+ key, value = line.split("=", 1)
137
+ env_vars[key.strip()] = value.strip().strip("\"'")
138
+
139
+ # Check for adapter-specific variables
140
+ if env_vars.get("LINEAR_API_KEY"):
141
+ logger.info(f"Discovered Linear configuration in {env_file}")
142
+ return "linear"
143
+ elif env_vars.get("GITHUB_TOKEN"):
144
+ logger.info(f"Discovered GitHub configuration in {env_file}")
145
+ return "github"
146
+ elif env_vars.get("JIRA_SERVER"):
147
+ logger.info(f"Discovered JIRA configuration in {env_file}")
148
+ return "jira"
149
+
150
+ except Exception as e:
151
+ logger.warning(f"Could not read {env_file}: {e}")
152
+
153
+ return None
154
+
155
+
156
+ def _save_adapter_to_config(adapter_name: str) -> None:
157
+ """Save adapter configuration to config file.
158
+
159
+ Args:
160
+ adapter_name: Name of the adapter to save as default
161
+
162
+ """
163
+ import logging
164
+
165
+ from .main import save_config
166
+
167
+ logger = logging.getLogger(__name__)
168
+
169
+ try:
170
+ config = load_config()
171
+ config["default_adapter"] = adapter_name
172
+
173
+ # Ensure adapters section exists
174
+ if "adapters" not in config:
175
+ config["adapters"] = {}
176
+
177
+ # Add basic adapter config if not exists
178
+ if adapter_name not in config["adapters"]:
179
+ if adapter_name == "aitrackdown":
180
+ config["adapters"][adapter_name] = {"base_path": ".aitrackdown"}
181
+ else:
182
+ config["adapters"][adapter_name] = {"type": adapter_name}
183
+
184
+ save_config(config)
185
+ logger.info(f"Saved {adapter_name} as default adapter")
186
+
187
+ except Exception as e:
188
+ logger.warning(f"Could not save adapter configuration: {e}")
189
+
190
+
191
+ @app.command()
192
+ def create(
193
+ title: str = typer.Argument(..., help="Ticket title"),
194
+ description: str | None = typer.Option(
195
+ None, "--description", "-d", help="Ticket description"
196
+ ),
197
+ priority: Priority = typer.Option(
198
+ Priority.MEDIUM, "--priority", "-p", help="Priority level"
199
+ ),
200
+ tags: list[str] | None = typer.Option(
201
+ None, "--tag", "-t", help="Tags (can be specified multiple times)"
202
+ ),
203
+ assignee: str | None = typer.Option(
204
+ None, "--assignee", "-a", help="Assignee username"
205
+ ),
206
+ project: str | None = typer.Option(
207
+ None,
208
+ "--project",
209
+ help="Parent project/epic ID (synonym for --epic)",
210
+ ),
211
+ epic: str | None = typer.Option(
212
+ None,
213
+ "--epic",
214
+ help="Parent epic/project ID (synonym for --project)",
215
+ ),
216
+ wait: bool = typer.Option(
217
+ False,
218
+ "--wait",
219
+ "-w",
220
+ help="Wait for operation to complete (synchronous mode, returns actual ticket ID)",
221
+ ),
222
+ timeout: float = typer.Option(
223
+ 30.0,
224
+ "--timeout",
225
+ help="Timeout in seconds for --wait mode (default: 30)",
226
+ ),
227
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
228
+ adapter: AdapterType | None = typer.Option(
229
+ None, "--adapter", help="Override default adapter"
230
+ ),
231
+ ) -> None:
232
+ """Create a new ticket with comprehensive health checks."""
233
+ from .utils import format_error_json, format_json_response, serialize_task
234
+
235
+ # IMMEDIATE HEALTH CHECK - Critical for reliability
236
+ health_monitor = QueueHealthMonitor()
237
+ health = health_monitor.check_health()
238
+
239
+ # Display health status
240
+ if health["status"] == HealthStatus.CRITICAL:
241
+ console.print("[red]🚨 CRITICAL: Queue system has serious issues![/red]")
242
+ for alert in health["alerts"]:
243
+ if alert["level"] == "critical":
244
+ console.print(f"[red] • {alert['message']}[/red]")
245
+
246
+ # Attempt auto-repair
247
+ console.print("[yellow]Attempting automatic repair...[/yellow]")
248
+ repair_result = health_monitor.auto_repair()
249
+
250
+ if repair_result["actions_taken"]:
251
+ for action in repair_result["actions_taken"]:
252
+ console.print(f"[yellow] ✓ {action}[/yellow]")
253
+
254
+ # Re-check health after repair
255
+ health = health_monitor.check_health()
256
+ if health["status"] == HealthStatus.CRITICAL:
257
+ console.print(
258
+ "[red]❌ Auto-repair failed. Manual intervention required.[/red]"
259
+ )
260
+ console.print(
261
+ "[red]Cannot safely create ticket. Please check system status.[/red]"
262
+ )
263
+ raise typer.Exit(1) from None
264
+ else:
265
+ console.print(
266
+ "[green]✓ Auto-repair successful. Proceeding with ticket creation.[/green]"
267
+ )
268
+ else:
269
+ console.print(
270
+ "[red]❌ No repair actions available. Manual intervention required.[/red]"
271
+ )
272
+ raise typer.Exit(1) from None
273
+
274
+ elif health["status"] == HealthStatus.WARNING:
275
+ console.print("[yellow]⚠️ Warning: Queue system has minor issues[/yellow]")
276
+ for alert in health["alerts"]:
277
+ if alert["level"] == "warning":
278
+ console.print(f"[yellow] • {alert['message']}[/yellow]")
279
+ console.print("[yellow]Proceeding with ticket creation...[/yellow]")
280
+
281
+ # Get the adapter name with priority: 1) argument, 2) config, 3) .env files, 4) default
282
+ if adapter:
283
+ # Priority 1: Command-line argument - save to config for future use
284
+ adapter_name = adapter.value
285
+ _save_adapter_to_config(adapter_name)
286
+ else:
287
+ # Priority 2: Check existing config
288
+ config = load_config()
289
+ adapter_name = config.get("default_adapter")
290
+
291
+ if not adapter_name or adapter_name == "aitrackdown":
292
+ # Priority 3: Check .env files and save if found
293
+ env_adapter = _discover_from_env_files()
294
+ if env_adapter:
295
+ adapter_name = env_adapter
296
+ _save_adapter_to_config(adapter_name)
297
+ else:
298
+ # Priority 4: Default
299
+ adapter_name = "aitrackdown"
300
+
301
+ # Resolve project/epic synonym - prefer whichever is provided
302
+ parent_epic_id = project or epic
303
+
304
+ # Create task data
305
+ # Import Priority for type checking
306
+ from ..core.models import Priority as PriorityEnum
307
+
308
+ task_data = {
309
+ "title": title,
310
+ "description": description,
311
+ "priority": priority.value if isinstance(priority, PriorityEnum) else priority,
312
+ "tags": tags or [],
313
+ "assignee": assignee,
314
+ "parent_epic": parent_epic_id,
315
+ }
316
+
317
+ # WORKAROUND: Use direct operation for Linear adapter to bypass worker subprocess issue
318
+ if adapter_name == "linear":
319
+ console.print(
320
+ "[yellow]⚠️[/yellow] Using direct operation for Linear adapter (bypassing queue)"
321
+ )
322
+ try:
323
+ # Load configuration and create adapter directly
324
+ config = load_config()
325
+ adapter_config = config.get("adapters", {}).get(adapter_name, {})
326
+
327
+ # Import and create adapter
328
+ from ..core.registry import AdapterRegistry
329
+
330
+ adapter_instance = AdapterRegistry.get_adapter(adapter_name, adapter_config)
331
+
332
+ # Create task directly
333
+ from ..core.models import Priority, Task
334
+
335
+ task = Task(
336
+ title=task_data["title"],
337
+ description=task_data.get("description"),
338
+ priority=(
339
+ Priority(task_data["priority"])
340
+ if task_data.get("priority")
341
+ else Priority.MEDIUM
342
+ ),
343
+ tags=task_data.get("tags", []),
344
+ assignee=task_data.get("assignee"),
345
+ parent_epic=task_data.get("parent_epic"),
346
+ )
347
+
348
+ # Create ticket synchronously
349
+ import asyncio
350
+
351
+ result = asyncio.run(adapter_instance.create(task))
352
+
353
+ if output_json:
354
+ data = serialize_task(result)
355
+ console.print(
356
+ format_json_response(
357
+ "success", data, message="Ticket created successfully"
358
+ )
359
+ )
360
+ else:
361
+ console.print(
362
+ f"[green]✓[/green] Ticket created successfully: {result.id}"
363
+ )
364
+ console.print(f" Title: {result.title}")
365
+ console.print(f" Priority: {result.priority}")
366
+ console.print(f" State: {result.state}")
367
+ # Get URL from metadata if available
368
+ if (
369
+ result.metadata
370
+ and "linear" in result.metadata
371
+ and "url" in result.metadata["linear"]
372
+ ):
373
+ console.print(f" URL: {result.metadata['linear']['url']}")
374
+
375
+ return result.id
376
+
377
+ except Exception as e:
378
+ if output_json:
379
+ console.print(format_error_json(e))
380
+ else:
381
+ console.print(f"[red]❌[/red] Failed to create ticket: {e}")
382
+ raise
383
+
384
+ # Use queue for other adapters
385
+ queue = Queue()
386
+ queue_id = queue.add(
387
+ ticket_data=task_data,
388
+ adapter=adapter_name,
389
+ operation="create",
390
+ project_dir=str(Path.cwd()), # Explicitly pass current project directory
391
+ )
392
+
393
+ # Register in ticket registry for tracking
394
+ registry = TicketRegistry()
395
+ registry.register_ticket_operation(
396
+ queue_id, adapter_name, "create", title, task_data
397
+ )
398
+
399
+ # Start worker if needed - must happen before polling
400
+ manager = WorkerManager()
401
+ worker_started = manager.start_if_needed()
402
+
403
+ if worker_started:
404
+ if not output_json:
405
+ console.print("[dim]Worker started to process request[/dim]")
406
+
407
+ # SYNCHRONOUS MODE: Poll until completion if --wait flag is set
408
+ if wait:
409
+ if not output_json:
410
+ console.print(
411
+ f"[yellow]⏳[/yellow] Waiting for operation to complete (timeout: {timeout}s)..."
412
+ )
413
+
414
+ try:
415
+ # Poll the queue until operation completes
416
+ completed_item = queue.poll_until_complete(queue_id, timeout=timeout)
417
+
418
+ # Extract result data
419
+ result = completed_item.result
420
+
421
+ # Extract ticket ID from result
422
+ ticket_id = result.get("id") if result else queue_id
423
+
424
+ if output_json:
425
+ # Return actual ticket data in JSON format
426
+ data = result if result else {"queue_id": queue_id}
427
+ console.print(
428
+ format_json_response(
429
+ "success", data, message="Ticket created successfully"
430
+ )
431
+ )
432
+ else:
433
+ # Display ticket creation success with actual ID
434
+ console.print(
435
+ f"[green]✓[/green] Ticket created successfully: {ticket_id}"
436
+ )
437
+ console.print(f" Title: {title}")
438
+ console.print(f" Priority: {priority}")
439
+
440
+ # Display additional metadata if available
441
+ if result:
442
+ if "url" in result:
443
+ console.print(f" URL: {result['url']}")
444
+ if "state" in result:
445
+ console.print(f" State: {result['state']}")
446
+
447
+ return ticket_id
448
+
449
+ except TimeoutError as e:
450
+ if output_json:
451
+ console.print(format_error_json(str(e), queue_id=queue_id))
452
+ else:
453
+ console.print(f"[red]❌[/red] Operation timed out after {timeout}s")
454
+ console.print(f" Queue ID: {queue_id}")
455
+ console.print(
456
+ f" Use 'mcp-ticketer ticket check {queue_id}' to check status later"
457
+ )
458
+ raise typer.Exit(1) from None
459
+
460
+ except RuntimeError as e:
461
+ if output_json:
462
+ console.print(format_error_json(str(e), queue_id=queue_id))
463
+ else:
464
+ console.print(f"[red]❌[/red] Operation failed: {e}")
465
+ console.print(f" Queue ID: {queue_id}")
466
+ raise typer.Exit(1) from None
467
+
468
+ # ASYNCHRONOUS MODE (default): Return queue ID immediately
469
+ else:
470
+ if output_json:
471
+ data = {
472
+ "queue_id": queue_id,
473
+ "title": title,
474
+ "priority": priority.value if hasattr(priority, "value") else priority,
475
+ "adapter": adapter_name,
476
+ "status": "queued",
477
+ }
478
+ console.print(
479
+ format_json_response("success", data, message="Ticket creation queued")
480
+ )
481
+ else:
482
+ console.print(f"[green]✓[/green] Queued ticket creation: {queue_id}")
483
+ console.print(f" Title: {title}")
484
+ console.print(f" Priority: {priority}")
485
+ console.print(f" Adapter: {adapter_name}")
486
+ console.print(
487
+ "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
488
+ )
489
+
490
+ # Give immediate feedback on processing
491
+ import time
492
+
493
+ time.sleep(1) # Brief pause to let worker start
494
+
495
+ # Check if item is being processed
496
+ item = queue.get_item(queue_id)
497
+ if item and item.status == QueueStatus.PROCESSING:
498
+ console.print("[green]✓ Item is being processed by worker[/green]")
499
+ elif item and item.status == QueueStatus.PENDING:
500
+ console.print("[yellow]⏳ Item is queued for processing[/yellow]")
501
+ else:
502
+ console.print(
503
+ "[red]⚠️ Item status unclear - check with 'mcp-ticketer ticket check {queue_id}'[/red]"
504
+ )
505
+
506
+
507
+ @app.command("list")
508
+ def list_tickets(
509
+ state: TicketState | None = typer.Option(
510
+ None, "--state", "-s", help="Filter by state"
511
+ ),
512
+ priority: Priority | None = typer.Option(
513
+ None, "--priority", "-p", help="Filter by priority"
514
+ ),
515
+ limit: int = typer.Option(10, "--limit", "-l", help="Maximum number of tickets"),
516
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
517
+ adapter: AdapterType | None = typer.Option(
518
+ None, "--adapter", help="Override default adapter"
519
+ ),
520
+ ) -> None:
521
+ """List tickets with optional filters."""
522
+ from .utils import format_json_response, serialize_task
523
+
524
+ async def _list() -> list[Any]:
525
+ adapter_instance = get_adapter(
526
+ override_adapter=adapter.value if adapter else None
527
+ )
528
+ filters = {}
529
+ if state:
530
+ filters["state"] = state
531
+ if priority:
532
+ filters["priority"] = priority
533
+ return await adapter_instance.list(limit=limit, filters=filters)
534
+
535
+ tickets = asyncio.run(_list())
536
+
537
+ if not tickets:
538
+ if output_json:
539
+ console.print(
540
+ format_json_response(
541
+ "success", {"tickets": [], "count": 0, "has_more": False}
542
+ )
543
+ )
544
+ else:
545
+ console.print("[yellow]No tickets found[/yellow]")
546
+ return
547
+
548
+ # JSON output
549
+ if output_json:
550
+ tickets_data = [serialize_task(t) for t in tickets]
551
+ data = {
552
+ "tickets": tickets_data,
553
+ "count": len(tickets_data),
554
+ "has_more": len(tickets)
555
+ >= limit, # Heuristic: if we got exactly limit, there might be more
556
+ }
557
+ console.print(format_json_response("success", data))
558
+ return
559
+
560
+ # Original table output
561
+ table = Table(title="Tickets")
562
+ table.add_column("ID", style="cyan", no_wrap=True)
563
+ table.add_column("Title", style="white")
564
+ table.add_column("State", style="green")
565
+ table.add_column("Priority", style="yellow")
566
+ table.add_column("Assignee", style="blue")
567
+
568
+ for ticket in tickets:
569
+ # Handle assignee field - Epic doesn't have assignee, Task does
570
+ assignee = getattr(ticket, "assignee", None) or "-"
571
+
572
+ table.add_row(
573
+ ticket.id or "N/A",
574
+ ticket.title,
575
+ ticket.state,
576
+ ticket.priority,
577
+ assignee,
578
+ )
579
+
580
+ console.print(table)
581
+
582
+
583
+ @app.command()
584
+ def show(
585
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
586
+ no_comments: bool = typer.Option(
587
+ False, "--no-comments", help="Hide comments (shown by default)"
588
+ ),
589
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
590
+ adapter: AdapterType | None = typer.Option(
591
+ None, "--adapter", help="Override default adapter"
592
+ ),
593
+ ) -> None:
594
+ """Show detailed ticket information with full context.
595
+
596
+ By default, displays ticket details along with all comments to provide
597
+ a holistic view of the ticket's history and context.
598
+
599
+ Use --no-comments to display only ticket metadata without comments.
600
+ Use --json to output in machine-readable JSON format.
601
+ """
602
+ from .utils import format_error_json, format_json_response, serialize_task
603
+
604
+ async def _show() -> tuple[Any, Any, Any]:
605
+ adapter_instance = get_adapter(
606
+ override_adapter=adapter.value if adapter else None
607
+ )
608
+ ticket = await adapter_instance.read(ticket_id)
609
+ ticket_comments = None
610
+ attachments = None
611
+
612
+ # Fetch comments by default (unless explicitly disabled)
613
+ if not no_comments and ticket:
614
+ try:
615
+ ticket_comments = await adapter_instance.get_comments(ticket_id)
616
+ except (NotImplementedError, AttributeError):
617
+ # Adapter doesn't support comments
618
+ pass
619
+
620
+ # Try to fetch attachments if available
621
+ if ticket and hasattr(adapter_instance, "list_attachments"):
622
+ try:
623
+ attachments = await adapter_instance.list_attachments(ticket_id)
624
+ except (NotImplementedError, AttributeError):
625
+ pass
626
+
627
+ return ticket, ticket_comments, attachments
628
+
629
+ try:
630
+ ticket, ticket_comments, attachments = asyncio.run(_show())
631
+
632
+ if not ticket:
633
+ if output_json:
634
+ console.print(
635
+ format_error_json(
636
+ f"Ticket not found: {ticket_id}", ticket_id=ticket_id
637
+ )
638
+ )
639
+ else:
640
+ console.print(f"[red]✗[/red] Ticket not found: {ticket_id}")
641
+ raise typer.Exit(1) from None
642
+
643
+ # JSON output
644
+ if output_json:
645
+ data = serialize_task(ticket)
646
+
647
+ # Add comments if available
648
+ if ticket_comments:
649
+ data["comments"] = [
650
+ {
651
+ "id": getattr(c, "id", None),
652
+ "text": c.content,
653
+ "author": c.author,
654
+ "created_at": (
655
+ c.created_at.isoformat()
656
+ if hasattr(c.created_at, "isoformat")
657
+ else str(c.created_at)
658
+ ),
659
+ }
660
+ for c in ticket_comments
661
+ ]
662
+
663
+ # Add attachments if available
664
+ if attachments:
665
+ data["attachments"] = attachments
666
+
667
+ console.print(format_json_response("success", data))
668
+ return
669
+
670
+ # Original formatted output continues below...
671
+ except Exception as e:
672
+ if output_json:
673
+ console.print(format_error_json(e, ticket_id=ticket_id))
674
+ raise typer.Exit(1) from None
675
+ raise
676
+
677
+ # Display ticket header with metadata
678
+ console.print(f"\n[bold cyan]┌─ Ticket: {ticket.id}[/bold cyan]")
679
+ console.print(f"[bold]│ {ticket.title}[/bold]")
680
+ console.print("└" + "─" * 60)
681
+
682
+ # Display metadata in organized sections
683
+ console.print("\n[bold]Status[/bold]")
684
+ console.print(f" State: [green]{ticket.state}[/green]")
685
+ console.print(f" Priority: [yellow]{ticket.priority}[/yellow]")
686
+
687
+ if ticket.assignee:
688
+ console.print(f" Assignee: {ticket.assignee}")
689
+
690
+ # Display timestamps if available
691
+ if ticket.created_at or ticket.updated_at:
692
+ console.print("\n[bold]Timeline[/bold]")
693
+ if ticket.created_at:
694
+ console.print(f" Created: {ticket.created_at}")
695
+ if ticket.updated_at:
696
+ console.print(f" Updated: {ticket.updated_at}")
697
+
698
+ # Display tags
699
+ if ticket.tags:
700
+ console.print("\n[bold]Tags[/bold]")
701
+ console.print(f" {', '.join(ticket.tags)}")
702
+
703
+ # Display description
704
+ if ticket.description:
705
+ console.print("\n[bold]Description[/bold]")
706
+ console.print(f" {ticket.description}")
707
+
708
+ # Display parent/child relationships
709
+ parent_info = []
710
+ if hasattr(ticket, "parent_epic") and ticket.parent_epic:
711
+ parent_info.append(f"Epic: {ticket.parent_epic}")
712
+ if hasattr(ticket, "parent_issue") and ticket.parent_issue:
713
+ parent_info.append(f"Parent Issue: {ticket.parent_issue}")
714
+
715
+ if parent_info:
716
+ console.print("\n[bold]Hierarchy[/bold]")
717
+ for info in parent_info:
718
+ console.print(f" {info}")
719
+
720
+ # Display attachments if available
721
+ if attachments and len(attachments) > 0:
722
+ console.print(f"\n[bold]Attachments ({len(attachments)})[/bold]")
723
+ for att in attachments:
724
+ att_title = att.get("title", "Untitled")
725
+ att_url = att.get("url", "")
726
+ console.print(f" 📎 {att_title}")
727
+ if att_url:
728
+ console.print(f" {att_url}")
729
+
730
+ # Display comments with enhanced formatting
731
+ if ticket_comments:
732
+ console.print(f"\n[bold]Activity & Comments ({len(ticket_comments)})[/bold]")
733
+ for i, comment in enumerate(ticket_comments, 1):
734
+ # Format timestamp
735
+ timestamp = comment.created_at if comment.created_at else "Unknown time"
736
+ author = comment.author if comment.author else "Unknown author"
737
+
738
+ console.print(f"\n[dim] {i}. {timestamp}[/dim]")
739
+ console.print(f" [cyan]@{author}[/cyan]")
740
+ console.print(f" {comment.content}")
741
+
742
+ # Footer with hint
743
+ if no_comments:
744
+ console.print(
745
+ "\n[dim]💡 Tip: Remove --no-comments to see activity and comments[/dim]"
746
+ )
747
+
748
+
749
+ @app.command()
750
+ def comment(
751
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
752
+ content: str = typer.Argument(..., help="Comment content"),
753
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
754
+ adapter: AdapterType | None = typer.Option(
755
+ None, "--adapter", help="Override default adapter"
756
+ ),
757
+ ) -> None:
758
+ """Add a comment to a ticket."""
759
+ from .utils import format_error_json, format_json_response
760
+
761
+ async def _comment() -> Comment:
762
+ adapter_instance = get_adapter(
763
+ override_adapter=adapter.value if adapter else None
764
+ )
765
+
766
+ # Create comment
767
+ comment_obj = Comment(
768
+ ticket_id=ticket_id,
769
+ content=content,
770
+ author="cli-user", # Could be made configurable
771
+ )
772
+
773
+ result = await adapter_instance.add_comment(comment_obj)
774
+ return result
775
+
776
+ try:
777
+ result = asyncio.run(_comment())
778
+
779
+ if output_json:
780
+ data = {
781
+ "id": result.id,
782
+ "ticket_id": ticket_id,
783
+ "text": content,
784
+ "author": result.author,
785
+ "created_at": (
786
+ result.created_at.isoformat()
787
+ if hasattr(result.created_at, "isoformat")
788
+ else str(result.created_at)
789
+ ),
790
+ }
791
+ console.print(
792
+ format_json_response(
793
+ "success", data, message="Comment added successfully"
794
+ )
795
+ )
796
+ else:
797
+ console.print("[green]✓[/green] Comment added successfully")
798
+ if result.id:
799
+ console.print(f"Comment ID: {result.id}")
800
+ console.print(f"Content: {content}")
801
+ except Exception as e:
802
+ if output_json:
803
+ console.print(format_error_json(e, ticket_id=ticket_id))
804
+ else:
805
+ console.print(f"[red]✗[/red] Failed to add comment: {e}")
806
+ raise typer.Exit(1) from None
807
+
808
+
809
+ @app.command()
810
+ def attach(
811
+ ticket_id: str = typer.Argument(..., help="Ticket ID or URL"),
812
+ file_path: Path = typer.Argument(..., help="Path to file to attach", exists=True),
813
+ description: str | None = typer.Option(
814
+ None, "--description", "-d", help="Attachment description or comment"
815
+ ),
816
+ adapter: AdapterType | None = typer.Option(
817
+ None, "--adapter", help="Override default adapter"
818
+ ),
819
+ ) -> None:
820
+ """Attach a file to a ticket.
821
+
822
+ Examples:
823
+ mcp-ticketer ticket attach 1M-157 docs/analysis.md
824
+ mcp-ticketer ticket attach PROJ-123 screenshot.png -d "Error screenshot"
825
+ mcp-ticketer ticket attach https://linear.app/.../issue/ABC-123 diagram.pdf
826
+ """
827
+
828
+ async def _attach() -> dict[str, Any]:
829
+ import mimetypes
830
+
831
+ adapter_instance = get_adapter(
832
+ override_adapter=adapter.value if adapter else None
833
+ )
834
+
835
+ # Detect MIME type
836
+ mime_type, _ = mimetypes.guess_type(str(file_path))
837
+ if not mime_type:
838
+ mime_type = "application/octet-stream"
839
+
840
+ # Method 1: Try Linear-specific upload (if available)
841
+ if hasattr(adapter_instance, "upload_file") and hasattr(
842
+ adapter_instance, "attach_file_to_issue"
843
+ ):
844
+ try:
845
+ # Upload file to Linear's S3
846
+ file_url = await adapter_instance.upload_file(
847
+ file_path=str(file_path), mime_type=mime_type
848
+ )
849
+
850
+ # Attach to issue
851
+ attachment = await adapter_instance.attach_file_to_issue(
852
+ issue_id=ticket_id,
853
+ file_url=file_url,
854
+ title=file_path.name,
855
+ subtitle=description,
856
+ )
857
+
858
+ return {
859
+ "status": "completed",
860
+ "attachment": attachment,
861
+ "file_url": file_url,
862
+ "method": "linear_native_upload",
863
+ }
864
+ except Exception:
865
+ # If Linear upload fails, fall through to next method
866
+ pass
867
+
868
+ # Method 2: Try generic add_attachment (if available)
869
+ if hasattr(adapter_instance, "add_attachment"):
870
+ try:
871
+ attachment = await adapter_instance.add_attachment(
872
+ ticket_id=ticket_id,
873
+ file_path=str(file_path),
874
+ description=description or "",
875
+ )
876
+ return {
877
+ "status": "completed",
878
+ "attachment": attachment,
879
+ "method": "adapter_native",
880
+ }
881
+ except NotImplementedError:
882
+ pass
883
+
884
+ # Method 3: Fallback - Add file reference as comment
885
+ from ..core.models import Comment
886
+
887
+ comment_content = f"📎 File reference: {file_path.name}"
888
+ if description:
889
+ comment_content += f"\n\n{description}"
890
+
891
+ comment_obj = Comment(
892
+ ticket_id=ticket_id,
893
+ content=comment_content,
894
+ author="cli-user",
895
+ )
896
+
897
+ comment = await adapter_instance.add_comment(comment_obj)
898
+ return {
899
+ "status": "completed",
900
+ "comment": comment,
901
+ "method": "comment_reference",
902
+ "note": "Adapter doesn't support attachments - added file reference as comment",
903
+ }
904
+
905
+ # Validate file before attempting upload
906
+ if not file_path.exists():
907
+ console.print(f"[red]✗[/red] File not found: {file_path}")
908
+ raise typer.Exit(1) from None
909
+
910
+ if not file_path.is_file():
911
+ console.print(f"[red]✗[/red] Path is not a file: {file_path}")
912
+ raise typer.Exit(1) from None
913
+
914
+ # Display file info
915
+ file_size = file_path.stat().st_size
916
+ size_mb = file_size / (1024 * 1024)
917
+ console.print(f"\n[dim]Attaching file to ticket {ticket_id}...[/dim]")
918
+ console.print(f" File: {file_path.name} ({size_mb:.2f} MB)")
919
+
920
+ # Detect MIME type
921
+ import mimetypes
922
+
923
+ mime_type, _ = mimetypes.guess_type(str(file_path))
924
+ if mime_type:
925
+ console.print(f" Type: {mime_type}")
926
+
927
+ try:
928
+ result = asyncio.run(_attach())
929
+
930
+ if result["status"] == "completed":
931
+ console.print(
932
+ f"\n[green]✓[/green] File attached successfully to {ticket_id}"
933
+ )
934
+
935
+ # Display attachment details based on method used
936
+ method = result.get("method", "unknown")
937
+
938
+ if method == "linear_native_upload":
939
+ console.print(" Method: Linear native upload")
940
+ if "file_url" in result:
941
+ console.print(f" URL: {result['file_url']}")
942
+ if "attachment" in result and isinstance(result["attachment"], dict):
943
+ att = result["attachment"]
944
+ if "id" in att:
945
+ console.print(f" ID: {att['id']}")
946
+ if "title" in att:
947
+ console.print(f" Title: {att['title']}")
948
+
949
+ elif method == "adapter_native":
950
+ console.print(" Method: Adapter native")
951
+ if "attachment" in result:
952
+ att = result["attachment"]
953
+ if isinstance(att, dict):
954
+ if "id" in att:
955
+ console.print(f" ID: {att['id']}")
956
+ if "url" in att:
957
+ console.print(f" URL: {att['url']}")
958
+
959
+ elif method == "comment_reference":
960
+ console.print(" Method: Comment reference")
961
+ console.print(f" [dim]{result.get('note', '')}[/dim]")
962
+ if "comment" in result:
963
+ comment = result["comment"]
964
+ if isinstance(comment, dict) and "id" in comment:
965
+ console.print(f" Comment ID: {comment['id']}")
966
+
967
+ else:
968
+ # Error case
969
+ error_msg = result.get("error", "Unknown error")
970
+ console.print(f"\n[red]✗[/red] Failed to attach file: {error_msg}")
971
+ raise typer.Exit(1) from None
972
+
973
+ except Exception as e:
974
+ console.print(f"\n[red]✗[/red] Failed to attach file: {e}")
975
+ raise typer.Exit(1) from None
976
+
977
+
978
+ @app.command()
979
+ def update(
980
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
981
+ title: str | None = typer.Option(None, "--title", help="New title"),
982
+ description: str | None = typer.Option(
983
+ None, "--description", "-d", help="New description"
984
+ ),
985
+ priority: Priority | None = typer.Option(
986
+ None, "--priority", "-p", help="New priority"
987
+ ),
988
+ assignee: str | None = typer.Option(None, "--assignee", "-a", help="New assignee"),
989
+ wait: bool = typer.Option(
990
+ False,
991
+ "--wait",
992
+ "-w",
993
+ help="Wait for operation to complete (synchronous mode)",
994
+ ),
995
+ timeout: float = typer.Option(
996
+ 30.0,
997
+ "--timeout",
998
+ help="Timeout in seconds for --wait mode (default: 30)",
999
+ ),
1000
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
1001
+ adapter: AdapterType | None = typer.Option(
1002
+ None, "--adapter", help="Override default adapter"
1003
+ ),
1004
+ ) -> None:
1005
+ """Update ticket fields."""
1006
+ from .utils import format_json_response
1007
+
1008
+ updates = {}
1009
+ if title:
1010
+ updates["title"] = title
1011
+ if description:
1012
+ updates["description"] = description
1013
+ if priority:
1014
+ updates["priority"] = (
1015
+ priority.value if isinstance(priority, Priority) else priority
1016
+ )
1017
+ if assignee:
1018
+ updates["assignee"] = assignee
1019
+
1020
+ if not updates:
1021
+ if output_json:
1022
+ console.print(
1023
+ format_json_response(
1024
+ "error",
1025
+ {"error": "No updates specified"},
1026
+ message="No updates specified",
1027
+ )
1028
+ )
1029
+ else:
1030
+ console.print("[yellow]No updates specified[/yellow]")
1031
+ raise typer.Exit(1) from None
1032
+
1033
+ # Get the adapter name
1034
+ config = load_config()
1035
+ adapter_name = (
1036
+ adapter.value if adapter else config.get("default_adapter", "aitrackdown")
1037
+ )
1038
+
1039
+ # Add ticket_id to updates
1040
+ updates["ticket_id"] = ticket_id
1041
+
1042
+ # Add to queue with explicit project directory
1043
+ queue = Queue()
1044
+ queue_id = queue.add(
1045
+ ticket_data=updates,
1046
+ adapter=adapter_name,
1047
+ operation="update",
1048
+ project_dir=str(Path.cwd()), # Explicitly pass current project directory
1049
+ )
1050
+
1051
+ # Start worker if needed
1052
+ manager = WorkerManager()
1053
+ if manager.start_if_needed():
1054
+ if not output_json:
1055
+ console.print("[dim]Worker started to process request[/dim]")
1056
+
1057
+ # SYNCHRONOUS MODE: Poll until completion if --wait flag is set
1058
+ if wait:
1059
+ from .utils import format_error_json
1060
+
1061
+ if not output_json:
1062
+ console.print(
1063
+ f"[yellow]⏳[/yellow] Waiting for update to complete (timeout: {timeout}s)..."
1064
+ )
1065
+
1066
+ try:
1067
+ # Poll the queue until operation completes
1068
+ completed_item = queue.poll_until_complete(queue_id, timeout=timeout)
1069
+ result = completed_item.result
1070
+
1071
+ if output_json:
1072
+ data = result if result else {"queue_id": queue_id, "id": ticket_id}
1073
+ console.print(
1074
+ format_json_response(
1075
+ "success", data, message="Ticket updated successfully"
1076
+ )
1077
+ )
1078
+ else:
1079
+ console.print(
1080
+ f"[green]✓[/green] Ticket updated successfully: {ticket_id}"
1081
+ )
1082
+ for key, value in updates.items():
1083
+ if key != "ticket_id":
1084
+ console.print(f" {key}: {value}")
1085
+
1086
+ except TimeoutError as e:
1087
+ if output_json:
1088
+ console.print(format_error_json(str(e), queue_id=queue_id))
1089
+ else:
1090
+ console.print(f"[red]❌[/red] Operation timed out after {timeout}s")
1091
+ console.print(f" Queue ID: {queue_id}")
1092
+ raise typer.Exit(1) from None
1093
+
1094
+ except RuntimeError as e:
1095
+ if output_json:
1096
+ console.print(format_error_json(str(e), queue_id=queue_id))
1097
+ else:
1098
+ console.print(f"[red]❌[/red] Operation failed: {e}")
1099
+ raise typer.Exit(1) from None
1100
+
1101
+ # ASYNCHRONOUS MODE (default)
1102
+ else:
1103
+ if output_json:
1104
+ updated_fields = [k for k in updates.keys() if k != "ticket_id"]
1105
+ data = {
1106
+ "id": ticket_id,
1107
+ "queue_id": queue_id,
1108
+ "updated_fields": updated_fields,
1109
+ **{k: v for k, v in updates.items() if k != "ticket_id"},
1110
+ }
1111
+ console.print(
1112
+ format_json_response("success", data, message="Ticket update queued")
1113
+ )
1114
+ else:
1115
+ console.print(f"[green]✓[/green] Queued ticket update: {queue_id}")
1116
+ for key, value in updates.items():
1117
+ if key != "ticket_id":
1118
+ console.print(f" {key}: {value}")
1119
+ console.print(
1120
+ "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
1121
+ )
1122
+
1123
+
1124
+ @app.command()
1125
+ def transition(
1126
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
1127
+ state_positional: TicketState | None = typer.Argument(
1128
+ None, help="Target state (positional - deprecated, use --state instead)"
1129
+ ),
1130
+ state: TicketState | None = typer.Option(
1131
+ None, "--state", "-s", help="Target state (recommended)"
1132
+ ),
1133
+ wait: bool = typer.Option(
1134
+ False,
1135
+ "--wait",
1136
+ "-w",
1137
+ help="Wait for operation to complete (synchronous mode)",
1138
+ ),
1139
+ timeout: float = typer.Option(
1140
+ 30.0,
1141
+ "--timeout",
1142
+ help="Timeout in seconds for --wait mode (default: 30)",
1143
+ ),
1144
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
1145
+ adapter: AdapterType | None = typer.Option(
1146
+ None, "--adapter", help="Override default adapter"
1147
+ ),
1148
+ ) -> None:
1149
+ """Change ticket state with validation.
1150
+
1151
+ Examples:
1152
+ # Recommended syntax with flag:
1153
+ mcp-ticketer ticket transition BTA-215 --state done
1154
+ mcp-ticketer ticket transition BTA-215 -s in_progress
1155
+
1156
+ # Legacy positional syntax (still supported):
1157
+ mcp-ticketer ticket transition BTA-215 done
1158
+
1159
+ """
1160
+ from .utils import format_json_response
1161
+
1162
+ # Determine which state to use (prefer flag over positional)
1163
+ target_state = state if state is not None else state_positional
1164
+
1165
+ if target_state is None:
1166
+ if output_json:
1167
+ console.print(
1168
+ format_json_response(
1169
+ "error", {"error": "State is required"}, message="State is required"
1170
+ )
1171
+ )
1172
+ else:
1173
+ console.print("[red]Error: State is required[/red]")
1174
+ console.print(
1175
+ "Use either:\n"
1176
+ " - Flag syntax (recommended): mcp-ticketer ticket transition TICKET-ID --state STATE\n"
1177
+ " - Positional syntax: mcp-ticketer ticket transition TICKET-ID STATE"
1178
+ )
1179
+ raise typer.Exit(1) from None
1180
+
1181
+ # Get the adapter name
1182
+ config = load_config()
1183
+ adapter_name = (
1184
+ adapter.value if adapter else config.get("default_adapter", "aitrackdown")
1185
+ )
1186
+
1187
+ # Add to queue with explicit project directory
1188
+ queue = Queue()
1189
+ state_value = target_state.value if hasattr(target_state, "value") else target_state
1190
+ queue_id = queue.add(
1191
+ ticket_data={
1192
+ "ticket_id": ticket_id,
1193
+ "state": state_value,
1194
+ },
1195
+ adapter=adapter_name,
1196
+ operation="transition",
1197
+ project_dir=str(Path.cwd()), # Explicitly pass current project directory
1198
+ )
1199
+
1200
+ # Start worker if needed
1201
+ manager = WorkerManager()
1202
+ if manager.start_if_needed():
1203
+ if not output_json:
1204
+ console.print("[dim]Worker started to process request[/dim]")
1205
+
1206
+ # SYNCHRONOUS MODE: Poll until completion if --wait flag is set
1207
+ if wait:
1208
+ from .utils import format_error_json
1209
+
1210
+ if not output_json:
1211
+ console.print(
1212
+ f"[yellow]⏳[/yellow] Waiting for transition to complete (timeout: {timeout}s)..."
1213
+ )
1214
+
1215
+ try:
1216
+ # Poll the queue until operation completes
1217
+ completed_item = queue.poll_until_complete(queue_id, timeout=timeout)
1218
+ result = completed_item.result
1219
+
1220
+ if output_json:
1221
+ data = (
1222
+ result
1223
+ if result
1224
+ else {
1225
+ "id": ticket_id,
1226
+ "new_state": state_value,
1227
+ "matched_state": state_value,
1228
+ "confidence": 1.0,
1229
+ }
1230
+ )
1231
+ console.print(
1232
+ format_json_response(
1233
+ "success", data, message="State transition completed"
1234
+ )
1235
+ )
1236
+ else:
1237
+ console.print(
1238
+ f"[green]✓[/green] State transition completed: {ticket_id} → {target_state}"
1239
+ )
1240
+
1241
+ except TimeoutError as e:
1242
+ if output_json:
1243
+ console.print(format_error_json(str(e), queue_id=queue_id))
1244
+ else:
1245
+ console.print(f"[red]❌[/red] Operation timed out after {timeout}s")
1246
+ console.print(f" Queue ID: {queue_id}")
1247
+ raise typer.Exit(1) from None
1248
+
1249
+ except RuntimeError as e:
1250
+ if output_json:
1251
+ console.print(format_error_json(str(e), queue_id=queue_id))
1252
+ else:
1253
+ console.print(f"[red]❌[/red] Operation failed: {e}")
1254
+ raise typer.Exit(1) from None
1255
+
1256
+ # ASYNCHRONOUS MODE (default)
1257
+ else:
1258
+ if output_json:
1259
+ data = {
1260
+ "id": ticket_id,
1261
+ "queue_id": queue_id,
1262
+ "new_state": state_value,
1263
+ "matched_state": state_value,
1264
+ "confidence": 1.0,
1265
+ }
1266
+ console.print(
1267
+ format_json_response("success", data, message="State transition queued")
1268
+ )
1269
+ else:
1270
+ console.print(f"[green]✓[/green] Queued state transition: {queue_id}")
1271
+ console.print(f" Ticket: {ticket_id} → {target_state}")
1272
+ console.print(
1273
+ "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
1274
+ )
1275
+
1276
+
1277
+ @app.command()
1278
+ def search(
1279
+ query: str | None = typer.Argument(None, help="Search query"),
1280
+ state: TicketState | None = typer.Option(None, "--state", "-s"),
1281
+ priority: Priority | None = typer.Option(None, "--priority", "-p"),
1282
+ assignee: str | None = typer.Option(None, "--assignee", "-a"),
1283
+ limit: int = typer.Option(10, "--limit", "-l"),
1284
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
1285
+ adapter: AdapterType | None = typer.Option(
1286
+ None, "--adapter", help="Override default adapter"
1287
+ ),
1288
+ ) -> None:
1289
+ """Search tickets with advanced query."""
1290
+ from .utils import format_json_response, serialize_task
1291
+
1292
+ async def _search() -> list[Any]:
1293
+ adapter_instance = get_adapter(
1294
+ override_adapter=adapter.value if adapter else None
1295
+ )
1296
+ search_query = SearchQuery(
1297
+ query=query,
1298
+ state=state,
1299
+ priority=priority,
1300
+ assignee=assignee,
1301
+ limit=limit,
1302
+ )
1303
+ return await adapter_instance.search(search_query)
1304
+
1305
+ tickets = asyncio.run(_search())
1306
+
1307
+ if not tickets:
1308
+ if output_json:
1309
+ console.print(
1310
+ format_json_response(
1311
+ "success", {"tickets": [], "query": query, "count": 0}
1312
+ )
1313
+ )
1314
+ else:
1315
+ console.print("[yellow]No tickets found matching query[/yellow]")
1316
+ return
1317
+
1318
+ # JSON output
1319
+ if output_json:
1320
+ tickets_data = [serialize_task(t) for t in tickets]
1321
+ data = {"tickets": tickets_data, "query": query, "count": len(tickets_data)}
1322
+ console.print(format_json_response("success", data))
1323
+ return
1324
+
1325
+ # Display results
1326
+ console.print(f"\n[bold]Found {len(tickets)} ticket(s)[/bold]\n")
1327
+
1328
+ for ticket in tickets:
1329
+ console.print(f"[cyan]{ticket.id}[/cyan]: {ticket.title}")
1330
+ console.print(f" State: {ticket.state} | Priority: {ticket.priority}")
1331
+ if ticket.assignee:
1332
+ console.print(f" Assignee: {ticket.assignee}")
1333
+ console.print()
1334
+
1335
+
1336
+ @app.command()
1337
+ def check(queue_id: str = typer.Argument(..., help="Queue ID to check")) -> None:
1338
+ """Check status of a queued operation."""
1339
+ queue = Queue()
1340
+ item = queue.get_item(queue_id)
1341
+
1342
+ if not item:
1343
+ console.print(f"[red]Queue item not found: {queue_id}[/red]")
1344
+ raise typer.Exit(1) from None
1345
+
1346
+ # Display status
1347
+ console.print(f"\n[bold]Queue Item: {item.id}[/bold]")
1348
+ console.print(f"Operation: {item.operation}")
1349
+ console.print(f"Adapter: {item.adapter}")
1350
+
1351
+ # Status with color
1352
+ if item.status == QueueStatus.COMPLETED:
1353
+ console.print(f"Status: [green]{item.status}[/green]")
1354
+ elif item.status == QueueStatus.FAILED:
1355
+ console.print(f"Status: [red]{item.status}[/red]")
1356
+ elif item.status == QueueStatus.PROCESSING:
1357
+ console.print(f"Status: [yellow]{item.status}[/yellow]")
1358
+ else:
1359
+ console.print(f"Status: {item.status}")
1360
+
1361
+ # Timestamps
1362
+ console.print(f"Created: {item.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
1363
+ if item.processed_at:
1364
+ console.print(f"Processed: {item.processed_at.strftime('%Y-%m-%d %H:%M:%S')}")
1365
+
1366
+ # Error or result
1367
+ if item.error_message:
1368
+ console.print(f"\n[red]Error:[/red] {item.error_message}")
1369
+ elif item.result:
1370
+ console.print("\n[green]Result:[/green]")
1371
+ for key, value in item.result.items():
1372
+ console.print(f" {key}: {value}")
1373
+
1374
+ if item.retry_count > 0:
1375
+ console.print(f"\nRetry Count: {item.retry_count}")