mcp-ticketer 0.1.1__py3-none-any.whl

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

Potentially problematic release.


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

@@ -0,0 +1,812 @@
1
+ """CLI implementation using Typer."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Optional, List
8
+ from enum import Enum
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich import print as rprint
14
+ from dotenv import load_dotenv
15
+
16
+ from ..core import Task, TicketState, Priority, AdapterRegistry
17
+ from ..core.models import SearchQuery
18
+ from ..adapters import AITrackdownAdapter
19
+ from ..queue import Queue, QueueStatus, WorkerManager
20
+ from .queue_commands import app as queue_app
21
+
22
+ # Load environment variables
23
+ load_dotenv()
24
+
25
+ app = typer.Typer(
26
+ name="mcp-ticketer",
27
+ help="Universal ticket management interface",
28
+ add_completion=False,
29
+ )
30
+ console = Console()
31
+
32
+ # Configuration file management
33
+ CONFIG_FILE = Path.home() / ".mcp-ticketer" / "config.json"
34
+
35
+
36
+ class AdapterType(str, Enum):
37
+ """Available adapter types."""
38
+ AITRACKDOWN = "aitrackdown"
39
+ LINEAR = "linear"
40
+ JIRA = "jira"
41
+ GITHUB = "github"
42
+
43
+
44
+ def load_config() -> dict:
45
+ """Load configuration from file."""
46
+ if CONFIG_FILE.exists():
47
+ with open(CONFIG_FILE, "r") as f:
48
+ return json.load(f)
49
+ return {"adapter": "aitrackdown", "config": {"base_path": ".aitrackdown"}}
50
+
51
+
52
+ def save_config(config: dict) -> None:
53
+ """Save configuration to file."""
54
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
55
+ with open(CONFIG_FILE, "w") as f:
56
+ json.dump(config, f, indent=2)
57
+
58
+
59
+ def merge_config(updates: dict) -> dict:
60
+ """Merge updates into existing config.
61
+
62
+ Args:
63
+ updates: Configuration updates to merge
64
+
65
+ Returns:
66
+ Updated configuration
67
+ """
68
+ config = load_config()
69
+
70
+ # Handle default_adapter
71
+ if "default_adapter" in updates:
72
+ config["default_adapter"] = updates["default_adapter"]
73
+
74
+ # Handle adapter-specific configurations
75
+ if "adapters" in updates:
76
+ if "adapters" not in config:
77
+ config["adapters"] = {}
78
+ for adapter_name, adapter_config in updates["adapters"].items():
79
+ if adapter_name not in config["adapters"]:
80
+ config["adapters"][adapter_name] = {}
81
+ config["adapters"][adapter_name].update(adapter_config)
82
+
83
+ return config
84
+
85
+
86
+ def get_adapter(override_adapter: Optional[str] = None, override_config: Optional[dict] = None):
87
+ """Get configured adapter instance.
88
+
89
+ Args:
90
+ override_adapter: Override the default adapter type
91
+ override_config: Override configuration for the adapter
92
+ """
93
+ config = load_config()
94
+
95
+ # Use override adapter if provided, otherwise use default
96
+ if override_adapter:
97
+ adapter_type = override_adapter
98
+ # If we have a stored config for this adapter, use it
99
+ adapters_config = config.get("adapters", {})
100
+ adapter_config = adapters_config.get(adapter_type, {})
101
+ # Override with provided config if any
102
+ if override_config:
103
+ adapter_config.update(override_config)
104
+ else:
105
+ # Use default adapter from config
106
+ adapter_type = config.get("default_adapter", "aitrackdown")
107
+ # Get config for the default adapter
108
+ adapters_config = config.get("adapters", {})
109
+ adapter_config = adapters_config.get(adapter_type, {})
110
+
111
+ # Fallback to legacy config format for backward compatibility
112
+ if not adapter_config and "config" in config:
113
+ adapter_config = config["config"]
114
+
115
+ # Add environment variables for authentication
116
+ import os
117
+ if adapter_type == "linear":
118
+ if not adapter_config.get("api_key"):
119
+ adapter_config["api_key"] = os.getenv("LINEAR_API_KEY")
120
+ elif adapter_type == "github":
121
+ if not adapter_config.get("api_key") and not adapter_config.get("token"):
122
+ adapter_config["api_key"] = os.getenv("GITHUB_TOKEN")
123
+ elif adapter_type == "jira":
124
+ if not adapter_config.get("api_token"):
125
+ adapter_config["api_token"] = os.getenv("JIRA_ACCESS_TOKEN")
126
+ if not adapter_config.get("email"):
127
+ adapter_config["email"] = os.getenv("JIRA_ACCESS_USER")
128
+
129
+ return AdapterRegistry.get_adapter(adapter_type, adapter_config)
130
+
131
+
132
+ @app.command()
133
+ def init(
134
+ adapter: AdapterType = typer.Option(
135
+ AdapterType.AITRACKDOWN,
136
+ "--adapter",
137
+ "-a",
138
+ help="Adapter type to use"
139
+ ),
140
+ base_path: Optional[str] = typer.Option(
141
+ None,
142
+ "--base-path",
143
+ "-p",
144
+ help="Base path for ticket storage (AITrackdown only)"
145
+ ),
146
+ api_key: Optional[str] = typer.Option(
147
+ None,
148
+ "--api-key",
149
+ help="API key for Linear or API token for JIRA"
150
+ ),
151
+ team_id: Optional[str] = typer.Option(
152
+ None,
153
+ "--team-id",
154
+ help="Linear team ID (required for Linear adapter)"
155
+ ),
156
+ jira_server: Optional[str] = typer.Option(
157
+ None,
158
+ "--jira-server",
159
+ help="JIRA server URL (e.g., https://company.atlassian.net)"
160
+ ),
161
+ jira_email: Optional[str] = typer.Option(
162
+ None,
163
+ "--jira-email",
164
+ help="JIRA user email for authentication"
165
+ ),
166
+ jira_project: Optional[str] = typer.Option(
167
+ None,
168
+ "--jira-project",
169
+ help="Default JIRA project key"
170
+ ),
171
+ github_owner: Optional[str] = typer.Option(
172
+ None,
173
+ "--github-owner",
174
+ help="GitHub repository owner"
175
+ ),
176
+ github_repo: Optional[str] = typer.Option(
177
+ None,
178
+ "--github-repo",
179
+ help="GitHub repository name"
180
+ ),
181
+ github_token: Optional[str] = typer.Option(
182
+ None,
183
+ "--github-token",
184
+ help="GitHub Personal Access Token"
185
+ ),
186
+ ) -> None:
187
+ """Initialize MCP Ticketer configuration."""
188
+ config = {
189
+ "default_adapter": adapter.value,
190
+ "adapters": {}
191
+ }
192
+
193
+ if adapter == AdapterType.AITRACKDOWN:
194
+ config["adapters"]["aitrackdown"] = {"base_path": base_path or ".aitrackdown"}
195
+ elif adapter == AdapterType.LINEAR:
196
+ # For Linear, we need team_id and optionally api_key
197
+ if not team_id:
198
+ console.print("[red]Error:[/red] --team-id is required for Linear adapter")
199
+ raise typer.Exit(1)
200
+
201
+ config["adapters"]["linear"] = {"team_id": team_id}
202
+
203
+ # Check for API key in environment or parameter
204
+ linear_api_key = api_key or os.getenv("LINEAR_API_KEY")
205
+ if not linear_api_key:
206
+ console.print("[yellow]Warning:[/yellow] No Linear API key provided.")
207
+ console.print("Set LINEAR_API_KEY environment variable or use --api-key option")
208
+ else:
209
+ config["adapters"]["linear"]["api_key"] = linear_api_key
210
+
211
+ elif adapter == AdapterType.JIRA:
212
+ # For JIRA, we need server, email, and API token
213
+ server = jira_server or os.getenv("JIRA_SERVER")
214
+ email = jira_email or os.getenv("JIRA_EMAIL")
215
+ token = api_key or os.getenv("JIRA_API_TOKEN")
216
+ project = jira_project or os.getenv("JIRA_PROJECT_KEY")
217
+
218
+ if not server:
219
+ console.print("[red]Error:[/red] JIRA server URL is required")
220
+ console.print("Use --jira-server or set JIRA_SERVER environment variable")
221
+ raise typer.Exit(1)
222
+
223
+ if not email:
224
+ console.print("[red]Error:[/red] JIRA email is required")
225
+ console.print("Use --jira-email or set JIRA_EMAIL environment variable")
226
+ raise typer.Exit(1)
227
+
228
+ if not token:
229
+ console.print("[red]Error:[/red] JIRA API token is required")
230
+ console.print("Use --api-key or set JIRA_API_TOKEN environment variable")
231
+ console.print("[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]")
232
+ raise typer.Exit(1)
233
+
234
+ config["adapters"]["jira"] = {
235
+ "server": server,
236
+ "email": email,
237
+ "api_token": token
238
+ }
239
+
240
+ if project:
241
+ config["adapters"]["jira"]["project_key"] = project
242
+ else:
243
+ console.print("[yellow]Warning:[/yellow] No default project key specified")
244
+ console.print("You may need to specify project key for some operations")
245
+
246
+ elif adapter == AdapterType.GITHUB:
247
+ # For GitHub, we need owner, repo, and token
248
+ owner = github_owner or os.getenv("GITHUB_OWNER")
249
+ repo = github_repo or os.getenv("GITHUB_REPO")
250
+ token = github_token or os.getenv("GITHUB_TOKEN")
251
+
252
+ if not owner:
253
+ console.print("[red]Error:[/red] GitHub repository owner is required")
254
+ console.print("Use --github-owner or set GITHUB_OWNER environment variable")
255
+ raise typer.Exit(1)
256
+
257
+ if not repo:
258
+ console.print("[red]Error:[/red] GitHub repository name is required")
259
+ console.print("Use --github-repo or set GITHUB_REPO environment variable")
260
+ raise typer.Exit(1)
261
+
262
+ if not token:
263
+ console.print("[red]Error:[/red] GitHub Personal Access Token is required")
264
+ console.print("Use --github-token or set GITHUB_TOKEN environment variable")
265
+ console.print("[dim]Create token at: https://github.com/settings/tokens/new[/dim]")
266
+ console.print("[dim]Required scopes: repo (for private repos) or public_repo (for public repos)[/dim]")
267
+ raise typer.Exit(1)
268
+
269
+ config["adapters"]["github"] = {
270
+ "owner": owner,
271
+ "repo": repo,
272
+ "token": token
273
+ }
274
+
275
+ save_config(config)
276
+ console.print(f"[green]✓[/green] Initialized with {adapter.value} adapter")
277
+ console.print(f"[dim]Configuration saved to {CONFIG_FILE}[/dim]")
278
+
279
+
280
+ @app.command("set")
281
+ def set_config(
282
+ adapter: Optional[AdapterType] = typer.Option(
283
+ None,
284
+ "--adapter",
285
+ "-a",
286
+ help="Set default adapter"
287
+ ),
288
+ team_key: Optional[str] = typer.Option(
289
+ None,
290
+ "--team-key",
291
+ help="Linear team key (e.g., BTA)"
292
+ ),
293
+ team_id: Optional[str] = typer.Option(
294
+ None,
295
+ "--team-id",
296
+ help="Linear team ID"
297
+ ),
298
+ owner: Optional[str] = typer.Option(
299
+ None,
300
+ "--owner",
301
+ help="GitHub repository owner"
302
+ ),
303
+ repo: Optional[str] = typer.Option(
304
+ None,
305
+ "--repo",
306
+ help="GitHub repository name"
307
+ ),
308
+ server: Optional[str] = typer.Option(
309
+ None,
310
+ "--server",
311
+ help="JIRA server URL"
312
+ ),
313
+ project: Optional[str] = typer.Option(
314
+ None,
315
+ "--project",
316
+ help="JIRA project key"
317
+ ),
318
+ base_path: Optional[str] = typer.Option(
319
+ None,
320
+ "--base-path",
321
+ help="AITrackdown base path"
322
+ ),
323
+ ) -> None:
324
+ """Set default adapter and adapter-specific configuration.
325
+
326
+ When called without arguments, shows current configuration.
327
+ """
328
+ if not any([adapter, team_key, team_id, owner, repo, server, project, base_path]):
329
+ # Show current configuration
330
+ config = load_config()
331
+ console.print("[bold]Current Configuration:[/bold]")
332
+ console.print(f"Default adapter: [cyan]{config.get('default_adapter', 'aitrackdown')}[/cyan]")
333
+
334
+ adapters_config = config.get("adapters", {})
335
+ if adapters_config:
336
+ console.print("\n[bold]Adapter Settings:[/bold]")
337
+ for adapter_name, adapter_config in adapters_config.items():
338
+ console.print(f"\n[cyan]{adapter_name}:[/cyan]")
339
+ for key, value in adapter_config.items():
340
+ # Don't display sensitive values like tokens
341
+ if "token" in key.lower() or "key" in key.lower() and "team" not in key.lower():
342
+ value = "***" if value else "not set"
343
+ console.print(f" {key}: {value}")
344
+ return
345
+
346
+ updates = {}
347
+
348
+ # Set default adapter
349
+ if adapter:
350
+ updates["default_adapter"] = adapter.value
351
+ console.print(f"[green]✓[/green] Default adapter set to: {adapter.value}")
352
+
353
+ # Build adapter-specific configuration
354
+ adapter_configs = {}
355
+
356
+ # Linear configuration
357
+ if team_key or team_id:
358
+ linear_config = {}
359
+ if team_key:
360
+ linear_config["team_key"] = team_key
361
+ if team_id:
362
+ linear_config["team_id"] = team_id
363
+ adapter_configs["linear"] = linear_config
364
+ console.print(f"[green]✓[/green] Linear settings updated")
365
+
366
+ # GitHub configuration
367
+ if owner or repo:
368
+ github_config = {}
369
+ if owner:
370
+ github_config["owner"] = owner
371
+ if repo:
372
+ github_config["repo"] = repo
373
+ adapter_configs["github"] = github_config
374
+ console.print(f"[green]✓[/green] GitHub settings updated")
375
+
376
+ # JIRA configuration
377
+ if server or project:
378
+ jira_config = {}
379
+ if server:
380
+ jira_config["server"] = server
381
+ if project:
382
+ jira_config["project_key"] = project
383
+ adapter_configs["jira"] = jira_config
384
+ console.print(f"[green]✓[/green] JIRA settings updated")
385
+
386
+ # AITrackdown configuration
387
+ if base_path:
388
+ adapter_configs["aitrackdown"] = {"base_path": base_path}
389
+ console.print(f"[green]✓[/green] AITrackdown settings updated")
390
+
391
+ if adapter_configs:
392
+ updates["adapters"] = adapter_configs
393
+
394
+ # Merge and save configuration
395
+ if updates:
396
+ config = merge_config(updates)
397
+ save_config(config)
398
+ console.print(f"[dim]Configuration saved to {CONFIG_FILE}[/dim]")
399
+
400
+
401
+ @app.command("status")
402
+ def status_command():
403
+ """Show queue and worker status."""
404
+ queue = Queue()
405
+ manager = WorkerManager()
406
+
407
+ # Get queue stats
408
+ stats = queue.get_stats()
409
+ pending = stats.get(QueueStatus.PENDING.value, 0)
410
+
411
+ # Show queue status
412
+ console.print("[bold]Queue Status:[/bold]")
413
+ console.print(f" Pending: {pending}")
414
+ console.print(f" Processing: {stats.get(QueueStatus.PROCESSING.value, 0)}")
415
+ console.print(f" Completed: {stats.get(QueueStatus.COMPLETED.value, 0)}")
416
+ console.print(f" Failed: {stats.get(QueueStatus.FAILED.value, 0)}")
417
+
418
+ # Show worker status
419
+ worker_status = manager.get_status()
420
+ if worker_status["running"]:
421
+ console.print(f"\n[green]● Worker is running[/green] (PID: {worker_status.get('pid')})")
422
+ else:
423
+ console.print("\n[red]○ Worker is not running[/red]")
424
+ if pending > 0:
425
+ console.print("[yellow]Note: There are pending items. Start worker with 'mcp-ticketer worker start'[/yellow]")
426
+
427
+
428
+ @app.command()
429
+ def create(
430
+ title: str = typer.Argument(..., help="Ticket title"),
431
+ description: Optional[str] = typer.Option(
432
+ None,
433
+ "--description",
434
+ "-d",
435
+ help="Ticket description"
436
+ ),
437
+ priority: Priority = typer.Option(
438
+ Priority.MEDIUM,
439
+ "--priority",
440
+ "-p",
441
+ help="Priority level"
442
+ ),
443
+ tags: Optional[List[str]] = typer.Option(
444
+ None,
445
+ "--tag",
446
+ "-t",
447
+ help="Tags (can be specified multiple times)"
448
+ ),
449
+ assignee: Optional[str] = typer.Option(
450
+ None,
451
+ "--assignee",
452
+ "-a",
453
+ help="Assignee username"
454
+ ),
455
+ adapter: Optional[AdapterType] = typer.Option(
456
+ None,
457
+ "--adapter",
458
+ help="Override default adapter"
459
+ ),
460
+ ) -> None:
461
+ """Create a new ticket."""
462
+ # Get the adapter name
463
+ config = load_config()
464
+ adapter_name = adapter.value if adapter else config.get("default_adapter", "aitrackdown")
465
+
466
+ # Create task data
467
+ task_data = {
468
+ "title": title,
469
+ "description": description,
470
+ "priority": priority.value if isinstance(priority, Priority) else priority,
471
+ "tags": tags or [],
472
+ "assignee": assignee,
473
+ }
474
+
475
+ # Add to queue
476
+ queue = Queue()
477
+ queue_id = queue.add(
478
+ ticket_data=task_data,
479
+ adapter=adapter_name,
480
+ operation="create"
481
+ )
482
+
483
+ console.print(f"[green]✓[/green] Queued ticket creation: {queue_id}")
484
+ console.print(f" Title: {title}")
485
+ console.print(f" Priority: {priority}")
486
+ console.print("[dim]Use 'mcp-ticketer status {queue_id}' to check progress[/dim]")
487
+
488
+ # Start worker if needed
489
+ manager = WorkerManager()
490
+ if manager.start_if_needed():
491
+ console.print("[dim]Worker started to process request[/dim]")
492
+
493
+
494
+ @app.command("list")
495
+ def list_tickets(
496
+ state: Optional[TicketState] = typer.Option(
497
+ None,
498
+ "--state",
499
+ "-s",
500
+ help="Filter by state"
501
+ ),
502
+ priority: Optional[Priority] = typer.Option(
503
+ None,
504
+ "--priority",
505
+ "-p",
506
+ help="Filter by priority"
507
+ ),
508
+ limit: int = typer.Option(
509
+ 10,
510
+ "--limit",
511
+ "-l",
512
+ help="Maximum number of tickets"
513
+ ),
514
+ adapter: Optional[AdapterType] = typer.Option(
515
+ None,
516
+ "--adapter",
517
+ help="Override default adapter"
518
+ ),
519
+ ) -> None:
520
+ """List tickets with optional filters."""
521
+ async def _list():
522
+ adapter_instance = get_adapter(override_adapter=adapter.value if adapter else None)
523
+ filters = {}
524
+ if state:
525
+ filters["state"] = state
526
+ if priority:
527
+ filters["priority"] = priority
528
+ return await adapter_instance.list(limit=limit, filters=filters)
529
+
530
+ tickets = asyncio.run(_list())
531
+
532
+ if not tickets:
533
+ console.print("[yellow]No tickets found[/yellow]")
534
+ return
535
+
536
+ # Create table
537
+ table = Table(title="Tickets")
538
+ table.add_column("ID", style="cyan", no_wrap=True)
539
+ table.add_column("Title", style="white")
540
+ table.add_column("State", style="green")
541
+ table.add_column("Priority", style="yellow")
542
+ table.add_column("Assignee", style="blue")
543
+
544
+ for ticket in tickets:
545
+ table.add_row(
546
+ ticket.id or "N/A",
547
+ ticket.title,
548
+ ticket.state,
549
+ ticket.priority,
550
+ ticket.assignee or "-",
551
+ )
552
+
553
+ console.print(table)
554
+
555
+
556
+ @app.command()
557
+ def show(
558
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
559
+ comments: bool = typer.Option(
560
+ False,
561
+ "--comments",
562
+ "-c",
563
+ help="Show comments"
564
+ ),
565
+ adapter: Optional[AdapterType] = typer.Option(
566
+ None,
567
+ "--adapter",
568
+ help="Override default adapter"
569
+ ),
570
+ ) -> None:
571
+ """Show detailed ticket information."""
572
+ async def _show():
573
+ adapter_instance = get_adapter(override_adapter=adapter.value if adapter else None)
574
+ ticket = await adapter_instance.read(ticket_id)
575
+ ticket_comments = None
576
+ if comments and ticket:
577
+ ticket_comments = await adapter_instance.get_comments(ticket_id)
578
+ return ticket, ticket_comments
579
+
580
+ ticket, ticket_comments = asyncio.run(_show())
581
+
582
+ if not ticket:
583
+ console.print(f"[red]✗[/red] Ticket not found: {ticket_id}")
584
+ raise typer.Exit(1)
585
+
586
+ # Display ticket details
587
+ console.print(f"\n[bold]Ticket: {ticket.id}[/bold]")
588
+ console.print(f"Title: {ticket.title}")
589
+ console.print(f"State: [green]{ticket.state}[/green]")
590
+ console.print(f"Priority: [yellow]{ticket.priority}[/yellow]")
591
+
592
+ if ticket.description:
593
+ console.print(f"\n[dim]Description:[/dim]")
594
+ console.print(ticket.description)
595
+
596
+ if ticket.tags:
597
+ console.print(f"\nTags: {', '.join(ticket.tags)}")
598
+
599
+ if ticket.assignee:
600
+ console.print(f"Assignee: {ticket.assignee}")
601
+
602
+ # Display comments if requested
603
+ if ticket_comments:
604
+ console.print(f"\n[bold]Comments ({len(ticket_comments)}):[/bold]")
605
+ for comment in ticket_comments:
606
+ console.print(f"\n[dim]{comment.created_at} - {comment.author}:[/dim]")
607
+ console.print(comment.content)
608
+
609
+
610
+ @app.command()
611
+ def update(
612
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
613
+ title: Optional[str] = typer.Option(None, "--title", help="New title"),
614
+ description: Optional[str] = typer.Option(
615
+ None,
616
+ "--description",
617
+ "-d",
618
+ help="New description"
619
+ ),
620
+ priority: Optional[Priority] = typer.Option(
621
+ None,
622
+ "--priority",
623
+ "-p",
624
+ help="New priority"
625
+ ),
626
+ assignee: Optional[str] = typer.Option(
627
+ None,
628
+ "--assignee",
629
+ "-a",
630
+ help="New assignee"
631
+ ),
632
+ adapter: Optional[AdapterType] = typer.Option(
633
+ None,
634
+ "--adapter",
635
+ help="Override default adapter"
636
+ ),
637
+ ) -> None:
638
+ """Update ticket fields."""
639
+ updates = {}
640
+ if title:
641
+ updates["title"] = title
642
+ if description:
643
+ updates["description"] = description
644
+ if priority:
645
+ updates["priority"] = priority.value if isinstance(priority, Priority) else priority
646
+ if assignee:
647
+ updates["assignee"] = assignee
648
+
649
+ if not updates:
650
+ console.print("[yellow]No updates specified[/yellow]")
651
+ raise typer.Exit(1)
652
+
653
+ # Get the adapter name
654
+ config = load_config()
655
+ adapter_name = adapter.value if adapter else config.get("default_adapter", "aitrackdown")
656
+
657
+ # Add ticket_id to updates
658
+ updates["ticket_id"] = ticket_id
659
+
660
+ # Add to queue
661
+ queue = Queue()
662
+ queue_id = queue.add(
663
+ ticket_data=updates,
664
+ adapter=adapter_name,
665
+ operation="update"
666
+ )
667
+
668
+ console.print(f"[green]✓[/green] Queued ticket update: {queue_id}")
669
+ for key, value in updates.items():
670
+ if key != "ticket_id":
671
+ console.print(f" {key}: {value}")
672
+ console.print("[dim]Use 'mcp-ticketer status {queue_id}' to check progress[/dim]")
673
+
674
+ # Start worker if needed
675
+ manager = WorkerManager()
676
+ if manager.start_if_needed():
677
+ console.print("[dim]Worker started to process request[/dim]")
678
+
679
+
680
+ @app.command()
681
+ def transition(
682
+ ticket_id: str = typer.Argument(..., help="Ticket ID"),
683
+ state: TicketState = typer.Argument(..., help="Target state"),
684
+ adapter: Optional[AdapterType] = typer.Option(
685
+ None,
686
+ "--adapter",
687
+ help="Override default adapter"
688
+ ),
689
+ ) -> None:
690
+ """Change ticket state with validation."""
691
+ # Get the adapter name
692
+ config = load_config()
693
+ adapter_name = adapter.value if adapter else config.get("default_adapter", "aitrackdown")
694
+
695
+ # Add to queue
696
+ queue = Queue()
697
+ queue_id = queue.add(
698
+ ticket_data={
699
+ "ticket_id": ticket_id,
700
+ "state": state.value if hasattr(state, 'value') else state
701
+ },
702
+ adapter=adapter_name,
703
+ operation="transition"
704
+ )
705
+
706
+ console.print(f"[green]✓[/green] Queued state transition: {queue_id}")
707
+ console.print(f" Ticket: {ticket_id} → {state}")
708
+ console.print("[dim]Use 'mcp-ticketer status {queue_id}' to check progress[/dim]")
709
+
710
+ # Start worker if needed
711
+ manager = WorkerManager()
712
+ if manager.start_if_needed():
713
+ console.print("[dim]Worker started to process request[/dim]")
714
+
715
+
716
+ @app.command()
717
+ def search(
718
+ query: Optional[str] = typer.Argument(None, help="Search query"),
719
+ state: Optional[TicketState] = typer.Option(None, "--state", "-s"),
720
+ priority: Optional[Priority] = typer.Option(None, "--priority", "-p"),
721
+ assignee: Optional[str] = typer.Option(None, "--assignee", "-a"),
722
+ limit: int = typer.Option(10, "--limit", "-l"),
723
+ adapter: Optional[AdapterType] = typer.Option(
724
+ None,
725
+ "--adapter",
726
+ help="Override default adapter"
727
+ ),
728
+ ) -> None:
729
+ """Search tickets with advanced query."""
730
+ async def _search():
731
+ adapter_instance = get_adapter(override_adapter=adapter.value if adapter else None)
732
+ search_query = SearchQuery(
733
+ query=query,
734
+ state=state,
735
+ priority=priority,
736
+ assignee=assignee,
737
+ limit=limit,
738
+ )
739
+ return await adapter_instance.search(search_query)
740
+
741
+ tickets = asyncio.run(_search())
742
+
743
+ if not tickets:
744
+ console.print("[yellow]No tickets found matching query[/yellow]")
745
+ return
746
+
747
+ # Display results
748
+ console.print(f"\n[bold]Found {len(tickets)} ticket(s)[/bold]\n")
749
+
750
+ for ticket in tickets:
751
+ console.print(f"[cyan]{ticket.id}[/cyan]: {ticket.title}")
752
+ console.print(f" State: {ticket.state} | Priority: {ticket.priority}")
753
+ if ticket.assignee:
754
+ console.print(f" Assignee: {ticket.assignee}")
755
+ console.print()
756
+
757
+
758
+ # Add queue command to main app
759
+ app.add_typer(queue_app, name="queue")
760
+
761
+
762
+ @app.command()
763
+ def check(
764
+ queue_id: str = typer.Argument(..., help="Queue ID to check")
765
+ ):
766
+ """Check status of a queued operation."""
767
+ queue = Queue()
768
+ item = queue.get_item(queue_id)
769
+
770
+ if not item:
771
+ console.print(f"[red]Queue item not found: {queue_id}[/red]")
772
+ raise typer.Exit(1)
773
+
774
+ # Display status
775
+ console.print(f"\n[bold]Queue Item: {item.id}[/bold]")
776
+ console.print(f"Operation: {item.operation}")
777
+ console.print(f"Adapter: {item.adapter}")
778
+
779
+ # Status with color
780
+ if item.status == QueueStatus.COMPLETED:
781
+ console.print(f"Status: [green]{item.status}[/green]")
782
+ elif item.status == QueueStatus.FAILED:
783
+ console.print(f"Status: [red]{item.status}[/red]")
784
+ elif item.status == QueueStatus.PROCESSING:
785
+ console.print(f"Status: [yellow]{item.status}[/yellow]")
786
+ else:
787
+ console.print(f"Status: {item.status}")
788
+
789
+ # Timestamps
790
+ console.print(f"Created: {item.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
791
+ if item.processed_at:
792
+ console.print(f"Processed: {item.processed_at.strftime('%Y-%m-%d %H:%M:%S')}")
793
+
794
+ # Error or result
795
+ if item.error_message:
796
+ console.print(f"\n[red]Error:[/red] {item.error_message}")
797
+ elif item.result:
798
+ console.print(f"\n[green]Result:[/green]")
799
+ for key, value in item.result.items():
800
+ console.print(f" {key}: {value}")
801
+
802
+ if item.retry_count > 0:
803
+ console.print(f"\nRetry Count: {item.retry_count}")
804
+
805
+
806
+ def main():
807
+ """Main entry point."""
808
+ app()
809
+
810
+
811
+ if __name__ == "__main__":
812
+ main()