mcp-ticketer 0.12.0__py3-none-any.whl → 2.2.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.

Files changed (129) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/aitrackdown.py +507 -6
  5. mcp_ticketer/adapters/asana/adapter.py +229 -0
  6. mcp_ticketer/adapters/asana/mappers.py +14 -0
  7. mcp_ticketer/adapters/github/__init__.py +26 -0
  8. mcp_ticketer/adapters/github/adapter.py +3229 -0
  9. mcp_ticketer/adapters/github/client.py +335 -0
  10. mcp_ticketer/adapters/github/mappers.py +797 -0
  11. mcp_ticketer/adapters/github/queries.py +692 -0
  12. mcp_ticketer/adapters/github/types.py +460 -0
  13. mcp_ticketer/adapters/hybrid.py +47 -5
  14. mcp_ticketer/adapters/jira/__init__.py +35 -0
  15. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  16. mcp_ticketer/adapters/jira/client.py +271 -0
  17. mcp_ticketer/adapters/jira/mappers.py +246 -0
  18. mcp_ticketer/adapters/jira/queries.py +216 -0
  19. mcp_ticketer/adapters/jira/types.py +304 -0
  20. mcp_ticketer/adapters/linear/adapter.py +2730 -139
  21. mcp_ticketer/adapters/linear/client.py +175 -3
  22. mcp_ticketer/adapters/linear/mappers.py +203 -8
  23. mcp_ticketer/adapters/linear/queries.py +280 -3
  24. mcp_ticketer/adapters/linear/types.py +120 -4
  25. mcp_ticketer/analysis/__init__.py +56 -0
  26. mcp_ticketer/analysis/dependency_graph.py +255 -0
  27. mcp_ticketer/analysis/health_assessment.py +304 -0
  28. mcp_ticketer/analysis/orphaned.py +218 -0
  29. mcp_ticketer/analysis/project_status.py +594 -0
  30. mcp_ticketer/analysis/similarity.py +224 -0
  31. mcp_ticketer/analysis/staleness.py +266 -0
  32. mcp_ticketer/automation/__init__.py +11 -0
  33. mcp_ticketer/automation/project_updates.py +378 -0
  34. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  35. mcp_ticketer/cli/auggie_configure.py +17 -5
  36. mcp_ticketer/cli/codex_configure.py +97 -61
  37. mcp_ticketer/cli/configure.py +1288 -105
  38. mcp_ticketer/cli/cursor_configure.py +314 -0
  39. mcp_ticketer/cli/diagnostics.py +13 -12
  40. mcp_ticketer/cli/discover.py +5 -0
  41. mcp_ticketer/cli/gemini_configure.py +17 -5
  42. mcp_ticketer/cli/init_command.py +880 -0
  43. mcp_ticketer/cli/install_mcp_server.py +418 -0
  44. mcp_ticketer/cli/instruction_commands.py +6 -0
  45. mcp_ticketer/cli/main.py +267 -3175
  46. mcp_ticketer/cli/mcp_configure.py +821 -119
  47. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  48. mcp_ticketer/cli/platform_detection.py +77 -12
  49. mcp_ticketer/cli/platform_installer.py +545 -0
  50. mcp_ticketer/cli/project_update_commands.py +350 -0
  51. mcp_ticketer/cli/setup_command.py +795 -0
  52. mcp_ticketer/cli/simple_health.py +12 -10
  53. mcp_ticketer/cli/ticket_commands.py +705 -103
  54. mcp_ticketer/cli/utils.py +113 -0
  55. mcp_ticketer/core/__init__.py +56 -6
  56. mcp_ticketer/core/adapter.py +533 -2
  57. mcp_ticketer/core/config.py +21 -21
  58. mcp_ticketer/core/exceptions.py +7 -1
  59. mcp_ticketer/core/label_manager.py +732 -0
  60. mcp_ticketer/core/mappers.py +31 -19
  61. mcp_ticketer/core/milestone_manager.py +252 -0
  62. mcp_ticketer/core/models.py +480 -0
  63. mcp_ticketer/core/onepassword_secrets.py +1 -1
  64. mcp_ticketer/core/priority_matcher.py +463 -0
  65. mcp_ticketer/core/project_config.py +132 -14
  66. mcp_ticketer/core/project_utils.py +281 -0
  67. mcp_ticketer/core/project_validator.py +376 -0
  68. mcp_ticketer/core/session_state.py +176 -0
  69. mcp_ticketer/core/state_matcher.py +625 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/mcp/server/__main__.py +2 -1
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/main.py +106 -25
  75. mcp_ticketer/mcp/server/routing.py +723 -0
  76. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  77. mcp_ticketer/mcp/server/tools/__init__.py +33 -11
  78. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  79. mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
  80. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  81. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  82. mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
  83. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  84. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  85. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  86. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  87. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  88. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  89. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  90. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  91. mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
  92. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  93. mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
  94. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  95. mcp_ticketer/queue/queue.py +68 -0
  96. mcp_ticketer/queue/worker.py +1 -1
  97. mcp_ticketer/utils/__init__.py +5 -0
  98. mcp_ticketer/utils/token_utils.py +246 -0
  99. mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
  100. mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
  101. mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
  102. py_mcp_installer/examples/phase3_demo.py +178 -0
  103. py_mcp_installer/scripts/manage_version.py +54 -0
  104. py_mcp_installer/setup.py +6 -0
  105. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  106. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  107. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  108. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  109. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  110. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  111. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  112. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  113. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  114. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  115. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  116. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  117. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  118. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  119. py_mcp_installer/tests/__init__.py +0 -0
  120. py_mcp_installer/tests/platforms/__init__.py +0 -0
  121. py_mcp_installer/tests/test_platform_detector.py +17 -0
  122. mcp_ticketer/adapters/github.py +0 -1574
  123. mcp_ticketer/adapters/jira.py +0 -1258
  124. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  125. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  126. mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
  127. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
  128. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
  129. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
@@ -213,11 +213,25 @@ def create(
213
213
  "--epic",
214
214
  help="Parent epic/project ID (synonym for --project)",
215
215
  ),
216
+ wait: bool = typer.Option(
217
+ False,
218
+ "--wait",
219
+ "-w",
220
+ help="Wait for operation to complete (synchronous mode, returns actual ticket ID)",
221
+ ),
222
+ timeout: float = typer.Option(
223
+ 30.0,
224
+ "--timeout",
225
+ help="Timeout in seconds for --wait mode (default: 30)",
226
+ ),
227
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
216
228
  adapter: AdapterType | None = typer.Option(
217
229
  None, "--adapter", help="Override default adapter"
218
230
  ),
219
231
  ) -> None:
220
232
  """Create a new ticket with comprehensive health checks."""
233
+ from .utils import format_error_json, format_json_response, serialize_task
234
+
221
235
  # IMMEDIATE HEALTH CHECK - Critical for reliability
222
236
  health_monitor = QueueHealthMonitor()
223
237
  health = health_monitor.check_health()
@@ -336,22 +350,35 @@ def create(
336
350
 
337
351
  result = asyncio.run(adapter_instance.create(task))
338
352
 
339
- console.print(f"[green]✓[/green] Ticket created successfully: {result.id}")
340
- console.print(f" Title: {result.title}")
341
- console.print(f" Priority: {result.priority}")
342
- console.print(f" State: {result.state}")
343
- # Get URL from metadata if available
344
- if (
345
- result.metadata
346
- and "linear" in result.metadata
347
- and "url" in result.metadata["linear"]
348
- ):
349
- console.print(f" URL: {result.metadata['linear']['url']}")
353
+ if output_json:
354
+ data = serialize_task(result)
355
+ console.print(
356
+ format_json_response(
357
+ "success", data, message="Ticket created successfully"
358
+ )
359
+ )
360
+ else:
361
+ console.print(
362
+ f"[green]✓[/green] Ticket created successfully: {result.id}"
363
+ )
364
+ console.print(f" Title: {result.title}")
365
+ console.print(f" Priority: {result.priority}")
366
+ console.print(f" State: {result.state}")
367
+ # Get URL from metadata if available
368
+ if (
369
+ result.metadata
370
+ and "linear" in result.metadata
371
+ and "url" in result.metadata["linear"]
372
+ ):
373
+ console.print(f" URL: {result.metadata['linear']['url']}")
350
374
 
351
375
  return result.id
352
376
 
353
377
  except Exception as e:
354
- console.print(f"[red]❌[/red] Failed to create ticket: {e}")
378
+ if output_json:
379
+ console.print(format_error_json(e))
380
+ else:
381
+ console.print(f"[red]❌[/red] Failed to create ticket: {e}")
355
382
  raise
356
383
 
357
384
  # Use queue for other adapters
@@ -369,51 +396,113 @@ def create(
369
396
  queue_id, adapter_name, "create", title, task_data
370
397
  )
371
398
 
372
- console.print(f"[green]✓[/green] Queued ticket creation: {queue_id}")
373
- console.print(f" Title: {title}")
374
- console.print(f" Priority: {priority}")
375
- console.print(f" Adapter: {adapter_name}")
376
- console.print(
377
- "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
378
- )
379
-
380
- # Start worker if needed with immediate feedback
399
+ # Start worker if needed - must happen before polling
381
400
  manager = WorkerManager()
382
401
  worker_started = manager.start_if_needed()
383
402
 
384
403
  if worker_started:
385
- console.print("[dim]Worker started to process request[/dim]")
404
+ if not output_json:
405
+ console.print("[dim]Worker started to process request[/dim]")
386
406
 
387
- # Give immediate feedback on processing
388
- import time
389
-
390
- time.sleep(1) # Brief pause to let worker start
391
-
392
- # Check if item is being processed
393
- item = queue.get_item(queue_id)
394
- if item and item.status == QueueStatus.PROCESSING:
395
- console.print("[green]✓ Item is being processed by worker[/green]")
396
- elif item and item.status == QueueStatus.PENDING:
397
- console.print("[yellow]⏳ Item is queued for processing[/yellow]")
398
- else:
407
+ # SYNCHRONOUS MODE: Poll until completion if --wait flag is set
408
+ if wait:
409
+ if not output_json:
399
410
  console.print(
400
- "[red]⚠️ Item status unclear - check with 'mcp-ticketer ticket check {queue_id}'[/red]"
411
+ f"[yellow]⏳[/yellow] Waiting for operation to complete (timeout: {timeout}s)..."
401
412
  )
413
+
414
+ try:
415
+ # Poll the queue until operation completes
416
+ completed_item = queue.poll_until_complete(queue_id, timeout=timeout)
417
+
418
+ # Extract result data
419
+ result = completed_item.result
420
+
421
+ # Extract ticket ID from result
422
+ ticket_id = result.get("id") if result else queue_id
423
+
424
+ if output_json:
425
+ # Return actual ticket data in JSON format
426
+ data = result if result else {"queue_id": queue_id}
427
+ console.print(
428
+ format_json_response(
429
+ "success", data, message="Ticket created successfully"
430
+ )
431
+ )
432
+ else:
433
+ # Display ticket creation success with actual ID
434
+ console.print(
435
+ f"[green]✓[/green] Ticket created successfully: {ticket_id}"
436
+ )
437
+ console.print(f" Title: {title}")
438
+ console.print(f" Priority: {priority}")
439
+
440
+ # Display additional metadata if available
441
+ if result:
442
+ if "url" in result:
443
+ console.print(f" URL: {result['url']}")
444
+ if "state" in result:
445
+ console.print(f" State: {result['state']}")
446
+
447
+ return ticket_id
448
+
449
+ except TimeoutError as e:
450
+ if output_json:
451
+ console.print(format_error_json(str(e), queue_id=queue_id))
452
+ else:
453
+ console.print(f"[red]❌[/red] Operation timed out after {timeout}s")
454
+ console.print(f" Queue ID: {queue_id}")
455
+ console.print(
456
+ f" Use 'mcp-ticketer ticket check {queue_id}' to check status later"
457
+ )
458
+ raise typer.Exit(1) from None
459
+
460
+ except RuntimeError as e:
461
+ if output_json:
462
+ console.print(format_error_json(str(e), queue_id=queue_id))
463
+ else:
464
+ console.print(f"[red]❌[/red] Operation failed: {e}")
465
+ console.print(f" Queue ID: {queue_id}")
466
+ raise typer.Exit(1) from None
467
+
468
+ # ASYNCHRONOUS MODE (default): Return queue ID immediately
402
469
  else:
403
- # Worker didn't start - this is a problem
404
- pending_count = queue.get_pending_count()
405
- if pending_count > 1: # More than just this item
406
- console.print(
407
- f"[red]❌ Worker failed to start with {pending_count} pending items![/red]"
408
- )
470
+ if output_json:
471
+ data = {
472
+ "queue_id": queue_id,
473
+ "title": title,
474
+ "priority": priority.value if hasattr(priority, "value") else priority,
475
+ "adapter": adapter_name,
476
+ "status": "queued",
477
+ }
409
478
  console.print(
410
- "[red]This is a critical issue. Try 'mcp-ticketer queue worker start' manually.[/red]"
479
+ format_json_response("success", data, message="Ticket creation queued")
411
480
  )
412
481
  else:
482
+ console.print(f"[green]✓[/green] Queued ticket creation: {queue_id}")
483
+ console.print(f" Title: {title}")
484
+ console.print(f" Priority: {priority}")
485
+ console.print(f" Adapter: {adapter_name}")
413
486
  console.print(
414
- "[yellow]Worker not started (no other pending items)[/yellow]"
487
+ "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
415
488
  )
416
489
 
490
+ # Give immediate feedback on processing
491
+ import time
492
+
493
+ time.sleep(1) # Brief pause to let worker start
494
+
495
+ # Check if item is being processed
496
+ item = queue.get_item(queue_id)
497
+ if item and item.status == QueueStatus.PROCESSING:
498
+ console.print("[green]✓ Item is being processed by worker[/green]")
499
+ elif item and item.status == QueueStatus.PENDING:
500
+ console.print("[yellow]⏳ Item is queued for processing[/yellow]")
501
+ else:
502
+ console.print(
503
+ "[red]⚠️ Item status unclear - check with 'mcp-ticketer ticket check {queue_id}'[/red]"
504
+ )
505
+
417
506
 
418
507
  @app.command("list")
419
508
  def list_tickets(
@@ -424,11 +513,13 @@ def list_tickets(
424
513
  None, "--priority", "-p", help="Filter by priority"
425
514
  ),
426
515
  limit: int = typer.Option(10, "--limit", "-l", help="Maximum number of tickets"),
516
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
427
517
  adapter: AdapterType | None = typer.Option(
428
518
  None, "--adapter", help="Override default adapter"
429
519
  ),
430
520
  ) -> None:
431
521
  """List tickets with optional filters."""
522
+ from .utils import format_json_response, serialize_task
432
523
 
433
524
  async def _list() -> list[Any]:
434
525
  adapter_instance = get_adapter(
@@ -444,10 +535,29 @@ def list_tickets(
444
535
  tickets = asyncio.run(_list())
445
536
 
446
537
  if not tickets:
447
- console.print("[yellow]No tickets found[/yellow]")
538
+ if output_json:
539
+ console.print(
540
+ format_json_response(
541
+ "success", {"tickets": [], "count": 0, "has_more": False}
542
+ )
543
+ )
544
+ else:
545
+ console.print("[yellow]No tickets found[/yellow]")
448
546
  return
449
547
 
450
- # Create table
548
+ # JSON output
549
+ if output_json:
550
+ tickets_data = [serialize_task(t) for t in tickets]
551
+ data = {
552
+ "tickets": tickets_data,
553
+ "count": len(tickets_data),
554
+ "has_more": len(tickets)
555
+ >= limit, # Heuristic: if we got exactly limit, there might be more
556
+ }
557
+ console.print(format_json_response("success", data))
558
+ return
559
+
560
+ # Original table output
451
561
  table = Table(title="Tickets")
452
562
  table.add_column("ID", style="cyan", no_wrap=True)
453
563
  table.add_column("Title", style="white")
@@ -473,62 +583,180 @@ def list_tickets(
473
583
  @app.command()
474
584
  def show(
475
585
  ticket_id: str = typer.Argument(..., help="Ticket ID"),
476
- comments: bool = typer.Option(False, "--comments", "-c", help="Show comments"),
586
+ no_comments: bool = typer.Option(
587
+ False, "--no-comments", help="Hide comments (shown by default)"
588
+ ),
589
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
477
590
  adapter: AdapterType | None = typer.Option(
478
591
  None, "--adapter", help="Override default adapter"
479
592
  ),
480
593
  ) -> None:
481
- """Show detailed ticket information."""
594
+ """Show detailed ticket information with full context.
595
+
596
+ By default, displays ticket details along with all comments to provide
597
+ a holistic view of the ticket's history and context.
598
+
599
+ Use --no-comments to display only ticket metadata without comments.
600
+ Use --json to output in machine-readable JSON format.
601
+ """
602
+ from .utils import format_error_json, format_json_response, serialize_task
482
603
 
483
- async def _show() -> tuple[Any, Any]:
604
+ async def _show() -> tuple[Any, Any, Any]:
484
605
  adapter_instance = get_adapter(
485
606
  override_adapter=adapter.value if adapter else None
486
607
  )
487
608
  ticket = await adapter_instance.read(ticket_id)
488
609
  ticket_comments = None
489
- if comments and ticket:
490
- ticket_comments = await adapter_instance.get_comments(ticket_id)
491
- return ticket, ticket_comments
610
+ attachments = None
492
611
 
493
- ticket, ticket_comments = asyncio.run(_show())
612
+ # Fetch comments by default (unless explicitly disabled)
613
+ if not no_comments and ticket:
614
+ try:
615
+ ticket_comments = await adapter_instance.get_comments(ticket_id)
616
+ except (NotImplementedError, AttributeError):
617
+ # Adapter doesn't support comments
618
+ pass
494
619
 
495
- if not ticket:
496
- console.print(f"[red]✗[/red] Ticket not found: {ticket_id}")
497
- raise typer.Exit(1) from None
620
+ # Try to fetch attachments if available
621
+ if ticket and hasattr(adapter_instance, "list_attachments"):
622
+ try:
623
+ attachments = await adapter_instance.list_attachments(ticket_id)
624
+ except (NotImplementedError, AttributeError):
625
+ pass
498
626
 
499
- # Display ticket details
500
- console.print(f"\n[bold]Ticket: {ticket.id}[/bold]")
501
- console.print(f"Title: {ticket.title}")
502
- console.print(f"State: [green]{ticket.state}[/green]")
503
- console.print(f"Priority: [yellow]{ticket.priority}[/yellow]")
627
+ return ticket, ticket_comments, attachments
504
628
 
505
- if ticket.description:
506
- console.print("\n[dim]Description:[/dim]")
507
- console.print(ticket.description)
629
+ try:
630
+ ticket, ticket_comments, attachments = asyncio.run(_show())
508
631
 
509
- if ticket.tags:
510
- console.print(f"\nTags: {', '.join(ticket.tags)}")
632
+ if not ticket:
633
+ if output_json:
634
+ console.print(
635
+ format_error_json(
636
+ f"Ticket not found: {ticket_id}", ticket_id=ticket_id
637
+ )
638
+ )
639
+ else:
640
+ console.print(f"[red]✗[/red] Ticket not found: {ticket_id}")
641
+ raise typer.Exit(1) from None
642
+
643
+ # JSON output
644
+ if output_json:
645
+ data = serialize_task(ticket)
646
+
647
+ # Add comments if available
648
+ if ticket_comments:
649
+ data["comments"] = [
650
+ {
651
+ "id": getattr(c, "id", None),
652
+ "text": c.content,
653
+ "author": c.author,
654
+ "created_at": (
655
+ c.created_at.isoformat()
656
+ if hasattr(c.created_at, "isoformat")
657
+ else str(c.created_at)
658
+ ),
659
+ }
660
+ for c in ticket_comments
661
+ ]
662
+
663
+ # Add attachments if available
664
+ if attachments:
665
+ data["attachments"] = attachments
666
+
667
+ console.print(format_json_response("success", data))
668
+ return
669
+
670
+ # Original formatted output continues below...
671
+ except Exception as e:
672
+ if output_json:
673
+ console.print(format_error_json(e, ticket_id=ticket_id))
674
+ raise typer.Exit(1) from None
675
+ raise
676
+
677
+ # Display ticket header with metadata
678
+ console.print(f"\n[bold cyan]┌─ Ticket: {ticket.id}[/bold cyan]")
679
+ console.print(f"[bold]│ {ticket.title}[/bold]")
680
+ console.print("└" + "─" * 60)
681
+
682
+ # Display metadata in organized sections
683
+ console.print("\n[bold]Status[/bold]")
684
+ console.print(f" State: [green]{ticket.state}[/green]")
685
+ console.print(f" Priority: [yellow]{ticket.priority}[/yellow]")
511
686
 
512
687
  if ticket.assignee:
513
- console.print(f"Assignee: {ticket.assignee}")
688
+ console.print(f" Assignee: {ticket.assignee}")
689
+
690
+ # Display timestamps if available
691
+ if ticket.created_at or ticket.updated_at:
692
+ console.print("\n[bold]Timeline[/bold]")
693
+ if ticket.created_at:
694
+ console.print(f" Created: {ticket.created_at}")
695
+ if ticket.updated_at:
696
+ console.print(f" Updated: {ticket.updated_at}")
697
+
698
+ # Display tags
699
+ if ticket.tags:
700
+ console.print("\n[bold]Tags[/bold]")
701
+ console.print(f" {', '.join(ticket.tags)}")
514
702
 
515
- # Display comments if requested
703
+ # Display description
704
+ if ticket.description:
705
+ console.print("\n[bold]Description[/bold]")
706
+ console.print(f" {ticket.description}")
707
+
708
+ # Display parent/child relationships
709
+ parent_info = []
710
+ if hasattr(ticket, "parent_epic") and ticket.parent_epic:
711
+ parent_info.append(f"Epic: {ticket.parent_epic}")
712
+ if hasattr(ticket, "parent_issue") and ticket.parent_issue:
713
+ parent_info.append(f"Parent Issue: {ticket.parent_issue}")
714
+
715
+ if parent_info:
716
+ console.print("\n[bold]Hierarchy[/bold]")
717
+ for info in parent_info:
718
+ console.print(f" {info}")
719
+
720
+ # Display attachments if available
721
+ if attachments and len(attachments) > 0:
722
+ console.print(f"\n[bold]Attachments ({len(attachments)})[/bold]")
723
+ for att in attachments:
724
+ att_title = att.get("title", "Untitled")
725
+ att_url = att.get("url", "")
726
+ console.print(f" 📎 {att_title}")
727
+ if att_url:
728
+ console.print(f" {att_url}")
729
+
730
+ # Display comments with enhanced formatting
516
731
  if ticket_comments:
517
- console.print(f"\n[bold]Comments ({len(ticket_comments)}):[/bold]")
518
- for comment in ticket_comments:
519
- console.print(f"\n[dim]{comment.created_at} - {comment.author}:[/dim]")
520
- console.print(comment.content)
732
+ console.print(f"\n[bold]Activity & Comments ({len(ticket_comments)})[/bold]")
733
+ for i, comment in enumerate(ticket_comments, 1):
734
+ # Format timestamp
735
+ timestamp = comment.created_at if comment.created_at else "Unknown time"
736
+ author = comment.author if comment.author else "Unknown author"
737
+
738
+ console.print(f"\n[dim] {i}. {timestamp}[/dim]")
739
+ console.print(f" [cyan]@{author}[/cyan]")
740
+ console.print(f" {comment.content}")
741
+
742
+ # Footer with hint
743
+ if no_comments:
744
+ console.print(
745
+ "\n[dim]💡 Tip: Remove --no-comments to see activity and comments[/dim]"
746
+ )
521
747
 
522
748
 
523
749
  @app.command()
524
750
  def comment(
525
751
  ticket_id: str = typer.Argument(..., help="Ticket ID"),
526
752
  content: str = typer.Argument(..., help="Comment content"),
753
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
527
754
  adapter: AdapterType | None = typer.Option(
528
755
  None, "--adapter", help="Override default adapter"
529
756
  ),
530
757
  ) -> None:
531
758
  """Add a comment to a ticket."""
759
+ from .utils import format_error_json, format_json_response
532
760
 
533
761
  async def _comment() -> Comment:
534
762
  adapter_instance = get_adapter(
@@ -547,12 +775,203 @@ def comment(
547
775
 
548
776
  try:
549
777
  result = asyncio.run(_comment())
550
- console.print("[green]✓[/green] Comment added successfully")
551
- if result.id:
552
- console.print(f"Comment ID: {result.id}")
553
- console.print(f"Content: {content}")
778
+
779
+ if output_json:
780
+ data = {
781
+ "id": result.id,
782
+ "ticket_id": ticket_id,
783
+ "text": content,
784
+ "author": result.author,
785
+ "created_at": (
786
+ result.created_at.isoformat()
787
+ if hasattr(result.created_at, "isoformat")
788
+ else str(result.created_at)
789
+ ),
790
+ }
791
+ console.print(
792
+ format_json_response(
793
+ "success", data, message="Comment added successfully"
794
+ )
795
+ )
796
+ else:
797
+ console.print("[green]✓[/green] Comment added successfully")
798
+ if result.id:
799
+ console.print(f"Comment ID: {result.id}")
800
+ console.print(f"Content: {content}")
554
801
  except Exception as e:
555
- console.print(f"[red]✗[/red] Failed to add comment: {e}")
802
+ if output_json:
803
+ console.print(format_error_json(e, ticket_id=ticket_id))
804
+ else:
805
+ console.print(f"[red]✗[/red] Failed to add comment: {e}")
806
+ raise typer.Exit(1) from None
807
+
808
+
809
+ @app.command()
810
+ def attach(
811
+ ticket_id: str = typer.Argument(..., help="Ticket ID or URL"),
812
+ file_path: Path = typer.Argument(..., help="Path to file to attach", exists=True),
813
+ description: str | None = typer.Option(
814
+ None, "--description", "-d", help="Attachment description or comment"
815
+ ),
816
+ adapter: AdapterType | None = typer.Option(
817
+ None, "--adapter", help="Override default adapter"
818
+ ),
819
+ ) -> None:
820
+ """Attach a file to a ticket.
821
+
822
+ Examples:
823
+ mcp-ticketer ticket attach 1M-157 docs/analysis.md
824
+ mcp-ticketer ticket attach PROJ-123 screenshot.png -d "Error screenshot"
825
+ mcp-ticketer ticket attach https://linear.app/.../issue/ABC-123 diagram.pdf
826
+ """
827
+
828
+ async def _attach() -> dict[str, Any]:
829
+ import mimetypes
830
+
831
+ adapter_instance = get_adapter(
832
+ override_adapter=adapter.value if adapter else None
833
+ )
834
+
835
+ # Detect MIME type
836
+ mime_type, _ = mimetypes.guess_type(str(file_path))
837
+ if not mime_type:
838
+ mime_type = "application/octet-stream"
839
+
840
+ # Method 1: Try Linear-specific upload (if available)
841
+ if hasattr(adapter_instance, "upload_file") and hasattr(
842
+ adapter_instance, "attach_file_to_issue"
843
+ ):
844
+ try:
845
+ # Upload file to Linear's S3
846
+ file_url = await adapter_instance.upload_file(
847
+ file_path=str(file_path), mime_type=mime_type
848
+ )
849
+
850
+ # Attach to issue
851
+ attachment = await adapter_instance.attach_file_to_issue(
852
+ issue_id=ticket_id,
853
+ file_url=file_url,
854
+ title=file_path.name,
855
+ subtitle=description,
856
+ )
857
+
858
+ return {
859
+ "status": "completed",
860
+ "attachment": attachment,
861
+ "file_url": file_url,
862
+ "method": "linear_native_upload",
863
+ }
864
+ except Exception:
865
+ # If Linear upload fails, fall through to next method
866
+ pass
867
+
868
+ # Method 2: Try generic add_attachment (if available)
869
+ if hasattr(adapter_instance, "add_attachment"):
870
+ try:
871
+ attachment = await adapter_instance.add_attachment(
872
+ ticket_id=ticket_id,
873
+ file_path=str(file_path),
874
+ description=description or "",
875
+ )
876
+ return {
877
+ "status": "completed",
878
+ "attachment": attachment,
879
+ "method": "adapter_native",
880
+ }
881
+ except NotImplementedError:
882
+ pass
883
+
884
+ # Method 3: Fallback - Add file reference as comment
885
+ from ..core.models import Comment
886
+
887
+ comment_content = f"📎 File reference: {file_path.name}"
888
+ if description:
889
+ comment_content += f"\n\n{description}"
890
+
891
+ comment_obj = Comment(
892
+ ticket_id=ticket_id,
893
+ content=comment_content,
894
+ author="cli-user",
895
+ )
896
+
897
+ comment = await adapter_instance.add_comment(comment_obj)
898
+ return {
899
+ "status": "completed",
900
+ "comment": comment,
901
+ "method": "comment_reference",
902
+ "note": "Adapter doesn't support attachments - added file reference as comment",
903
+ }
904
+
905
+ # Validate file before attempting upload
906
+ if not file_path.exists():
907
+ console.print(f"[red]✗[/red] File not found: {file_path}")
908
+ raise typer.Exit(1) from None
909
+
910
+ if not file_path.is_file():
911
+ console.print(f"[red]✗[/red] Path is not a file: {file_path}")
912
+ raise typer.Exit(1) from None
913
+
914
+ # Display file info
915
+ file_size = file_path.stat().st_size
916
+ size_mb = file_size / (1024 * 1024)
917
+ console.print(f"\n[dim]Attaching file to ticket {ticket_id}...[/dim]")
918
+ console.print(f" File: {file_path.name} ({size_mb:.2f} MB)")
919
+
920
+ # Detect MIME type
921
+ import mimetypes
922
+
923
+ mime_type, _ = mimetypes.guess_type(str(file_path))
924
+ if mime_type:
925
+ console.print(f" Type: {mime_type}")
926
+
927
+ try:
928
+ result = asyncio.run(_attach())
929
+
930
+ if result["status"] == "completed":
931
+ console.print(
932
+ f"\n[green]✓[/green] File attached successfully to {ticket_id}"
933
+ )
934
+
935
+ # Display attachment details based on method used
936
+ method = result.get("method", "unknown")
937
+
938
+ if method == "linear_native_upload":
939
+ console.print(" Method: Linear native upload")
940
+ if "file_url" in result:
941
+ console.print(f" URL: {result['file_url']}")
942
+ if "attachment" in result and isinstance(result["attachment"], dict):
943
+ att = result["attachment"]
944
+ if "id" in att:
945
+ console.print(f" ID: {att['id']}")
946
+ if "title" in att:
947
+ console.print(f" Title: {att['title']}")
948
+
949
+ elif method == "adapter_native":
950
+ console.print(" Method: Adapter native")
951
+ if "attachment" in result:
952
+ att = result["attachment"]
953
+ if isinstance(att, dict):
954
+ if "id" in att:
955
+ console.print(f" ID: {att['id']}")
956
+ if "url" in att:
957
+ console.print(f" URL: {att['url']}")
958
+
959
+ elif method == "comment_reference":
960
+ console.print(" Method: Comment reference")
961
+ console.print(f" [dim]{result.get('note', '')}[/dim]")
962
+ if "comment" in result:
963
+ comment = result["comment"]
964
+ if isinstance(comment, dict) and "id" in comment:
965
+ console.print(f" Comment ID: {comment['id']}")
966
+
967
+ else:
968
+ # Error case
969
+ error_msg = result.get("error", "Unknown error")
970
+ console.print(f"\n[red]✗[/red] Failed to attach file: {error_msg}")
971
+ raise typer.Exit(1) from None
972
+
973
+ except Exception as e:
974
+ console.print(f"\n[red]✗[/red] Failed to attach file: {e}")
556
975
  raise typer.Exit(1) from None
557
976
 
558
977
 
@@ -567,11 +986,25 @@ def update(
567
986
  None, "--priority", "-p", help="New priority"
568
987
  ),
569
988
  assignee: str | None = typer.Option(None, "--assignee", "-a", help="New assignee"),
989
+ wait: bool = typer.Option(
990
+ False,
991
+ "--wait",
992
+ "-w",
993
+ help="Wait for operation to complete (synchronous mode)",
994
+ ),
995
+ timeout: float = typer.Option(
996
+ 30.0,
997
+ "--timeout",
998
+ help="Timeout in seconds for --wait mode (default: 30)",
999
+ ),
1000
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
570
1001
  adapter: AdapterType | None = typer.Option(
571
1002
  None, "--adapter", help="Override default adapter"
572
1003
  ),
573
1004
  ) -> None:
574
1005
  """Update ticket fields."""
1006
+ from .utils import format_json_response
1007
+
575
1008
  updates = {}
576
1009
  if title:
577
1010
  updates["title"] = title
@@ -585,7 +1018,16 @@ def update(
585
1018
  updates["assignee"] = assignee
586
1019
 
587
1020
  if not updates:
588
- console.print("[yellow]No updates specified[/yellow]")
1021
+ if output_json:
1022
+ console.print(
1023
+ format_json_response(
1024
+ "error",
1025
+ {"error": "No updates specified"},
1026
+ message="No updates specified",
1027
+ )
1028
+ )
1029
+ else:
1030
+ console.print("[yellow]No updates specified[/yellow]")
589
1031
  raise typer.Exit(1) from None
590
1032
 
591
1033
  # Get the adapter name
@@ -606,18 +1048,77 @@ def update(
606
1048
  project_dir=str(Path.cwd()), # Explicitly pass current project directory
607
1049
  )
608
1050
 
609
- console.print(f"[green]✓[/green] Queued ticket update: {queue_id}")
610
- for key, value in updates.items():
611
- if key != "ticket_id":
612
- console.print(f" {key}: {value}")
613
- console.print(
614
- "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
615
- )
616
-
617
1051
  # Start worker if needed
618
1052
  manager = WorkerManager()
619
1053
  if manager.start_if_needed():
620
- console.print("[dim]Worker started to process request[/dim]")
1054
+ if not output_json:
1055
+ console.print("[dim]Worker started to process request[/dim]")
1056
+
1057
+ # SYNCHRONOUS MODE: Poll until completion if --wait flag is set
1058
+ if wait:
1059
+ from .utils import format_error_json
1060
+
1061
+ if not output_json:
1062
+ console.print(
1063
+ f"[yellow]⏳[/yellow] Waiting for update to complete (timeout: {timeout}s)..."
1064
+ )
1065
+
1066
+ try:
1067
+ # Poll the queue until operation completes
1068
+ completed_item = queue.poll_until_complete(queue_id, timeout=timeout)
1069
+ result = completed_item.result
1070
+
1071
+ if output_json:
1072
+ data = result if result else {"queue_id": queue_id, "id": ticket_id}
1073
+ console.print(
1074
+ format_json_response(
1075
+ "success", data, message="Ticket updated successfully"
1076
+ )
1077
+ )
1078
+ else:
1079
+ console.print(
1080
+ f"[green]✓[/green] Ticket updated successfully: {ticket_id}"
1081
+ )
1082
+ for key, value in updates.items():
1083
+ if key != "ticket_id":
1084
+ console.print(f" {key}: {value}")
1085
+
1086
+ except TimeoutError as e:
1087
+ if output_json:
1088
+ console.print(format_error_json(str(e), queue_id=queue_id))
1089
+ else:
1090
+ console.print(f"[red]❌[/red] Operation timed out after {timeout}s")
1091
+ console.print(f" Queue ID: {queue_id}")
1092
+ raise typer.Exit(1) from None
1093
+
1094
+ except RuntimeError as e:
1095
+ if output_json:
1096
+ console.print(format_error_json(str(e), queue_id=queue_id))
1097
+ else:
1098
+ console.print(f"[red]❌[/red] Operation failed: {e}")
1099
+ raise typer.Exit(1) from None
1100
+
1101
+ # ASYNCHRONOUS MODE (default)
1102
+ else:
1103
+ if output_json:
1104
+ updated_fields = [k for k in updates.keys() if k != "ticket_id"]
1105
+ data = {
1106
+ "id": ticket_id,
1107
+ "queue_id": queue_id,
1108
+ "updated_fields": updated_fields,
1109
+ **{k: v for k, v in updates.items() if k != "ticket_id"},
1110
+ }
1111
+ console.print(
1112
+ format_json_response("success", data, message="Ticket update queued")
1113
+ )
1114
+ else:
1115
+ console.print(f"[green]✓[/green] Queued ticket update: {queue_id}")
1116
+ for key, value in updates.items():
1117
+ if key != "ticket_id":
1118
+ console.print(f" {key}: {value}")
1119
+ console.print(
1120
+ "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
1121
+ )
621
1122
 
622
1123
 
623
1124
  @app.command()
@@ -629,6 +1130,18 @@ def transition(
629
1130
  state: TicketState | None = typer.Option(
630
1131
  None, "--state", "-s", help="Target state (recommended)"
631
1132
  ),
1133
+ wait: bool = typer.Option(
1134
+ False,
1135
+ "--wait",
1136
+ "-w",
1137
+ help="Wait for operation to complete (synchronous mode)",
1138
+ ),
1139
+ timeout: float = typer.Option(
1140
+ 30.0,
1141
+ "--timeout",
1142
+ help="Timeout in seconds for --wait mode (default: 30)",
1143
+ ),
1144
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
632
1145
  adapter: AdapterType | None = typer.Option(
633
1146
  None, "--adapter", help="Override default adapter"
634
1147
  ),
@@ -644,16 +1157,25 @@ def transition(
644
1157
  mcp-ticketer ticket transition BTA-215 done
645
1158
 
646
1159
  """
1160
+ from .utils import format_json_response
1161
+
647
1162
  # Determine which state to use (prefer flag over positional)
648
1163
  target_state = state if state is not None else state_positional
649
1164
 
650
1165
  if target_state is None:
651
- console.print("[red]Error: State is required[/red]")
652
- console.print(
653
- "Use either:\n"
654
- " - Flag syntax (recommended): mcp-ticketer ticket transition TICKET-ID --state STATE\n"
655
- " - Positional syntax: mcp-ticketer ticket transition TICKET-ID STATE"
656
- )
1166
+ if output_json:
1167
+ console.print(
1168
+ format_json_response(
1169
+ "error", {"error": "State is required"}, message="State is required"
1170
+ )
1171
+ )
1172
+ else:
1173
+ console.print("[red]Error: State is required[/red]")
1174
+ console.print(
1175
+ "Use either:\n"
1176
+ " - Flag syntax (recommended): mcp-ticketer ticket transition TICKET-ID --state STATE\n"
1177
+ " - Positional syntax: mcp-ticketer ticket transition TICKET-ID STATE"
1178
+ )
657
1179
  raise typer.Exit(1) from None
658
1180
 
659
1181
  # Get the adapter name
@@ -664,28 +1186,92 @@ def transition(
664
1186
 
665
1187
  # Add to queue with explicit project directory
666
1188
  queue = Queue()
1189
+ state_value = target_state.value if hasattr(target_state, "value") else target_state
667
1190
  queue_id = queue.add(
668
1191
  ticket_data={
669
1192
  "ticket_id": ticket_id,
670
- "state": (
671
- target_state.value if hasattr(target_state, "value") else target_state
672
- ),
1193
+ "state": state_value,
673
1194
  },
674
1195
  adapter=adapter_name,
675
1196
  operation="transition",
676
1197
  project_dir=str(Path.cwd()), # Explicitly pass current project directory
677
1198
  )
678
1199
 
679
- console.print(f"[green]✓[/green] Queued state transition: {queue_id}")
680
- console.print(f" Ticket: {ticket_id} → {target_state}")
681
- console.print(
682
- "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
683
- )
684
-
685
1200
  # Start worker if needed
686
1201
  manager = WorkerManager()
687
1202
  if manager.start_if_needed():
688
- console.print("[dim]Worker started to process request[/dim]")
1203
+ if not output_json:
1204
+ console.print("[dim]Worker started to process request[/dim]")
1205
+
1206
+ # SYNCHRONOUS MODE: Poll until completion if --wait flag is set
1207
+ if wait:
1208
+ from .utils import format_error_json
1209
+
1210
+ if not output_json:
1211
+ console.print(
1212
+ f"[yellow]⏳[/yellow] Waiting for transition to complete (timeout: {timeout}s)..."
1213
+ )
1214
+
1215
+ try:
1216
+ # Poll the queue until operation completes
1217
+ completed_item = queue.poll_until_complete(queue_id, timeout=timeout)
1218
+ result = completed_item.result
1219
+
1220
+ if output_json:
1221
+ data = (
1222
+ result
1223
+ if result
1224
+ else {
1225
+ "id": ticket_id,
1226
+ "new_state": state_value,
1227
+ "matched_state": state_value,
1228
+ "confidence": 1.0,
1229
+ }
1230
+ )
1231
+ console.print(
1232
+ format_json_response(
1233
+ "success", data, message="State transition completed"
1234
+ )
1235
+ )
1236
+ else:
1237
+ console.print(
1238
+ f"[green]✓[/green] State transition completed: {ticket_id} → {target_state}"
1239
+ )
1240
+
1241
+ except TimeoutError as e:
1242
+ if output_json:
1243
+ console.print(format_error_json(str(e), queue_id=queue_id))
1244
+ else:
1245
+ console.print(f"[red]❌[/red] Operation timed out after {timeout}s")
1246
+ console.print(f" Queue ID: {queue_id}")
1247
+ raise typer.Exit(1) from None
1248
+
1249
+ except RuntimeError as e:
1250
+ if output_json:
1251
+ console.print(format_error_json(str(e), queue_id=queue_id))
1252
+ else:
1253
+ console.print(f"[red]❌[/red] Operation failed: {e}")
1254
+ raise typer.Exit(1) from None
1255
+
1256
+ # ASYNCHRONOUS MODE (default)
1257
+ else:
1258
+ if output_json:
1259
+ data = {
1260
+ "id": ticket_id,
1261
+ "queue_id": queue_id,
1262
+ "new_state": state_value,
1263
+ "matched_state": state_value,
1264
+ "confidence": 1.0,
1265
+ }
1266
+ console.print(
1267
+ format_json_response("success", data, message="State transition queued")
1268
+ )
1269
+ else:
1270
+ console.print(f"[green]✓[/green] Queued state transition: {queue_id}")
1271
+ console.print(f" Ticket: {ticket_id} → {target_state}")
1272
+ console.print(
1273
+ "[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
1274
+ )
689
1275
 
690
1276
 
691
1277
  @app.command()
@@ -695,11 +1281,13 @@ def search(
695
1281
  priority: Priority | None = typer.Option(None, "--priority", "-p"),
696
1282
  assignee: str | None = typer.Option(None, "--assignee", "-a"),
697
1283
  limit: int = typer.Option(10, "--limit", "-l"),
1284
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
698
1285
  adapter: AdapterType | None = typer.Option(
699
1286
  None, "--adapter", help="Override default adapter"
700
1287
  ),
701
1288
  ) -> None:
702
1289
  """Search tickets with advanced query."""
1290
+ from .utils import format_json_response, serialize_task
703
1291
 
704
1292
  async def _search() -> list[Any]:
705
1293
  adapter_instance = get_adapter(
@@ -717,7 +1305,21 @@ def search(
717
1305
  tickets = asyncio.run(_search())
718
1306
 
719
1307
  if not tickets:
720
- console.print("[yellow]No tickets found matching query[/yellow]")
1308
+ if output_json:
1309
+ console.print(
1310
+ format_json_response(
1311
+ "success", {"tickets": [], "query": query, "count": 0}
1312
+ )
1313
+ )
1314
+ else:
1315
+ console.print("[yellow]No tickets found matching query[/yellow]")
1316
+ return
1317
+
1318
+ # JSON output
1319
+ if output_json:
1320
+ tickets_data = [serialize_task(t) for t in tickets]
1321
+ data = {"tickets": tickets_data, "query": query, "count": len(tickets_data)}
1322
+ console.print(format_json_response("success", data))
721
1323
  return
722
1324
 
723
1325
  # Display results