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,523 @@
1
+ """Centralized CLI utilities and common patterns."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from typing import Optional, Dict, Any, Callable, TypeVar, List
7
+ from pathlib import Path
8
+ from functools import wraps
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich.panel import Panel
14
+ from rich.progress import Progress, SpinnerColumn, TextColumn
15
+
16
+ from ..core import Task, TicketState, Priority, AdapterRegistry
17
+ from ..core.config import get_config, get_adapter_config
18
+ from ..queue import Queue, WorkerManager, QueueStatus
19
+
20
+ # Type variable for async functions
21
+ T = TypeVar('T')
22
+
23
+ console = Console()
24
+
25
+
26
+ class CommonPatterns:
27
+ """Common CLI patterns and utilities."""
28
+
29
+ # Configuration file management
30
+ CONFIG_FILE = Path.home() / ".mcp-ticketer" / "config.json"
31
+
32
+ @staticmethod
33
+ def load_config() -> dict:
34
+ """Load configuration from file."""
35
+ if CommonPatterns.CONFIG_FILE.exists():
36
+ with open(CommonPatterns.CONFIG_FILE, "r") as f:
37
+ return json.load(f)
38
+ return {"adapter": "aitrackdown", "config": {"base_path": ".aitrackdown"}}
39
+
40
+ @staticmethod
41
+ def save_config(config: dict) -> None:
42
+ """Save configuration to file."""
43
+ CommonPatterns.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
44
+ with open(CommonPatterns.CONFIG_FILE, "w") as f:
45
+ json.dump(config, f, indent=2)
46
+
47
+ @staticmethod
48
+ def merge_config(updates: dict) -> dict:
49
+ """Merge updates into existing config."""
50
+ config = CommonPatterns.load_config()
51
+
52
+ # Handle default_adapter
53
+ if "default_adapter" in updates:
54
+ config["default_adapter"] = updates["default_adapter"]
55
+
56
+ # Handle adapter-specific configurations
57
+ if "adapters" in updates:
58
+ if "adapters" not in config:
59
+ config["adapters"] = {}
60
+ for adapter_name, adapter_config in updates["adapters"].items():
61
+ if adapter_name not in config["adapters"]:
62
+ config["adapters"][adapter_name] = {}
63
+ config["adapters"][adapter_name].update(adapter_config)
64
+
65
+ return config
66
+
67
+ @staticmethod
68
+ def get_adapter(override_adapter: Optional[str] = None, override_config: Optional[dict] = None):
69
+ """Get configured adapter instance with environment variable support."""
70
+ config = CommonPatterns.load_config()
71
+
72
+ # Use override adapter if provided, otherwise use default
73
+ if override_adapter:
74
+ adapter_type = override_adapter
75
+ # If we have a stored config for this adapter, use it
76
+ adapters_config = config.get("adapters", {})
77
+ adapter_config = adapters_config.get(adapter_type, {})
78
+ # Override with provided config if any
79
+ if override_config:
80
+ adapter_config.update(override_config)
81
+ else:
82
+ # Use default adapter from config
83
+ adapter_type = config.get("default_adapter", "aitrackdown")
84
+ # Get config for the default adapter
85
+ adapters_config = config.get("adapters", {})
86
+ adapter_config = adapters_config.get(adapter_type, {})
87
+
88
+ # Fallback to legacy config format for backward compatibility
89
+ if not adapter_config and "config" in config:
90
+ adapter_config = config["config"]
91
+
92
+ # Add environment variables for authentication
93
+ adapter_config = CommonPatterns._add_env_auth(adapter_type, adapter_config)
94
+
95
+ return AdapterRegistry.get_adapter(adapter_type, adapter_config)
96
+
97
+ @staticmethod
98
+ def _add_env_auth(adapter_type: str, adapter_config: dict) -> dict:
99
+ """Add environment variable authentication to adapter config."""
100
+ auth_mapping = {
101
+ "linear": [("api_key", "LINEAR_API_KEY")],
102
+ "github": [("api_key", "GITHUB_TOKEN"), ("token", "GITHUB_TOKEN")],
103
+ "jira": [("api_token", "JIRA_ACCESS_TOKEN"), ("email", "JIRA_ACCESS_USER")],
104
+ }
105
+
106
+ if adapter_type in auth_mapping:
107
+ for config_key, env_var in auth_mapping[adapter_type]:
108
+ if not adapter_config.get(config_key):
109
+ env_value = os.getenv(env_var)
110
+ if env_value:
111
+ adapter_config[config_key] = env_value
112
+
113
+ return adapter_config
114
+
115
+ @staticmethod
116
+ def queue_operation(
117
+ ticket_data: Dict[str, Any],
118
+ operation: str,
119
+ adapter_name: Optional[str] = None,
120
+ show_progress: bool = True
121
+ ) -> str:
122
+ """Queue an operation and optionally start the worker."""
123
+ if not adapter_name:
124
+ config = CommonPatterns.load_config()
125
+ adapter_name = config.get("default_adapter", "aitrackdown")
126
+
127
+ # Add to queue
128
+ queue = Queue()
129
+ queue_id = queue.add(
130
+ ticket_data=ticket_data,
131
+ adapter=adapter_name,
132
+ operation=operation
133
+ )
134
+
135
+ if show_progress:
136
+ console.print(f"[green]✓[/green] Queued {operation}: {queue_id}")
137
+ console.print("[dim]Use 'mcp-ticketer check {queue_id}' to check progress[/dim]")
138
+
139
+ # Start worker if needed
140
+ manager = WorkerManager()
141
+ if manager.start_if_needed():
142
+ if show_progress:
143
+ console.print("[dim]Worker started to process request[/dim]")
144
+
145
+ return queue_id
146
+
147
+ @staticmethod
148
+ def display_ticket_table(tickets: List[Task], title: str = "Tickets") -> None:
149
+ """Display tickets in a formatted table."""
150
+ if not tickets:
151
+ console.print("[yellow]No tickets found[/yellow]")
152
+ return
153
+
154
+ table = Table(title=title)
155
+ table.add_column("ID", style="cyan", no_wrap=True)
156
+ table.add_column("Title", style="white")
157
+ table.add_column("State", style="green")
158
+ table.add_column("Priority", style="yellow")
159
+ table.add_column("Assignee", style="blue")
160
+
161
+ for ticket in tickets:
162
+ table.add_row(
163
+ ticket.id or "N/A",
164
+ ticket.title,
165
+ ticket.state,
166
+ ticket.priority,
167
+ ticket.assignee or "-",
168
+ )
169
+
170
+ console.print(table)
171
+
172
+ @staticmethod
173
+ def display_ticket_details(ticket: Task, comments: Optional[List] = None) -> None:
174
+ """Display detailed ticket information."""
175
+ console.print(f"\n[bold]Ticket: {ticket.id}[/bold]")
176
+ console.print(f"Title: {ticket.title}")
177
+ console.print(f"State: [green]{ticket.state}[/green]")
178
+ console.print(f"Priority: [yellow]{ticket.priority}[/yellow]")
179
+
180
+ if ticket.description:
181
+ console.print(f"\n[dim]Description:[/dim]")
182
+ console.print(ticket.description)
183
+
184
+ if ticket.tags:
185
+ console.print(f"\nTags: {', '.join(ticket.tags)}")
186
+
187
+ if ticket.assignee:
188
+ console.print(f"Assignee: {ticket.assignee}")
189
+
190
+ # Display comments if provided
191
+ if comments:
192
+ console.print(f"\n[bold]Comments ({len(comments)}):[/bold]")
193
+ for comment in comments:
194
+ console.print(f"\n[dim]{comment.created_at} - {comment.author}:[/dim]")
195
+ console.print(comment.content)
196
+
197
+ @staticmethod
198
+ def display_queue_status() -> None:
199
+ """Display queue and worker status."""
200
+ queue = Queue()
201
+ manager = WorkerManager()
202
+
203
+ # Get queue stats
204
+ stats = queue.get_stats()
205
+ pending = stats.get(QueueStatus.PENDING.value, 0)
206
+
207
+ # Show queue status
208
+ console.print("[bold]Queue Status:[/bold]")
209
+ console.print(f" Pending: {pending}")
210
+ console.print(f" Processing: {stats.get(QueueStatus.PROCESSING.value, 0)}")
211
+ console.print(f" Completed: {stats.get(QueueStatus.COMPLETED.value, 0)}")
212
+ console.print(f" Failed: {stats.get(QueueStatus.FAILED.value, 0)}")
213
+
214
+ # Show worker status
215
+ worker_status = manager.get_status()
216
+ if worker_status["running"]:
217
+ console.print(f"\n[green]● Worker is running[/green] (PID: {worker_status.get('pid')})")
218
+ else:
219
+ console.print("\n[red]○ Worker is not running[/red]")
220
+ if pending > 0:
221
+ console.print("[yellow]Note: There are pending items. Start worker with 'mcp-ticketer queue start'[/yellow]")
222
+
223
+
224
+ def async_command(f: Callable[..., T]) -> Callable[..., T]:
225
+ """Decorator to handle async CLI commands."""
226
+ @wraps(f)
227
+ def wrapper(*args, **kwargs):
228
+ return asyncio.run(f(*args, **kwargs))
229
+ return wrapper
230
+
231
+
232
+ def with_adapter(f: Callable) -> Callable:
233
+ """Decorator to inject adapter instance into CLI commands."""
234
+ @wraps(f)
235
+ def wrapper(adapter: Optional[str] = None, *args, **kwargs):
236
+ adapter_instance = CommonPatterns.get_adapter(override_adapter=adapter)
237
+ return f(adapter_instance, *args, **kwargs)
238
+ return wrapper
239
+
240
+
241
+ def with_progress(message: str = "Processing..."):
242
+ """Decorator to show progress spinner for long-running operations."""
243
+ def decorator(f: Callable) -> Callable:
244
+ @wraps(f)
245
+ def wrapper(*args, **kwargs):
246
+ with Progress(
247
+ SpinnerColumn(),
248
+ TextColumn("[progress.description]{task.description}"),
249
+ transient=True,
250
+ ) as progress:
251
+ progress.add_task(description=message, total=None)
252
+ return f(*args, **kwargs)
253
+ return wrapper
254
+ return decorator
255
+
256
+
257
+ def validate_required_fields(**field_map):
258
+ """Decorator to validate required fields are provided."""
259
+ def decorator(f: Callable) -> Callable:
260
+ @wraps(f)
261
+ def wrapper(*args, **kwargs):
262
+ missing_fields = []
263
+ for field_name, display_name in field_map.items():
264
+ if field_name in kwargs and kwargs[field_name] is None:
265
+ missing_fields.append(display_name)
266
+
267
+ if missing_fields:
268
+ console.print(f"[red]Error:[/red] Missing required fields: {', '.join(missing_fields)}")
269
+ raise typer.Exit(1)
270
+
271
+ return f(*args, **kwargs)
272
+ return wrapper
273
+ return decorator
274
+
275
+
276
+ def handle_adapter_errors(f: Callable) -> Callable:
277
+ """Decorator to handle common adapter errors gracefully."""
278
+ @wraps(f)
279
+ def wrapper(*args, **kwargs):
280
+ try:
281
+ return f(*args, **kwargs)
282
+ except ConnectionError as e:
283
+ console.print(f"[red]Connection Error:[/red] {e}")
284
+ console.print("Check your network connection and adapter configuration")
285
+ raise typer.Exit(1)
286
+ except ValueError as e:
287
+ console.print(f"[red]Configuration Error:[/red] {e}")
288
+ console.print("Run 'mcp-ticketer init' to configure your adapter")
289
+ raise typer.Exit(1)
290
+ except Exception as e:
291
+ console.print(f"[red]Unexpected Error:[/red] {e}")
292
+ raise typer.Exit(1)
293
+ return wrapper
294
+
295
+
296
+ class ConfigValidator:
297
+ """Configuration validation utilities."""
298
+
299
+ @staticmethod
300
+ def validate_adapter_config(adapter_type: str, config: dict) -> List[str]:
301
+ """Validate adapter configuration and return list of issues."""
302
+ issues = []
303
+
304
+ validation_rules = {
305
+ "github": [
306
+ ("token", "GitHub token"),
307
+ ("owner", "Repository owner"),
308
+ ("repo", "Repository name"),
309
+ ],
310
+ "jira": [
311
+ ("server", "JIRA server URL"),
312
+ ("email", "User email"),
313
+ ("api_token", "API token"),
314
+ ],
315
+ "linear": [
316
+ ("api_key", "API key"),
317
+ ("team_key", "Team key"),
318
+ ],
319
+ "aitrackdown": [
320
+ ("base_path", "Base path"),
321
+ ],
322
+ }
323
+
324
+ if adapter_type in validation_rules:
325
+ for field, display_name in validation_rules[adapter_type]:
326
+ if not config.get(field):
327
+ env_var = ConfigValidator._get_env_var(adapter_type, field)
328
+ if env_var and not os.getenv(env_var):
329
+ issues.append(f"Missing {display_name} (config.{field} or {env_var})")
330
+
331
+ return issues
332
+
333
+ @staticmethod
334
+ def _get_env_var(adapter_type: str, field: str) -> Optional[str]:
335
+ """Get corresponding environment variable name for a config field."""
336
+ env_mapping = {
337
+ "github": {"token": "GITHUB_TOKEN", "owner": "GITHUB_OWNER", "repo": "GITHUB_REPO"},
338
+ "jira": {"api_token": "JIRA_API_TOKEN", "email": "JIRA_EMAIL", "server": "JIRA_SERVER"},
339
+ "linear": {"api_key": "LINEAR_API_KEY"},
340
+ }
341
+ return env_mapping.get(adapter_type, {}).get(field)
342
+
343
+
344
+ class CommandBuilder:
345
+ """Builder for common CLI command patterns."""
346
+
347
+ def __init__(self):
348
+ self._validators = []
349
+ self._handlers = []
350
+ self._decorators = []
351
+
352
+ def with_adapter_validation(self):
353
+ """Add adapter configuration validation."""
354
+ self._validators.append(self._validate_adapter)
355
+ return self
356
+
357
+ def with_async_support(self):
358
+ """Add async support to command."""
359
+ self._decorators.append(async_command)
360
+ return self
361
+
362
+ def with_error_handling(self):
363
+ """Add error handling decorator."""
364
+ self._decorators.append(handle_adapter_errors)
365
+ return self
366
+
367
+ def with_progress(self, message: str = "Processing..."):
368
+ """Add progress spinner."""
369
+ self._decorators.append(with_progress(message))
370
+ return self
371
+
372
+ def build(self, func: Callable) -> Callable:
373
+ """Build the decorated function."""
374
+ decorated_func = func
375
+ for decorator in reversed(self._decorators):
376
+ decorated_func = decorator(decorated_func)
377
+ return decorated_func
378
+
379
+ def _validate_adapter(self, *args, **kwargs):
380
+ """Validate adapter configuration."""
381
+ config = CommonPatterns.load_config()
382
+ default_adapter = config.get("default_adapter", "aitrackdown")
383
+ adapter_config = config.get("adapters", {}).get(default_adapter, {})
384
+
385
+ issues = ConfigValidator.validate_adapter_config(default_adapter, adapter_config)
386
+ if issues:
387
+ console.print("[red]Configuration Issues:[/red]")
388
+ for issue in issues:
389
+ console.print(f" • {issue}")
390
+ console.print("Run 'mcp-ticketer init' to fix configuration")
391
+ raise typer.Exit(1)
392
+
393
+
394
+ def create_standard_ticket_command(operation: str):
395
+ """Create a standard ticket operation command."""
396
+ def command_template(
397
+ ticket_id: Optional[str] = None,
398
+ title: Optional[str] = None,
399
+ description: Optional[str] = None,
400
+ priority: Optional[Priority] = None,
401
+ state: Optional[TicketState] = None,
402
+ assignee: Optional[str] = None,
403
+ tags: Optional[List[str]] = None,
404
+ adapter: Optional[str] = None,
405
+ ):
406
+ """Template for ticket commands."""
407
+ # Build ticket data
408
+ ticket_data = {}
409
+ if ticket_id:
410
+ ticket_data["ticket_id"] = ticket_id
411
+ if title:
412
+ ticket_data["title"] = title
413
+ if description:
414
+ ticket_data["description"] = description
415
+ if priority:
416
+ ticket_data["priority"] = priority.value if hasattr(priority, 'value') else priority
417
+ if state:
418
+ ticket_data["state"] = state.value if hasattr(state, 'value') else state
419
+ if assignee:
420
+ ticket_data["assignee"] = assignee
421
+ if tags:
422
+ ticket_data["tags"] = tags
423
+
424
+ # Queue the operation
425
+ queue_id = CommonPatterns.queue_operation(ticket_data, operation, adapter)
426
+
427
+ # Show confirmation
428
+ console.print(f"[green]✓[/green] Queued {operation}: {queue_id}")
429
+ return queue_id
430
+
431
+ return command_template
432
+
433
+
434
+ # Reusable command components
435
+ class TicketCommands:
436
+ """Reusable ticket command implementations."""
437
+
438
+ @staticmethod
439
+ @async_command
440
+ @handle_adapter_errors
441
+ async def list_tickets(
442
+ adapter_instance,
443
+ state: Optional[TicketState] = None,
444
+ priority: Optional[Priority] = None,
445
+ limit: int = 10
446
+ ):
447
+ """List tickets with filters."""
448
+ filters = {}
449
+ if state:
450
+ filters["state"] = state
451
+ if priority:
452
+ filters["priority"] = priority
453
+
454
+ tickets = await adapter_instance.list(limit=limit, filters=filters)
455
+ CommonPatterns.display_ticket_table(tickets)
456
+
457
+ @staticmethod
458
+ @async_command
459
+ @handle_adapter_errors
460
+ async def show_ticket(
461
+ adapter_instance,
462
+ ticket_id: str,
463
+ show_comments: bool = False
464
+ ):
465
+ """Show ticket details."""
466
+ ticket = await adapter_instance.read(ticket_id)
467
+ if not ticket:
468
+ console.print(f"[red]✗[/red] Ticket not found: {ticket_id}")
469
+ raise typer.Exit(1)
470
+
471
+ comments = None
472
+ if show_comments:
473
+ comments = await adapter_instance.get_comments(ticket_id)
474
+
475
+ CommonPatterns.display_ticket_details(ticket, comments)
476
+
477
+ @staticmethod
478
+ def create_ticket(
479
+ title: str,
480
+ description: Optional[str] = None,
481
+ priority: Priority = Priority.MEDIUM,
482
+ tags: Optional[List[str]] = None,
483
+ assignee: Optional[str] = None,
484
+ adapter: Optional[str] = None
485
+ ) -> str:
486
+ """Create a new ticket."""
487
+ ticket_data = {
488
+ "title": title,
489
+ "description": description,
490
+ "priority": priority.value if isinstance(priority, Priority) else priority,
491
+ "tags": tags or [],
492
+ "assignee": assignee,
493
+ }
494
+
495
+ return CommonPatterns.queue_operation(ticket_data, "create", adapter)
496
+
497
+ @staticmethod
498
+ def update_ticket(
499
+ ticket_id: str,
500
+ updates: Dict[str, Any],
501
+ adapter: Optional[str] = None
502
+ ) -> str:
503
+ """Update a ticket."""
504
+ if not updates:
505
+ console.print("[yellow]No updates specified[/yellow]")
506
+ raise typer.Exit(1)
507
+
508
+ updates["ticket_id"] = ticket_id
509
+ return CommonPatterns.queue_operation(updates, "update", adapter)
510
+
511
+ @staticmethod
512
+ def transition_ticket(
513
+ ticket_id: str,
514
+ state: TicketState,
515
+ adapter: Optional[str] = None
516
+ ) -> str:
517
+ """Transition ticket state."""
518
+ ticket_data = {
519
+ "ticket_id": ticket_id,
520
+ "state": state.value if hasattr(state, 'value') else state
521
+ }
522
+
523
+ return CommonPatterns.queue_operation(ticket_data, "transition", adapter)
@@ -0,0 +1,15 @@
1
+ """Core models and abstractions for MCP Ticketer."""
2
+
3
+ from .models import Epic, Task, Comment, TicketState, Priority
4
+ from .adapter import BaseAdapter
5
+ from .registry import AdapterRegistry
6
+
7
+ __all__ = [
8
+ "Epic",
9
+ "Task",
10
+ "Comment",
11
+ "TicketState",
12
+ "Priority",
13
+ "BaseAdapter",
14
+ "AdapterRegistry",
15
+ ]