mcp-ticketer 0.1.12__py3-none-any.whl → 0.1.13__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,402 @@
1
+ """CLI command for auto-discovering configuration from .env files."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.panel import Panel
11
+ from rich import print as rprint
12
+
13
+ from ..core.env_discovery import EnvDiscovery, DiscoveredAdapter
14
+ from ..core.project_config import (
15
+ ConfigResolver,
16
+ TicketerConfig,
17
+ AdapterConfig,
18
+ ConfigValidator,
19
+ )
20
+
21
+ console = Console()
22
+ app = typer.Typer(help="Auto-discover configuration from .env files")
23
+
24
+
25
+ def _mask_sensitive(value: str, key: str) -> str:
26
+ """Mask sensitive values for display.
27
+
28
+ Args:
29
+ value: Value to potentially mask
30
+ key: Key name to determine if masking needed
31
+
32
+ Returns:
33
+ Masked or original value
34
+ """
35
+ sensitive_keys = ["token", "key", "password", "secret", "api_token"]
36
+
37
+ # Check if key contains any sensitive pattern
38
+ key_lower = key.lower()
39
+ is_sensitive = any(pattern in key_lower for pattern in sensitive_keys)
40
+
41
+ # Don't mask team_id, team_key, project_key, etc.
42
+ if "team" in key_lower or "project" in key_lower:
43
+ is_sensitive = False
44
+
45
+ if is_sensitive and value:
46
+ # Show first 4 and last 4 characters
47
+ if len(value) > 12:
48
+ return f"{value[:4]}...{value[-4:]}"
49
+ else:
50
+ return "***"
51
+
52
+ return value
53
+
54
+
55
+ def _display_discovered_adapter(adapter: DiscoveredAdapter, discovery: EnvDiscovery) -> None:
56
+ """Display information about a discovered adapter.
57
+
58
+ Args:
59
+ adapter: Discovered adapter to display
60
+ discovery: EnvDiscovery instance for validation
61
+ """
62
+ # Header
63
+ completeness = "✅ Complete" if adapter.is_complete() else "⚠️ Incomplete"
64
+ confidence_percent = int(adapter.confidence * 100)
65
+
66
+ console.print(
67
+ f"\n[bold cyan]{adapter.adapter_type.upper()}[/bold cyan] "
68
+ f"({completeness}, {confidence_percent}% confidence)"
69
+ )
70
+
71
+ # Configuration details
72
+ console.print(f" [dim]Found in: {adapter.found_in}[/dim]")
73
+
74
+ for key, value in adapter.config.items():
75
+ if key == "adapter":
76
+ continue
77
+
78
+ display_value = _mask_sensitive(str(value), key)
79
+ console.print(f" {key}: [green]{display_value}[/green]")
80
+
81
+ # Missing fields
82
+ if adapter.missing_fields:
83
+ console.print(f" [yellow]Missing:[/yellow] {', '.join(adapter.missing_fields)}")
84
+
85
+ # Validation warnings
86
+ warnings = discovery.validate_discovered_config(adapter)
87
+ if warnings:
88
+ for warning in warnings:
89
+ console.print(f" {warning}")
90
+
91
+
92
+ @app.command()
93
+ def show(
94
+ project_path: Optional[Path] = typer.Option(
95
+ None,
96
+ "--path",
97
+ "-p",
98
+ help="Project path to scan (defaults to current directory)"
99
+ ),
100
+ ) -> None:
101
+ """Show discovered configuration without saving."""
102
+ proj_path = project_path or Path.cwd()
103
+
104
+ console.print(f"\n[bold]🔍 Auto-discovering configuration in:[/bold] {proj_path}\n")
105
+
106
+ # Discover
107
+ discovery = EnvDiscovery(proj_path)
108
+ result = discovery.discover()
109
+
110
+ # Show env files found
111
+ if result.env_files_found:
112
+ console.print("[bold]Environment files found:[/bold]")
113
+ for env_file in result.env_files_found:
114
+ console.print(f" ✅ {env_file}")
115
+ else:
116
+ console.print("[yellow]No .env files found[/yellow]")
117
+ return
118
+
119
+ # Show discovered adapters
120
+ if result.adapters:
121
+ console.print("\n[bold]Detected adapter configurations:[/bold]")
122
+ for adapter in sorted(result.adapters, key=lambda a: a.confidence, reverse=True):
123
+ _display_discovered_adapter(adapter, discovery)
124
+
125
+ # Show recommended adapter
126
+ primary = result.get_primary_adapter()
127
+ if primary:
128
+ console.print(
129
+ f"\n[bold green]Recommended adapter:[/bold green] {primary.adapter_type} "
130
+ f"(most complete configuration)"
131
+ )
132
+ else:
133
+ console.print("\n[yellow]No adapter configurations detected[/yellow]")
134
+ console.print("[dim]Make sure your .env file contains adapter credentials[/dim]")
135
+
136
+ # Show warnings
137
+ if result.warnings:
138
+ console.print("\n[bold yellow]Warnings:[/bold yellow]")
139
+ for warning in result.warnings:
140
+ console.print(f" {warning}")
141
+
142
+
143
+ @app.command()
144
+ def save(
145
+ adapter: Optional[str] = typer.Option(
146
+ None,
147
+ "--adapter",
148
+ "-a",
149
+ help="Which adapter to save (defaults to recommended)"
150
+ ),
151
+ global_config: bool = typer.Option(
152
+ False,
153
+ "--global",
154
+ "-g",
155
+ help="Save to global config instead of project config"
156
+ ),
157
+ dry_run: bool = typer.Option(
158
+ False,
159
+ "--dry-run",
160
+ help="Show what would be saved without saving"
161
+ ),
162
+ project_path: Optional[Path] = typer.Option(
163
+ None,
164
+ "--path",
165
+ "-p",
166
+ help="Project path to scan (defaults to current directory)"
167
+ ),
168
+ ) -> None:
169
+ """Discover configuration and save to config file.
170
+
171
+ By default, saves to project-specific config (.mcp-ticketer/config.json).
172
+ Use --global to save to global config (~/.mcp-ticketer/config.json).
173
+ """
174
+ proj_path = project_path or Path.cwd()
175
+
176
+ console.print(f"\n[bold]🔍 Auto-discovering configuration in:[/bold] {proj_path}\n")
177
+
178
+ # Discover
179
+ discovery = EnvDiscovery(proj_path)
180
+ result = discovery.discover()
181
+
182
+ if not result.adapters:
183
+ console.print("[red]No adapter configurations detected[/red]")
184
+ console.print("[dim]Make sure your .env file contains adapter credentials[/dim]")
185
+ raise typer.Exit(1)
186
+
187
+ # Determine which adapter to save
188
+ if adapter:
189
+ discovered_adapter = result.get_adapter_by_type(adapter)
190
+ if not discovered_adapter:
191
+ console.print(f"[red]No configuration found for adapter: {adapter}[/red]")
192
+ console.print(f"[dim]Available: {', '.join(a.adapter_type for a in result.adapters)}[/dim]")
193
+ raise typer.Exit(1)
194
+ else:
195
+ # Use recommended adapter
196
+ discovered_adapter = result.get_primary_adapter()
197
+ if not discovered_adapter:
198
+ console.print("[red]Could not determine recommended adapter[/red]")
199
+ raise typer.Exit(1)
200
+
201
+ console.print(
202
+ f"[bold]Using recommended adapter:[/bold] {discovered_adapter.adapter_type}"
203
+ )
204
+
205
+ # Display what will be saved
206
+ _display_discovered_adapter(discovered_adapter, discovery)
207
+
208
+ # Validate configuration
209
+ is_valid, error_msg = ConfigValidator.validate(
210
+ discovered_adapter.adapter_type,
211
+ discovered_adapter.config
212
+ )
213
+
214
+ if not is_valid:
215
+ console.print(f"\n[red]Configuration validation failed:[/red] {error_msg}")
216
+ console.print("[dim]Fix the configuration in your .env file and try again[/dim]")
217
+ raise typer.Exit(1)
218
+
219
+ if dry_run:
220
+ console.print("\n[yellow]Dry run - no changes made[/yellow]")
221
+ return
222
+
223
+ # Load or create config
224
+ resolver = ConfigResolver(proj_path)
225
+
226
+ if global_config:
227
+ config = resolver.load_global_config()
228
+ else:
229
+ config = resolver.load_project_config() or TicketerConfig()
230
+
231
+ # Set default adapter
232
+ config.default_adapter = discovered_adapter.adapter_type
233
+
234
+ # Create adapter config
235
+ adapter_config = AdapterConfig.from_dict(discovered_adapter.config)
236
+
237
+ # Add to config
238
+ config.adapters[discovered_adapter.adapter_type] = adapter_config
239
+
240
+ # Save
241
+ try:
242
+ if global_config:
243
+ resolver.save_global_config(config)
244
+ config_location = resolver.GLOBAL_CONFIG_PATH
245
+ else:
246
+ resolver.save_project_config(config, proj_path)
247
+ config_location = proj_path / resolver.PROJECT_CONFIG_SUBPATH
248
+
249
+ console.print(f"\n[green]✅ Configuration saved to:[/green] {config_location}")
250
+ console.print(f"[green]✅ Default adapter set to:[/green] {discovered_adapter.adapter_type}")
251
+
252
+ except Exception as e:
253
+ console.print(f"\n[red]Failed to save configuration:[/red] {e}")
254
+ raise typer.Exit(1)
255
+
256
+
257
+ @app.command()
258
+ def interactive(
259
+ project_path: Optional[Path] = typer.Option(
260
+ None,
261
+ "--path",
262
+ "-p",
263
+ help="Project path to scan (defaults to current directory)"
264
+ ),
265
+ ) -> None:
266
+ """Interactive mode for discovering and saving configuration."""
267
+ proj_path = project_path or Path.cwd()
268
+
269
+ console.print(f"\n[bold]🔍 Auto-discovering configuration in:[/bold] {proj_path}\n")
270
+
271
+ # Discover
272
+ discovery = EnvDiscovery(proj_path)
273
+ result = discovery.discover()
274
+
275
+ # Show env files
276
+ if result.env_files_found:
277
+ console.print("[bold]Environment files found:[/bold]")
278
+ for env_file in result.env_files_found:
279
+ console.print(f" ✅ {env_file}")
280
+ else:
281
+ console.print("[red]No .env files found[/red]")
282
+ raise typer.Exit(1)
283
+
284
+ # Show discovered adapters
285
+ if not result.adapters:
286
+ console.print("\n[red]No adapter configurations detected[/red]")
287
+ console.print("[dim]Make sure your .env file contains adapter credentials[/dim]")
288
+ raise typer.Exit(1)
289
+
290
+ console.print("\n[bold]Detected adapter configurations:[/bold]")
291
+ for i, adapter in enumerate(result.adapters, 1):
292
+ completeness = "✅" if adapter.is_complete() else "⚠️ "
293
+ console.print(
294
+ f" {i}. {completeness} [cyan]{adapter.adapter_type}[/cyan] "
295
+ f"({int(adapter.confidence * 100)}% confidence)"
296
+ )
297
+
298
+ # Show warnings
299
+ if result.warnings:
300
+ console.print("\n[bold yellow]Warnings:[/bold yellow]")
301
+ for warning in result.warnings:
302
+ console.print(f" {warning}")
303
+
304
+ # Ask user which adapter to save
305
+ primary = result.get_primary_adapter()
306
+ console.print(
307
+ f"\n[bold]Recommended:[/bold] {primary.adapter_type if primary else 'None'}"
308
+ )
309
+
310
+ # Prompt for selection
311
+ console.print("\n[bold]Select an option:[/bold]")
312
+ console.print(" 1. Save recommended adapter to project config")
313
+ console.print(" 2. Save recommended adapter to global config")
314
+ console.print(" 3. Choose different adapter")
315
+ console.print(" 4. Save all adapters")
316
+ console.print(" 5. Cancel")
317
+
318
+ choice = typer.prompt("Enter choice", type=int, default=1)
319
+
320
+ if choice == 5:
321
+ console.print("[yellow]Cancelled[/yellow]")
322
+ return
323
+
324
+ # Determine adapters to save
325
+ if choice in [1, 2]:
326
+ if not primary:
327
+ console.print("[red]No recommended adapter found[/red]")
328
+ raise typer.Exit(1)
329
+ adapters_to_save = [primary]
330
+ default_adapter = primary.adapter_type
331
+ elif choice == 3:
332
+ # Let user choose
333
+ console.print("\n[bold]Available adapters:[/bold]")
334
+ for i, adapter in enumerate(result.adapters, 1):
335
+ console.print(f" {i}. {adapter.adapter_type}")
336
+
337
+ adapter_choice = typer.prompt("Select adapter", type=int, default=1)
338
+ if 1 <= adapter_choice <= len(result.adapters):
339
+ selected = result.adapters[adapter_choice - 1]
340
+ adapters_to_save = [selected]
341
+ default_adapter = selected.adapter_type
342
+ else:
343
+ console.print("[red]Invalid choice[/red]")
344
+ raise typer.Exit(1)
345
+ else: # choice == 4
346
+ adapters_to_save = result.adapters
347
+ default_adapter = primary.adapter_type if primary else result.adapters[0].adapter_type
348
+
349
+ # Determine save location
350
+ save_global = choice == 2
351
+
352
+ # Load or create config
353
+ resolver = ConfigResolver(proj_path)
354
+
355
+ if save_global:
356
+ config = resolver.load_global_config()
357
+ else:
358
+ config = resolver.load_project_config() or TicketerConfig()
359
+
360
+ # Set default adapter
361
+ config.default_adapter = default_adapter
362
+
363
+ # Add adapters
364
+ for discovered_adapter in adapters_to_save:
365
+ # Validate
366
+ is_valid, error_msg = ConfigValidator.validate(
367
+ discovered_adapter.adapter_type,
368
+ discovered_adapter.config
369
+ )
370
+
371
+ if not is_valid:
372
+ console.print(
373
+ f"\n[yellow]Warning:[/yellow] {discovered_adapter.adapter_type} "
374
+ f"validation failed: {error_msg}"
375
+ )
376
+ continue
377
+
378
+ # Create adapter config
379
+ adapter_config = AdapterConfig.from_dict(discovered_adapter.config)
380
+ config.adapters[discovered_adapter.adapter_type] = adapter_config
381
+
382
+ console.print(f" ✅ Added {discovered_adapter.adapter_type}")
383
+
384
+ # Save
385
+ try:
386
+ if save_global:
387
+ resolver.save_global_config(config)
388
+ config_location = resolver.GLOBAL_CONFIG_PATH
389
+ else:
390
+ resolver.save_project_config(config, proj_path)
391
+ config_location = proj_path / resolver.PROJECT_CONFIG_SUBPATH
392
+
393
+ console.print(f"\n[green]✅ Configuration saved to:[/green] {config_location}")
394
+ console.print(f"[green]✅ Default adapter:[/green] {config.default_adapter}")
395
+
396
+ except Exception as e:
397
+ console.print(f"\n[red]Failed to save configuration:[/red] {e}")
398
+ raise typer.Exit(1)
399
+
400
+
401
+ if __name__ == "__main__":
402
+ app()
mcp_ticketer/cli/main.py CHANGED
@@ -21,6 +21,7 @@ from .queue_commands import app as queue_app
21
21
  from ..__version__ import __version__
22
22
  from .configure import configure_wizard, show_current_config, set_adapter_config
23
23
  from .migrate_config import migrate_config_command
24
+ from .discover import app as discover_app
24
25
 
25
26
  # Load environment variables
26
27
  load_dotenv()
@@ -865,6 +866,9 @@ def search(
865
866
  # Add queue command to main app
866
867
  app.add_typer(queue_app, name="queue")
867
868
 
869
+ # Add discover command to main app
870
+ app.add_typer(discover_app, name="discover")
871
+
868
872
 
869
873
  @app.command()
870
874
  def check(
@@ -1,6 +1,6 @@
1
1
  """Core models and abstractions for MCP Ticketer."""
2
2
 
3
- from .models import Epic, Task, Comment, TicketState, Priority
3
+ from .models import Epic, Task, Comment, TicketState, Priority, TicketType
4
4
  from .adapter import BaseAdapter
5
5
  from .registry import AdapterRegistry
6
6
 
@@ -10,6 +10,7 @@ __all__ = [
10
10
  "Comment",
11
11
  "TicketState",
12
12
  "Priority",
13
+ "TicketType",
13
14
  "BaseAdapter",
14
15
  "AdapterRegistry",
15
16
  ]
@@ -1,8 +1,8 @@
1
1
  """Base adapter abstract class for ticket systems."""
2
2
 
3
3
  from abc import ABC, abstractmethod
4
- from typing import List, Optional, Dict, Any, TypeVar, Generic
5
- from .models import Epic, Task, Comment, SearchQuery, TicketState
4
+ from typing import List, Optional, Dict, Any, TypeVar, Generic, Union
5
+ from .models import Epic, Task, Comment, SearchQuery, TicketState, TicketType
6
6
 
7
7
  # Generic type for tickets
8
8
  T = TypeVar("T", Epic, Task)
@@ -206,6 +206,159 @@ class BaseAdapter(ABC, Generic[T]):
206
206
  return False
207
207
  return current_state.can_transition_to(target_state)
208
208
 
209
+ # Epic/Issue/Task Hierarchy Methods
210
+
211
+ async def create_epic(
212
+ self,
213
+ title: str,
214
+ description: Optional[str] = None,
215
+ **kwargs
216
+ ) -> Optional[Epic]:
217
+ """Create epic (top-level grouping).
218
+
219
+ Args:
220
+ title: Epic title
221
+ description: Epic description
222
+ **kwargs: Additional adapter-specific fields
223
+
224
+ Returns:
225
+ Created epic or None if failed
226
+ """
227
+ epic = Epic(
228
+ title=title,
229
+ description=description,
230
+ ticket_type=TicketType.EPIC,
231
+ **{k: v for k, v in kwargs.items() if k in Epic.__fields__}
232
+ )
233
+ result = await self.create(epic)
234
+ if isinstance(result, Epic):
235
+ return result
236
+ return None
237
+
238
+ async def get_epic(self, epic_id: str) -> Optional[Epic]:
239
+ """Get epic by ID.
240
+
241
+ Args:
242
+ epic_id: Epic identifier
243
+
244
+ Returns:
245
+ Epic if found, None otherwise
246
+ """
247
+ # Default implementation - subclasses should override for platform-specific logic
248
+ result = await self.read(epic_id)
249
+ if isinstance(result, Epic):
250
+ return result
251
+ return None
252
+
253
+ async def list_epics(self, **kwargs) -> List[Epic]:
254
+ """List all epics.
255
+
256
+ Args:
257
+ **kwargs: Adapter-specific filter parameters
258
+
259
+ Returns:
260
+ List of epics
261
+ """
262
+ # Default implementation - subclasses should override
263
+ filters = kwargs.copy()
264
+ filters["ticket_type"] = TicketType.EPIC
265
+ results = await self.list(filters=filters)
266
+ return [r for r in results if isinstance(r, Epic)]
267
+
268
+ async def create_issue(
269
+ self,
270
+ title: str,
271
+ description: Optional[str] = None,
272
+ epic_id: Optional[str] = None,
273
+ **kwargs
274
+ ) -> Optional[Task]:
275
+ """Create issue, optionally linked to epic.
276
+
277
+ Args:
278
+ title: Issue title
279
+ description: Issue description
280
+ epic_id: Optional parent epic ID
281
+ **kwargs: Additional adapter-specific fields
282
+
283
+ Returns:
284
+ Created issue or None if failed
285
+ """
286
+ task = Task(
287
+ title=title,
288
+ description=description,
289
+ ticket_type=TicketType.ISSUE,
290
+ parent_epic=epic_id,
291
+ **{k: v for k, v in kwargs.items() if k in Task.__fields__}
292
+ )
293
+ return await self.create(task)
294
+
295
+ async def list_issues_by_epic(self, epic_id: str) -> List[Task]:
296
+ """List all issues in epic.
297
+
298
+ Args:
299
+ epic_id: Epic identifier
300
+
301
+ Returns:
302
+ List of issues belonging to epic
303
+ """
304
+ # Default implementation - subclasses should override for efficiency
305
+ filters = {"parent_epic": epic_id, "ticket_type": TicketType.ISSUE}
306
+ results = await self.list(filters=filters)
307
+ return [r for r in results if isinstance(r, Task) and r.is_issue()]
308
+
309
+ async def create_task(
310
+ self,
311
+ title: str,
312
+ parent_id: str,
313
+ description: Optional[str] = None,
314
+ **kwargs
315
+ ) -> Optional[Task]:
316
+ """Create task as sub-ticket of parent issue.
317
+
318
+ Args:
319
+ title: Task title
320
+ parent_id: Required parent issue ID
321
+ description: Task description
322
+ **kwargs: Additional adapter-specific fields
323
+
324
+ Returns:
325
+ Created task or None if failed
326
+
327
+ Raises:
328
+ ValueError: If parent_id is not provided
329
+ """
330
+ if not parent_id:
331
+ raise ValueError("Tasks must have a parent_id (issue)")
332
+
333
+ task = Task(
334
+ title=title,
335
+ description=description,
336
+ ticket_type=TicketType.TASK,
337
+ parent_issue=parent_id,
338
+ **{k: v for k, v in kwargs.items() if k in Task.__fields__}
339
+ )
340
+
341
+ # Validate hierarchy before creating
342
+ errors = task.validate_hierarchy()
343
+ if errors:
344
+ raise ValueError(f"Invalid task hierarchy: {'; '.join(errors)}")
345
+
346
+ return await self.create(task)
347
+
348
+ async def list_tasks_by_issue(self, issue_id: str) -> List[Task]:
349
+ """List all tasks under an issue.
350
+
351
+ Args:
352
+ issue_id: Issue identifier
353
+
354
+ Returns:
355
+ List of tasks belonging to issue
356
+ """
357
+ # Default implementation - subclasses should override for efficiency
358
+ filters = {"parent_issue": issue_id, "ticket_type": TicketType.TASK}
359
+ results = await self.list(filters=filters)
360
+ return [r for r in results if isinstance(r, Task) and r.is_task()]
361
+
209
362
  async def close(self) -> None:
210
363
  """Close adapter and cleanup resources."""
211
364
  pass