mcp-ticketer 0.1.21__py3-none-any.whl → 0.1.23__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 (42) hide show
  1. mcp_ticketer/__init__.py +7 -7
  2. mcp_ticketer/__version__.py +4 -2
  3. mcp_ticketer/adapters/__init__.py +4 -4
  4. mcp_ticketer/adapters/aitrackdown.py +66 -49
  5. mcp_ticketer/adapters/github.py +192 -125
  6. mcp_ticketer/adapters/hybrid.py +99 -53
  7. mcp_ticketer/adapters/jira.py +161 -151
  8. mcp_ticketer/adapters/linear.py +396 -246
  9. mcp_ticketer/cache/__init__.py +1 -1
  10. mcp_ticketer/cache/memory.py +15 -16
  11. mcp_ticketer/cli/__init__.py +1 -1
  12. mcp_ticketer/cli/configure.py +69 -93
  13. mcp_ticketer/cli/discover.py +43 -35
  14. mcp_ticketer/cli/main.py +283 -298
  15. mcp_ticketer/cli/mcp_configure.py +39 -15
  16. mcp_ticketer/cli/migrate_config.py +11 -13
  17. mcp_ticketer/cli/queue_commands.py +21 -58
  18. mcp_ticketer/cli/utils.py +121 -66
  19. mcp_ticketer/core/__init__.py +2 -2
  20. mcp_ticketer/core/adapter.py +46 -39
  21. mcp_ticketer/core/config.py +128 -92
  22. mcp_ticketer/core/env_discovery.py +69 -37
  23. mcp_ticketer/core/http_client.py +57 -40
  24. mcp_ticketer/core/mappers.py +98 -54
  25. mcp_ticketer/core/models.py +38 -24
  26. mcp_ticketer/core/project_config.py +145 -80
  27. mcp_ticketer/core/registry.py +16 -16
  28. mcp_ticketer/mcp/__init__.py +1 -1
  29. mcp_ticketer/mcp/server.py +199 -145
  30. mcp_ticketer/queue/__init__.py +2 -2
  31. mcp_ticketer/queue/__main__.py +1 -1
  32. mcp_ticketer/queue/manager.py +30 -26
  33. mcp_ticketer/queue/queue.py +147 -85
  34. mcp_ticketer/queue/run_worker.py +2 -3
  35. mcp_ticketer/queue/worker.py +55 -40
  36. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/METADATA +1 -1
  37. mcp_ticketer-0.1.23.dist-info/RECORD +42 -0
  38. mcp_ticketer-0.1.21.dist-info/RECORD +0 -42
  39. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/WHEEL +0 -0
  40. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/entry_points.txt +0 -0
  41. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/licenses/LICENSE +0 -0
  42. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/top_level.txt +0 -0
mcp_ticketer/cli/utils.py CHANGED
@@ -2,71 +2,97 @@
2
2
 
3
3
  import asyncio
4
4
  import json
5
+ import logging
5
6
  import os
6
- from typing import Optional, Dict, Any, Callable, TypeVar, List
7
- from pathlib import Path
8
7
  from functools import wraps
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Optional, TypeVar
9
10
 
10
11
  import typer
11
12
  from rich.console import Console
12
- from rich.table import Table
13
- from rich.panel import Panel
14
13
  from rich.progress import Progress, SpinnerColumn, TextColumn
14
+ from rich.table import Table
15
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
16
+ from ..core import AdapterRegistry, Priority, Task, TicketState
17
+ from ..queue import Queue, QueueStatus, WorkerManager
19
18
 
20
19
  # Type variable for async functions
21
- T = TypeVar('T')
20
+ T = TypeVar("T")
22
21
 
23
22
  console = Console()
23
+ logger = logging.getLogger(__name__)
24
24
 
25
25
 
26
26
  class CommonPatterns:
27
27
  """Common CLI patterns and utilities."""
28
28
 
29
- # Configuration file management
30
- CONFIG_FILE = Path.home() / ".mcp-ticketer" / "config.json"
29
+ # Configuration file management - PROJECT-LOCAL ONLY
30
+ CONFIG_FILE = Path.cwd() / ".mcp-ticketer" / "config.json"
31
31
 
32
32
  @staticmethod
33
33
  def load_config() -> dict:
34
- """Load configuration from file.
34
+ """Load configuration from project-local config file ONLY.
35
+
36
+ SECURITY: This method ONLY reads from the current project directory
37
+ to prevent configuration leakage across projects. It will NEVER read
38
+ from user home directory or system-wide locations.
35
39
 
36
40
  Resolution order:
37
41
  1. Project-specific config (.mcp-ticketer/config.json in cwd)
38
- 2. Global config (~/.mcp-ticketer/config.json)
42
+ 2. Default to aitrackdown adapter
39
43
 
40
44
  Returns:
41
- Configuration dictionary
45
+ Configuration dictionary with adapter and config keys.
46
+ Defaults to aitrackdown if no local config exists.
47
+
42
48
  """
43
- # Check project-specific config first
49
+ # ONLY check project-specific config in current working directory
44
50
  project_config = Path.cwd() / ".mcp-ticketer" / "config.json"
45
51
  if project_config.exists():
52
+ # Validate that config file is actually in project directory
46
53
  try:
47
- with open(project_config, "r") as f:
48
- return json.load(f)
49
- except (json.JSONDecodeError, IOError) as e:
50
- console.print(f"[yellow]Warning: Could not load project config: {e}[/yellow]")
51
- # Fall through to global config
52
-
53
- # Fall back to global config
54
- if CommonPatterns.CONFIG_FILE.exists():
55
- try:
56
- with open(CommonPatterns.CONFIG_FILE, "r") as f:
57
- return json.load(f)
58
- except (json.JSONDecodeError, IOError) as e:
59
- console.print(f"[yellow]Warning: Could not load global config: {e}[/yellow]")
54
+ if not project_config.resolve().is_relative_to(Path.cwd().resolve()):
55
+ logger.error(
56
+ f"Security violation: Config file {project_config} "
57
+ "is not within project directory"
58
+ )
59
+ raise ValueError(
60
+ f"Security violation: Config file {project_config} "
61
+ "is not within project directory"
62
+ )
63
+ except (ValueError, RuntimeError):
64
+ # is_relative_to may raise ValueError in some cases
65
+ pass
60
66
 
61
- # Default fallback
67
+ try:
68
+ with open(project_config) as f:
69
+ config = json.load(f)
70
+ logger.info(
71
+ f"Loaded configuration from project-local: {project_config}"
72
+ )
73
+ return config
74
+ except (OSError, json.JSONDecodeError) as e:
75
+ logger.warning(f"Could not load project config: {e}, using defaults")
76
+ console.print(
77
+ f"[yellow]Warning: Could not load project config: {e}[/yellow]"
78
+ )
79
+
80
+ # Default to aitrackdown with local base path
81
+ logger.info("No project-local config found, defaulting to aitrackdown adapter")
62
82
  return {"adapter": "aitrackdown", "config": {"base_path": ".aitrackdown"}}
63
83
 
64
84
  @staticmethod
65
85
  def save_config(config: dict) -> None:
66
- """Save configuration to file."""
67
- CommonPatterns.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
68
- with open(CommonPatterns.CONFIG_FILE, "w") as f:
86
+ """Save configuration to project-local config file ONLY.
87
+
88
+ SECURITY: This method ONLY saves to the current project directory
89
+ to prevent configuration leakage across projects.
90
+ """
91
+ project_config = Path.cwd() / ".mcp-ticketer" / "config.json"
92
+ project_config.parent.mkdir(parents=True, exist_ok=True)
93
+ with open(project_config, "w") as f:
69
94
  json.dump(config, f, indent=2)
95
+ logger.info(f"Saved configuration to project-local: {project_config}")
70
96
 
71
97
  @staticmethod
72
98
  def merge_config(updates: dict) -> dict:
@@ -89,7 +115,9 @@ class CommonPatterns:
89
115
  return config
90
116
 
91
117
  @staticmethod
92
- def get_adapter(override_adapter: Optional[str] = None, override_config: Optional[dict] = None):
118
+ def get_adapter(
119
+ override_adapter: Optional[str] = None, override_config: Optional[dict] = None
120
+ ):
93
121
  """Get configured adapter instance with environment variable support."""
94
122
  config = CommonPatterns.load_config()
95
123
 
@@ -138,10 +166,10 @@ class CommonPatterns:
138
166
 
139
167
  @staticmethod
140
168
  def queue_operation(
141
- ticket_data: Dict[str, Any],
169
+ ticket_data: dict[str, Any],
142
170
  operation: str,
143
171
  adapter_name: Optional[str] = None,
144
- show_progress: bool = True
172
+ show_progress: bool = True,
145
173
  ) -> str:
146
174
  """Queue an operation and optionally start the worker."""
147
175
  if not adapter_name:
@@ -151,14 +179,14 @@ class CommonPatterns:
151
179
  # Add to queue
152
180
  queue = Queue()
153
181
  queue_id = queue.add(
154
- ticket_data=ticket_data,
155
- adapter=adapter_name,
156
- operation=operation
182
+ ticket_data=ticket_data, adapter=adapter_name, operation=operation
157
183
  )
158
184
 
159
185
  if show_progress:
160
186
  console.print(f"[green]✓[/green] Queued {operation}: {queue_id}")
161
- console.print("[dim]Use 'mcp-ticketer check {queue_id}' to check progress[/dim]")
187
+ console.print(
188
+ "[dim]Use 'mcp-ticketer check {queue_id}' to check progress[/dim]"
189
+ )
162
190
 
163
191
  # Start worker if needed
164
192
  manager = WorkerManager()
@@ -169,7 +197,7 @@ class CommonPatterns:
169
197
  return queue_id
170
198
 
171
199
  @staticmethod
172
- def display_ticket_table(tickets: List[Task], title: str = "Tickets") -> None:
200
+ def display_ticket_table(tickets: list[Task], title: str = "Tickets") -> None:
173
201
  """Display tickets in a formatted table."""
174
202
  if not tickets:
175
203
  console.print("[yellow]No tickets found[/yellow]")
@@ -194,7 +222,7 @@ class CommonPatterns:
194
222
  console.print(table)
195
223
 
196
224
  @staticmethod
197
- def display_ticket_details(ticket: Task, comments: Optional[List] = None) -> None:
225
+ def display_ticket_details(ticket: Task, comments: Optional[list] = None) -> None:
198
226
  """Display detailed ticket information."""
199
227
  console.print(f"\n[bold]Ticket: {ticket.id}[/bold]")
200
228
  console.print(f"Title: {ticket.title}")
@@ -202,7 +230,7 @@ class CommonPatterns:
202
230
  console.print(f"Priority: [yellow]{ticket.priority}[/yellow]")
203
231
 
204
232
  if ticket.description:
205
- console.print(f"\n[dim]Description:[/dim]")
233
+ console.print("\n[dim]Description:[/dim]")
206
234
  console.print(ticket.description)
207
235
 
208
236
  if ticket.tags:
@@ -238,32 +266,41 @@ class CommonPatterns:
238
266
  # Show worker status
239
267
  worker_status = manager.get_status()
240
268
  if worker_status["running"]:
241
- console.print(f"\n[green]● Worker is running[/green] (PID: {worker_status.get('pid')})")
269
+ console.print(
270
+ f"\n[green]● Worker is running[/green] (PID: {worker_status.get('pid')})"
271
+ )
242
272
  else:
243
273
  console.print("\n[red]○ Worker is not running[/red]")
244
274
  if pending > 0:
245
- console.print("[yellow]Note: There are pending items. Start worker with 'mcp-ticketer queue start'[/yellow]")
275
+ console.print(
276
+ "[yellow]Note: There are pending items. Start worker with 'mcp-ticketer queue start'[/yellow]"
277
+ )
246
278
 
247
279
 
248
280
  def async_command(f: Callable[..., T]) -> Callable[..., T]:
249
281
  """Decorator to handle async CLI commands."""
282
+
250
283
  @wraps(f)
251
284
  def wrapper(*args, **kwargs):
252
285
  return asyncio.run(f(*args, **kwargs))
286
+
253
287
  return wrapper
254
288
 
255
289
 
256
290
  def with_adapter(f: Callable) -> Callable:
257
291
  """Decorator to inject adapter instance into CLI commands."""
292
+
258
293
  @wraps(f)
259
294
  def wrapper(adapter: Optional[str] = None, *args, **kwargs):
260
295
  adapter_instance = CommonPatterns.get_adapter(override_adapter=adapter)
261
296
  return f(adapter_instance, *args, **kwargs)
297
+
262
298
  return wrapper
263
299
 
264
300
 
265
301
  def with_progress(message: str = "Processing..."):
266
302
  """Decorator to show progress spinner for long-running operations."""
303
+
267
304
  def decorator(f: Callable) -> Callable:
268
305
  @wraps(f)
269
306
  def wrapper(*args, **kwargs):
@@ -274,12 +311,15 @@ def with_progress(message: str = "Processing..."):
274
311
  ) as progress:
275
312
  progress.add_task(description=message, total=None)
276
313
  return f(*args, **kwargs)
314
+
277
315
  return wrapper
316
+
278
317
  return decorator
279
318
 
280
319
 
281
320
  def validate_required_fields(**field_map):
282
321
  """Decorator to validate required fields are provided."""
322
+
283
323
  def decorator(f: Callable) -> Callable:
284
324
  @wraps(f)
285
325
  def wrapper(*args, **kwargs):
@@ -289,16 +329,21 @@ def validate_required_fields(**field_map):
289
329
  missing_fields.append(display_name)
290
330
 
291
331
  if missing_fields:
292
- console.print(f"[red]Error:[/red] Missing required fields: {', '.join(missing_fields)}")
332
+ console.print(
333
+ f"[red]Error:[/red] Missing required fields: {', '.join(missing_fields)}"
334
+ )
293
335
  raise typer.Exit(1)
294
336
 
295
337
  return f(*args, **kwargs)
338
+
296
339
  return wrapper
340
+
297
341
  return decorator
298
342
 
299
343
 
300
344
  def handle_adapter_errors(f: Callable) -> Callable:
301
345
  """Decorator to handle common adapter errors gracefully."""
346
+
302
347
  @wraps(f)
303
348
  def wrapper(*args, **kwargs):
304
349
  try:
@@ -314,6 +359,7 @@ def handle_adapter_errors(f: Callable) -> Callable:
314
359
  except Exception as e:
315
360
  console.print(f"[red]Unexpected Error:[/red] {e}")
316
361
  raise typer.Exit(1)
362
+
317
363
  return wrapper
318
364
 
319
365
 
@@ -321,7 +367,7 @@ class ConfigValidator:
321
367
  """Configuration validation utilities."""
322
368
 
323
369
  @staticmethod
324
- def validate_adapter_config(adapter_type: str, config: dict) -> List[str]:
370
+ def validate_adapter_config(adapter_type: str, config: dict) -> list[str]:
325
371
  """Validate adapter configuration and return list of issues."""
326
372
  issues = []
327
373
 
@@ -350,7 +396,9 @@ class ConfigValidator:
350
396
  if not config.get(field):
351
397
  env_var = ConfigValidator._get_env_var(adapter_type, field)
352
398
  if env_var and not os.getenv(env_var):
353
- issues.append(f"Missing {display_name} (config.{field} or {env_var})")
399
+ issues.append(
400
+ f"Missing {display_name} (config.{field} or {env_var})"
401
+ )
354
402
 
355
403
  return issues
356
404
 
@@ -358,8 +406,16 @@ class ConfigValidator:
358
406
  def _get_env_var(adapter_type: str, field: str) -> Optional[str]:
359
407
  """Get corresponding environment variable name for a config field."""
360
408
  env_mapping = {
361
- "github": {"token": "GITHUB_TOKEN", "owner": "GITHUB_OWNER", "repo": "GITHUB_REPO"},
362
- "jira": {"api_token": "JIRA_API_TOKEN", "email": "JIRA_EMAIL", "server": "JIRA_SERVER"},
409
+ "github": {
410
+ "token": "GITHUB_TOKEN",
411
+ "owner": "GITHUB_OWNER",
412
+ "repo": "GITHUB_REPO",
413
+ },
414
+ "jira": {
415
+ "api_token": "JIRA_API_TOKEN",
416
+ "email": "JIRA_EMAIL",
417
+ "server": "JIRA_SERVER",
418
+ },
363
419
  "linear": {"api_key": "LINEAR_API_KEY"},
364
420
  }
365
421
  return env_mapping.get(adapter_type, {}).get(field)
@@ -406,7 +462,9 @@ class CommandBuilder:
406
462
  default_adapter = config.get("default_adapter", "aitrackdown")
407
463
  adapter_config = config.get("adapters", {}).get(default_adapter, {})
408
464
 
409
- issues = ConfigValidator.validate_adapter_config(default_adapter, adapter_config)
465
+ issues = ConfigValidator.validate_adapter_config(
466
+ default_adapter, adapter_config
467
+ )
410
468
  if issues:
411
469
  console.print("[red]Configuration Issues:[/red]")
412
470
  for issue in issues:
@@ -417,6 +475,7 @@ class CommandBuilder:
417
475
 
418
476
  def create_standard_ticket_command(operation: str):
419
477
  """Create a standard ticket operation command."""
478
+
420
479
  def command_template(
421
480
  ticket_id: Optional[str] = None,
422
481
  title: Optional[str] = None,
@@ -424,7 +483,7 @@ def create_standard_ticket_command(operation: str):
424
483
  priority: Optional[Priority] = None,
425
484
  state: Optional[TicketState] = None,
426
485
  assignee: Optional[str] = None,
427
- tags: Optional[List[str]] = None,
486
+ tags: Optional[list[str]] = None,
428
487
  adapter: Optional[str] = None,
429
488
  ):
430
489
  """Template for ticket commands."""
@@ -437,9 +496,11 @@ def create_standard_ticket_command(operation: str):
437
496
  if description:
438
497
  ticket_data["description"] = description
439
498
  if priority:
440
- ticket_data["priority"] = priority.value if hasattr(priority, 'value') else priority
499
+ ticket_data["priority"] = (
500
+ priority.value if hasattr(priority, "value") else priority
501
+ )
441
502
  if state:
442
- ticket_data["state"] = state.value if hasattr(state, 'value') else state
503
+ ticket_data["state"] = state.value if hasattr(state, "value") else state
443
504
  if assignee:
444
505
  ticket_data["assignee"] = assignee
445
506
  if tags:
@@ -466,7 +527,7 @@ class TicketCommands:
466
527
  adapter_instance,
467
528
  state: Optional[TicketState] = None,
468
529
  priority: Optional[Priority] = None,
469
- limit: int = 10
530
+ limit: int = 10,
470
531
  ):
471
532
  """List tickets with filters."""
472
533
  filters = {}
@@ -482,9 +543,7 @@ class TicketCommands:
482
543
  @async_command
483
544
  @handle_adapter_errors
484
545
  async def show_ticket(
485
- adapter_instance,
486
- ticket_id: str,
487
- show_comments: bool = False
546
+ adapter_instance, ticket_id: str, show_comments: bool = False
488
547
  ):
489
548
  """Show ticket details."""
490
549
  ticket = await adapter_instance.read(ticket_id)
@@ -503,9 +562,9 @@ class TicketCommands:
503
562
  title: str,
504
563
  description: Optional[str] = None,
505
564
  priority: Priority = Priority.MEDIUM,
506
- tags: Optional[List[str]] = None,
565
+ tags: Optional[list[str]] = None,
507
566
  assignee: Optional[str] = None,
508
- adapter: Optional[str] = None
567
+ adapter: Optional[str] = None,
509
568
  ) -> str:
510
569
  """Create a new ticket."""
511
570
  ticket_data = {
@@ -520,9 +579,7 @@ class TicketCommands:
520
579
 
521
580
  @staticmethod
522
581
  def update_ticket(
523
- ticket_id: str,
524
- updates: Dict[str, Any],
525
- adapter: Optional[str] = None
582
+ ticket_id: str, updates: dict[str, Any], adapter: Optional[str] = None
526
583
  ) -> str:
527
584
  """Update a ticket."""
528
585
  if not updates:
@@ -534,14 +591,12 @@ class TicketCommands:
534
591
 
535
592
  @staticmethod
536
593
  def transition_ticket(
537
- ticket_id: str,
538
- state: TicketState,
539
- adapter: Optional[str] = None
594
+ ticket_id: str, state: TicketState, adapter: Optional[str] = None
540
595
  ) -> str:
541
596
  """Transition ticket state."""
542
597
  ticket_data = {
543
598
  "ticket_id": ticket_id,
544
- "state": state.value if hasattr(state, 'value') else state
599
+ "state": state.value if hasattr(state, "value") else state,
545
600
  }
546
601
 
547
- return CommonPatterns.queue_operation(ticket_data, "transition", adapter)
602
+ return CommonPatterns.queue_operation(ticket_data, "transition", adapter)
@@ -1,7 +1,7 @@
1
1
  """Core models and abstractions for MCP Ticketer."""
2
2
 
3
- from .models import Epic, Task, Comment, TicketState, Priority, TicketType
4
3
  from .adapter import BaseAdapter
4
+ from .models import Comment, Epic, Priority, Task, TicketState, TicketType
5
5
  from .registry import AdapterRegistry
6
6
 
7
7
  __all__ = [
@@ -13,4 +13,4 @@ __all__ = [
13
13
  "TicketType",
14
14
  "BaseAdapter",
15
15
  "AdapterRegistry",
16
- ]
16
+ ]