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

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()
@@ -159,11 +160,22 @@ def get_adapter(override_adapter: Optional[str] = None, override_config: Optiona
159
160
 
160
161
  @app.command()
161
162
  def init(
162
- adapter: AdapterType = typer.Option(
163
- AdapterType.AITRACKDOWN,
163
+ adapter: Optional[str] = typer.Option(
164
+ None,
164
165
  "--adapter",
165
166
  "-a",
166
- help="Adapter type to use"
167
+ help="Adapter type to use (auto-detected from .env if not specified)"
168
+ ),
169
+ project_path: Optional[str] = typer.Option(
170
+ None,
171
+ "--path",
172
+ help="Project path (default: current directory)"
173
+ ),
174
+ global_config: bool = typer.Option(
175
+ False,
176
+ "--global",
177
+ "-g",
178
+ help="Save to global config instead of project-specific"
167
179
  ),
168
180
  base_path: Optional[str] = typer.Option(
169
181
  None,
@@ -212,97 +224,306 @@ def init(
212
224
  help="GitHub Personal Access Token"
213
225
  ),
214
226
  ) -> None:
215
- """Initialize MCP Ticketer configuration."""
227
+ """Initialize mcp-ticketer for the current project.
228
+
229
+ Creates .mcp-ticketer/config.json in the current directory with
230
+ auto-detected or specified adapter configuration.
231
+
232
+ Examples:
233
+ # Auto-detect from .env.local
234
+ mcp-ticketer init
235
+
236
+ # Force specific adapter
237
+ mcp-ticketer init --adapter linear
238
+
239
+ # Initialize for different project
240
+ mcp-ticketer init --path /path/to/project
241
+
242
+ # Save globally (not recommended)
243
+ mcp-ticketer init --global
244
+ """
245
+ from pathlib import Path
246
+ from ..core.project_config import ConfigResolver
247
+ from ..core.env_discovery import discover_config
248
+
249
+ # Determine project path
250
+ proj_path = Path(project_path) if project_path else Path.cwd()
251
+
252
+ # Check if already initialized (unless using --global)
253
+ if not global_config:
254
+ config_path = proj_path / ".mcp-ticketer" / "config.json"
255
+
256
+ if config_path.exists():
257
+ if not typer.confirm(
258
+ f"Configuration already exists at {config_path}. Overwrite?",
259
+ default=False
260
+ ):
261
+ console.print("[yellow]Initialization cancelled.[/yellow]")
262
+ raise typer.Exit(0)
263
+
264
+ # 1. Try auto-discovery if no adapter specified
265
+ discovered = None
266
+ adapter_type = adapter
267
+
268
+ if not adapter_type:
269
+ console.print("[cyan]🔍 Auto-discovering configuration from .env files...[/cyan]")
270
+ discovered = discover_config(proj_path)
271
+
272
+ if discovered and discovered.adapters:
273
+ primary = discovered.get_primary_adapter()
274
+ if primary:
275
+ adapter_type = primary.adapter_type
276
+ console.print(f"[green]✓ Detected {adapter_type} adapter from environment files[/green]")
277
+
278
+ # Show what was discovered
279
+ console.print(f"\n[dim]Configuration found in: {primary.found_in}[/dim]")
280
+ console.print(f"[dim]Confidence: {primary.confidence:.0%}[/dim]")
281
+ else:
282
+ adapter_type = "aitrackdown" # Fallback
283
+ console.print("[yellow]⚠ No credentials found, defaulting to aitrackdown[/yellow]")
284
+ else:
285
+ adapter_type = "aitrackdown" # Fallback
286
+ console.print("[yellow]⚠ No .env files found, defaulting to aitrackdown[/yellow]")
287
+
288
+ # 2. Create configuration based on adapter type
216
289
  config = {
217
- "default_adapter": adapter.value,
290
+ "default_adapter": adapter_type,
218
291
  "adapters": {}
219
292
  }
220
293
 
221
- if adapter == AdapterType.AITRACKDOWN:
294
+ # 3. If discovered and matches adapter_type, use discovered config
295
+ if discovered and adapter_type != "aitrackdown":
296
+ discovered_adapter = discovered.get_adapter_by_type(adapter_type)
297
+ if discovered_adapter:
298
+ config["adapters"][adapter_type] = discovered_adapter.config
299
+
300
+ # 4. Handle manual configuration for specific adapters
301
+ if adapter_type == "aitrackdown":
222
302
  config["adapters"]["aitrackdown"] = {"base_path": base_path or ".aitrackdown"}
223
- elif adapter == AdapterType.LINEAR:
224
- # For Linear, we need team_id and optionally api_key
225
- if not team_id:
226
- console.print("[red]Error:[/red] --team-id is required for Linear adapter")
227
- raise typer.Exit(1)
228
-
229
- config["adapters"]["linear"] = {"team_id": team_id}
230
-
231
- # Check for API key in environment or parameter
232
- linear_api_key = api_key or os.getenv("LINEAR_API_KEY")
233
- if not linear_api_key:
234
- console.print("[yellow]Warning:[/yellow] No Linear API key provided.")
235
- console.print("Set LINEAR_API_KEY environment variable or use --api-key option")
236
- else:
237
- config["adapters"]["linear"]["api_key"] = linear_api_key
238
-
239
- elif adapter == AdapterType.JIRA:
240
- # For JIRA, we need server, email, and API token
241
- server = jira_server or os.getenv("JIRA_SERVER")
242
- email = jira_email or os.getenv("JIRA_EMAIL")
243
- token = api_key or os.getenv("JIRA_API_TOKEN")
244
- project = jira_project or os.getenv("JIRA_PROJECT_KEY")
245
-
246
- if not server:
247
- console.print("[red]Error:[/red] JIRA server URL is required")
248
- console.print("Use --jira-server or set JIRA_SERVER environment variable")
249
- raise typer.Exit(1)
250
-
251
- if not email:
252
- console.print("[red]Error:[/red] JIRA email is required")
253
- console.print("Use --jira-email or set JIRA_EMAIL environment variable")
254
- raise typer.Exit(1)
255
-
256
- if not token:
257
- console.print("[red]Error:[/red] JIRA API token is required")
258
- console.print("Use --api-key or set JIRA_API_TOKEN environment variable")
259
- console.print("[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]")
260
- raise typer.Exit(1)
261
-
262
- config["adapters"]["jira"] = {
263
- "server": server,
264
- "email": email,
265
- "api_token": token
266
- }
267
303
 
268
- if project:
269
- config["adapters"]["jira"]["project_key"] = project
304
+ elif adapter_type == "linear":
305
+ # If not auto-discovered, build from CLI params
306
+ if adapter_type not in config["adapters"]:
307
+ linear_config = {}
308
+
309
+ # Team ID
310
+ if team_id:
311
+ linear_config["team_id"] = team_id
312
+
313
+ # API Key
314
+ linear_api_key = api_key or os.getenv("LINEAR_API_KEY")
315
+ if linear_api_key:
316
+ linear_config["api_key"] = linear_api_key
317
+ elif not discovered:
318
+ console.print("[yellow]Warning:[/yellow] No Linear API key provided.")
319
+ console.print("Set LINEAR_API_KEY environment variable or use --api-key option")
320
+
321
+ if linear_config:
322
+ config["adapters"]["linear"] = linear_config
323
+
324
+ elif adapter_type == "jira":
325
+ # If not auto-discovered, build from CLI params
326
+ if adapter_type not in config["adapters"]:
327
+ server = jira_server or os.getenv("JIRA_SERVER")
328
+ email = jira_email or os.getenv("JIRA_EMAIL")
329
+ token = api_key or os.getenv("JIRA_API_TOKEN")
330
+ project = jira_project or os.getenv("JIRA_PROJECT_KEY")
331
+
332
+ if not server:
333
+ console.print("[red]Error:[/red] JIRA server URL is required")
334
+ console.print("Use --jira-server or set JIRA_SERVER environment variable")
335
+ raise typer.Exit(1)
336
+
337
+ if not email:
338
+ console.print("[red]Error:[/red] JIRA email is required")
339
+ console.print("Use --jira-email or set JIRA_EMAIL environment variable")
340
+ raise typer.Exit(1)
341
+
342
+ if not token:
343
+ console.print("[red]Error:[/red] JIRA API token is required")
344
+ console.print("Use --api-key or set JIRA_API_TOKEN environment variable")
345
+ console.print("[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]")
346
+ raise typer.Exit(1)
347
+
348
+ jira_config = {
349
+ "server": server,
350
+ "email": email,
351
+ "api_token": token
352
+ }
353
+
354
+ if project:
355
+ jira_config["project_key"] = project
356
+
357
+ config["adapters"]["jira"] = jira_config
358
+
359
+ elif adapter_type == "github":
360
+ # If not auto-discovered, build from CLI params
361
+ if adapter_type not in config["adapters"]:
362
+ owner = github_owner or os.getenv("GITHUB_OWNER")
363
+ repo = github_repo or os.getenv("GITHUB_REPO")
364
+ token = github_token or os.getenv("GITHUB_TOKEN")
365
+
366
+ if not owner:
367
+ console.print("[red]Error:[/red] GitHub repository owner is required")
368
+ console.print("Use --github-owner or set GITHUB_OWNER environment variable")
369
+ raise typer.Exit(1)
370
+
371
+ if not repo:
372
+ console.print("[red]Error:[/red] GitHub repository name is required")
373
+ console.print("Use --github-repo or set GITHUB_REPO environment variable")
374
+ raise typer.Exit(1)
375
+
376
+ if not token:
377
+ console.print("[red]Error:[/red] GitHub Personal Access Token is required")
378
+ console.print("Use --github-token or set GITHUB_TOKEN environment variable")
379
+ console.print("[dim]Create token at: https://github.com/settings/tokens/new[/dim]")
380
+ console.print("[dim]Required scopes: repo (for private repos) or public_repo (for public repos)[/dim]")
381
+ raise typer.Exit(1)
382
+
383
+ config["adapters"]["github"] = {
384
+ "owner": owner,
385
+ "repo": repo,
386
+ "token": token
387
+ }
388
+
389
+ # 5. Save to appropriate location
390
+ if global_config:
391
+ # Save to ~/.mcp-ticketer/config.json
392
+ resolver = ConfigResolver(project_path=proj_path)
393
+ config_file_path = resolver.GLOBAL_CONFIG_PATH
394
+ config_file_path.parent.mkdir(parents=True, exist_ok=True)
395
+
396
+ with open(config_file_path, 'w') as f:
397
+ json.dump(config, f, indent=2)
398
+
399
+ console.print(f"[green]✓ Initialized with {adapter_type} adapter[/green]")
400
+ console.print(f"[dim]Global configuration saved to {config_file_path}[/dim]")
401
+ else:
402
+ # Save to ./.mcp-ticketer/config.json (PROJECT-SPECIFIC)
403
+ config_file_path = proj_path / ".mcp-ticketer" / "config.json"
404
+ config_file_path.parent.mkdir(parents=True, exist_ok=True)
405
+
406
+ with open(config_file_path, 'w') as f:
407
+ json.dump(config, f, indent=2)
408
+
409
+ console.print(f"[green]✓ Initialized with {adapter_type} adapter[/green]")
410
+ console.print(f"[dim]Project configuration saved to {config_file_path}[/dim]")
411
+
412
+ # Add .mcp-ticketer to .gitignore if not already there
413
+ gitignore_path = proj_path / ".gitignore"
414
+ if gitignore_path.exists():
415
+ gitignore_content = gitignore_path.read_text()
416
+ if ".mcp-ticketer" not in gitignore_content:
417
+ with open(gitignore_path, 'a') as f:
418
+ f.write("\n# MCP Ticketer\n.mcp-ticketer/\n")
419
+ console.print("[dim]✓ Added .mcp-ticketer/ to .gitignore[/dim]")
270
420
  else:
271
- console.print("[yellow]Warning:[/yellow] No default project key specified")
272
- console.print("You may need to specify project key for some operations")
273
-
274
- elif adapter == AdapterType.GITHUB:
275
- # For GitHub, we need owner, repo, and token
276
- owner = github_owner or os.getenv("GITHUB_OWNER")
277
- repo = github_repo or os.getenv("GITHUB_REPO")
278
- token = github_token or os.getenv("GITHUB_TOKEN")
279
-
280
- if not owner:
281
- console.print("[red]Error:[/red] GitHub repository owner is required")
282
- console.print("Use --github-owner or set GITHUB_OWNER environment variable")
283
- raise typer.Exit(1)
284
-
285
- if not repo:
286
- console.print("[red]Error:[/red] GitHub repository name is required")
287
- console.print("Use --github-repo or set GITHUB_REPO environment variable")
288
- raise typer.Exit(1)
289
-
290
- if not token:
291
- console.print("[red]Error:[/red] GitHub Personal Access Token is required")
292
- console.print("Use --github-token or set GITHUB_TOKEN environment variable")
293
- console.print("[dim]Create token at: https://github.com/settings/tokens/new[/dim]")
294
- console.print("[dim]Required scopes: repo (for private repos) or public_repo (for public repos)[/dim]")
295
- raise typer.Exit(1)
296
-
297
- config["adapters"]["github"] = {
298
- "owner": owner,
299
- "repo": repo,
300
- "token": token
301
- }
302
-
303
- save_config(config)
304
- console.print(f"[green]✓[/green] Initialized with {adapter.value} adapter")
305
- console.print(f"[dim]Configuration saved to {CONFIG_FILE}[/dim]")
421
+ # Create .gitignore if it doesn't exist
422
+ with open(gitignore_path, 'w') as f:
423
+ f.write("# MCP Ticketer\n.mcp-ticketer/\n")
424
+ console.print("[dim]✓ Created .gitignore with .mcp-ticketer/[/dim]")
425
+
426
+
427
+ @app.command()
428
+ def install(
429
+ adapter: Optional[str] = typer.Option(
430
+ None,
431
+ "--adapter",
432
+ "-a",
433
+ help="Adapter type to use (auto-detected from .env if not specified)"
434
+ ),
435
+ project_path: Optional[str] = typer.Option(
436
+ None,
437
+ "--path",
438
+ help="Project path (default: current directory)"
439
+ ),
440
+ global_config: bool = typer.Option(
441
+ False,
442
+ "--global",
443
+ "-g",
444
+ help="Save to global config instead of project-specific"
445
+ ),
446
+ base_path: Optional[str] = typer.Option(
447
+ None,
448
+ "--base-path",
449
+ "-p",
450
+ help="Base path for ticket storage (AITrackdown only)"
451
+ ),
452
+ api_key: Optional[str] = typer.Option(
453
+ None,
454
+ "--api-key",
455
+ help="API key for Linear or API token for JIRA"
456
+ ),
457
+ team_id: Optional[str] = typer.Option(
458
+ None,
459
+ "--team-id",
460
+ help="Linear team ID (required for Linear adapter)"
461
+ ),
462
+ jira_server: Optional[str] = typer.Option(
463
+ None,
464
+ "--jira-server",
465
+ help="JIRA server URL (e.g., https://company.atlassian.net)"
466
+ ),
467
+ jira_email: Optional[str] = typer.Option(
468
+ None,
469
+ "--jira-email",
470
+ help="JIRA user email for authentication"
471
+ ),
472
+ jira_project: Optional[str] = typer.Option(
473
+ None,
474
+ "--jira-project",
475
+ help="Default JIRA project key"
476
+ ),
477
+ github_owner: Optional[str] = typer.Option(
478
+ None,
479
+ "--github-owner",
480
+ help="GitHub repository owner"
481
+ ),
482
+ github_repo: Optional[str] = typer.Option(
483
+ None,
484
+ "--github-repo",
485
+ help="GitHub repository name"
486
+ ),
487
+ github_token: Optional[str] = typer.Option(
488
+ None,
489
+ "--github-token",
490
+ help="GitHub Personal Access Token"
491
+ ),
492
+ ) -> None:
493
+ """Initialize mcp-ticketer for the current project (alias for init).
494
+
495
+ This command is synonymous with 'init' and provides the same functionality.
496
+ Creates .mcp-ticketer/config.json in the current directory with
497
+ auto-detected or specified adapter configuration.
498
+
499
+ Examples:
500
+ # Auto-detect from .env.local
501
+ mcp-ticketer install
502
+
503
+ # Force specific adapter
504
+ mcp-ticketer install --adapter linear
505
+
506
+ # Initialize for different project
507
+ mcp-ticketer install --path /path/to/project
508
+
509
+ # Save globally (not recommended)
510
+ mcp-ticketer install --global
511
+ """
512
+ # Call init with all parameters
513
+ init(
514
+ adapter=adapter,
515
+ project_path=project_path,
516
+ global_config=global_config,
517
+ base_path=base_path,
518
+ api_key=api_key,
519
+ team_id=team_id,
520
+ jira_server=jira_server,
521
+ jira_email=jira_email,
522
+ jira_project=jira_project,
523
+ github_owner=github_owner,
524
+ github_repo=github_repo,
525
+ github_token=github_token,
526
+ )
306
527
 
307
528
 
308
529
  @app.command("set")
@@ -865,6 +1086,9 @@ def search(
865
1086
  # Add queue command to main app
866
1087
  app.add_typer(queue_app, name="queue")
867
1088
 
1089
+ # Add discover command to main app
1090
+ app.add_typer(discover_app, name="discover")
1091
+
868
1092
 
869
1093
  @app.command()
870
1094
  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