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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/aitrackdown.py +507 -6
- mcp_ticketer/adapters/asana/adapter.py +229 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/adapter.py +2730 -139
- mcp_ticketer/adapters/linear/client.py +175 -3
- mcp_ticketer/adapters/linear/mappers.py +203 -8
- mcp_ticketer/adapters/linear/queries.py +280 -3
- mcp_ticketer/adapters/linear/types.py +120 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cli/adapter_diagnostics.py +3 -1
- mcp_ticketer/cli/auggie_configure.py +17 -5
- mcp_ticketer/cli/codex_configure.py +97 -61
- mcp_ticketer/cli/configure.py +1288 -105
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +13 -12
- mcp_ticketer/cli/discover.py +5 -0
- mcp_ticketer/cli/gemini_configure.py +17 -5
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +267 -3175
- mcp_ticketer/cli/mcp_configure.py +821 -119
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +795 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +705 -103
- mcp_ticketer/cli/utils.py +113 -0
- mcp_ticketer/core/__init__.py +56 -6
- mcp_ticketer/core/adapter.py +533 -2
- mcp_ticketer/core/config.py +21 -21
- mcp_ticketer/core/exceptions.py +7 -1
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +31 -19
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +480 -0
- mcp_ticketer/core/onepassword_secrets.py +1 -1
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +132 -14
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +625 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/mcp/server/__main__.py +2 -1
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +33 -11
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
- mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- mcp_ticketer/queue/queue.py +68 -0
- mcp_ticketer/queue/worker.py +1 -1
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1574
- mcp_ticketer/adapters/jira.py +0 -1258
- mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
- mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
- mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
+
if not output_json:
|
|
405
|
+
console.print("[dim]Worker started to process request[/dim]")
|
|
386
406
|
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
"[
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
"
|
|
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
|
-
"[
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
ticket_comments = await adapter_instance.get_comments(ticket_id)
|
|
491
|
-
return ticket, ticket_comments
|
|
610
|
+
attachments = None
|
|
492
611
|
|
|
493
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
console.print(ticket.description)
|
|
629
|
+
try:
|
|
630
|
+
ticket, ticket_comments, attachments = asyncio.run(_show())
|
|
508
631
|
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
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)})
|
|
518
|
-
for comment in ticket_comments:
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
551
|
-
if
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|