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,1013 @@
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
+ adapter: AdapterType | None = typer.Option(
217
+ None, "--adapter", help="Override default adapter"
218
+ ),
219
+ ) -> None:
220
+ """Create a new ticket with comprehensive health checks."""
221
+ # IMMEDIATE HEALTH CHECK - Critical for reliability
222
+ health_monitor = QueueHealthMonitor()
223
+ health = health_monitor.check_health()
224
+
225
+ # Display health status
226
+ if health["status"] == HealthStatus.CRITICAL:
227
+ console.print("[red]🚨 CRITICAL: Queue system has serious issues![/red]")
228
+ for alert in health["alerts"]:
229
+ if alert["level"] == "critical":
230
+ console.print(f"[red] • {alert['message']}[/red]")
231
+
232
+ # Attempt auto-repair
233
+ console.print("[yellow]Attempting automatic repair...[/yellow]")
234
+ repair_result = health_monitor.auto_repair()
235
+
236
+ if repair_result["actions_taken"]:
237
+ for action in repair_result["actions_taken"]:
238
+ console.print(f"[yellow] ✓ {action}[/yellow]")
239
+
240
+ # Re-check health after repair
241
+ health = health_monitor.check_health()
242
+ if health["status"] == HealthStatus.CRITICAL:
243
+ console.print(
244
+ "[red]❌ Auto-repair failed. Manual intervention required.[/red]"
245
+ )
246
+ console.print(
247
+ "[red]Cannot safely create ticket. Please check system status.[/red]"
248
+ )
249
+ raise typer.Exit(1) from None
250
+ else:
251
+ console.print(
252
+ "[green]✓ Auto-repair successful. Proceeding with ticket creation.[/green]"
253
+ )
254
+ else:
255
+ console.print(
256
+ "[red]❌ No repair actions available. Manual intervention required.[/red]"
257
+ )
258
+ raise typer.Exit(1) from None
259
+
260
+ elif health["status"] == HealthStatus.WARNING:
261
+ console.print("[yellow]⚠️ Warning: Queue system has minor issues[/yellow]")
262
+ for alert in health["alerts"]:
263
+ if alert["level"] == "warning":
264
+ console.print(f"[yellow] • {alert['message']}[/yellow]")
265
+ console.print("[yellow]Proceeding with ticket creation...[/yellow]")
266
+
267
+ # Get the adapter name with priority: 1) argument, 2) config, 3) .env files, 4) default
268
+ if adapter:
269
+ # Priority 1: Command-line argument - save to config for future use
270
+ adapter_name = adapter.value
271
+ _save_adapter_to_config(adapter_name)
272
+ else:
273
+ # Priority 2: Check existing config
274
+ config = load_config()
275
+ adapter_name = config.get("default_adapter")
276
+
277
+ if not adapter_name or adapter_name == "aitrackdown":
278
+ # Priority 3: Check .env files and save if found
279
+ env_adapter = _discover_from_env_files()
280
+ if env_adapter:
281
+ adapter_name = env_adapter
282
+ _save_adapter_to_config(adapter_name)
283
+ else:
284
+ # Priority 4: Default
285
+ adapter_name = "aitrackdown"
286
+
287
+ # Resolve project/epic synonym - prefer whichever is provided
288
+ parent_epic_id = project or epic
289
+
290
+ # Create task data
291
+ # Import Priority for type checking
292
+ from ..core.models import Priority as PriorityEnum
293
+
294
+ task_data = {
295
+ "title": title,
296
+ "description": description,
297
+ "priority": priority.value if isinstance(priority, PriorityEnum) else priority,
298
+ "tags": tags or [],
299
+ "assignee": assignee,
300
+ "parent_epic": parent_epic_id,
301
+ }
302
+
303
+ # WORKAROUND: Use direct operation for Linear adapter to bypass worker subprocess issue
304
+ if adapter_name == "linear":
305
+ console.print(
306
+ "[yellow]⚠️[/yellow] Using direct operation for Linear adapter (bypassing queue)"
307
+ )
308
+ try:
309
+ # Load configuration and create adapter directly
310
+ config = load_config()
311
+ adapter_config = config.get("adapters", {}).get(adapter_name, {})
312
+
313
+ # Import and create adapter
314
+ from ..core.registry import AdapterRegistry
315
+
316
+ adapter_instance = AdapterRegistry.get_adapter(adapter_name, adapter_config)
317
+
318
+ # Create task directly
319
+ from ..core.models import Priority, Task
320
+
321
+ task = Task(
322
+ title=task_data["title"],
323
+ description=task_data.get("description"),
324
+ priority=(
325
+ Priority(task_data["priority"])
326
+ if task_data.get("priority")
327
+ else Priority.MEDIUM
328
+ ),
329
+ tags=task_data.get("tags", []),
330
+ assignee=task_data.get("assignee"),
331
+ parent_epic=task_data.get("parent_epic"),
332
+ )
333
+
334
+ # Create ticket synchronously
335
+ import asyncio
336
+
337
+ result = asyncio.run(adapter_instance.create(task))
338
+
339
+ console.print(f"[green]✓[/green] Ticket created successfully: {result.id}")
340
+ console.print(f" Title: {result.title}")
341
+ console.print(f" Priority: {result.priority}")
342
+ console.print(f" State: {result.state}")
343
+ # Get URL from metadata if available
344
+ if (
345
+ result.metadata
346
+ and "linear" in result.metadata
347
+ and "url" in result.metadata["linear"]
348
+ ):
349
+ console.print(f" URL: {result.metadata['linear']['url']}")
350
+
351
+ return result.id
352
+
353
+ except Exception as e:
354
+ console.print(f"[red]❌[/red] Failed to create ticket: {e}")
355
+ raise
356
+
357
+ # Use queue for other adapters
358
+ queue = Queue()
359
+ queue_id = queue.add(
360
+ ticket_data=task_data,
361
+ adapter=adapter_name,
362
+ operation="create",
363
+ project_dir=str(Path.cwd()), # Explicitly pass current project directory
364
+ )
365
+
366
+ # Register in ticket registry for tracking
367
+ registry = TicketRegistry()
368
+ registry.register_ticket_operation(
369
+ queue_id, adapter_name, "create", title, task_data
370
+ )
371
+
372
+ console.print(f"[green]✓[/green] Queued ticket creation: {queue_id}")
373
+ console.print(f" Title: {title}")
374
+ console.print(f" Priority: {priority}")
375
+ console.print(f" Adapter: {adapter_name}")
376
+ console.print(
377
+ "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
378
+ )
379
+
380
+ # Start worker if needed with immediate feedback
381
+ manager = WorkerManager()
382
+ worker_started = manager.start_if_needed()
383
+
384
+ if worker_started:
385
+ console.print("[dim]Worker started to process request[/dim]")
386
+
387
+ # Give immediate feedback on processing
388
+ import time
389
+
390
+ time.sleep(1) # Brief pause to let worker start
391
+
392
+ # Check if item is being processed
393
+ item = queue.get_item(queue_id)
394
+ if item and item.status == QueueStatus.PROCESSING:
395
+ console.print("[green]✓ Item is being processed by worker[/green]")
396
+ elif item and item.status == QueueStatus.PENDING:
397
+ console.print("[yellow]⏳ Item is queued for processing[/yellow]")
398
+ else:
399
+ console.print(
400
+ "[red]⚠️ Item status unclear - check with 'mcp-ticketer ticket check {queue_id}'[/red]"
401
+ )
402
+ else:
403
+ # Worker didn't start - this is a problem
404
+ pending_count = queue.get_pending_count()
405
+ if pending_count > 1: # More than just this item
406
+ console.print(
407
+ f"[red]❌ Worker failed to start with {pending_count} pending items![/red]"
408
+ )
409
+ console.print(
410
+ "[red]This is a critical issue. Try 'mcp-ticketer queue worker start' manually.[/red]"
411
+ )
412
+ else:
413
+ console.print(
414
+ "[yellow]Worker not started (no other pending items)[/yellow]"
415
+ )
416
+
417
+
418
+ @app.command("list")
419
+ def list_tickets(
420
+ state: TicketState | None = typer.Option(
421
+ None, "--state", "-s", help="Filter by state"
422
+ ),
423
+ priority: Priority | None = typer.Option(
424
+ None, "--priority", "-p", help="Filter by priority"
425
+ ),
426
+ limit: int = typer.Option(10, "--limit", "-l", help="Maximum number of tickets"),
427
+ adapter: AdapterType | None = typer.Option(
428
+ None, "--adapter", help="Override default adapter"
429
+ ),
430
+ ) -> None:
431
+ """List tickets with optional filters."""
432
+
433
+ async def _list() -> list[Any]:
434
+ adapter_instance = get_adapter(
435
+ override_adapter=adapter.value if adapter else None
436
+ )
437
+ filters = {}
438
+ if state:
439
+ filters["state"] = state
440
+ if priority:
441
+ filters["priority"] = priority
442
+ return await adapter_instance.list(limit=limit, filters=filters)
443
+
444
+ tickets = asyncio.run(_list())
445
+
446
+ if not tickets:
447
+ console.print("[yellow]No tickets found[/yellow]")
448
+ return
449
+
450
+ # Create table
451
+ table = Table(title="Tickets")
452
+ table.add_column("ID", style="cyan", no_wrap=True)
453
+ table.add_column("Title", style="white")
454
+ table.add_column("State", style="green")
455
+ table.add_column("Priority", style="yellow")
456
+ table.add_column("Assignee", style="blue")
457
+
458
+ for ticket in tickets:
459
+ # Handle assignee field - Epic doesn't have assignee, Task does
460
+ assignee = getattr(ticket, "assignee", None) or "-"
461
+
462
+ table.add_row(
463
+ ticket.id or "N/A",
464
+ ticket.title,
465
+ ticket.state,
466
+ ticket.priority,
467
+ assignee,
468
+ )
469
+
470
+ console.print(table)
471
+
472
+
473
+ @app.command()
474
+ def show(
475
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
476
+ no_comments: bool = typer.Option(
477
+ False, "--no-comments", help="Hide comments (shown by default)"
478
+ ),
479
+ adapter: AdapterType | None = typer.Option(
480
+ None, "--adapter", help="Override default adapter"
481
+ ),
482
+ ) -> None:
483
+ """Show detailed ticket information with full context.
484
+
485
+ By default, displays ticket details along with all comments to provide
486
+ a holistic view of the ticket's history and context.
487
+
488
+ Use --no-comments to display only ticket metadata without comments.
489
+ """
490
+
491
+ async def _show() -> tuple[Any, Any, Any]:
492
+ adapter_instance = get_adapter(
493
+ override_adapter=adapter.value if adapter else None
494
+ )
495
+ ticket = await adapter_instance.read(ticket_id)
496
+ ticket_comments = None
497
+ attachments = None
498
+
499
+ # Fetch comments by default (unless explicitly disabled)
500
+ if not no_comments and ticket:
501
+ try:
502
+ ticket_comments = await adapter_instance.get_comments(ticket_id)
503
+ except (NotImplementedError, AttributeError):
504
+ # Adapter doesn't support comments
505
+ pass
506
+
507
+ # Try to fetch attachments if available
508
+ if ticket and hasattr(adapter_instance, "list_attachments"):
509
+ try:
510
+ attachments = await adapter_instance.list_attachments(ticket_id)
511
+ except (NotImplementedError, AttributeError):
512
+ pass
513
+
514
+ return ticket, ticket_comments, attachments
515
+
516
+ ticket, ticket_comments, attachments = asyncio.run(_show())
517
+
518
+ if not ticket:
519
+ console.print(f"[red]✗[/red] Ticket not found: {ticket_id}")
520
+ raise typer.Exit(1) from None
521
+
522
+ # Display ticket header with metadata
523
+ console.print(f"\n[bold cyan]┌─ Ticket: {ticket.id}[/bold cyan]")
524
+ console.print(f"[bold]│ {ticket.title}[/bold]")
525
+ console.print("└" + "─" * 60)
526
+
527
+ # Display metadata in organized sections
528
+ console.print("\n[bold]Status[/bold]")
529
+ console.print(f" State: [green]{ticket.state}[/green]")
530
+ console.print(f" Priority: [yellow]{ticket.priority}[/yellow]")
531
+
532
+ if ticket.assignee:
533
+ console.print(f" Assignee: {ticket.assignee}")
534
+
535
+ # Display timestamps if available
536
+ if ticket.created_at or ticket.updated_at:
537
+ console.print("\n[bold]Timeline[/bold]")
538
+ if ticket.created_at:
539
+ console.print(f" Created: {ticket.created_at}")
540
+ if ticket.updated_at:
541
+ console.print(f" Updated: {ticket.updated_at}")
542
+
543
+ # Display tags
544
+ if ticket.tags:
545
+ console.print("\n[bold]Tags[/bold]")
546
+ console.print(f" {', '.join(ticket.tags)}")
547
+
548
+ # Display description
549
+ if ticket.description:
550
+ console.print("\n[bold]Description[/bold]")
551
+ console.print(f" {ticket.description}")
552
+
553
+ # Display parent/child relationships
554
+ parent_info = []
555
+ if hasattr(ticket, "parent_epic") and ticket.parent_epic:
556
+ parent_info.append(f"Epic: {ticket.parent_epic}")
557
+ if hasattr(ticket, "parent_issue") and ticket.parent_issue:
558
+ parent_info.append(f"Parent Issue: {ticket.parent_issue}")
559
+
560
+ if parent_info:
561
+ console.print("\n[bold]Hierarchy[/bold]")
562
+ for info in parent_info:
563
+ console.print(f" {info}")
564
+
565
+ # Display attachments if available
566
+ if attachments and len(attachments) > 0:
567
+ console.print(f"\n[bold]Attachments ({len(attachments)})[/bold]")
568
+ for att in attachments:
569
+ att_title = att.get("title", "Untitled")
570
+ att_url = att.get("url", "")
571
+ console.print(f" 📎 {att_title}")
572
+ if att_url:
573
+ console.print(f" {att_url}")
574
+
575
+ # Display comments with enhanced formatting
576
+ if ticket_comments:
577
+ console.print(f"\n[bold]Activity & Comments ({len(ticket_comments)})[/bold]")
578
+ for i, comment in enumerate(ticket_comments, 1):
579
+ # Format timestamp
580
+ timestamp = comment.created_at if comment.created_at else "Unknown time"
581
+ author = comment.author if comment.author else "Unknown author"
582
+
583
+ console.print(f"\n[dim] {i}. {timestamp}[/dim]")
584
+ console.print(f" [cyan]@{author}[/cyan]")
585
+ console.print(f" {comment.content}")
586
+
587
+ # Footer with hint
588
+ if no_comments:
589
+ console.print(
590
+ "\n[dim]💡 Tip: Remove --no-comments to see activity and comments[/dim]"
591
+ )
592
+
593
+
594
+ @app.command()
595
+ def comment(
596
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
597
+ content: str = typer.Argument(..., help="Comment content"),
598
+ adapter: AdapterType | None = typer.Option(
599
+ None, "--adapter", help="Override default adapter"
600
+ ),
601
+ ) -> None:
602
+ """Add a comment to a ticket."""
603
+
604
+ async def _comment() -> Comment:
605
+ adapter_instance = get_adapter(
606
+ override_adapter=adapter.value if adapter else None
607
+ )
608
+
609
+ # Create comment
610
+ comment_obj = Comment(
611
+ ticket_id=ticket_id,
612
+ content=content,
613
+ author="cli-user", # Could be made configurable
614
+ )
615
+
616
+ result = await adapter_instance.add_comment(comment_obj)
617
+ return result
618
+
619
+ try:
620
+ result = asyncio.run(_comment())
621
+ console.print("[green]✓[/green] Comment added successfully")
622
+ if result.id:
623
+ console.print(f"Comment ID: {result.id}")
624
+ console.print(f"Content: {content}")
625
+ except Exception as e:
626
+ console.print(f"[red]✗[/red] Failed to add comment: {e}")
627
+ raise typer.Exit(1) from None
628
+
629
+
630
+ @app.command()
631
+ def attach(
632
+ ticket_id: str = typer.Argument(..., help="Ticket ID or URL"),
633
+ file_path: Path = typer.Argument(..., help="Path to file to attach", exists=True),
634
+ description: str | None = typer.Option(
635
+ None, "--description", "-d", help="Attachment description or comment"
636
+ ),
637
+ adapter: AdapterType | None = typer.Option(
638
+ None, "--adapter", help="Override default adapter"
639
+ ),
640
+ ) -> None:
641
+ """Attach a file to a ticket.
642
+
643
+ Examples:
644
+ mcp-ticketer ticket attach 1M-157 docs/analysis.md
645
+ mcp-ticketer ticket attach PROJ-123 screenshot.png -d "Error screenshot"
646
+ mcp-ticketer ticket attach https://linear.app/.../issue/ABC-123 diagram.pdf
647
+ """
648
+
649
+ async def _attach() -> dict[str, Any]:
650
+ import mimetypes
651
+
652
+ adapter_instance = get_adapter(
653
+ override_adapter=adapter.value if adapter else None
654
+ )
655
+
656
+ # Detect MIME type
657
+ mime_type, _ = mimetypes.guess_type(str(file_path))
658
+ if not mime_type:
659
+ mime_type = "application/octet-stream"
660
+
661
+ # Method 1: Try Linear-specific upload (if available)
662
+ if hasattr(adapter_instance, "upload_file") and hasattr(
663
+ adapter_instance, "attach_file_to_issue"
664
+ ):
665
+ try:
666
+ # Upload file to Linear's S3
667
+ file_url = await adapter_instance.upload_file(
668
+ file_path=str(file_path), mime_type=mime_type
669
+ )
670
+
671
+ # Attach to issue
672
+ attachment = await adapter_instance.attach_file_to_issue(
673
+ issue_id=ticket_id,
674
+ file_url=file_url,
675
+ title=file_path.name,
676
+ subtitle=description,
677
+ )
678
+
679
+ return {
680
+ "status": "completed",
681
+ "attachment": attachment,
682
+ "file_url": file_url,
683
+ "method": "linear_native_upload",
684
+ }
685
+ except Exception:
686
+ # If Linear upload fails, fall through to next method
687
+ pass
688
+
689
+ # Method 2: Try generic add_attachment (if available)
690
+ if hasattr(adapter_instance, "add_attachment"):
691
+ try:
692
+ attachment = await adapter_instance.add_attachment(
693
+ ticket_id=ticket_id,
694
+ file_path=str(file_path),
695
+ description=description or "",
696
+ )
697
+ return {
698
+ "status": "completed",
699
+ "attachment": attachment,
700
+ "method": "adapter_native",
701
+ }
702
+ except NotImplementedError:
703
+ pass
704
+
705
+ # Method 3: Fallback - Add file reference as comment
706
+ from ..core.models import Comment
707
+
708
+ comment_content = f"📎 File reference: {file_path.name}"
709
+ if description:
710
+ comment_content += f"\n\n{description}"
711
+
712
+ comment_obj = Comment(
713
+ ticket_id=ticket_id,
714
+ content=comment_content,
715
+ author="cli-user",
716
+ )
717
+
718
+ comment = await adapter_instance.add_comment(comment_obj)
719
+ return {
720
+ "status": "completed",
721
+ "comment": comment,
722
+ "method": "comment_reference",
723
+ "note": "Adapter doesn't support attachments - added file reference as comment",
724
+ }
725
+
726
+ # Validate file before attempting upload
727
+ if not file_path.exists():
728
+ console.print(f"[red]✗[/red] File not found: {file_path}")
729
+ raise typer.Exit(1) from None
730
+
731
+ if not file_path.is_file():
732
+ console.print(f"[red]✗[/red] Path is not a file: {file_path}")
733
+ raise typer.Exit(1) from None
734
+
735
+ # Display file info
736
+ file_size = file_path.stat().st_size
737
+ size_mb = file_size / (1024 * 1024)
738
+ console.print(f"\n[dim]Attaching file to ticket {ticket_id}...[/dim]")
739
+ console.print(f" File: {file_path.name} ({size_mb:.2f} MB)")
740
+
741
+ # Detect MIME type
742
+ import mimetypes
743
+
744
+ mime_type, _ = mimetypes.guess_type(str(file_path))
745
+ if mime_type:
746
+ console.print(f" Type: {mime_type}")
747
+
748
+ try:
749
+ result = asyncio.run(_attach())
750
+
751
+ if result["status"] == "completed":
752
+ console.print(
753
+ f"\n[green]✓[/green] File attached successfully to {ticket_id}"
754
+ )
755
+
756
+ # Display attachment details based on method used
757
+ method = result.get("method", "unknown")
758
+
759
+ if method == "linear_native_upload":
760
+ console.print(" Method: Linear native upload")
761
+ if "file_url" in result:
762
+ console.print(f" URL: {result['file_url']}")
763
+ if "attachment" in result and isinstance(result["attachment"], dict):
764
+ att = result["attachment"]
765
+ if "id" in att:
766
+ console.print(f" ID: {att['id']}")
767
+ if "title" in att:
768
+ console.print(f" Title: {att['title']}")
769
+
770
+ elif method == "adapter_native":
771
+ console.print(" Method: Adapter native")
772
+ if "attachment" in result:
773
+ att = result["attachment"]
774
+ if isinstance(att, dict):
775
+ if "id" in att:
776
+ console.print(f" ID: {att['id']}")
777
+ if "url" in att:
778
+ console.print(f" URL: {att['url']}")
779
+
780
+ elif method == "comment_reference":
781
+ console.print(" Method: Comment reference")
782
+ console.print(f" [dim]{result.get('note', '')}[/dim]")
783
+ if "comment" in result:
784
+ comment = result["comment"]
785
+ if isinstance(comment, dict) and "id" in comment:
786
+ console.print(f" Comment ID: {comment['id']}")
787
+
788
+ else:
789
+ # Error case
790
+ error_msg = result.get("error", "Unknown error")
791
+ console.print(f"\n[red]✗[/red] Failed to attach file: {error_msg}")
792
+ raise typer.Exit(1) from None
793
+
794
+ except Exception as e:
795
+ console.print(f"\n[red]✗[/red] Failed to attach file: {e}")
796
+ raise typer.Exit(1) from None
797
+
798
+
799
+ @app.command()
800
+ def update(
801
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
802
+ title: str | None = typer.Option(None, "--title", help="New title"),
803
+ description: str | None = typer.Option(
804
+ None, "--description", "-d", help="New description"
805
+ ),
806
+ priority: Priority | None = typer.Option(
807
+ None, "--priority", "-p", help="New priority"
808
+ ),
809
+ assignee: str | None = typer.Option(None, "--assignee", "-a", help="New assignee"),
810
+ adapter: AdapterType | None = typer.Option(
811
+ None, "--adapter", help="Override default adapter"
812
+ ),
813
+ ) -> None:
814
+ """Update ticket fields."""
815
+ updates = {}
816
+ if title:
817
+ updates["title"] = title
818
+ if description:
819
+ updates["description"] = description
820
+ if priority:
821
+ updates["priority"] = (
822
+ priority.value if isinstance(priority, Priority) else priority
823
+ )
824
+ if assignee:
825
+ updates["assignee"] = assignee
826
+
827
+ if not updates:
828
+ console.print("[yellow]No updates specified[/yellow]")
829
+ raise typer.Exit(1) from None
830
+
831
+ # Get the adapter name
832
+ config = load_config()
833
+ adapter_name = (
834
+ adapter.value if adapter else config.get("default_adapter", "aitrackdown")
835
+ )
836
+
837
+ # Add ticket_id to updates
838
+ updates["ticket_id"] = ticket_id
839
+
840
+ # Add to queue with explicit project directory
841
+ queue = Queue()
842
+ queue_id = queue.add(
843
+ ticket_data=updates,
844
+ adapter=adapter_name,
845
+ operation="update",
846
+ project_dir=str(Path.cwd()), # Explicitly pass current project directory
847
+ )
848
+
849
+ console.print(f"[green]✓[/green] Queued ticket update: {queue_id}")
850
+ for key, value in updates.items():
851
+ if key != "ticket_id":
852
+ console.print(f" {key}: {value}")
853
+ console.print(
854
+ "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
855
+ )
856
+
857
+ # Start worker if needed
858
+ manager = WorkerManager()
859
+ if manager.start_if_needed():
860
+ console.print("[dim]Worker started to process request[/dim]")
861
+
862
+
863
+ @app.command()
864
+ def transition(
865
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
866
+ state_positional: TicketState | None = typer.Argument(
867
+ None, help="Target state (positional - deprecated, use --state instead)"
868
+ ),
869
+ state: TicketState | None = typer.Option(
870
+ None, "--state", "-s", help="Target state (recommended)"
871
+ ),
872
+ adapter: AdapterType | None = typer.Option(
873
+ None, "--adapter", help="Override default adapter"
874
+ ),
875
+ ) -> None:
876
+ """Change ticket state with validation.
877
+
878
+ Examples:
879
+ # Recommended syntax with flag:
880
+ mcp-ticketer ticket transition BTA-215 --state done
881
+ mcp-ticketer ticket transition BTA-215 -s in_progress
882
+
883
+ # Legacy positional syntax (still supported):
884
+ mcp-ticketer ticket transition BTA-215 done
885
+
886
+ """
887
+ # Determine which state to use (prefer flag over positional)
888
+ target_state = state if state is not None else state_positional
889
+
890
+ if target_state is None:
891
+ console.print("[red]Error: State is required[/red]")
892
+ console.print(
893
+ "Use either:\n"
894
+ " - Flag syntax (recommended): mcp-ticketer ticket transition TICKET-ID --state STATE\n"
895
+ " - Positional syntax: mcp-ticketer ticket transition TICKET-ID STATE"
896
+ )
897
+ raise typer.Exit(1) from None
898
+
899
+ # Get the adapter name
900
+ config = load_config()
901
+ adapter_name = (
902
+ adapter.value if adapter else config.get("default_adapter", "aitrackdown")
903
+ )
904
+
905
+ # Add to queue with explicit project directory
906
+ queue = Queue()
907
+ queue_id = queue.add(
908
+ ticket_data={
909
+ "ticket_id": ticket_id,
910
+ "state": (
911
+ target_state.value if hasattr(target_state, "value") else target_state
912
+ ),
913
+ },
914
+ adapter=adapter_name,
915
+ operation="transition",
916
+ project_dir=str(Path.cwd()), # Explicitly pass current project directory
917
+ )
918
+
919
+ console.print(f"[green]✓[/green] Queued state transition: {queue_id}")
920
+ console.print(f" Ticket: {ticket_id} → {target_state}")
921
+ console.print(
922
+ "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
923
+ )
924
+
925
+ # Start worker if needed
926
+ manager = WorkerManager()
927
+ if manager.start_if_needed():
928
+ console.print("[dim]Worker started to process request[/dim]")
929
+
930
+
931
+ @app.command()
932
+ def search(
933
+ query: str | None = typer.Argument(None, help="Search query"),
934
+ state: TicketState | None = typer.Option(None, "--state", "-s"),
935
+ priority: Priority | None = typer.Option(None, "--priority", "-p"),
936
+ assignee: str | None = typer.Option(None, "--assignee", "-a"),
937
+ limit: int = typer.Option(10, "--limit", "-l"),
938
+ adapter: AdapterType | None = typer.Option(
939
+ None, "--adapter", help="Override default adapter"
940
+ ),
941
+ ) -> None:
942
+ """Search tickets with advanced query."""
943
+
944
+ async def _search() -> list[Any]:
945
+ adapter_instance = get_adapter(
946
+ override_adapter=adapter.value if adapter else None
947
+ )
948
+ search_query = SearchQuery(
949
+ query=query,
950
+ state=state,
951
+ priority=priority,
952
+ assignee=assignee,
953
+ limit=limit,
954
+ )
955
+ return await adapter_instance.search(search_query)
956
+
957
+ tickets = asyncio.run(_search())
958
+
959
+ if not tickets:
960
+ console.print("[yellow]No tickets found matching query[/yellow]")
961
+ return
962
+
963
+ # Display results
964
+ console.print(f"\n[bold]Found {len(tickets)} ticket(s)[/bold]\n")
965
+
966
+ for ticket in tickets:
967
+ console.print(f"[cyan]{ticket.id}[/cyan]: {ticket.title}")
968
+ console.print(f" State: {ticket.state} | Priority: {ticket.priority}")
969
+ if ticket.assignee:
970
+ console.print(f" Assignee: {ticket.assignee}")
971
+ console.print()
972
+
973
+
974
+ @app.command()
975
+ def check(queue_id: str = typer.Argument(..., help="Queue ID to check")) -> None:
976
+ """Check status of a queued operation."""
977
+ queue = Queue()
978
+ item = queue.get_item(queue_id)
979
+
980
+ if not item:
981
+ console.print(f"[red]Queue item not found: {queue_id}[/red]")
982
+ raise typer.Exit(1) from None
983
+
984
+ # Display status
985
+ console.print(f"\n[bold]Queue Item: {item.id}[/bold]")
986
+ console.print(f"Operation: {item.operation}")
987
+ console.print(f"Adapter: {item.adapter}")
988
+
989
+ # Status with color
990
+ if item.status == QueueStatus.COMPLETED:
991
+ console.print(f"Status: [green]{item.status}[/green]")
992
+ elif item.status == QueueStatus.FAILED:
993
+ console.print(f"Status: [red]{item.status}[/red]")
994
+ elif item.status == QueueStatus.PROCESSING:
995
+ console.print(f"Status: [yellow]{item.status}[/yellow]")
996
+ else:
997
+ console.print(f"Status: {item.status}")
998
+
999
+ # Timestamps
1000
+ console.print(f"Created: {item.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
1001
+ if item.processed_at:
1002
+ console.print(f"Processed: {item.processed_at.strftime('%Y-%m-%d %H:%M:%S')}")
1003
+
1004
+ # Error or result
1005
+ if item.error_message:
1006
+ console.print(f"\n[red]Error:[/red] {item.error_message}")
1007
+ elif item.result:
1008
+ console.print("\n[green]Result:[/green]")
1009
+ for key, value in item.result.items():
1010
+ console.print(f" {key}: {value}")
1011
+
1012
+ if item.retry_count > 0:
1013
+ console.print(f"\nRetry Count: {item.retry_count}")