mcp-ticketer 0.3.6__py3-none-any.whl → 0.4.0__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.

@@ -0,0 +1,765 @@
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 Optional
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: Optional[Path] = 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(f"[yellow]Warning: Could not load project config: {e}[/yellow]")
56
+
57
+ logger.info("No project-local config found, defaulting to aitrackdown adapter")
58
+ return {"adapter": "aitrackdown", "config": {"base_path": ".aitrackdown"}}
59
+
60
+
61
+ def save_config(config: dict) -> None:
62
+ """Save configuration to project-local config file."""
63
+ import logging
64
+
65
+ logger = logging.getLogger(__name__)
66
+ project_config = Path.cwd() / ".mcp-ticketer" / "config.json"
67
+ project_config.parent.mkdir(parents=True, exist_ok=True)
68
+ with open(project_config, "w") as f:
69
+ json.dump(config, f, indent=2)
70
+ logger.info(f"Saved configuration to: {project_config}")
71
+
72
+
73
+ def get_adapter(override_adapter: Optional[str] = None, override_config: Optional[dict] = None):
74
+ """Get configured adapter instance."""
75
+ config = load_config()
76
+
77
+ if override_adapter:
78
+ adapter_type = override_adapter
79
+ adapters_config = config.get("adapters", {})
80
+ adapter_config = adapters_config.get(adapter_type, {})
81
+ if override_config:
82
+ adapter_config.update(override_config)
83
+ else:
84
+ adapter_type = config.get("default_adapter", "aitrackdown")
85
+ adapters_config = config.get("adapters", {})
86
+ adapter_config = adapters_config.get(adapter_type, {})
87
+
88
+ if not adapter_config and "config" in config:
89
+ adapter_config = config["config"]
90
+
91
+ # Add environment variables for authentication
92
+ if adapter_type == "linear":
93
+ if not adapter_config.get("api_key"):
94
+ adapter_config["api_key"] = os.getenv("LINEAR_API_KEY")
95
+ elif adapter_type == "github":
96
+ if not adapter_config.get("api_key") and not adapter_config.get("token"):
97
+ adapter_config["api_key"] = os.getenv("GITHUB_TOKEN")
98
+ elif adapter_type == "jira":
99
+ if not adapter_config.get("api_token"):
100
+ adapter_config["api_token"] = os.getenv("JIRA_ACCESS_TOKEN")
101
+ if not adapter_config.get("email"):
102
+ adapter_config["email"] = os.getenv("JIRA_ACCESS_USER")
103
+
104
+ return AdapterRegistry.get_adapter(adapter_type, adapter_config)
105
+
106
+
107
+ def _discover_from_env_files() -> Optional[str]:
108
+ """Discover adapter configuration from .env or .env.local files.
109
+
110
+ Returns:
111
+ Adapter name if discovered, None otherwise
112
+
113
+ """
114
+ import logging
115
+ from pathlib import Path
116
+
117
+ logger = logging.getLogger(__name__)
118
+
119
+ # Check .env.local first, then .env
120
+ env_files = [".env.local", ".env"]
121
+
122
+ for env_file in env_files:
123
+ env_path = Path.cwd() / env_file
124
+ if env_path.exists():
125
+ try:
126
+ # Simple .env parsing (key=value format)
127
+ env_vars = {}
128
+ with open(env_path) as f:
129
+ for line in f:
130
+ line = line.strip()
131
+ if line and not line.startswith("#") and "=" in line:
132
+ key, value = line.split("=", 1)
133
+ env_vars[key.strip()] = value.strip().strip("\"'")
134
+
135
+ # Check for adapter-specific variables
136
+ if env_vars.get("LINEAR_API_KEY"):
137
+ logger.info(f"Discovered Linear configuration in {env_file}")
138
+ return "linear"
139
+ elif env_vars.get("GITHUB_TOKEN"):
140
+ logger.info(f"Discovered GitHub configuration in {env_file}")
141
+ return "github"
142
+ elif env_vars.get("JIRA_SERVER"):
143
+ logger.info(f"Discovered JIRA configuration in {env_file}")
144
+ return "jira"
145
+
146
+ except Exception as e:
147
+ logger.warning(f"Could not read {env_file}: {e}")
148
+
149
+ return None
150
+
151
+
152
+ def _save_adapter_to_config(adapter_name: str) -> None:
153
+ """Save adapter configuration to config file.
154
+
155
+ Args:
156
+ adapter_name: Name of the adapter to save as default
157
+
158
+ """
159
+ import logging
160
+
161
+ from .main import save_config
162
+
163
+ logger = logging.getLogger(__name__)
164
+
165
+ try:
166
+ config = load_config()
167
+ config["default_adapter"] = adapter_name
168
+
169
+ # Ensure adapters section exists
170
+ if "adapters" not in config:
171
+ config["adapters"] = {}
172
+
173
+ # Add basic adapter config if not exists
174
+ if adapter_name not in config["adapters"]:
175
+ if adapter_name == "aitrackdown":
176
+ config["adapters"][adapter_name] = {"base_path": ".aitrackdown"}
177
+ else:
178
+ config["adapters"][adapter_name] = {"type": adapter_name}
179
+
180
+ save_config(config)
181
+ logger.info(f"Saved {adapter_name} as default adapter")
182
+
183
+ except Exception as e:
184
+ logger.warning(f"Could not save adapter configuration: {e}")
185
+
186
+
187
+ @app.command()
188
+ def create(
189
+ title: str = typer.Argument(..., help="Ticket title"),
190
+ description: Optional[str] = typer.Option(
191
+ None, "--description", "-d", help="Ticket description"
192
+ ),
193
+ priority: Priority = typer.Option(
194
+ Priority.MEDIUM, "--priority", "-p", help="Priority level"
195
+ ),
196
+ tags: Optional[list[str]] = typer.Option(
197
+ None, "--tag", "-t", help="Tags (can be specified multiple times)"
198
+ ),
199
+ assignee: Optional[str] = typer.Option(
200
+ None, "--assignee", "-a", help="Assignee username"
201
+ ),
202
+ project: Optional[str] = typer.Option(
203
+ None,
204
+ "--project",
205
+ help="Parent project/epic ID (synonym for --epic)",
206
+ ),
207
+ epic: Optional[str] = typer.Option(
208
+ None,
209
+ "--epic",
210
+ help="Parent epic/project ID (synonym for --project)",
211
+ ),
212
+ adapter: Optional[AdapterType] = typer.Option(
213
+ None, "--adapter", help="Override default adapter"
214
+ ),
215
+ ) -> None:
216
+ """Create a new ticket with comprehensive health checks."""
217
+ # IMMEDIATE HEALTH CHECK - Critical for reliability
218
+ health_monitor = QueueHealthMonitor()
219
+ health = health_monitor.check_health()
220
+
221
+ # Display health status
222
+ if health["status"] == HealthStatus.CRITICAL:
223
+ console.print("[red]🚨 CRITICAL: Queue system has serious issues![/red]")
224
+ for alert in health["alerts"]:
225
+ if alert["level"] == "critical":
226
+ console.print(f"[red] • {alert['message']}[/red]")
227
+
228
+ # Attempt auto-repair
229
+ console.print("[yellow]Attempting automatic repair...[/yellow]")
230
+ repair_result = health_monitor.auto_repair()
231
+
232
+ if repair_result["actions_taken"]:
233
+ for action in repair_result["actions_taken"]:
234
+ console.print(f"[yellow] ✓ {action}[/yellow]")
235
+
236
+ # Re-check health after repair
237
+ health = health_monitor.check_health()
238
+ if health["status"] == HealthStatus.CRITICAL:
239
+ console.print(
240
+ "[red]❌ Auto-repair failed. Manual intervention required.[/red]"
241
+ )
242
+ console.print(
243
+ "[red]Cannot safely create ticket. Please check system status.[/red]"
244
+ )
245
+ raise typer.Exit(1)
246
+ else:
247
+ console.print(
248
+ "[green]✓ Auto-repair successful. Proceeding with ticket creation.[/green]"
249
+ )
250
+ else:
251
+ console.print(
252
+ "[red]❌ No repair actions available. Manual intervention required.[/red]"
253
+ )
254
+ raise typer.Exit(1)
255
+
256
+ elif health["status"] == HealthStatus.WARNING:
257
+ console.print("[yellow]⚠️ Warning: Queue system has minor issues[/yellow]")
258
+ for alert in health["alerts"]:
259
+ if alert["level"] == "warning":
260
+ console.print(f"[yellow] • {alert['message']}[/yellow]")
261
+ console.print("[yellow]Proceeding with ticket creation...[/yellow]")
262
+
263
+ # Get the adapter name with priority: 1) argument, 2) config, 3) .env files, 4) default
264
+ if adapter:
265
+ # Priority 1: Command-line argument - save to config for future use
266
+ adapter_name = adapter.value
267
+ _save_adapter_to_config(adapter_name)
268
+ else:
269
+ # Priority 2: Check existing config
270
+ config = load_config()
271
+ adapter_name = config.get("default_adapter")
272
+
273
+ if not adapter_name or adapter_name == "aitrackdown":
274
+ # Priority 3: Check .env files and save if found
275
+ env_adapter = _discover_from_env_files()
276
+ if env_adapter:
277
+ adapter_name = env_adapter
278
+ _save_adapter_to_config(adapter_name)
279
+ else:
280
+ # Priority 4: Default
281
+ adapter_name = "aitrackdown"
282
+
283
+ # Resolve project/epic synonym - prefer whichever is provided
284
+ parent_epic_id = project or epic
285
+
286
+ # Create task data
287
+ # Import Priority for type checking
288
+ from ..core.models import Priority as PriorityEnum
289
+
290
+ task_data = {
291
+ "title": title,
292
+ "description": description,
293
+ "priority": priority.value if isinstance(priority, PriorityEnum) else priority,
294
+ "tags": tags or [],
295
+ "assignee": assignee,
296
+ "parent_epic": parent_epic_id,
297
+ }
298
+
299
+ # WORKAROUND: Use direct operation for Linear adapter to bypass worker subprocess issue
300
+ if adapter_name == "linear":
301
+ console.print(
302
+ "[yellow]⚠️[/yellow] Using direct operation for Linear adapter (bypassing queue)"
303
+ )
304
+ try:
305
+ # Load configuration and create adapter directly
306
+ config = load_config()
307
+ adapter_config = config.get("adapters", {}).get(adapter_name, {})
308
+
309
+ # Import and create adapter
310
+ from ..core.registry import AdapterRegistry
311
+
312
+ adapter_instance = AdapterRegistry.get_adapter(adapter_name, adapter_config)
313
+
314
+ # Create task directly
315
+ from ..core.models import Priority, Task
316
+
317
+ task = Task(
318
+ title=task_data["title"],
319
+ description=task_data.get("description"),
320
+ priority=(
321
+ Priority(task_data["priority"])
322
+ if task_data.get("priority")
323
+ else Priority.MEDIUM
324
+ ),
325
+ tags=task_data.get("tags", []),
326
+ assignee=task_data.get("assignee"),
327
+ parent_epic=task_data.get("parent_epic"),
328
+ )
329
+
330
+ # Create ticket synchronously
331
+ import asyncio
332
+
333
+ result = asyncio.run(adapter_instance.create(task))
334
+
335
+ console.print(f"[green]✓[/green] Ticket created successfully: {result.id}")
336
+ console.print(f" Title: {result.title}")
337
+ console.print(f" Priority: {result.priority}")
338
+ console.print(f" State: {result.state}")
339
+ # Get URL from metadata if available
340
+ if (
341
+ result.metadata
342
+ and "linear" in result.metadata
343
+ and "url" in result.metadata["linear"]
344
+ ):
345
+ console.print(f" URL: {result.metadata['linear']['url']}")
346
+
347
+ return result.id
348
+
349
+ except Exception as e:
350
+ console.print(f"[red]❌[/red] Failed to create ticket: {e}")
351
+ raise
352
+
353
+ # Use queue for other adapters
354
+ queue = Queue()
355
+ queue_id = queue.add(
356
+ ticket_data=task_data,
357
+ adapter=adapter_name,
358
+ operation="create",
359
+ project_dir=str(Path.cwd()), # Explicitly pass current project directory
360
+ )
361
+
362
+ # Register in ticket registry for tracking
363
+ registry = TicketRegistry()
364
+ registry.register_ticket_operation(
365
+ queue_id, adapter_name, "create", title, task_data
366
+ )
367
+
368
+ console.print(f"[green]✓[/green] Queued ticket creation: {queue_id}")
369
+ console.print(f" Title: {title}")
370
+ console.print(f" Priority: {priority}")
371
+ console.print(f" Adapter: {adapter_name}")
372
+ console.print("[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]")
373
+
374
+ # Start worker if needed with immediate feedback
375
+ manager = WorkerManager()
376
+ worker_started = manager.start_if_needed()
377
+
378
+ if worker_started:
379
+ console.print("[dim]Worker started to process request[/dim]")
380
+
381
+ # Give immediate feedback on processing
382
+ import time
383
+
384
+ time.sleep(1) # Brief pause to let worker start
385
+
386
+ # Check if item is being processed
387
+ item = queue.get_item(queue_id)
388
+ if item and item.status == QueueStatus.PROCESSING:
389
+ console.print("[green]✓ Item is being processed by worker[/green]")
390
+ elif item and item.status == QueueStatus.PENDING:
391
+ console.print("[yellow]⏳ Item is queued for processing[/yellow]")
392
+ else:
393
+ console.print(
394
+ "[red]⚠️ Item status unclear - check with 'mcp-ticketer ticket check {queue_id}'[/red]"
395
+ )
396
+ else:
397
+ # Worker didn't start - this is a problem
398
+ pending_count = queue.get_pending_count()
399
+ if pending_count > 1: # More than just this item
400
+ console.print(
401
+ f"[red]❌ Worker failed to start with {pending_count} pending items![/red]"
402
+ )
403
+ console.print(
404
+ "[red]This is a critical issue. Try 'mcp-ticketer queue worker start' manually.[/red]"
405
+ )
406
+ else:
407
+ console.print(
408
+ "[yellow]Worker not started (no other pending items)[/yellow]"
409
+ )
410
+
411
+
412
+ @app.command("list")
413
+ def list_tickets(
414
+ state: Optional[TicketState] = typer.Option(
415
+ None, "--state", "-s", help="Filter by state"
416
+ ),
417
+ priority: Optional[Priority] = typer.Option(
418
+ None, "--priority", "-p", help="Filter by priority"
419
+ ),
420
+ limit: int = typer.Option(10, "--limit", "-l", help="Maximum number of tickets"),
421
+ adapter: Optional[AdapterType] = typer.Option(
422
+ None, "--adapter", help="Override default adapter"
423
+ ),
424
+ ) -> None:
425
+ """List tickets with optional filters."""
426
+
427
+ async def _list():
428
+ adapter_instance = get_adapter(
429
+ override_adapter=adapter.value if adapter else None
430
+ )
431
+ filters = {}
432
+ if state:
433
+ filters["state"] = state
434
+ if priority:
435
+ filters["priority"] = priority
436
+ return await adapter_instance.list(limit=limit, filters=filters)
437
+
438
+ tickets = asyncio.run(_list())
439
+
440
+ if not tickets:
441
+ console.print("[yellow]No tickets found[/yellow]")
442
+ return
443
+
444
+ # Create table
445
+ table = Table(title="Tickets")
446
+ table.add_column("ID", style="cyan", no_wrap=True)
447
+ table.add_column("Title", style="white")
448
+ table.add_column("State", style="green")
449
+ table.add_column("Priority", style="yellow")
450
+ table.add_column("Assignee", style="blue")
451
+
452
+ for ticket in tickets:
453
+ # Handle assignee field - Epic doesn't have assignee, Task does
454
+ assignee = getattr(ticket, "assignee", None) or "-"
455
+
456
+ table.add_row(
457
+ ticket.id or "N/A",
458
+ ticket.title,
459
+ ticket.state,
460
+ ticket.priority,
461
+ assignee,
462
+ )
463
+
464
+ console.print(table)
465
+
466
+
467
+ @app.command()
468
+ def show(
469
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
470
+ comments: bool = typer.Option(False, "--comments", "-c", help="Show comments"),
471
+ adapter: Optional[AdapterType] = typer.Option(
472
+ None, "--adapter", help="Override default adapter"
473
+ ),
474
+ ) -> None:
475
+ """Show detailed ticket information."""
476
+
477
+ async def _show():
478
+ adapter_instance = get_adapter(
479
+ override_adapter=adapter.value if adapter else None
480
+ )
481
+ ticket = await adapter_instance.read(ticket_id)
482
+ ticket_comments = None
483
+ if comments and ticket:
484
+ ticket_comments = await adapter_instance.get_comments(ticket_id)
485
+ return ticket, ticket_comments
486
+
487
+ ticket, ticket_comments = asyncio.run(_show())
488
+
489
+ if not ticket:
490
+ console.print(f"[red]✗[/red] Ticket not found: {ticket_id}")
491
+ raise typer.Exit(1)
492
+
493
+ # Display ticket details
494
+ console.print(f"\n[bold]Ticket: {ticket.id}[/bold]")
495
+ console.print(f"Title: {ticket.title}")
496
+ console.print(f"State: [green]{ticket.state}[/green]")
497
+ console.print(f"Priority: [yellow]{ticket.priority}[/yellow]")
498
+
499
+ if ticket.description:
500
+ console.print("\n[dim]Description:[/dim]")
501
+ console.print(ticket.description)
502
+
503
+ if ticket.tags:
504
+ console.print(f"\nTags: {', '.join(ticket.tags)}")
505
+
506
+ if ticket.assignee:
507
+ console.print(f"Assignee: {ticket.assignee}")
508
+
509
+ # Display comments if requested
510
+ if ticket_comments:
511
+ console.print(f"\n[bold]Comments ({len(ticket_comments)}):[/bold]")
512
+ for comment in ticket_comments:
513
+ console.print(f"\n[dim]{comment.created_at} - {comment.author}:[/dim]")
514
+ console.print(comment.content)
515
+
516
+
517
+ @app.command()
518
+ def comment(
519
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
520
+ content: str = typer.Argument(..., help="Comment content"),
521
+ adapter: Optional[AdapterType] = typer.Option(
522
+ None, "--adapter", help="Override default adapter"
523
+ ),
524
+ ) -> None:
525
+ """Add a comment to a ticket."""
526
+
527
+ async def _comment():
528
+ adapter_instance = get_adapter(
529
+ override_adapter=adapter.value if adapter else None
530
+ )
531
+
532
+ # Create comment
533
+ comment_obj = Comment(
534
+ ticket_id=ticket_id,
535
+ content=content,
536
+ author="cli-user", # Could be made configurable
537
+ )
538
+
539
+ result = await adapter_instance.add_comment(comment_obj)
540
+ return result
541
+
542
+ try:
543
+ result = asyncio.run(_comment())
544
+ console.print("[green]✓[/green] Comment added successfully")
545
+ if result.id:
546
+ console.print(f"Comment ID: {result.id}")
547
+ console.print(f"Content: {content}")
548
+ except Exception as e:
549
+ console.print(f"[red]✗[/red] Failed to add comment: {e}")
550
+ raise typer.Exit(1)
551
+
552
+
553
+ @app.command()
554
+ def update(
555
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
556
+ title: Optional[str] = typer.Option(None, "--title", help="New title"),
557
+ description: Optional[str] = typer.Option(
558
+ None, "--description", "-d", help="New description"
559
+ ),
560
+ priority: Optional[Priority] = typer.Option(
561
+ None, "--priority", "-p", help="New priority"
562
+ ),
563
+ assignee: Optional[str] = typer.Option(
564
+ None, "--assignee", "-a", help="New assignee"
565
+ ),
566
+ adapter: Optional[AdapterType] = typer.Option(
567
+ None, "--adapter", help="Override default adapter"
568
+ ),
569
+ ) -> None:
570
+ """Update ticket fields."""
571
+ updates = {}
572
+ if title:
573
+ updates["title"] = title
574
+ if description:
575
+ updates["description"] = description
576
+ if priority:
577
+ updates["priority"] = (
578
+ priority.value if isinstance(priority, Priority) else priority
579
+ )
580
+ if assignee:
581
+ updates["assignee"] = assignee
582
+
583
+ if not updates:
584
+ console.print("[yellow]No updates specified[/yellow]")
585
+ raise typer.Exit(1)
586
+
587
+ # Get the adapter name
588
+ config = load_config()
589
+ adapter_name = (
590
+ adapter.value if adapter else config.get("default_adapter", "aitrackdown")
591
+ )
592
+
593
+ # Add ticket_id to updates
594
+ updates["ticket_id"] = ticket_id
595
+
596
+ # Add to queue with explicit project directory
597
+ queue = Queue()
598
+ queue_id = queue.add(
599
+ ticket_data=updates,
600
+ adapter=adapter_name,
601
+ operation="update",
602
+ project_dir=str(Path.cwd()), # Explicitly pass current project directory
603
+ )
604
+
605
+ console.print(f"[green]✓[/green] Queued ticket update: {queue_id}")
606
+ for key, value in updates.items():
607
+ if key != "ticket_id":
608
+ console.print(f" {key}: {value}")
609
+ console.print("[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]")
610
+
611
+ # Start worker if needed
612
+ manager = WorkerManager()
613
+ if manager.start_if_needed():
614
+ console.print("[dim]Worker started to process request[/dim]")
615
+
616
+
617
+ @app.command()
618
+ def transition(
619
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
620
+ state_positional: Optional[TicketState] = typer.Argument(
621
+ None, help="Target state (positional - deprecated, use --state instead)"
622
+ ),
623
+ state: Optional[TicketState] = typer.Option(
624
+ None, "--state", "-s", help="Target state (recommended)"
625
+ ),
626
+ adapter: Optional[AdapterType] = typer.Option(
627
+ None, "--adapter", help="Override default adapter"
628
+ ),
629
+ ) -> None:
630
+ """Change ticket state with validation.
631
+
632
+ Examples:
633
+ # Recommended syntax with flag:
634
+ mcp-ticketer ticket transition BTA-215 --state done
635
+ mcp-ticketer ticket transition BTA-215 -s in_progress
636
+
637
+ # Legacy positional syntax (still supported):
638
+ mcp-ticketer ticket transition BTA-215 done
639
+
640
+ """
641
+ # Determine which state to use (prefer flag over positional)
642
+ target_state = state if state is not None else state_positional
643
+
644
+ if target_state is None:
645
+ console.print("[red]Error: State is required[/red]")
646
+ console.print(
647
+ "Use either:\n"
648
+ " - Flag syntax (recommended): mcp-ticketer ticket transition TICKET-ID --state STATE\n"
649
+ " - Positional syntax: mcp-ticketer ticket transition TICKET-ID STATE"
650
+ )
651
+ raise typer.Exit(1)
652
+
653
+ # Get the adapter name
654
+ config = load_config()
655
+ adapter_name = (
656
+ adapter.value if adapter else config.get("default_adapter", "aitrackdown")
657
+ )
658
+
659
+ # Add to queue with explicit project directory
660
+ queue = Queue()
661
+ queue_id = queue.add(
662
+ ticket_data={
663
+ "ticket_id": ticket_id,
664
+ "state": (
665
+ target_state.value if hasattr(target_state, "value") else target_state
666
+ ),
667
+ },
668
+ adapter=adapter_name,
669
+ operation="transition",
670
+ project_dir=str(Path.cwd()), # Explicitly pass current project directory
671
+ )
672
+
673
+ console.print(f"[green]✓[/green] Queued state transition: {queue_id}")
674
+ console.print(f" Ticket: {ticket_id} → {target_state}")
675
+ console.print("[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]")
676
+
677
+ # Start worker if needed
678
+ manager = WorkerManager()
679
+ if manager.start_if_needed():
680
+ console.print("[dim]Worker started to process request[/dim]")
681
+
682
+
683
+ @app.command()
684
+ def search(
685
+ query: Optional[str] = typer.Argument(None, help="Search query"),
686
+ state: Optional[TicketState] = typer.Option(None, "--state", "-s"),
687
+ priority: Optional[Priority] = typer.Option(None, "--priority", "-p"),
688
+ assignee: Optional[str] = typer.Option(None, "--assignee", "-a"),
689
+ limit: int = typer.Option(10, "--limit", "-l"),
690
+ adapter: Optional[AdapterType] = typer.Option(
691
+ None, "--adapter", help="Override default adapter"
692
+ ),
693
+ ) -> None:
694
+ """Search tickets with advanced query."""
695
+
696
+ async def _search():
697
+ adapter_instance = get_adapter(
698
+ override_adapter=adapter.value if adapter else None
699
+ )
700
+ search_query = SearchQuery(
701
+ query=query,
702
+ state=state,
703
+ priority=priority,
704
+ assignee=assignee,
705
+ limit=limit,
706
+ )
707
+ return await adapter_instance.search(search_query)
708
+
709
+ tickets = asyncio.run(_search())
710
+
711
+ if not tickets:
712
+ console.print("[yellow]No tickets found matching query[/yellow]")
713
+ return
714
+
715
+ # Display results
716
+ console.print(f"\n[bold]Found {len(tickets)} ticket(s)[/bold]\n")
717
+
718
+ for ticket in tickets:
719
+ console.print(f"[cyan]{ticket.id}[/cyan]: {ticket.title}")
720
+ console.print(f" State: {ticket.state} | Priority: {ticket.priority}")
721
+ if ticket.assignee:
722
+ console.print(f" Assignee: {ticket.assignee}")
723
+ console.print()
724
+
725
+
726
+ @app.command()
727
+ def check(queue_id: str = typer.Argument(..., help="Queue ID to check")):
728
+ """Check status of a queued operation."""
729
+ queue = Queue()
730
+ item = queue.get_item(queue_id)
731
+
732
+ if not item:
733
+ console.print(f"[red]Queue item not found: {queue_id}[/red]")
734
+ raise typer.Exit(1)
735
+
736
+ # Display status
737
+ console.print(f"\n[bold]Queue Item: {item.id}[/bold]")
738
+ console.print(f"Operation: {item.operation}")
739
+ console.print(f"Adapter: {item.adapter}")
740
+
741
+ # Status with color
742
+ if item.status == QueueStatus.COMPLETED:
743
+ console.print(f"Status: [green]{item.status}[/green]")
744
+ elif item.status == QueueStatus.FAILED:
745
+ console.print(f"Status: [red]{item.status}[/red]")
746
+ elif item.status == QueueStatus.PROCESSING:
747
+ console.print(f"Status: [yellow]{item.status}[/yellow]")
748
+ else:
749
+ console.print(f"Status: {item.status}")
750
+
751
+ # Timestamps
752
+ console.print(f"Created: {item.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
753
+ if item.processed_at:
754
+ console.print(f"Processed: {item.processed_at.strftime('%Y-%m-%d %H:%M:%S')}")
755
+
756
+ # Error or result
757
+ if item.error_message:
758
+ console.print(f"\n[red]Error:[/red] {item.error_message}")
759
+ elif item.result:
760
+ console.print("\n[green]Result:[/green]")
761
+ for key, value in item.result.items():
762
+ console.print(f" {key}: {value}")
763
+
764
+ if item.retry_count > 0:
765
+ console.print(f"\nRetry Count: {item.retry_count}")