mcp-ticketer 0.3.0__py3-none-any.whl → 2.2.9__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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +930 -52
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1537 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -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 +58 -16
- 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/__init__.py +1 -1
- mcp_ticketer/adapters/linear/adapter.py +3810 -462
- mcp_ticketer/adapters/linear/client.py +312 -69
- mcp_ticketer/adapters/linear/mappers.py +305 -85
- mcp_ticketer/adapters/linear/queries.py +317 -17
- mcp_ticketer/adapters/linear/types.py +187 -64
- mcp_ticketer/adapters/linear.py +2 -2
- 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/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +91 -54
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +1323 -151
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +209 -114
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +256 -130
- mcp_ticketer/cli/main.py +140 -1544
- mcp_ticketer/cli/mcp_configure.py +1013 -100
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +794 -0
- mcp_ticketer/cli/simple_health.py +84 -59
- mcp_ticketer/cli/ticket_commands.py +1375 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +195 -72
- mcp_ticketer/core/__init__.py +64 -1
- mcp_ticketer/core/adapter.py +618 -18
- mcp_ticketer/core/config.py +77 -68
- mcp_ticketer/core/env_discovery.py +75 -16
- mcp_ticketer/core/env_loader.py +121 -97
- mcp_ticketer/core/exceptions.py +32 -24
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +566 -19
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +189 -49
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +69 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- 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 +150 -0
- 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 +318 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +78 -63
- mcp_ticketer/queue/queue.py +108 -21
- mcp_ticketer/queue/run_worker.py +2 -2
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +96 -58
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.9.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 -1354
- mcp_ticketer/adapters/jira.py +0 -1011
- mcp_ticketer/mcp/server.py +0 -2030
- mcp_ticketer-0.3.0.dist-info/METADATA +0 -414
- mcp_ticketer-0.3.0.dist-info/RECORD +0 -59
- mcp_ticketer-0.3.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
"""Ticket management commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from ..core import AdapterRegistry, Priority, TicketState
|
|
15
|
+
from ..core.models import Comment, SearchQuery
|
|
16
|
+
from ..queue import Queue, QueueStatus, WorkerManager
|
|
17
|
+
from ..queue.health_monitor import HealthStatus, QueueHealthMonitor
|
|
18
|
+
from ..queue.ticket_registry import TicketRegistry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Moved from main.py to avoid circular import
|
|
22
|
+
class AdapterType(str, Enum):
|
|
23
|
+
"""Available adapter types."""
|
|
24
|
+
|
|
25
|
+
AITRACKDOWN = "aitrackdown"
|
|
26
|
+
LINEAR = "linear"
|
|
27
|
+
JIRA = "jira"
|
|
28
|
+
GITHUB = "github"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="ticket",
|
|
33
|
+
help="Ticket management operations (create, list, update, search, etc.)",
|
|
34
|
+
)
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Configuration functions (moved from main.py to avoid circular import)
|
|
39
|
+
def load_config(project_dir: Path | None = None) -> dict:
|
|
40
|
+
"""Load configuration from project-local config file."""
|
|
41
|
+
import logging
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
base_dir = project_dir or Path.cwd()
|
|
45
|
+
project_config = base_dir / ".mcp-ticketer" / "config.json"
|
|
46
|
+
|
|
47
|
+
if project_config.exists():
|
|
48
|
+
try:
|
|
49
|
+
with open(project_config) as f:
|
|
50
|
+
config = json.load(f)
|
|
51
|
+
logger.info(f"Loaded configuration from: {project_config}")
|
|
52
|
+
return config
|
|
53
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
54
|
+
logger.warning(f"Could not load project config: {e}, using defaults")
|
|
55
|
+
console.print(
|
|
56
|
+
f"[yellow]Warning: Could not load project config: {e}[/yellow]"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
logger.info("No project-local config found, defaulting to aitrackdown adapter")
|
|
60
|
+
return {"adapter": "aitrackdown", "config": {"base_path": ".aitrackdown"}}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def save_config(config: dict) -> None:
|
|
64
|
+
"""Save configuration to project-local config file."""
|
|
65
|
+
import logging
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
project_config = Path.cwd() / ".mcp-ticketer" / "config.json"
|
|
69
|
+
project_config.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
with open(project_config, "w") as f:
|
|
71
|
+
json.dump(config, f, indent=2)
|
|
72
|
+
logger.info(f"Saved configuration to: {project_config}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_adapter(
|
|
76
|
+
override_adapter: str | None = None, override_config: dict | None = None
|
|
77
|
+
) -> Any:
|
|
78
|
+
"""Get configured adapter instance."""
|
|
79
|
+
config = load_config()
|
|
80
|
+
|
|
81
|
+
if override_adapter:
|
|
82
|
+
adapter_type = override_adapter
|
|
83
|
+
adapters_config = config.get("adapters", {})
|
|
84
|
+
adapter_config = adapters_config.get(adapter_type, {})
|
|
85
|
+
if override_config:
|
|
86
|
+
adapter_config.update(override_config)
|
|
87
|
+
else:
|
|
88
|
+
adapter_type = config.get("default_adapter", "aitrackdown")
|
|
89
|
+
adapters_config = config.get("adapters", {})
|
|
90
|
+
adapter_config = adapters_config.get(adapter_type, {})
|
|
91
|
+
|
|
92
|
+
if not adapter_config and "config" in config:
|
|
93
|
+
adapter_config = config["config"]
|
|
94
|
+
|
|
95
|
+
# Add environment variables for authentication
|
|
96
|
+
if adapter_type == "linear":
|
|
97
|
+
if not adapter_config.get("api_key"):
|
|
98
|
+
adapter_config["api_key"] = os.getenv("LINEAR_API_KEY")
|
|
99
|
+
elif adapter_type == "github":
|
|
100
|
+
if not adapter_config.get("api_key") and not adapter_config.get("token"):
|
|
101
|
+
adapter_config["api_key"] = os.getenv("GITHUB_TOKEN")
|
|
102
|
+
elif adapter_type == "jira":
|
|
103
|
+
if not adapter_config.get("api_token"):
|
|
104
|
+
adapter_config["api_token"] = os.getenv("JIRA_ACCESS_TOKEN")
|
|
105
|
+
if not adapter_config.get("email"):
|
|
106
|
+
adapter_config["email"] = os.getenv("JIRA_ACCESS_USER")
|
|
107
|
+
|
|
108
|
+
return AdapterRegistry.get_adapter(adapter_type, adapter_config)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _discover_from_env_files() -> str | None:
|
|
112
|
+
"""Discover adapter configuration from .env or .env.local files.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Adapter name if discovered, None otherwise
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
import logging
|
|
119
|
+
from pathlib import Path
|
|
120
|
+
|
|
121
|
+
logger = logging.getLogger(__name__)
|
|
122
|
+
|
|
123
|
+
# Check .env.local first, then .env
|
|
124
|
+
env_files = [".env.local", ".env"]
|
|
125
|
+
|
|
126
|
+
for env_file in env_files:
|
|
127
|
+
env_path = Path.cwd() / env_file
|
|
128
|
+
if env_path.exists():
|
|
129
|
+
try:
|
|
130
|
+
# Simple .env parsing (key=value format)
|
|
131
|
+
env_vars = {}
|
|
132
|
+
with open(env_path) as f:
|
|
133
|
+
for line in f:
|
|
134
|
+
line = line.strip()
|
|
135
|
+
if line and not line.startswith("#") and "=" in line:
|
|
136
|
+
key, value = line.split("=", 1)
|
|
137
|
+
env_vars[key.strip()] = value.strip().strip("\"'")
|
|
138
|
+
|
|
139
|
+
# Check for adapter-specific variables
|
|
140
|
+
if env_vars.get("LINEAR_API_KEY"):
|
|
141
|
+
logger.info(f"Discovered Linear configuration in {env_file}")
|
|
142
|
+
return "linear"
|
|
143
|
+
elif env_vars.get("GITHUB_TOKEN"):
|
|
144
|
+
logger.info(f"Discovered GitHub configuration in {env_file}")
|
|
145
|
+
return "github"
|
|
146
|
+
elif env_vars.get("JIRA_SERVER"):
|
|
147
|
+
logger.info(f"Discovered JIRA configuration in {env_file}")
|
|
148
|
+
return "jira"
|
|
149
|
+
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.warning(f"Could not read {env_file}: {e}")
|
|
152
|
+
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _save_adapter_to_config(adapter_name: str) -> None:
|
|
157
|
+
"""Save adapter configuration to config file.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
adapter_name: Name of the adapter to save as default
|
|
161
|
+
|
|
162
|
+
"""
|
|
163
|
+
import logging
|
|
164
|
+
|
|
165
|
+
from .main import save_config
|
|
166
|
+
|
|
167
|
+
logger = logging.getLogger(__name__)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
config = load_config()
|
|
171
|
+
config["default_adapter"] = adapter_name
|
|
172
|
+
|
|
173
|
+
# Ensure adapters section exists
|
|
174
|
+
if "adapters" not in config:
|
|
175
|
+
config["adapters"] = {}
|
|
176
|
+
|
|
177
|
+
# Add basic adapter config if not exists
|
|
178
|
+
if adapter_name not in config["adapters"]:
|
|
179
|
+
if adapter_name == "aitrackdown":
|
|
180
|
+
config["adapters"][adapter_name] = {"base_path": ".aitrackdown"}
|
|
181
|
+
else:
|
|
182
|
+
config["adapters"][adapter_name] = {"type": adapter_name}
|
|
183
|
+
|
|
184
|
+
save_config(config)
|
|
185
|
+
logger.info(f"Saved {adapter_name} as default adapter")
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.warning(f"Could not save adapter configuration: {e}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@app.command()
|
|
192
|
+
def create(
|
|
193
|
+
title: str = typer.Argument(..., help="Ticket title"),
|
|
194
|
+
description: str | None = typer.Option(
|
|
195
|
+
None, "--description", "-d", help="Ticket description"
|
|
196
|
+
),
|
|
197
|
+
priority: Priority = typer.Option(
|
|
198
|
+
Priority.MEDIUM, "--priority", "-p", help="Priority level"
|
|
199
|
+
),
|
|
200
|
+
tags: list[str] | None = typer.Option(
|
|
201
|
+
None, "--tag", "-t", help="Tags (can be specified multiple times)"
|
|
202
|
+
),
|
|
203
|
+
assignee: str | None = typer.Option(
|
|
204
|
+
None, "--assignee", "-a", help="Assignee username"
|
|
205
|
+
),
|
|
206
|
+
project: str | None = typer.Option(
|
|
207
|
+
None,
|
|
208
|
+
"--project",
|
|
209
|
+
help="Parent project/epic ID (synonym for --epic)",
|
|
210
|
+
),
|
|
211
|
+
epic: str | None = typer.Option(
|
|
212
|
+
None,
|
|
213
|
+
"--epic",
|
|
214
|
+
help="Parent epic/project ID (synonym for --project)",
|
|
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"),
|
|
228
|
+
adapter: AdapterType | None = typer.Option(
|
|
229
|
+
None, "--adapter", help="Override default adapter"
|
|
230
|
+
),
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Create a new ticket with comprehensive health checks."""
|
|
233
|
+
from .utils import format_error_json, format_json_response, serialize_task
|
|
234
|
+
|
|
235
|
+
# IMMEDIATE HEALTH CHECK - Critical for reliability
|
|
236
|
+
health_monitor = QueueHealthMonitor()
|
|
237
|
+
health = health_monitor.check_health()
|
|
238
|
+
|
|
239
|
+
# Display health status
|
|
240
|
+
if health["status"] == HealthStatus.CRITICAL:
|
|
241
|
+
console.print("[red]🚨 CRITICAL: Queue system has serious issues![/red]")
|
|
242
|
+
for alert in health["alerts"]:
|
|
243
|
+
if alert["level"] == "critical":
|
|
244
|
+
console.print(f"[red] • {alert['message']}[/red]")
|
|
245
|
+
|
|
246
|
+
# Attempt auto-repair
|
|
247
|
+
console.print("[yellow]Attempting automatic repair...[/yellow]")
|
|
248
|
+
repair_result = health_monitor.auto_repair()
|
|
249
|
+
|
|
250
|
+
if repair_result["actions_taken"]:
|
|
251
|
+
for action in repair_result["actions_taken"]:
|
|
252
|
+
console.print(f"[yellow] ✓ {action}[/yellow]")
|
|
253
|
+
|
|
254
|
+
# Re-check health after repair
|
|
255
|
+
health = health_monitor.check_health()
|
|
256
|
+
if health["status"] == HealthStatus.CRITICAL:
|
|
257
|
+
console.print(
|
|
258
|
+
"[red]❌ Auto-repair failed. Manual intervention required.[/red]"
|
|
259
|
+
)
|
|
260
|
+
console.print(
|
|
261
|
+
"[red]Cannot safely create ticket. Please check system status.[/red]"
|
|
262
|
+
)
|
|
263
|
+
raise typer.Exit(1) from None
|
|
264
|
+
else:
|
|
265
|
+
console.print(
|
|
266
|
+
"[green]✓ Auto-repair successful. Proceeding with ticket creation.[/green]"
|
|
267
|
+
)
|
|
268
|
+
else:
|
|
269
|
+
console.print(
|
|
270
|
+
"[red]❌ No repair actions available. Manual intervention required.[/red]"
|
|
271
|
+
)
|
|
272
|
+
raise typer.Exit(1) from None
|
|
273
|
+
|
|
274
|
+
elif health["status"] == HealthStatus.WARNING:
|
|
275
|
+
console.print("[yellow]⚠️ Warning: Queue system has minor issues[/yellow]")
|
|
276
|
+
for alert in health["alerts"]:
|
|
277
|
+
if alert["level"] == "warning":
|
|
278
|
+
console.print(f"[yellow] • {alert['message']}[/yellow]")
|
|
279
|
+
console.print("[yellow]Proceeding with ticket creation...[/yellow]")
|
|
280
|
+
|
|
281
|
+
# Get the adapter name with priority: 1) argument, 2) config, 3) .env files, 4) default
|
|
282
|
+
if adapter:
|
|
283
|
+
# Priority 1: Command-line argument - save to config for future use
|
|
284
|
+
adapter_name = adapter.value
|
|
285
|
+
_save_adapter_to_config(adapter_name)
|
|
286
|
+
else:
|
|
287
|
+
# Priority 2: Check existing config
|
|
288
|
+
config = load_config()
|
|
289
|
+
adapter_name = config.get("default_adapter")
|
|
290
|
+
|
|
291
|
+
if not adapter_name or adapter_name == "aitrackdown":
|
|
292
|
+
# Priority 3: Check .env files and save if found
|
|
293
|
+
env_adapter = _discover_from_env_files()
|
|
294
|
+
if env_adapter:
|
|
295
|
+
adapter_name = env_adapter
|
|
296
|
+
_save_adapter_to_config(adapter_name)
|
|
297
|
+
else:
|
|
298
|
+
# Priority 4: Default
|
|
299
|
+
adapter_name = "aitrackdown"
|
|
300
|
+
|
|
301
|
+
# Resolve project/epic synonym - prefer whichever is provided
|
|
302
|
+
parent_epic_id = project or epic
|
|
303
|
+
|
|
304
|
+
# Create task data
|
|
305
|
+
# Import Priority for type checking
|
|
306
|
+
from ..core.models import Priority as PriorityEnum
|
|
307
|
+
|
|
308
|
+
task_data = {
|
|
309
|
+
"title": title,
|
|
310
|
+
"description": description,
|
|
311
|
+
"priority": priority.value if isinstance(priority, PriorityEnum) else priority,
|
|
312
|
+
"tags": tags or [],
|
|
313
|
+
"assignee": assignee,
|
|
314
|
+
"parent_epic": parent_epic_id,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
# WORKAROUND: Use direct operation for Linear adapter to bypass worker subprocess issue
|
|
318
|
+
if adapter_name == "linear":
|
|
319
|
+
console.print(
|
|
320
|
+
"[yellow]⚠️[/yellow] Using direct operation for Linear adapter (bypassing queue)"
|
|
321
|
+
)
|
|
322
|
+
try:
|
|
323
|
+
# Load configuration and create adapter directly
|
|
324
|
+
config = load_config()
|
|
325
|
+
adapter_config = config.get("adapters", {}).get(adapter_name, {})
|
|
326
|
+
|
|
327
|
+
# Import and create adapter
|
|
328
|
+
from ..core.registry import AdapterRegistry
|
|
329
|
+
|
|
330
|
+
adapter_instance = AdapterRegistry.get_adapter(adapter_name, adapter_config)
|
|
331
|
+
|
|
332
|
+
# Create task directly
|
|
333
|
+
from ..core.models import Priority, Task
|
|
334
|
+
|
|
335
|
+
task = Task(
|
|
336
|
+
title=task_data["title"],
|
|
337
|
+
description=task_data.get("description"),
|
|
338
|
+
priority=(
|
|
339
|
+
Priority(task_data["priority"])
|
|
340
|
+
if task_data.get("priority")
|
|
341
|
+
else Priority.MEDIUM
|
|
342
|
+
),
|
|
343
|
+
tags=task_data.get("tags", []),
|
|
344
|
+
assignee=task_data.get("assignee"),
|
|
345
|
+
parent_epic=task_data.get("parent_epic"),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Create ticket synchronously
|
|
349
|
+
import asyncio
|
|
350
|
+
|
|
351
|
+
result = asyncio.run(adapter_instance.create(task))
|
|
352
|
+
|
|
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']}")
|
|
374
|
+
|
|
375
|
+
return result.id
|
|
376
|
+
|
|
377
|
+
except Exception as 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}")
|
|
382
|
+
raise
|
|
383
|
+
|
|
384
|
+
# Use queue for other adapters
|
|
385
|
+
queue = Queue()
|
|
386
|
+
queue_id = queue.add(
|
|
387
|
+
ticket_data=task_data,
|
|
388
|
+
adapter=adapter_name,
|
|
389
|
+
operation="create",
|
|
390
|
+
project_dir=str(Path.cwd()), # Explicitly pass current project directory
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Register in ticket registry for tracking
|
|
394
|
+
registry = TicketRegistry()
|
|
395
|
+
registry.register_ticket_operation(
|
|
396
|
+
queue_id, adapter_name, "create", title, task_data
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Start worker if needed - must happen before polling
|
|
400
|
+
manager = WorkerManager()
|
|
401
|
+
worker_started = manager.start_if_needed()
|
|
402
|
+
|
|
403
|
+
if worker_started:
|
|
404
|
+
if not output_json:
|
|
405
|
+
console.print("[dim]Worker started to process request[/dim]")
|
|
406
|
+
|
|
407
|
+
# SYNCHRONOUS MODE: Poll until completion if --wait flag is set
|
|
408
|
+
if wait:
|
|
409
|
+
if not output_json:
|
|
410
|
+
console.print(
|
|
411
|
+
f"[yellow]⏳[/yellow] Waiting for operation to complete (timeout: {timeout}s)..."
|
|
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
|
|
469
|
+
else:
|
|
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
|
+
}
|
|
478
|
+
console.print(
|
|
479
|
+
format_json_response("success", data, message="Ticket creation queued")
|
|
480
|
+
)
|
|
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}")
|
|
486
|
+
console.print(
|
|
487
|
+
"[dim]Use 'mcp-ticketer ticket check {queue_id}' to check progress[/dim]"
|
|
488
|
+
)
|
|
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
|
+
|
|
506
|
+
|
|
507
|
+
@app.command("list")
|
|
508
|
+
def list_tickets(
|
|
509
|
+
state: TicketState | None = typer.Option(
|
|
510
|
+
None, "--state", "-s", help="Filter by state"
|
|
511
|
+
),
|
|
512
|
+
priority: Priority | None = typer.Option(
|
|
513
|
+
None, "--priority", "-p", help="Filter by priority"
|
|
514
|
+
),
|
|
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"),
|
|
517
|
+
adapter: AdapterType | None = typer.Option(
|
|
518
|
+
None, "--adapter", help="Override default adapter"
|
|
519
|
+
),
|
|
520
|
+
) -> None:
|
|
521
|
+
"""List tickets with optional filters."""
|
|
522
|
+
from .utils import format_json_response, serialize_task
|
|
523
|
+
|
|
524
|
+
async def _list() -> list[Any]:
|
|
525
|
+
adapter_instance = get_adapter(
|
|
526
|
+
override_adapter=adapter.value if adapter else None
|
|
527
|
+
)
|
|
528
|
+
filters = {}
|
|
529
|
+
if state:
|
|
530
|
+
filters["state"] = state
|
|
531
|
+
if priority:
|
|
532
|
+
filters["priority"] = priority
|
|
533
|
+
return await adapter_instance.list(limit=limit, filters=filters)
|
|
534
|
+
|
|
535
|
+
tickets = asyncio.run(_list())
|
|
536
|
+
|
|
537
|
+
if not tickets:
|
|
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]")
|
|
546
|
+
return
|
|
547
|
+
|
|
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
|
|
561
|
+
table = Table(title="Tickets")
|
|
562
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
563
|
+
table.add_column("Title", style="white")
|
|
564
|
+
table.add_column("State", style="green")
|
|
565
|
+
table.add_column("Priority", style="yellow")
|
|
566
|
+
table.add_column("Assignee", style="blue")
|
|
567
|
+
|
|
568
|
+
for ticket in tickets:
|
|
569
|
+
# Handle assignee field - Epic doesn't have assignee, Task does
|
|
570
|
+
assignee = getattr(ticket, "assignee", None) or "-"
|
|
571
|
+
|
|
572
|
+
table.add_row(
|
|
573
|
+
ticket.id or "N/A",
|
|
574
|
+
ticket.title,
|
|
575
|
+
ticket.state,
|
|
576
|
+
ticket.priority,
|
|
577
|
+
assignee,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
console.print(table)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@app.command()
|
|
584
|
+
def show(
|
|
585
|
+
ticket_id: str = typer.Argument(..., help="Ticket ID"),
|
|
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"),
|
|
590
|
+
adapter: AdapterType | None = typer.Option(
|
|
591
|
+
None, "--adapter", help="Override default adapter"
|
|
592
|
+
),
|
|
593
|
+
) -> None:
|
|
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
|
|
603
|
+
|
|
604
|
+
async def _show() -> tuple[Any, Any, Any]:
|
|
605
|
+
adapter_instance = get_adapter(
|
|
606
|
+
override_adapter=adapter.value if adapter else None
|
|
607
|
+
)
|
|
608
|
+
ticket = await adapter_instance.read(ticket_id)
|
|
609
|
+
ticket_comments = None
|
|
610
|
+
attachments = None
|
|
611
|
+
|
|
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
|
|
619
|
+
|
|
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
|
|
626
|
+
|
|
627
|
+
return ticket, ticket_comments, attachments
|
|
628
|
+
|
|
629
|
+
try:
|
|
630
|
+
ticket, ticket_comments, attachments = asyncio.run(_show())
|
|
631
|
+
|
|
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]")
|
|
686
|
+
|
|
687
|
+
if 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)}")
|
|
702
|
+
|
|
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
|
|
731
|
+
if ticket_comments:
|
|
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
|
+
)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
@app.command()
|
|
750
|
+
def comment(
|
|
751
|
+
ticket_id: str = typer.Argument(..., help="Ticket ID"),
|
|
752
|
+
content: str = typer.Argument(..., help="Comment content"),
|
|
753
|
+
output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
754
|
+
adapter: AdapterType | None = typer.Option(
|
|
755
|
+
None, "--adapter", help="Override default adapter"
|
|
756
|
+
),
|
|
757
|
+
) -> None:
|
|
758
|
+
"""Add a comment to a ticket."""
|
|
759
|
+
from .utils import format_error_json, format_json_response
|
|
760
|
+
|
|
761
|
+
async def _comment() -> Comment:
|
|
762
|
+
adapter_instance = get_adapter(
|
|
763
|
+
override_adapter=adapter.value if adapter else None
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
# Create comment
|
|
767
|
+
comment_obj = Comment(
|
|
768
|
+
ticket_id=ticket_id,
|
|
769
|
+
content=content,
|
|
770
|
+
author="cli-user", # Could be made configurable
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
result = await adapter_instance.add_comment(comment_obj)
|
|
774
|
+
return result
|
|
775
|
+
|
|
776
|
+
try:
|
|
777
|
+
result = asyncio.run(_comment())
|
|
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}")
|
|
801
|
+
except Exception as 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}")
|
|
975
|
+
raise typer.Exit(1) from None
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
@app.command()
|
|
979
|
+
def update(
|
|
980
|
+
ticket_id: str = typer.Argument(..., help="Ticket ID"),
|
|
981
|
+
title: str | None = typer.Option(None, "--title", help="New title"),
|
|
982
|
+
description: str | None = typer.Option(
|
|
983
|
+
None, "--description", "-d", help="New description"
|
|
984
|
+
),
|
|
985
|
+
priority: Priority | None = typer.Option(
|
|
986
|
+
None, "--priority", "-p", help="New priority"
|
|
987
|
+
),
|
|
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"),
|
|
1001
|
+
adapter: AdapterType | None = typer.Option(
|
|
1002
|
+
None, "--adapter", help="Override default adapter"
|
|
1003
|
+
),
|
|
1004
|
+
) -> None:
|
|
1005
|
+
"""Update ticket fields."""
|
|
1006
|
+
from .utils import format_json_response
|
|
1007
|
+
|
|
1008
|
+
updates = {}
|
|
1009
|
+
if title:
|
|
1010
|
+
updates["title"] = title
|
|
1011
|
+
if description:
|
|
1012
|
+
updates["description"] = description
|
|
1013
|
+
if priority:
|
|
1014
|
+
updates["priority"] = (
|
|
1015
|
+
priority.value if isinstance(priority, Priority) else priority
|
|
1016
|
+
)
|
|
1017
|
+
if assignee:
|
|
1018
|
+
updates["assignee"] = assignee
|
|
1019
|
+
|
|
1020
|
+
if not updates:
|
|
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]")
|
|
1031
|
+
raise typer.Exit(1) from None
|
|
1032
|
+
|
|
1033
|
+
# Get the adapter name
|
|
1034
|
+
config = load_config()
|
|
1035
|
+
adapter_name = (
|
|
1036
|
+
adapter.value if adapter else config.get("default_adapter", "aitrackdown")
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
# Add ticket_id to updates
|
|
1040
|
+
updates["ticket_id"] = ticket_id
|
|
1041
|
+
|
|
1042
|
+
# Add to queue with explicit project directory
|
|
1043
|
+
queue = Queue()
|
|
1044
|
+
queue_id = queue.add(
|
|
1045
|
+
ticket_data=updates,
|
|
1046
|
+
adapter=adapter_name,
|
|
1047
|
+
operation="update",
|
|
1048
|
+
project_dir=str(Path.cwd()), # Explicitly pass current project directory
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
# Start worker if needed
|
|
1052
|
+
manager = WorkerManager()
|
|
1053
|
+
if manager.start_if_needed():
|
|
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
|
+
)
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
@app.command()
|
|
1125
|
+
def transition(
|
|
1126
|
+
ticket_id: str = typer.Argument(..., help="Ticket ID"),
|
|
1127
|
+
state_positional: TicketState | None = typer.Argument(
|
|
1128
|
+
None, help="Target state (positional - deprecated, use --state instead)"
|
|
1129
|
+
),
|
|
1130
|
+
state: TicketState | None = typer.Option(
|
|
1131
|
+
None, "--state", "-s", help="Target state (recommended)"
|
|
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"),
|
|
1145
|
+
adapter: AdapterType | None = typer.Option(
|
|
1146
|
+
None, "--adapter", help="Override default adapter"
|
|
1147
|
+
),
|
|
1148
|
+
) -> None:
|
|
1149
|
+
"""Change ticket state with validation.
|
|
1150
|
+
|
|
1151
|
+
Examples:
|
|
1152
|
+
# Recommended syntax with flag:
|
|
1153
|
+
mcp-ticketer ticket transition BTA-215 --state done
|
|
1154
|
+
mcp-ticketer ticket transition BTA-215 -s in_progress
|
|
1155
|
+
|
|
1156
|
+
# Legacy positional syntax (still supported):
|
|
1157
|
+
mcp-ticketer ticket transition BTA-215 done
|
|
1158
|
+
|
|
1159
|
+
"""
|
|
1160
|
+
from .utils import format_json_response
|
|
1161
|
+
|
|
1162
|
+
# Determine which state to use (prefer flag over positional)
|
|
1163
|
+
target_state = state if state is not None else state_positional
|
|
1164
|
+
|
|
1165
|
+
if target_state is None:
|
|
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
|
+
)
|
|
1179
|
+
raise typer.Exit(1) from None
|
|
1180
|
+
|
|
1181
|
+
# Get the adapter name
|
|
1182
|
+
config = load_config()
|
|
1183
|
+
adapter_name = (
|
|
1184
|
+
adapter.value if adapter else config.get("default_adapter", "aitrackdown")
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
# Add to queue with explicit project directory
|
|
1188
|
+
queue = Queue()
|
|
1189
|
+
state_value = target_state.value if hasattr(target_state, "value") else target_state
|
|
1190
|
+
queue_id = queue.add(
|
|
1191
|
+
ticket_data={
|
|
1192
|
+
"ticket_id": ticket_id,
|
|
1193
|
+
"state": state_value,
|
|
1194
|
+
},
|
|
1195
|
+
adapter=adapter_name,
|
|
1196
|
+
operation="transition",
|
|
1197
|
+
project_dir=str(Path.cwd()), # Explicitly pass current project directory
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
# Start worker if needed
|
|
1201
|
+
manager = WorkerManager()
|
|
1202
|
+
if manager.start_if_needed():
|
|
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
|
+
)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
@app.command()
|
|
1278
|
+
def search(
|
|
1279
|
+
query: str | None = typer.Argument(None, help="Search query"),
|
|
1280
|
+
state: TicketState | None = typer.Option(None, "--state", "-s"),
|
|
1281
|
+
priority: Priority | None = typer.Option(None, "--priority", "-p"),
|
|
1282
|
+
assignee: str | None = typer.Option(None, "--assignee", "-a"),
|
|
1283
|
+
limit: int = typer.Option(10, "--limit", "-l"),
|
|
1284
|
+
output_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
1285
|
+
adapter: AdapterType | None = typer.Option(
|
|
1286
|
+
None, "--adapter", help="Override default adapter"
|
|
1287
|
+
),
|
|
1288
|
+
) -> None:
|
|
1289
|
+
"""Search tickets with advanced query."""
|
|
1290
|
+
from .utils import format_json_response, serialize_task
|
|
1291
|
+
|
|
1292
|
+
async def _search() -> list[Any]:
|
|
1293
|
+
adapter_instance = get_adapter(
|
|
1294
|
+
override_adapter=adapter.value if adapter else None
|
|
1295
|
+
)
|
|
1296
|
+
search_query = SearchQuery(
|
|
1297
|
+
query=query,
|
|
1298
|
+
state=state,
|
|
1299
|
+
priority=priority,
|
|
1300
|
+
assignee=assignee,
|
|
1301
|
+
limit=limit,
|
|
1302
|
+
)
|
|
1303
|
+
return await adapter_instance.search(search_query)
|
|
1304
|
+
|
|
1305
|
+
tickets = asyncio.run(_search())
|
|
1306
|
+
|
|
1307
|
+
if not tickets:
|
|
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))
|
|
1323
|
+
return
|
|
1324
|
+
|
|
1325
|
+
# Display results
|
|
1326
|
+
console.print(f"\n[bold]Found {len(tickets)} ticket(s)[/bold]\n")
|
|
1327
|
+
|
|
1328
|
+
for ticket in tickets:
|
|
1329
|
+
console.print(f"[cyan]{ticket.id}[/cyan]: {ticket.title}")
|
|
1330
|
+
console.print(f" State: {ticket.state} | Priority: {ticket.priority}")
|
|
1331
|
+
if ticket.assignee:
|
|
1332
|
+
console.print(f" Assignee: {ticket.assignee}")
|
|
1333
|
+
console.print()
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
@app.command()
|
|
1337
|
+
def check(queue_id: str = typer.Argument(..., help="Queue ID to check")) -> None:
|
|
1338
|
+
"""Check status of a queued operation."""
|
|
1339
|
+
queue = Queue()
|
|
1340
|
+
item = queue.get_item(queue_id)
|
|
1341
|
+
|
|
1342
|
+
if not item:
|
|
1343
|
+
console.print(f"[red]Queue item not found: {queue_id}[/red]")
|
|
1344
|
+
raise typer.Exit(1) from None
|
|
1345
|
+
|
|
1346
|
+
# Display status
|
|
1347
|
+
console.print(f"\n[bold]Queue Item: {item.id}[/bold]")
|
|
1348
|
+
console.print(f"Operation: {item.operation}")
|
|
1349
|
+
console.print(f"Adapter: {item.adapter}")
|
|
1350
|
+
|
|
1351
|
+
# Status with color
|
|
1352
|
+
if item.status == QueueStatus.COMPLETED:
|
|
1353
|
+
console.print(f"Status: [green]{item.status}[/green]")
|
|
1354
|
+
elif item.status == QueueStatus.FAILED:
|
|
1355
|
+
console.print(f"Status: [red]{item.status}[/red]")
|
|
1356
|
+
elif item.status == QueueStatus.PROCESSING:
|
|
1357
|
+
console.print(f"Status: [yellow]{item.status}[/yellow]")
|
|
1358
|
+
else:
|
|
1359
|
+
console.print(f"Status: {item.status}")
|
|
1360
|
+
|
|
1361
|
+
# Timestamps
|
|
1362
|
+
console.print(f"Created: {item.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
1363
|
+
if item.processed_at:
|
|
1364
|
+
console.print(f"Processed: {item.processed_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
1365
|
+
|
|
1366
|
+
# Error or result
|
|
1367
|
+
if item.error_message:
|
|
1368
|
+
console.print(f"\n[red]Error:[/red] {item.error_message}")
|
|
1369
|
+
elif item.result:
|
|
1370
|
+
console.print("\n[green]Result:[/green]")
|
|
1371
|
+
for key, value in item.result.items():
|
|
1372
|
+
console.print(f" {key}: {value}")
|
|
1373
|
+
|
|
1374
|
+
if item.retry_count > 0:
|
|
1375
|
+
console.print(f"\nRetry Count: {item.retry_count}")
|