mcp-ticketer 0.2.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 +421 -0
- 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 -1284
- 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 -1895
- mcp_ticketer-0.2.0.dist-info/METADATA +0 -414
- mcp_ticketer-0.2.0.dist-info/RECORD +0 -58
- mcp_ticketer-0.2.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
mcp_ticketer/cli/configure.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""Interactive configuration wizard for MCP Ticketer."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
|
-
from
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
5
8
|
|
|
9
|
+
import httpx
|
|
6
10
|
import typer
|
|
7
11
|
from rich.console import Console
|
|
8
12
|
from rich.panel import Panel
|
|
@@ -22,6 +26,264 @@ from ..core.project_config import (
|
|
|
22
26
|
console = Console()
|
|
23
27
|
|
|
24
28
|
|
|
29
|
+
def _load_existing_adapter_config(adapter_type: str) -> dict[str, Any] | None:
|
|
30
|
+
"""Load existing adapter configuration from project config if available.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
----
|
|
34
|
+
adapter_type: Type of adapter (linear, jira, github, aitrackdown)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
-------
|
|
38
|
+
Dictionary with existing configuration if available, None otherwise
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
config_path = Path.cwd() / ".mcp-ticketer" / "config.json"
|
|
42
|
+
if not config_path.exists():
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
with open(config_path) as f:
|
|
47
|
+
config = json.load(f)
|
|
48
|
+
return config.get("adapters", {}).get(adapter_type)
|
|
49
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
50
|
+
console.print(f"[yellow]Warning: Could not load existing config: {e}[/yellow]")
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _mask_sensitive_value(value: str, show_chars: int = 8) -> str:
|
|
55
|
+
"""Mask sensitive value for display.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
----
|
|
59
|
+
value: The sensitive value to mask
|
|
60
|
+
show_chars: Number of characters to show at start (default: 8)
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
-------
|
|
64
|
+
Masked string like "ghp_1234****" or "****" for short values
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
if not value:
|
|
68
|
+
return "****"
|
|
69
|
+
if len(value) <= show_chars:
|
|
70
|
+
return "****"
|
|
71
|
+
return f"{value[:show_chars]}****"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _validate_api_credentials(
|
|
75
|
+
adapter_type: str,
|
|
76
|
+
credentials: dict[str, str],
|
|
77
|
+
max_retries: int = 3,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""Validate API credentials with real API calls.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
----
|
|
83
|
+
adapter_type: Type of adapter (linear, github, jira)
|
|
84
|
+
credentials: Dictionary with adapter-specific credentials
|
|
85
|
+
max_retries: Maximum retry attempts on validation failure
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
-------
|
|
89
|
+
True if validation succeeds
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
------
|
|
93
|
+
typer.Exit: If user gives up after max retries
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
for attempt in range(1, max_retries + 1):
|
|
97
|
+
try:
|
|
98
|
+
if adapter_type == "linear":
|
|
99
|
+
# Test Linear API with viewer query
|
|
100
|
+
response = httpx.post(
|
|
101
|
+
"https://api.linear.app/graphql",
|
|
102
|
+
headers={"Authorization": credentials["api_key"]},
|
|
103
|
+
json={"query": "{ viewer { id name email } }"},
|
|
104
|
+
timeout=10.0,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if response.status_code == 200:
|
|
108
|
+
data = response.json()
|
|
109
|
+
if "data" in data and "viewer" in data["data"]:
|
|
110
|
+
viewer = data["data"]["viewer"]
|
|
111
|
+
console.print(
|
|
112
|
+
f"[green]✓ Linear API key verified (user: {viewer.get('name', viewer.get('email', 'Unknown'))})[/green]"
|
|
113
|
+
)
|
|
114
|
+
return True
|
|
115
|
+
else:
|
|
116
|
+
errors = data.get("errors", [])
|
|
117
|
+
if errors:
|
|
118
|
+
error_msg = errors[0].get("message", "Unknown error")
|
|
119
|
+
raise ValueError(f"API returned error: {error_msg}")
|
|
120
|
+
raise ValueError("Invalid API response format")
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"API returned status {response.status_code}: {response.text}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
elif adapter_type == "github":
|
|
127
|
+
# Test GitHub API with user endpoint
|
|
128
|
+
response = httpx.get(
|
|
129
|
+
"https://api.github.com/user",
|
|
130
|
+
headers={
|
|
131
|
+
"Authorization": f"Bearer {credentials['token']}",
|
|
132
|
+
"Accept": "application/vnd.github+json",
|
|
133
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
134
|
+
},
|
|
135
|
+
timeout=10.0,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if response.status_code == 200:
|
|
139
|
+
user_data = response.json()
|
|
140
|
+
login = user_data.get("login", "Unknown")
|
|
141
|
+
console.print(
|
|
142
|
+
f"[green]✓ GitHub token verified (user: {login})[/green]"
|
|
143
|
+
)
|
|
144
|
+
return True
|
|
145
|
+
elif response.status_code == 401:
|
|
146
|
+
raise ValueError("Invalid token or token has expired")
|
|
147
|
+
elif response.status_code == 403:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
"Token lacks required permissions (need 'repo' scope)"
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"GitHub API returned {response.status_code}: {response.text}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
elif adapter_type == "jira":
|
|
157
|
+
# Test JIRA API with myself endpoint
|
|
158
|
+
response = httpx.get(
|
|
159
|
+
f"{credentials['server'].rstrip('/')}/rest/api/2/myself",
|
|
160
|
+
auth=(credentials["email"], credentials["api_token"]),
|
|
161
|
+
headers={"Accept": "application/json"},
|
|
162
|
+
timeout=10.0,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if response.status_code == 200:
|
|
166
|
+
user_data = response.json()
|
|
167
|
+
name = user_data.get("displayName", credentials["email"])
|
|
168
|
+
console.print(
|
|
169
|
+
f"[green]✓ JIRA credentials verified (user: {name})[/green]"
|
|
170
|
+
)
|
|
171
|
+
return True
|
|
172
|
+
elif response.status_code == 401:
|
|
173
|
+
raise ValueError("Invalid credentials or API token has expired")
|
|
174
|
+
elif response.status_code == 404:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"Invalid JIRA server URL: {credentials['server']}"
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"JIRA API returned {response.status_code}: {response.text}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
else:
|
|
184
|
+
# Unknown adapter type, skip validation
|
|
185
|
+
console.print(
|
|
186
|
+
f"[yellow]Warning: No validation available for adapter '{adapter_type}'[/yellow]"
|
|
187
|
+
)
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
except httpx.TimeoutException:
|
|
191
|
+
console.print(
|
|
192
|
+
f"[red]✗ API validation timed out (attempt {attempt}/{max_retries})[/red]"
|
|
193
|
+
)
|
|
194
|
+
except httpx.NetworkError as e:
|
|
195
|
+
console.print(
|
|
196
|
+
f"[red]✗ Network error during validation: {e} (attempt {attempt}/{max_retries})[/red]"
|
|
197
|
+
)
|
|
198
|
+
except ValueError as e:
|
|
199
|
+
console.print(
|
|
200
|
+
f"[red]✗ API validation failed: {e} (attempt {attempt}/{max_retries})[/red]"
|
|
201
|
+
)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
console.print(
|
|
204
|
+
f"[red]✗ Unexpected error during validation: {e} (attempt {attempt}/{max_retries})[/red]"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Ask user if they want to retry
|
|
208
|
+
if attempt < max_retries:
|
|
209
|
+
retry = Confirm.ask(
|
|
210
|
+
"Re-enter credentials and try again?", default=True
|
|
211
|
+
)
|
|
212
|
+
if not retry:
|
|
213
|
+
console.print(
|
|
214
|
+
"[yellow]Skipping validation. Configuration saved but may not work.[/yellow]"
|
|
215
|
+
)
|
|
216
|
+
return True # Allow saving unvalidated config
|
|
217
|
+
else:
|
|
218
|
+
console.print("[red]Max retries exceeded[/red]")
|
|
219
|
+
final_choice = Confirm.ask(
|
|
220
|
+
"Save configuration anyway? (it may not work)", default=False
|
|
221
|
+
)
|
|
222
|
+
if final_choice:
|
|
223
|
+
console.print(
|
|
224
|
+
"[yellow]Configuration saved without validation[/yellow]"
|
|
225
|
+
)
|
|
226
|
+
return True
|
|
227
|
+
raise typer.Exit(1) from None
|
|
228
|
+
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _retry_setting(
|
|
233
|
+
setting_name: str,
|
|
234
|
+
prompt_func: Callable[[], Any],
|
|
235
|
+
validate_func: Callable[[Any], tuple[bool, str | None]],
|
|
236
|
+
max_retries: int = 3,
|
|
237
|
+
) -> Any:
|
|
238
|
+
"""Retry a configuration setting with validation.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
----
|
|
242
|
+
setting_name: Human-readable name of the setting
|
|
243
|
+
prompt_func: Function that prompts for the setting value
|
|
244
|
+
validate_func: Function that validates the value (returns tuple of success, error_msg)
|
|
245
|
+
max_retries: Maximum number of retry attempts
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
-------
|
|
249
|
+
Validated setting value
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
------
|
|
253
|
+
typer.Exit: If max retries exceeded
|
|
254
|
+
|
|
255
|
+
"""
|
|
256
|
+
for attempt in range(1, max_retries + 1):
|
|
257
|
+
try:
|
|
258
|
+
value = prompt_func()
|
|
259
|
+
is_valid, error = validate_func(value)
|
|
260
|
+
|
|
261
|
+
if is_valid:
|
|
262
|
+
return value
|
|
263
|
+
console.print(f"[red]✗ {error}[/red]")
|
|
264
|
+
if attempt < max_retries:
|
|
265
|
+
console.print(
|
|
266
|
+
f"[yellow]Attempt {attempt}/{max_retries} - Please try again[/yellow]"
|
|
267
|
+
)
|
|
268
|
+
else:
|
|
269
|
+
console.print(f"[red]Failed after {max_retries} attempts[/red]")
|
|
270
|
+
retry = Confirm.ask("Retry this setting?", default=True)
|
|
271
|
+
if retry:
|
|
272
|
+
# Extend attempts
|
|
273
|
+
max_retries += 3
|
|
274
|
+
console.print(
|
|
275
|
+
f"[yellow]Extending retries (new limit: {max_retries})[/yellow]"
|
|
276
|
+
)
|
|
277
|
+
continue
|
|
278
|
+
raise typer.Exit(1) from None
|
|
279
|
+
except KeyboardInterrupt:
|
|
280
|
+
console.print("\n[yellow]Configuration cancelled[/yellow]")
|
|
281
|
+
raise typer.Exit(0) from None
|
|
282
|
+
|
|
283
|
+
console.print(f"[red]Could not configure {setting_name}[/red]")
|
|
284
|
+
raise typer.Exit(1)
|
|
285
|
+
|
|
286
|
+
|
|
25
287
|
def configure_wizard() -> None:
|
|
26
288
|
"""Run interactive configuration wizard."""
|
|
27
289
|
console.print(
|
|
@@ -46,24 +308,25 @@ def configure_wizard() -> None:
|
|
|
46
308
|
|
|
47
309
|
# Step 2: Choose where to save
|
|
48
310
|
console.print("\n[bold]Step 2: Configuration Scope[/bold]")
|
|
49
|
-
console.print(
|
|
50
|
-
|
|
311
|
+
console.print(
|
|
312
|
+
"1. Project-specific (recommended): .mcp-ticketer/config.json in project root"
|
|
313
|
+
)
|
|
314
|
+
console.print("2. Legacy global (deprecated): saves to project config for security")
|
|
51
315
|
|
|
52
|
-
scope = Prompt.ask("Save configuration as", choices=["1", "2"], default="
|
|
316
|
+
scope = Prompt.ask("Save configuration as", choices=["1", "2"], default="1")
|
|
53
317
|
|
|
54
318
|
resolver = ConfigResolver()
|
|
55
319
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
320
|
+
# Always save to project config (global config removed for security)
|
|
321
|
+
resolver.save_project_config(config)
|
|
322
|
+
config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
|
|
323
|
+
|
|
324
|
+
if scope == "2":
|
|
59
325
|
console.print(
|
|
60
|
-
|
|
326
|
+
"[yellow]Note: Global config is deprecated for security. Saving to project config instead.[/yellow]"
|
|
61
327
|
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
resolver.save_project_config(config)
|
|
65
|
-
config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
|
|
66
|
-
console.print(f"\n[green]✓[/green] Configuration saved to {config_path}")
|
|
328
|
+
|
|
329
|
+
console.print(f"\n[green]✓[/green] Configuration saved to {config_path}")
|
|
67
330
|
|
|
68
331
|
# Show usage instructions
|
|
69
332
|
console.print("\n[bold]Usage:[/bold]")
|
|
@@ -96,173 +359,1082 @@ def _configure_single_adapter() -> TicketerConfig:
|
|
|
96
359
|
adapter_type = adapter_type_map[adapter_choice]
|
|
97
360
|
|
|
98
361
|
# Configure the selected adapter
|
|
362
|
+
default_values: dict[str, str] = {}
|
|
99
363
|
if adapter_type == AdapterType.LINEAR:
|
|
100
|
-
adapter_config = _configure_linear()
|
|
364
|
+
adapter_config, default_values = _configure_linear(interactive=True)
|
|
101
365
|
elif adapter_type == AdapterType.JIRA:
|
|
102
|
-
adapter_config = _configure_jira()
|
|
366
|
+
adapter_config, default_values = _configure_jira(interactive=True)
|
|
103
367
|
elif adapter_type == AdapterType.GITHUB:
|
|
104
|
-
adapter_config = _configure_github()
|
|
368
|
+
adapter_config, default_values = _configure_github(interactive=True)
|
|
105
369
|
else:
|
|
106
|
-
adapter_config = _configure_aitrackdown()
|
|
370
|
+
adapter_config, default_values = _configure_aitrackdown(interactive=True)
|
|
107
371
|
|
|
108
|
-
# Create config
|
|
372
|
+
# Create config with default values
|
|
109
373
|
config = TicketerConfig(
|
|
110
374
|
default_adapter=adapter_type.value,
|
|
111
375
|
adapters={adapter_type.value: adapter_config},
|
|
376
|
+
default_user=default_values.get("default_user"),
|
|
377
|
+
default_project=default_values.get("default_project"),
|
|
378
|
+
default_epic=default_values.get("default_epic"),
|
|
379
|
+
default_tags=default_values.get("default_tags"),
|
|
112
380
|
)
|
|
113
381
|
|
|
114
382
|
return config
|
|
115
383
|
|
|
116
384
|
|
|
117
|
-
def _configure_linear(
|
|
118
|
-
|
|
119
|
-
|
|
385
|
+
def _configure_linear(
|
|
386
|
+
existing_config: dict[str, Any] | None = None,
|
|
387
|
+
interactive: bool = True,
|
|
388
|
+
api_key: str | None = None,
|
|
389
|
+
team_id: str | None = None,
|
|
390
|
+
team_key: str | None = None,
|
|
391
|
+
**kwargs: Any,
|
|
392
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
393
|
+
"""Configure Linear adapter with option to preserve existing settings.
|
|
120
394
|
|
|
121
|
-
|
|
122
|
-
api_key = os.getenv("LINEAR_API_KEY") or ""
|
|
123
|
-
if api_key:
|
|
124
|
-
console.print("[dim]Found LINEAR_API_KEY in environment[/dim]")
|
|
125
|
-
use_env = Confirm.ask("Use this API key?", default=True)
|
|
126
|
-
if not use_env:
|
|
127
|
-
api_key = ""
|
|
395
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
128
396
|
|
|
129
|
-
|
|
130
|
-
|
|
397
|
+
Args:
|
|
398
|
+
----
|
|
399
|
+
existing_config: Optional existing configuration to preserve/update
|
|
400
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
401
|
+
api_key: Pre-provided API key (optional, for programmatic mode)
|
|
402
|
+
team_id: Pre-provided team ID (optional, for programmatic mode)
|
|
403
|
+
team_key: Pre-provided team key (optional, for programmatic mode)
|
|
404
|
+
**kwargs: Additional configuration parameters
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
-------
|
|
408
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
409
|
+
- AdapterConfig: Configured Linear adapter configuration
|
|
410
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
131
411
|
|
|
132
|
-
|
|
133
|
-
|
|
412
|
+
"""
|
|
413
|
+
if interactive:
|
|
414
|
+
console.print("\n[bold cyan]Linear Configuration[/bold cyan]")
|
|
134
415
|
|
|
135
|
-
#
|
|
136
|
-
|
|
416
|
+
# Load existing configuration if available
|
|
417
|
+
if existing_config is None and interactive:
|
|
418
|
+
existing_config = _load_existing_adapter_config("linear")
|
|
137
419
|
|
|
138
|
-
#
|
|
139
|
-
|
|
420
|
+
# Check if we have existing config
|
|
421
|
+
has_existing = (
|
|
422
|
+
existing_config is not None and existing_config.get("adapter") == "linear"
|
|
423
|
+
)
|
|
140
424
|
|
|
141
|
-
config_dict = {
|
|
142
|
-
"adapter": AdapterType.LINEAR.value,
|
|
143
|
-
"api_key": api_key,
|
|
144
|
-
}
|
|
425
|
+
config_dict: dict[str, Any] = {"adapter": AdapterType.LINEAR.value}
|
|
145
426
|
|
|
146
|
-
if
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
427
|
+
if has_existing and interactive:
|
|
428
|
+
preserve = Confirm.ask(
|
|
429
|
+
"Existing Linear configuration found. Preserve current settings?",
|
|
430
|
+
default=True,
|
|
431
|
+
)
|
|
432
|
+
if preserve:
|
|
433
|
+
console.print("[green]✓[/green] Keeping existing configuration")
|
|
434
|
+
config_dict = existing_config.copy()
|
|
435
|
+
|
|
436
|
+
# Allow updating specific fields
|
|
437
|
+
update_fields = Confirm.ask("Update specific fields?", default=False)
|
|
438
|
+
if not update_fields:
|
|
439
|
+
# Extract default values before returning
|
|
440
|
+
default_values = {}
|
|
441
|
+
if "user_email" in config_dict:
|
|
442
|
+
default_values["default_user"] = config_dict.pop("user_email")
|
|
443
|
+
if "default_epic" in config_dict:
|
|
444
|
+
default_values["default_epic"] = config_dict.pop("default_epic")
|
|
445
|
+
if "default_project" in config_dict:
|
|
446
|
+
default_values["default_project"] = config_dict.pop(
|
|
447
|
+
"default_project"
|
|
448
|
+
)
|
|
449
|
+
if "default_tags" in config_dict:
|
|
450
|
+
default_values["default_tags"] = config_dict.pop("default_tags")
|
|
451
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
452
|
+
|
|
453
|
+
console.print(
|
|
454
|
+
"[yellow]Enter new values or press Enter to keep current[/yellow]"
|
|
455
|
+
)
|
|
152
456
|
|
|
153
|
-
#
|
|
457
|
+
# API Key with validation loop
|
|
458
|
+
current_key = config_dict.get("api_key", "") if has_existing else ""
|
|
459
|
+
final_api_key = api_key or os.getenv("LINEAR_API_KEY") or ""
|
|
460
|
+
|
|
461
|
+
if interactive:
|
|
462
|
+
# Interactive mode: prompt with retry
|
|
463
|
+
if final_api_key and not current_key:
|
|
464
|
+
console.print("[dim]Found LINEAR_API_KEY in environment[/dim]")
|
|
465
|
+
use_env = Confirm.ask("Use this API key?", default=True)
|
|
466
|
+
if use_env:
|
|
467
|
+
current_key = final_api_key
|
|
468
|
+
|
|
469
|
+
# Validation loop for API key
|
|
470
|
+
api_key_validated = False
|
|
471
|
+
while not api_key_validated:
|
|
472
|
+
|
|
473
|
+
def prompt_api_key() -> str:
|
|
474
|
+
if current_key:
|
|
475
|
+
masked = _mask_sensitive_value(current_key)
|
|
476
|
+
api_key_prompt = f"Linear API Key [current: {masked}]"
|
|
477
|
+
return Prompt.ask(api_key_prompt, password=True, default=current_key)
|
|
478
|
+
return Prompt.ask("Linear API Key", password=True)
|
|
479
|
+
|
|
480
|
+
def validate_api_key(key: str) -> tuple[bool, str | None]:
|
|
481
|
+
if not key or len(key) < 10:
|
|
482
|
+
return False, "API key must be at least 10 characters"
|
|
483
|
+
return True, None
|
|
484
|
+
|
|
485
|
+
final_api_key = _retry_setting("API Key", prompt_api_key, validate_api_key)
|
|
486
|
+
|
|
487
|
+
# Validate API key with real API call
|
|
488
|
+
try:
|
|
489
|
+
api_key_validated = _validate_api_credentials(
|
|
490
|
+
"linear", {"api_key": final_api_key}
|
|
491
|
+
)
|
|
492
|
+
except typer.Exit:
|
|
493
|
+
# User cancelled, propagate the exit
|
|
494
|
+
raise
|
|
495
|
+
except Exception as e:
|
|
496
|
+
console.print(f"[red]Validation error: {e}[/red]")
|
|
497
|
+
retry = Confirm.ask("Re-enter API key?", default=True)
|
|
498
|
+
if not retry:
|
|
499
|
+
raise typer.Exit(1) from None
|
|
500
|
+
|
|
501
|
+
elif not final_api_key:
|
|
502
|
+
raise ValueError(
|
|
503
|
+
"Linear API key is required (provide api_key parameter or set LINEAR_API_KEY environment variable)"
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
config_dict["api_key"] = final_api_key
|
|
507
|
+
|
|
508
|
+
# Team Key/ID (programmatic mode: use provided values, interactive: prompt)
|
|
509
|
+
current_team_key = config_dict.get("team_key", "") if has_existing else ""
|
|
510
|
+
config_dict.get("team_id", "") if has_existing else ""
|
|
511
|
+
final_team_key = team_key or os.getenv("LINEAR_TEAM_KEY") or ""
|
|
512
|
+
final_team_id = team_id or os.getenv("LINEAR_TEAM_ID") or ""
|
|
513
|
+
|
|
514
|
+
if interactive:
|
|
515
|
+
# Interactive mode: prompt for team key (preferred over team_id)
|
|
516
|
+
def prompt_team_key() -> str:
|
|
517
|
+
if current_team_key:
|
|
518
|
+
team_key_prompt = f"Linear Team Key [current: {current_team_key}]"
|
|
519
|
+
return Prompt.ask(team_key_prompt, default=current_team_key)
|
|
520
|
+
return Prompt.ask("Linear Team Key (e.g., 'ENG', 'BTA')")
|
|
521
|
+
|
|
522
|
+
def validate_team_key(key: str) -> tuple[bool, str | None]:
|
|
523
|
+
if not key or len(key) < 2:
|
|
524
|
+
return False, "Team key must be at least 2 characters"
|
|
525
|
+
return True, None
|
|
526
|
+
|
|
527
|
+
final_team_key = _retry_setting("Team Key", prompt_team_key, validate_team_key)
|
|
528
|
+
config_dict["team_key"] = final_team_key
|
|
529
|
+
|
|
530
|
+
# Remove team_id if present (will be resolved from team_key)
|
|
531
|
+
if "team_id" in config_dict:
|
|
532
|
+
del config_dict["team_id"]
|
|
533
|
+
else:
|
|
534
|
+
# Programmatic mode: use whichever was provided
|
|
535
|
+
if final_team_key:
|
|
536
|
+
config_dict["team_key"] = final_team_key
|
|
537
|
+
if final_team_id:
|
|
538
|
+
config_dict["team_id"] = final_team_id
|
|
539
|
+
if not final_team_key and not final_team_id:
|
|
540
|
+
raise ValueError(
|
|
541
|
+
"Linear requires either team_key or team_id (provide parameter or set LINEAR_TEAM_KEY/LINEAR_TEAM_ID environment variable)"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# User email configuration (optional, for default assignee) - only in interactive mode
|
|
545
|
+
if interactive:
|
|
546
|
+
current_user_email = config_dict.get("user_email", "") if has_existing else ""
|
|
547
|
+
|
|
548
|
+
def prompt_user_email() -> str:
|
|
549
|
+
if current_user_email:
|
|
550
|
+
user_email_prompt = (
|
|
551
|
+
f"Your Linear email (optional, for auto-assignment) "
|
|
552
|
+
f"[current: {current_user_email}]"
|
|
553
|
+
)
|
|
554
|
+
return Prompt.ask(user_email_prompt, default=current_user_email)
|
|
555
|
+
return Prompt.ask(
|
|
556
|
+
"Your Linear email (optional, for auto-assignment)", default=""
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
def validate_user_email(email: str) -> tuple[bool, str | None]:
|
|
560
|
+
if not email: # Optional field
|
|
561
|
+
return True, None
|
|
562
|
+
import re
|
|
563
|
+
|
|
564
|
+
email_pattern = re.compile(
|
|
565
|
+
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
566
|
+
)
|
|
567
|
+
if not email_pattern.match(email):
|
|
568
|
+
return False, f"Invalid email format: {email}"
|
|
569
|
+
return True, None
|
|
570
|
+
|
|
571
|
+
user_email = _retry_setting(
|
|
572
|
+
"User Email", prompt_user_email, validate_user_email
|
|
573
|
+
)
|
|
574
|
+
if user_email:
|
|
575
|
+
config_dict["user_email"] = user_email
|
|
576
|
+
console.print(f"[green]✓[/green] Will use {user_email} as default assignee")
|
|
577
|
+
|
|
578
|
+
# ============================================================
|
|
579
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
580
|
+
# ============================================================
|
|
581
|
+
|
|
582
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
583
|
+
console.print("Configure default values for ticket creation:")
|
|
584
|
+
|
|
585
|
+
# Default epic/project
|
|
586
|
+
current_default_epic = (
|
|
587
|
+
config_dict.get("default_epic", "") if has_existing else ""
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
def prompt_default_epic() -> str:
|
|
591
|
+
if current_default_epic:
|
|
592
|
+
return Prompt.ask(
|
|
593
|
+
f"Default epic/project ID (optional) [current: {current_default_epic}]",
|
|
594
|
+
default=current_default_epic,
|
|
595
|
+
)
|
|
596
|
+
return Prompt.ask(
|
|
597
|
+
"Default epic/project ID (optional, accepts project URLs or IDs like 'PROJ-123')",
|
|
598
|
+
default="",
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
def validate_default_epic(epic_id: str) -> tuple[bool, str | None]:
|
|
602
|
+
if not epic_id: # Optional field
|
|
603
|
+
return True, None
|
|
604
|
+
# Basic validation - just check it's not empty when provided
|
|
605
|
+
if len(epic_id.strip()) < 2:
|
|
606
|
+
return False, "Epic/project ID must be at least 2 characters"
|
|
607
|
+
return True, None
|
|
608
|
+
|
|
609
|
+
default_epic = _retry_setting(
|
|
610
|
+
"Default Epic/Project", prompt_default_epic, validate_default_epic
|
|
611
|
+
)
|
|
612
|
+
if default_epic:
|
|
613
|
+
config_dict["default_epic"] = default_epic
|
|
614
|
+
config_dict["default_project"] = default_epic # Set both for compatibility
|
|
615
|
+
console.print(
|
|
616
|
+
f"[green]✓[/green] Will use '{default_epic}' as default epic/project"
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# Default tags
|
|
620
|
+
current_default_tags = (
|
|
621
|
+
config_dict.get("default_tags", []) if has_existing else []
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
def prompt_default_tags() -> str:
|
|
625
|
+
if current_default_tags:
|
|
626
|
+
tags_str = ", ".join(current_default_tags)
|
|
627
|
+
return Prompt.ask(
|
|
628
|
+
f"Default tags (optional, comma-separated) [current: {tags_str}]",
|
|
629
|
+
default=tags_str,
|
|
630
|
+
)
|
|
631
|
+
return Prompt.ask(
|
|
632
|
+
"Default tags (optional, comma-separated, e.g., 'bug,urgent')",
|
|
633
|
+
default="",
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
def validate_default_tags(tags_input: str) -> tuple[bool, str | None]:
|
|
637
|
+
if not tags_input: # Optional field
|
|
638
|
+
return True, None
|
|
639
|
+
# Parse and validate tags
|
|
640
|
+
tags = [tag.strip() for tag in tags_input.split(",") if tag.strip()]
|
|
641
|
+
if not tags:
|
|
642
|
+
return False, "Please provide at least one tag or leave empty"
|
|
643
|
+
# Check each tag is reasonable
|
|
644
|
+
for tag in tags:
|
|
645
|
+
if len(tag) < 2:
|
|
646
|
+
return False, f"Tag '{tag}' must be at least 2 characters"
|
|
647
|
+
if len(tag) > 50:
|
|
648
|
+
return False, f"Tag '{tag}' is too long (max 50 characters)"
|
|
649
|
+
return True, None
|
|
650
|
+
|
|
651
|
+
default_tags_input = _retry_setting(
|
|
652
|
+
"Default Tags", prompt_default_tags, validate_default_tags
|
|
653
|
+
)
|
|
654
|
+
if default_tags_input:
|
|
655
|
+
default_tags = [
|
|
656
|
+
tag.strip() for tag in default_tags_input.split(",") if tag.strip()
|
|
657
|
+
]
|
|
658
|
+
config_dict["default_tags"] = default_tags
|
|
659
|
+
console.print(f"[green]✓[/green] Will use tags: {', '.join(default_tags)}")
|
|
660
|
+
|
|
661
|
+
# Validate with detailed error reporting
|
|
154
662
|
is_valid, error = ConfigValidator.validate_linear_config(config_dict)
|
|
663
|
+
|
|
155
664
|
if not is_valid:
|
|
156
|
-
console.print(
|
|
157
|
-
|
|
665
|
+
console.print("\n[red]❌ Configuration Validation Failed[/red]")
|
|
666
|
+
console.print(f"[red]Error: {error}[/red]\n")
|
|
667
|
+
|
|
668
|
+
# Show which settings were problematic
|
|
669
|
+
console.print("[yellow]Problematic settings:[/yellow]")
|
|
670
|
+
if "api_key" not in config_dict or not config_dict["api_key"]:
|
|
671
|
+
console.print(" • [red]API Key[/red] - Missing or empty")
|
|
672
|
+
if "team_key" not in config_dict and "team_id" not in config_dict:
|
|
673
|
+
console.print(
|
|
674
|
+
" • [red]Team Key/ID[/red] - Neither team_key nor team_id provided"
|
|
675
|
+
)
|
|
676
|
+
if "user_email" in config_dict:
|
|
677
|
+
email = config_dict["user_email"]
|
|
678
|
+
import re
|
|
679
|
+
|
|
680
|
+
if not re.match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", email):
|
|
681
|
+
console.print(f" • [red]User Email[/red] - Invalid format: {email}")
|
|
682
|
+
|
|
683
|
+
# Offer to retry specific settings
|
|
684
|
+
console.print("\n[cyan]Options:[/cyan]")
|
|
685
|
+
console.print(" 1. Retry configuration from scratch")
|
|
686
|
+
console.print(" 2. Fix specific settings")
|
|
687
|
+
console.print(" 3. Exit")
|
|
688
|
+
|
|
689
|
+
choice = Prompt.ask("Choose an option", choices=["1", "2", "3"], default="2")
|
|
690
|
+
|
|
691
|
+
if choice == "1":
|
|
692
|
+
# Recursive retry
|
|
693
|
+
return _configure_linear(existing_config=None)
|
|
694
|
+
if choice == "2":
|
|
695
|
+
# Fix specific settings
|
|
696
|
+
return _configure_linear(existing_config=config_dict)
|
|
697
|
+
raise typer.Exit(1) from None
|
|
698
|
+
|
|
699
|
+
console.print("[green]✓ Configuration validated successfully[/green]")
|
|
700
|
+
|
|
701
|
+
# Extract default values to return separately (not part of AdapterConfig)
|
|
702
|
+
default_values = {}
|
|
703
|
+
if "user_email" in config_dict:
|
|
704
|
+
default_values["default_user"] = config_dict.pop("user_email")
|
|
705
|
+
if "default_epic" in config_dict:
|
|
706
|
+
default_values["default_epic"] = config_dict.pop("default_epic")
|
|
707
|
+
if "default_project" in config_dict:
|
|
708
|
+
default_values["default_project"] = config_dict.pop("default_project")
|
|
709
|
+
if "default_tags" in config_dict:
|
|
710
|
+
default_values["default_tags"] = config_dict.pop("default_tags")
|
|
711
|
+
|
|
712
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _configure_jira(
|
|
716
|
+
interactive: bool = True,
|
|
717
|
+
server: str | None = None,
|
|
718
|
+
email: str | None = None,
|
|
719
|
+
api_token: str | None = None,
|
|
720
|
+
project_key: str | None = None,
|
|
721
|
+
**kwargs: Any,
|
|
722
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
723
|
+
"""Configure JIRA adapter.
|
|
724
|
+
|
|
725
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
158
726
|
|
|
159
|
-
|
|
727
|
+
Args:
|
|
728
|
+
----
|
|
729
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
730
|
+
server: Pre-provided JIRA server URL (optional)
|
|
731
|
+
email: Pre-provided JIRA user email (optional)
|
|
732
|
+
api_token: Pre-provided JIRA API token (optional)
|
|
733
|
+
project_key: Pre-provided default project key (optional)
|
|
734
|
+
**kwargs: Additional configuration parameters
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
-------
|
|
738
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
739
|
+
- AdapterConfig: Configured JIRA adapter configuration
|
|
740
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
160
741
|
|
|
742
|
+
"""
|
|
743
|
+
if interactive:
|
|
744
|
+
console.print("\n[bold]Configure JIRA Integration:[/bold]")
|
|
745
|
+
|
|
746
|
+
# Load existing configuration if available
|
|
747
|
+
existing_config = _load_existing_adapter_config("jira") if interactive else None
|
|
748
|
+
has_existing = existing_config is not None
|
|
749
|
+
|
|
750
|
+
# Server URL (with existing value as default)
|
|
751
|
+
existing_server = existing_config.get("server", "") if has_existing else ""
|
|
752
|
+
final_server = server or os.getenv("JIRA_SERVER") or ""
|
|
753
|
+
|
|
754
|
+
if interactive:
|
|
755
|
+
if not final_server and existing_server:
|
|
756
|
+
final_server = Prompt.ask(
|
|
757
|
+
f"JIRA Server URL [current: {existing_server}]",
|
|
758
|
+
default=existing_server,
|
|
759
|
+
)
|
|
760
|
+
elif not final_server:
|
|
761
|
+
final_server = Prompt.ask(
|
|
762
|
+
"JIRA Server URL (e.g., https://company.atlassian.net)"
|
|
763
|
+
)
|
|
764
|
+
elif not interactive and not final_server:
|
|
765
|
+
raise ValueError(
|
|
766
|
+
"JIRA server URL is required (provide server parameter or set JIRA_SERVER environment variable)"
|
|
767
|
+
)
|
|
161
768
|
|
|
162
|
-
|
|
163
|
-
"""
|
|
164
|
-
|
|
769
|
+
# Email (with existing value as default)
|
|
770
|
+
existing_email = existing_config.get("email", "") if has_existing else ""
|
|
771
|
+
final_email = email or os.getenv("JIRA_EMAIL") or ""
|
|
165
772
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
773
|
+
if interactive:
|
|
774
|
+
if not final_email and existing_email:
|
|
775
|
+
final_email = Prompt.ask(
|
|
776
|
+
f"JIRA User Email [current: {existing_email}]",
|
|
777
|
+
default=existing_email,
|
|
778
|
+
)
|
|
779
|
+
elif not final_email:
|
|
780
|
+
final_email = Prompt.ask("JIRA User Email")
|
|
781
|
+
elif not interactive and not final_email:
|
|
782
|
+
raise ValueError(
|
|
783
|
+
"JIRA email is required (provide email parameter or set JIRA_EMAIL environment variable)"
|
|
784
|
+
)
|
|
170
785
|
|
|
171
|
-
#
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
email = Prompt.ask("JIRA User Email")
|
|
786
|
+
# API Token with validation loop
|
|
787
|
+
existing_token = existing_config.get("api_token", "") if has_existing else ""
|
|
788
|
+
final_api_token = api_token or os.getenv("JIRA_API_TOKEN") or ""
|
|
175
789
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
790
|
+
if interactive:
|
|
791
|
+
# Validation loop for JIRA credentials
|
|
792
|
+
jira_validated = False
|
|
793
|
+
while not jira_validated:
|
|
794
|
+
if not final_api_token and existing_token:
|
|
795
|
+
masked = _mask_sensitive_value(existing_token)
|
|
796
|
+
console.print(
|
|
797
|
+
"[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]"
|
|
798
|
+
)
|
|
799
|
+
final_api_token = Prompt.ask(
|
|
800
|
+
f"JIRA API Token [current: {masked}]",
|
|
801
|
+
password=True,
|
|
802
|
+
default=existing_token,
|
|
803
|
+
)
|
|
804
|
+
elif not final_api_token:
|
|
805
|
+
console.print(
|
|
806
|
+
"[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]"
|
|
807
|
+
)
|
|
808
|
+
final_api_token = Prompt.ask("JIRA API Token", password=True)
|
|
809
|
+
|
|
810
|
+
# Validate JIRA credentials with real API call
|
|
811
|
+
try:
|
|
812
|
+
jira_validated = _validate_api_credentials(
|
|
813
|
+
"jira",
|
|
814
|
+
{
|
|
815
|
+
"server": final_server,
|
|
816
|
+
"email": final_email,
|
|
817
|
+
"api_token": final_api_token,
|
|
818
|
+
},
|
|
819
|
+
)
|
|
820
|
+
except typer.Exit:
|
|
821
|
+
# User cancelled, propagate the exit
|
|
822
|
+
raise
|
|
823
|
+
except Exception as e:
|
|
824
|
+
console.print(f"[red]Validation error: {e}[/red]")
|
|
825
|
+
retry = Confirm.ask("Re-enter credentials?", default=True)
|
|
826
|
+
if not retry:
|
|
827
|
+
raise typer.Exit(1) from None
|
|
828
|
+
# Reset to prompt again
|
|
829
|
+
final_api_token = ""
|
|
830
|
+
|
|
831
|
+
elif not interactive and not final_api_token:
|
|
832
|
+
raise ValueError(
|
|
833
|
+
"JIRA API token is required (provide api_token parameter or set JIRA_API_TOKEN environment variable)"
|
|
181
834
|
)
|
|
182
|
-
api_token = Prompt.ask("JIRA API Token", password=True)
|
|
183
835
|
|
|
184
|
-
# Project Key
|
|
185
|
-
|
|
836
|
+
# Project Key (optional, with existing value as default)
|
|
837
|
+
existing_project_key = (
|
|
838
|
+
existing_config.get("project_key", "") if has_existing else ""
|
|
839
|
+
)
|
|
840
|
+
final_project_key = project_key or os.getenv("JIRA_PROJECT_KEY") or ""
|
|
841
|
+
|
|
842
|
+
if interactive:
|
|
843
|
+
if not final_project_key and existing_project_key:
|
|
844
|
+
final_project_key = Prompt.ask(
|
|
845
|
+
f"Default Project Key (optional, e.g., PROJ) [current: {existing_project_key}]",
|
|
846
|
+
default=existing_project_key,
|
|
847
|
+
)
|
|
848
|
+
elif not final_project_key:
|
|
849
|
+
final_project_key = Prompt.ask(
|
|
850
|
+
"Default Project Key (optional, e.g., PROJ)", default=""
|
|
851
|
+
)
|
|
186
852
|
|
|
187
853
|
config_dict = {
|
|
188
854
|
"adapter": AdapterType.JIRA.value,
|
|
189
|
-
"server":
|
|
190
|
-
"email":
|
|
191
|
-
"api_token":
|
|
855
|
+
"server": final_server.rstrip("/"),
|
|
856
|
+
"email": final_email,
|
|
857
|
+
"api_token": final_api_token,
|
|
192
858
|
}
|
|
193
859
|
|
|
194
|
-
if
|
|
195
|
-
config_dict["project_key"] =
|
|
860
|
+
if final_project_key:
|
|
861
|
+
config_dict["project_key"] = final_project_key
|
|
196
862
|
|
|
197
863
|
# Validate
|
|
198
864
|
is_valid, error = ConfigValidator.validate_jira_config(config_dict)
|
|
199
865
|
if not is_valid:
|
|
200
|
-
|
|
201
|
-
|
|
866
|
+
if interactive:
|
|
867
|
+
console.print(f"[red]Configuration error: {error}[/red]")
|
|
868
|
+
raise typer.Exit(1) from None
|
|
869
|
+
raise ValueError(f"JIRA configuration validation failed: {error}")
|
|
870
|
+
|
|
871
|
+
# ============================================================
|
|
872
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
873
|
+
# ============================================================
|
|
874
|
+
default_values = {}
|
|
875
|
+
|
|
876
|
+
if interactive:
|
|
877
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
878
|
+
console.print("Configure default values for ticket creation:")
|
|
879
|
+
|
|
880
|
+
# Default user/assignee
|
|
881
|
+
existing_user = existing_config.get("user_email", "") if has_existing else ""
|
|
882
|
+
if existing_user:
|
|
883
|
+
user_input = Prompt.ask(
|
|
884
|
+
f"Default assignee/user (optional, JIRA username or email) [current: {existing_user}]",
|
|
885
|
+
default=existing_user,
|
|
886
|
+
)
|
|
887
|
+
else:
|
|
888
|
+
user_input = Prompt.ask(
|
|
889
|
+
"Default assignee/user (optional, JIRA username or email)",
|
|
890
|
+
default="",
|
|
891
|
+
show_default=False,
|
|
892
|
+
)
|
|
893
|
+
if user_input:
|
|
894
|
+
default_values["default_user"] = user_input
|
|
895
|
+
console.print(
|
|
896
|
+
f"[green]✓[/green] Will use '{user_input}' as default assignee"
|
|
897
|
+
)
|
|
202
898
|
|
|
203
|
-
|
|
899
|
+
# Default epic/project
|
|
900
|
+
existing_epic = existing_config.get("default_epic", "") if has_existing else ""
|
|
901
|
+
if existing_epic:
|
|
902
|
+
epic_input = Prompt.ask(
|
|
903
|
+
f"Default epic/project ID (optional, e.g., 'PROJ-123') [current: {existing_epic}]",
|
|
904
|
+
default=existing_epic,
|
|
905
|
+
)
|
|
906
|
+
else:
|
|
907
|
+
epic_input = Prompt.ask(
|
|
908
|
+
"Default epic/project ID (optional, e.g., 'PROJ-123')",
|
|
909
|
+
default="",
|
|
910
|
+
show_default=False,
|
|
911
|
+
)
|
|
912
|
+
if epic_input:
|
|
913
|
+
default_values["default_epic"] = epic_input
|
|
914
|
+
default_values["default_project"] = epic_input # Compatibility
|
|
915
|
+
console.print(
|
|
916
|
+
f"[green]✓[/green] Will use '{epic_input}' as default epic/project"
|
|
917
|
+
)
|
|
204
918
|
|
|
919
|
+
# Default tags
|
|
920
|
+
existing_tags = existing_config.get("default_tags", []) if has_existing else []
|
|
921
|
+
existing_tags_str = ", ".join(existing_tags) if existing_tags else ""
|
|
922
|
+
if existing_tags_str:
|
|
923
|
+
tags_input = Prompt.ask(
|
|
924
|
+
f"Default tags/labels (optional, comma-separated, e.g., 'bug,urgent') [current: {existing_tags_str}]",
|
|
925
|
+
default=existing_tags_str,
|
|
926
|
+
)
|
|
927
|
+
else:
|
|
928
|
+
tags_input = Prompt.ask(
|
|
929
|
+
"Default tags/labels (optional, comma-separated, e.g., 'bug,urgent')",
|
|
930
|
+
default="",
|
|
931
|
+
show_default=False,
|
|
932
|
+
)
|
|
933
|
+
if tags_input:
|
|
934
|
+
tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
|
|
935
|
+
if tags_list:
|
|
936
|
+
default_values["default_tags"] = tags_list
|
|
937
|
+
console.print(f"[green]✓[/green] Will use tags: {', '.join(tags_list)}")
|
|
205
938
|
|
|
206
|
-
|
|
207
|
-
"""Configure GitHub adapter."""
|
|
208
|
-
console.print("\n[bold]Configure GitHub Integration:[/bold]")
|
|
939
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
209
940
|
|
|
210
|
-
# Token
|
|
211
|
-
token = os.getenv("GITHUB_TOKEN") or ""
|
|
212
|
-
if token:
|
|
213
|
-
console.print("[dim]Found GITHUB_TOKEN in environment[/dim]")
|
|
214
|
-
use_env = Confirm.ask("Use this token?", default=True)
|
|
215
|
-
if not use_env:
|
|
216
|
-
token = ""
|
|
217
941
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
942
|
+
def _configure_github(
|
|
943
|
+
interactive: bool = True,
|
|
944
|
+
token: str | None = None,
|
|
945
|
+
repo_url: str | None = None,
|
|
946
|
+
owner: str | None = None,
|
|
947
|
+
repo: str | None = None,
|
|
948
|
+
**kwargs: Any,
|
|
949
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
950
|
+
"""Configure GitHub adapter.
|
|
951
|
+
|
|
952
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
953
|
+
|
|
954
|
+
Args:
|
|
955
|
+
----
|
|
956
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
957
|
+
token: Pre-provided GitHub Personal Access Token (optional)
|
|
958
|
+
repo_url: Pre-provided GitHub repository URL (optional, preferred)
|
|
959
|
+
owner: Pre-provided repository owner (optional, fallback)
|
|
960
|
+
repo: Pre-provided repository name (optional, fallback)
|
|
961
|
+
**kwargs: Additional configuration parameters
|
|
962
|
+
|
|
963
|
+
Returns:
|
|
964
|
+
-------
|
|
965
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
966
|
+
- AdapterConfig: Configured GitHub adapter configuration
|
|
967
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
968
|
+
|
|
969
|
+
"""
|
|
970
|
+
if interactive:
|
|
971
|
+
console.print("\n[bold]Configure GitHub Integration:[/bold]")
|
|
972
|
+
|
|
973
|
+
# Load existing configuration if available
|
|
974
|
+
existing_config = _load_existing_adapter_config("github") if interactive else None
|
|
975
|
+
has_existing = existing_config is not None
|
|
976
|
+
|
|
977
|
+
# Token with validation loop
|
|
978
|
+
existing_token = existing_config.get("token", "") if has_existing else ""
|
|
979
|
+
final_token = token or os.getenv("GITHUB_TOKEN") or ""
|
|
980
|
+
|
|
981
|
+
if interactive:
|
|
982
|
+
github_validated = False
|
|
983
|
+
while not github_validated:
|
|
984
|
+
if final_token and not existing_token:
|
|
985
|
+
console.print("[dim]Found GITHUB_TOKEN in environment[/dim]")
|
|
986
|
+
use_env = Confirm.ask("Use this token?", default=True)
|
|
987
|
+
if not use_env:
|
|
988
|
+
final_token = ""
|
|
989
|
+
|
|
990
|
+
if not final_token:
|
|
991
|
+
if existing_token:
|
|
992
|
+
# Show masked existing token as default
|
|
993
|
+
masked = _mask_sensitive_value(existing_token)
|
|
994
|
+
console.print(
|
|
995
|
+
"[dim]Create token at: https://github.com/settings/tokens/new[/dim]"
|
|
996
|
+
)
|
|
997
|
+
console.print(
|
|
998
|
+
"[dim]Required scopes: repo (or public_repo for public repos)[/dim]"
|
|
999
|
+
)
|
|
1000
|
+
final_token = Prompt.ask(
|
|
1001
|
+
f"GitHub Personal Access Token [current: {masked}]",
|
|
1002
|
+
password=True,
|
|
1003
|
+
default=existing_token,
|
|
1004
|
+
)
|
|
1005
|
+
else:
|
|
1006
|
+
console.print(
|
|
1007
|
+
"[dim]Create token at: https://github.com/settings/tokens/new[/dim]"
|
|
1008
|
+
)
|
|
1009
|
+
console.print(
|
|
1010
|
+
"[dim]Required scopes: repo (or public_repo for public repos)[/dim]"
|
|
1011
|
+
)
|
|
1012
|
+
final_token = Prompt.ask(
|
|
1013
|
+
"GitHub Personal Access Token", password=True
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
# Validate GitHub token with real API call
|
|
1017
|
+
try:
|
|
1018
|
+
github_validated = _validate_api_credentials(
|
|
1019
|
+
"github", {"token": final_token}
|
|
1020
|
+
)
|
|
1021
|
+
except typer.Exit:
|
|
1022
|
+
# User cancelled, propagate the exit
|
|
1023
|
+
raise
|
|
1024
|
+
except Exception as e:
|
|
1025
|
+
console.print(f"[red]Validation error: {e}[/red]")
|
|
1026
|
+
retry = Confirm.ask("Re-enter token?", default=True)
|
|
1027
|
+
if not retry:
|
|
1028
|
+
raise typer.Exit(1) from None
|
|
1029
|
+
# Reset to prompt again
|
|
1030
|
+
final_token = ""
|
|
1031
|
+
|
|
1032
|
+
elif not final_token:
|
|
1033
|
+
raise ValueError(
|
|
1034
|
+
"GitHub token is required (provide token parameter or set GITHUB_TOKEN environment variable)"
|
|
221
1035
|
)
|
|
1036
|
+
|
|
1037
|
+
# Repository URL/Owner/Repo - Prioritize repo_url, fallback to owner/repo
|
|
1038
|
+
existing_owner = existing_config.get("owner", "") if has_existing else ""
|
|
1039
|
+
existing_repo = existing_config.get("repo", "") if has_existing else ""
|
|
1040
|
+
|
|
1041
|
+
final_owner = ""
|
|
1042
|
+
final_repo = ""
|
|
1043
|
+
|
|
1044
|
+
# Step 1: Try to get URL from parameter or environment
|
|
1045
|
+
url_input = repo_url or os.getenv("GITHUB_REPO_URL") or ""
|
|
1046
|
+
|
|
1047
|
+
# Step 2: Parse URL if provided
|
|
1048
|
+
if url_input:
|
|
1049
|
+
from ..core.url_parser import parse_github_repo_url
|
|
1050
|
+
|
|
1051
|
+
parsed_owner, parsed_repo, error = parse_github_repo_url(url_input)
|
|
1052
|
+
if parsed_owner and parsed_repo:
|
|
1053
|
+
final_owner = parsed_owner
|
|
1054
|
+
final_repo = parsed_repo
|
|
1055
|
+
if interactive:
|
|
1056
|
+
console.print(
|
|
1057
|
+
f"[dim]✓ Extracted repository: {final_owner}/{final_repo}[/dim]"
|
|
1058
|
+
)
|
|
1059
|
+
else:
|
|
1060
|
+
# URL parsing failed
|
|
1061
|
+
if interactive:
|
|
1062
|
+
console.print(f"[yellow]Warning: {error}[/yellow]")
|
|
1063
|
+
else:
|
|
1064
|
+
raise ValueError(f"Failed to parse GitHub repository URL: {error}")
|
|
1065
|
+
|
|
1066
|
+
# Step 3: Interactive mode - prompt for URL if not provided
|
|
1067
|
+
if interactive and not final_owner and not final_repo:
|
|
222
1068
|
console.print(
|
|
223
|
-
"[dim]
|
|
1069
|
+
"[dim]Enter your GitHub repository URL (e.g., https://github.com/owner/repo)[/dim]"
|
|
224
1070
|
)
|
|
225
|
-
token = Prompt.ask("GitHub Personal Access Token", password=True)
|
|
226
1071
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
1072
|
+
# Show existing as default if available
|
|
1073
|
+
if existing_owner and existing_repo:
|
|
1074
|
+
existing_url = f"https://github.com/{existing_owner}/{existing_repo}"
|
|
1075
|
+
console.print(f"[dim]Current repository: {existing_url}[/dim]")
|
|
1076
|
+
|
|
1077
|
+
# Keep prompting until we get a valid URL
|
|
1078
|
+
while not final_owner or not final_repo:
|
|
1079
|
+
from ..core.url_parser import parse_github_repo_url
|
|
1080
|
+
|
|
1081
|
+
default_prompt = (
|
|
1082
|
+
f"https://github.com/{existing_owner}/{existing_repo}"
|
|
1083
|
+
if existing_owner and existing_repo
|
|
1084
|
+
else "https://github.com/"
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
url_prompt = Prompt.ask(
|
|
1088
|
+
"GitHub Repository URL",
|
|
1089
|
+
default=default_prompt,
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
parsed_owner, parsed_repo, error = parse_github_repo_url(url_prompt)
|
|
1093
|
+
if parsed_owner and parsed_repo:
|
|
1094
|
+
final_owner = parsed_owner
|
|
1095
|
+
final_repo = parsed_repo
|
|
1096
|
+
console.print(f"[dim]✓ Repository: {final_owner}/{final_repo}[/dim]")
|
|
1097
|
+
break
|
|
1098
|
+
else:
|
|
1099
|
+
console.print(f"[red]Error: {error}[/red]")
|
|
1100
|
+
console.print(
|
|
1101
|
+
"[yellow]Please enter a valid GitHub repository URL[/yellow]"
|
|
1102
|
+
)
|
|
231
1103
|
|
|
232
|
-
#
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
1104
|
+
# Step 4: Non-interactive fallback - use individual owner/repo parameters
|
|
1105
|
+
if not final_owner or not final_repo:
|
|
1106
|
+
fallback_owner = owner or os.getenv("GITHUB_OWNER") or ""
|
|
1107
|
+
fallback_repo = repo or os.getenv("GITHUB_REPO") or ""
|
|
1108
|
+
|
|
1109
|
+
# In non-interactive mode, both must be provided if URL wasn't
|
|
1110
|
+
if not interactive:
|
|
1111
|
+
if not fallback_owner or not fallback_repo:
|
|
1112
|
+
raise ValueError(
|
|
1113
|
+
"GitHub repository is required. Provide either:\n"
|
|
1114
|
+
" - repo_url parameter or GITHUB_REPO_URL environment variable, OR\n"
|
|
1115
|
+
" - Both owner and repo parameters (or GITHUB_OWNER/GITHUB_REPO environment variables)"
|
|
1116
|
+
)
|
|
1117
|
+
final_owner = fallback_owner
|
|
1118
|
+
final_repo = fallback_repo
|
|
1119
|
+
else:
|
|
1120
|
+
# Interactive mode with fallback values
|
|
1121
|
+
if fallback_owner:
|
|
1122
|
+
final_owner = fallback_owner
|
|
1123
|
+
if fallback_repo:
|
|
1124
|
+
final_repo = fallback_repo
|
|
236
1125
|
|
|
237
1126
|
config_dict = {
|
|
238
1127
|
"adapter": AdapterType.GITHUB.value,
|
|
239
|
-
"token":
|
|
240
|
-
"owner":
|
|
241
|
-
"repo":
|
|
242
|
-
"project_id": f"{
|
|
1128
|
+
"token": final_token,
|
|
1129
|
+
"owner": final_owner,
|
|
1130
|
+
"repo": final_repo,
|
|
1131
|
+
"project_id": f"{final_owner}/{final_repo}", # Convenience field
|
|
243
1132
|
}
|
|
244
1133
|
|
|
245
1134
|
# Validate
|
|
246
1135
|
is_valid, error = ConfigValidator.validate_github_config(config_dict)
|
|
247
1136
|
if not is_valid:
|
|
248
|
-
|
|
249
|
-
|
|
1137
|
+
if interactive:
|
|
1138
|
+
console.print(f"[red]Configuration error: {error}[/red]")
|
|
1139
|
+
raise typer.Exit(1) from None
|
|
1140
|
+
raise ValueError(f"GitHub configuration validation failed: {error}")
|
|
1141
|
+
|
|
1142
|
+
# ============================================================
|
|
1143
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
1144
|
+
# ============================================================
|
|
1145
|
+
default_values = {}
|
|
1146
|
+
|
|
1147
|
+
if interactive:
|
|
1148
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
1149
|
+
console.print("Configure default values for ticket creation:")
|
|
1150
|
+
|
|
1151
|
+
# Default user/assignee
|
|
1152
|
+
existing_user = existing_config.get("user_email", "") if has_existing else ""
|
|
1153
|
+
if existing_user:
|
|
1154
|
+
user_input = Prompt.ask(
|
|
1155
|
+
f"Default assignee/user (optional, GitHub username) [current: {existing_user}]",
|
|
1156
|
+
default=existing_user,
|
|
1157
|
+
)
|
|
1158
|
+
else:
|
|
1159
|
+
user_input = Prompt.ask(
|
|
1160
|
+
"Default assignee/user (optional, GitHub username)",
|
|
1161
|
+
default="",
|
|
1162
|
+
show_default=False,
|
|
1163
|
+
)
|
|
1164
|
+
if user_input:
|
|
1165
|
+
default_values["default_user"] = user_input
|
|
1166
|
+
console.print(
|
|
1167
|
+
f"[green]✓[/green] Will use '{user_input}' as default assignee"
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
# Default epic/project (milestone for GitHub)
|
|
1171
|
+
existing_epic = existing_config.get("default_epic", "") if has_existing else ""
|
|
1172
|
+
if existing_epic:
|
|
1173
|
+
epic_input = Prompt.ask(
|
|
1174
|
+
f"Default milestone/project (optional, e.g., 'v1.0' or milestone number) [current: {existing_epic}]",
|
|
1175
|
+
default=existing_epic,
|
|
1176
|
+
)
|
|
1177
|
+
else:
|
|
1178
|
+
epic_input = Prompt.ask(
|
|
1179
|
+
"Default milestone/project (optional, e.g., 'v1.0' or milestone number)",
|
|
1180
|
+
default="",
|
|
1181
|
+
show_default=False,
|
|
1182
|
+
)
|
|
1183
|
+
if epic_input:
|
|
1184
|
+
default_values["default_epic"] = epic_input
|
|
1185
|
+
default_values["default_project"] = epic_input # Compatibility
|
|
1186
|
+
console.print(
|
|
1187
|
+
f"[green]✓[/green] Will use '{epic_input}' as default milestone/project"
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
# Default tags (labels for GitHub)
|
|
1191
|
+
existing_tags = existing_config.get("default_tags", []) if has_existing else []
|
|
1192
|
+
existing_tags_str = ", ".join(existing_tags) if existing_tags else ""
|
|
1193
|
+
if existing_tags_str:
|
|
1194
|
+
tags_input = Prompt.ask(
|
|
1195
|
+
f"Default labels (optional, comma-separated, e.g., 'bug,enhancement') [current: {existing_tags_str}]",
|
|
1196
|
+
default=existing_tags_str,
|
|
1197
|
+
)
|
|
1198
|
+
else:
|
|
1199
|
+
tags_input = Prompt.ask(
|
|
1200
|
+
"Default labels (optional, comma-separated, e.g., 'bug,enhancement')",
|
|
1201
|
+
default="",
|
|
1202
|
+
show_default=False,
|
|
1203
|
+
)
|
|
1204
|
+
if tags_input:
|
|
1205
|
+
tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
|
|
1206
|
+
if tags_list:
|
|
1207
|
+
default_values["default_tags"] = tags_list
|
|
1208
|
+
console.print(
|
|
1209
|
+
f"[green]✓[/green] Will use labels: {', '.join(tags_list)}"
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def _configure_aitrackdown(
|
|
1216
|
+
interactive: bool = True, base_path: str | None = None, **kwargs: Any
|
|
1217
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
1218
|
+
"""Configure AITrackdown adapter.
|
|
1219
|
+
|
|
1220
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
1221
|
+
|
|
1222
|
+
Args:
|
|
1223
|
+
----
|
|
1224
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
1225
|
+
base_path: Pre-provided base path for ticket storage (optional)
|
|
1226
|
+
**kwargs: Additional configuration parameters
|
|
1227
|
+
|
|
1228
|
+
Returns:
|
|
1229
|
+
-------
|
|
1230
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
1231
|
+
- AdapterConfig: Configured AITrackdown adapter configuration
|
|
1232
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
250
1233
|
|
|
251
|
-
|
|
1234
|
+
"""
|
|
1235
|
+
if interactive:
|
|
1236
|
+
console.print("\n[bold]Configure AITrackdown (File-based):[/bold]")
|
|
252
1237
|
|
|
1238
|
+
# Load existing configuration if available
|
|
1239
|
+
existing_config = (
|
|
1240
|
+
_load_existing_adapter_config("aitrackdown") if interactive else None
|
|
1241
|
+
)
|
|
1242
|
+
has_existing = existing_config is not None
|
|
253
1243
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
1244
|
+
# Base path with existing value as default
|
|
1245
|
+
existing_base_path = (
|
|
1246
|
+
existing_config.get("base_path", ".aitrackdown") if has_existing else ""
|
|
1247
|
+
)
|
|
1248
|
+
final_base_path = base_path or existing_base_path or ".aitrackdown"
|
|
257
1249
|
|
|
258
|
-
|
|
1250
|
+
if interactive:
|
|
1251
|
+
if existing_base_path:
|
|
1252
|
+
final_base_path = Prompt.ask(
|
|
1253
|
+
f"Base path for ticket storage [current: {existing_base_path}]",
|
|
1254
|
+
default=existing_base_path,
|
|
1255
|
+
)
|
|
1256
|
+
else:
|
|
1257
|
+
final_base_path = Prompt.ask(
|
|
1258
|
+
"Base path for ticket storage", default=".aitrackdown"
|
|
1259
|
+
)
|
|
259
1260
|
|
|
260
1261
|
config_dict = {
|
|
261
1262
|
"adapter": AdapterType.AITRACKDOWN.value,
|
|
262
|
-
"base_path":
|
|
1263
|
+
"base_path": final_base_path,
|
|
263
1264
|
}
|
|
264
1265
|
|
|
265
|
-
|
|
1266
|
+
# ============================================================
|
|
1267
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
1268
|
+
# ============================================================
|
|
1269
|
+
default_values = {}
|
|
1270
|
+
|
|
1271
|
+
if interactive:
|
|
1272
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
1273
|
+
console.print("Configure default values for ticket creation:")
|
|
1274
|
+
|
|
1275
|
+
# Default user/assignee
|
|
1276
|
+
existing_user = existing_config.get("user_email", "") if has_existing else ""
|
|
1277
|
+
if existing_user:
|
|
1278
|
+
user_input = Prompt.ask(
|
|
1279
|
+
f"Default assignee/user (optional) [current: {existing_user}]",
|
|
1280
|
+
default=existing_user,
|
|
1281
|
+
)
|
|
1282
|
+
else:
|
|
1283
|
+
user_input = Prompt.ask(
|
|
1284
|
+
"Default assignee/user (optional)", default="", show_default=False
|
|
1285
|
+
)
|
|
1286
|
+
if user_input:
|
|
1287
|
+
default_values["default_user"] = user_input
|
|
1288
|
+
console.print(
|
|
1289
|
+
f"[green]✓[/green] Will use '{user_input}' as default assignee"
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
# Default epic/project
|
|
1293
|
+
existing_epic = existing_config.get("default_epic", "") if has_existing else ""
|
|
1294
|
+
if existing_epic:
|
|
1295
|
+
epic_input = Prompt.ask(
|
|
1296
|
+
f"Default epic/project ID (optional) [current: {existing_epic}]",
|
|
1297
|
+
default=existing_epic,
|
|
1298
|
+
)
|
|
1299
|
+
else:
|
|
1300
|
+
epic_input = Prompt.ask(
|
|
1301
|
+
"Default epic/project ID (optional)", default="", show_default=False
|
|
1302
|
+
)
|
|
1303
|
+
if epic_input:
|
|
1304
|
+
default_values["default_epic"] = epic_input
|
|
1305
|
+
default_values["default_project"] = epic_input # Compatibility
|
|
1306
|
+
console.print(
|
|
1307
|
+
f"[green]✓[/green] Will use '{epic_input}' as default epic/project"
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
# Default tags
|
|
1311
|
+
existing_tags = existing_config.get("default_tags", []) if has_existing else []
|
|
1312
|
+
existing_tags_str = ", ".join(existing_tags) if existing_tags else ""
|
|
1313
|
+
if existing_tags_str:
|
|
1314
|
+
tags_input = Prompt.ask(
|
|
1315
|
+
f"Default tags (optional, comma-separated) [current: {existing_tags_str}]",
|
|
1316
|
+
default=existing_tags_str,
|
|
1317
|
+
)
|
|
1318
|
+
else:
|
|
1319
|
+
tags_input = Prompt.ask(
|
|
1320
|
+
"Default tags (optional, comma-separated)", default="", show_default=False
|
|
1321
|
+
)
|
|
1322
|
+
if tags_input:
|
|
1323
|
+
tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
|
|
1324
|
+
if tags_list:
|
|
1325
|
+
default_values["default_tags"] = tags_list
|
|
1326
|
+
console.print(f"[green]✓[/green] Will use tags: {', '.join(tags_list)}")
|
|
1327
|
+
|
|
1328
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def prompt_default_values(
|
|
1332
|
+
adapter_type: str,
|
|
1333
|
+
existing_values: dict[str, Any] | None = None,
|
|
1334
|
+
) -> dict[str, Any]:
|
|
1335
|
+
"""Prompt user for default values (for ticket creation).
|
|
1336
|
+
|
|
1337
|
+
This is a standalone function that can be called independently of adapter configuration.
|
|
1338
|
+
Used when adapter credentials exist but default values need to be set or updated.
|
|
1339
|
+
|
|
1340
|
+
Args:
|
|
1341
|
+
----
|
|
1342
|
+
adapter_type: Type of adapter (linear, jira, github, aitrackdown)
|
|
1343
|
+
existing_values: Optional existing default values to show as current values
|
|
1344
|
+
|
|
1345
|
+
Returns:
|
|
1346
|
+
-------
|
|
1347
|
+
Dictionary containing default_user, default_epic, default_project, default_tags
|
|
1348
|
+
(only includes keys that were provided by the user)
|
|
1349
|
+
|
|
1350
|
+
"""
|
|
1351
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
1352
|
+
console.print("Configure default values for ticket creation:")
|
|
1353
|
+
|
|
1354
|
+
default_values = {}
|
|
1355
|
+
existing_values = existing_values or {}
|
|
1356
|
+
|
|
1357
|
+
# Default user/assignee
|
|
1358
|
+
current_user = existing_values.get("default_user", "")
|
|
1359
|
+
if current_user:
|
|
1360
|
+
user_input = Prompt.ask(
|
|
1361
|
+
f"Default assignee/user (optional) [current: {current_user}]",
|
|
1362
|
+
default=current_user,
|
|
1363
|
+
show_default=False,
|
|
1364
|
+
)
|
|
1365
|
+
else:
|
|
1366
|
+
user_input = Prompt.ask(
|
|
1367
|
+
"Default assignee/user (optional)",
|
|
1368
|
+
default="",
|
|
1369
|
+
show_default=False,
|
|
1370
|
+
)
|
|
1371
|
+
if user_input:
|
|
1372
|
+
default_values["default_user"] = user_input
|
|
1373
|
+
console.print(f"[green]✓[/green] Will use '{user_input}' as default assignee")
|
|
1374
|
+
|
|
1375
|
+
# Default epic/project
|
|
1376
|
+
current_epic = existing_values.get("default_epic") or existing_values.get(
|
|
1377
|
+
"default_project", ""
|
|
1378
|
+
)
|
|
1379
|
+
|
|
1380
|
+
# Adapter-specific messaging
|
|
1381
|
+
if adapter_type == "github":
|
|
1382
|
+
epic_label = "milestone/project"
|
|
1383
|
+
epic_example = "e.g., 'v1.0' or milestone number"
|
|
1384
|
+
elif adapter_type == "linear":
|
|
1385
|
+
epic_label = "epic/project ID"
|
|
1386
|
+
epic_example = "e.g., 'PROJ-123' or UUID"
|
|
1387
|
+
else:
|
|
1388
|
+
epic_label = "epic/project ID"
|
|
1389
|
+
epic_example = "e.g., 'PROJ-123'"
|
|
1390
|
+
|
|
1391
|
+
if current_epic:
|
|
1392
|
+
epic_input = Prompt.ask(
|
|
1393
|
+
f"Default {epic_label} (optional) [current: {current_epic}]",
|
|
1394
|
+
default=current_epic,
|
|
1395
|
+
show_default=False,
|
|
1396
|
+
)
|
|
1397
|
+
else:
|
|
1398
|
+
epic_input = Prompt.ask(
|
|
1399
|
+
f"Default {epic_label} (optional, {epic_example})",
|
|
1400
|
+
default="",
|
|
1401
|
+
show_default=False,
|
|
1402
|
+
)
|
|
1403
|
+
if epic_input:
|
|
1404
|
+
default_values["default_epic"] = epic_input
|
|
1405
|
+
default_values["default_project"] = epic_input # Compatibility
|
|
1406
|
+
console.print(
|
|
1407
|
+
f"[green]✓[/green] Will use '{epic_input}' as default {epic_label}"
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
# Default tags
|
|
1411
|
+
current_tags = existing_values.get("default_tags", [])
|
|
1412
|
+
current_tags_str = ", ".join(current_tags) if current_tags else ""
|
|
1413
|
+
|
|
1414
|
+
# Adapter-specific messaging
|
|
1415
|
+
tags_label = "labels" if adapter_type == "github" else "tags/labels"
|
|
1416
|
+
|
|
1417
|
+
if current_tags_str:
|
|
1418
|
+
tags_input = Prompt.ask(
|
|
1419
|
+
f"Default {tags_label} (optional, comma-separated) [current: {current_tags_str}]",
|
|
1420
|
+
default=current_tags_str,
|
|
1421
|
+
show_default=False,
|
|
1422
|
+
)
|
|
1423
|
+
else:
|
|
1424
|
+
tags_input = Prompt.ask(
|
|
1425
|
+
f"Default {tags_label} (optional, comma-separated, e.g., 'bug,urgent')",
|
|
1426
|
+
default="",
|
|
1427
|
+
show_default=False,
|
|
1428
|
+
)
|
|
1429
|
+
if tags_input:
|
|
1430
|
+
tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
|
|
1431
|
+
if tags_list:
|
|
1432
|
+
default_values["default_tags"] = tags_list
|
|
1433
|
+
console.print(
|
|
1434
|
+
f"[green]✓[/green] Will use {tags_label}: {', '.join(tags_list)}"
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
return default_values
|
|
266
1438
|
|
|
267
1439
|
|
|
268
1440
|
def _configure_hybrid_mode() -> TicketerConfig:
|
|
@@ -296,24 +1468,29 @@ def _configure_hybrid_mode() -> TicketerConfig:
|
|
|
296
1468
|
|
|
297
1469
|
if len(selected_adapters) < 2:
|
|
298
1470
|
console.print("[red]Hybrid mode requires at least 2 adapters[/red]")
|
|
299
|
-
raise typer.Exit(1)
|
|
1471
|
+
raise typer.Exit(1) from None
|
|
300
1472
|
|
|
301
1473
|
# Configure each adapter
|
|
302
1474
|
adapters = {}
|
|
1475
|
+
default_values: dict[str, str] = {}
|
|
303
1476
|
for adapter_type in selected_adapters:
|
|
304
1477
|
console.print(f"\n[cyan]Configuring {adapter_type.value}...[/cyan]")
|
|
305
1478
|
|
|
306
1479
|
if adapter_type == AdapterType.LINEAR:
|
|
307
|
-
adapter_config = _configure_linear()
|
|
1480
|
+
adapter_config, adapter_defaults = _configure_linear(interactive=True)
|
|
308
1481
|
elif adapter_type == AdapterType.JIRA:
|
|
309
|
-
adapter_config = _configure_jira()
|
|
1482
|
+
adapter_config, adapter_defaults = _configure_jira(interactive=True)
|
|
310
1483
|
elif adapter_type == AdapterType.GITHUB:
|
|
311
|
-
adapter_config = _configure_github()
|
|
1484
|
+
adapter_config, adapter_defaults = _configure_github(interactive=True)
|
|
312
1485
|
else:
|
|
313
|
-
adapter_config = _configure_aitrackdown()
|
|
1486
|
+
adapter_config, adapter_defaults = _configure_aitrackdown(interactive=True)
|
|
314
1487
|
|
|
315
1488
|
adapters[adapter_type.value] = adapter_config
|
|
316
1489
|
|
|
1490
|
+
# Only save defaults from the first/primary adapter
|
|
1491
|
+
if not default_values:
|
|
1492
|
+
default_values = adapter_defaults
|
|
1493
|
+
|
|
317
1494
|
# Select primary adapter
|
|
318
1495
|
console.print("\n[bold]Select primary adapter (source of truth):[/bold]")
|
|
319
1496
|
for idx, adapter_type in enumerate(selected_adapters, 1):
|
|
@@ -353,9 +1530,15 @@ def _configure_hybrid_mode() -> TicketerConfig:
|
|
|
353
1530
|
sync_strategy=sync_strategy,
|
|
354
1531
|
)
|
|
355
1532
|
|
|
356
|
-
# Create full config
|
|
1533
|
+
# Create full config with default values
|
|
357
1534
|
config = TicketerConfig(
|
|
358
|
-
default_adapter=primary_adapter,
|
|
1535
|
+
default_adapter=primary_adapter,
|
|
1536
|
+
adapters=adapters,
|
|
1537
|
+
hybrid_mode=hybrid_config,
|
|
1538
|
+
default_user=default_values.get("default_user"),
|
|
1539
|
+
default_project=default_values.get("default_project"),
|
|
1540
|
+
default_epic=default_values.get("default_epic"),
|
|
1541
|
+
default_tags=default_values.get("default_tags"),
|
|
359
1542
|
)
|
|
360
1543
|
|
|
361
1544
|
return config
|
|
@@ -366,31 +1549,17 @@ def show_current_config() -> None:
|
|
|
366
1549
|
resolver = ConfigResolver()
|
|
367
1550
|
|
|
368
1551
|
# Try to load configs
|
|
369
|
-
global_config = resolver.load_global_config()
|
|
370
1552
|
project_config = resolver.load_project_config()
|
|
371
1553
|
|
|
372
1554
|
console.print("[bold]Current Configuration:[/bold]\n")
|
|
373
1555
|
|
|
374
|
-
#
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if global_config.adapters:
|
|
380
|
-
table = Table(title="Global Adapters")
|
|
381
|
-
table.add_column("Adapter", style="cyan")
|
|
382
|
-
table.add_column("Configured", style="green")
|
|
383
|
-
|
|
384
|
-
for name, config in global_config.adapters.items():
|
|
385
|
-
configured = "✓" if config.enabled else "✗"
|
|
386
|
-
table.add_row(name, configured)
|
|
387
|
-
|
|
388
|
-
console.print(table)
|
|
389
|
-
else:
|
|
390
|
-
console.print("[yellow]No global configuration found[/yellow]")
|
|
1556
|
+
# Note about global config deprecation
|
|
1557
|
+
console.print(
|
|
1558
|
+
"[dim]Note: Global config has been deprecated for security reasons.[/dim]"
|
|
1559
|
+
)
|
|
1560
|
+
console.print("[dim]All configuration is now project-specific only.[/dim]\n")
|
|
391
1561
|
|
|
392
1562
|
# Project config
|
|
393
|
-
console.print()
|
|
394
1563
|
project_config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
|
|
395
1564
|
if project_config_path.exists():
|
|
396
1565
|
console.print(f"[cyan]Project:[/cyan] {project_config_path}")
|
|
@@ -440,16 +1609,17 @@ def show_current_config() -> None:
|
|
|
440
1609
|
|
|
441
1610
|
|
|
442
1611
|
def set_adapter_config(
|
|
443
|
-
adapter:
|
|
444
|
-
api_key:
|
|
445
|
-
project_id:
|
|
446
|
-
team_id:
|
|
1612
|
+
adapter: str | None = None,
|
|
1613
|
+
api_key: str | None = None,
|
|
1614
|
+
project_id: str | None = None,
|
|
1615
|
+
team_id: str | None = None,
|
|
447
1616
|
global_scope: bool = False,
|
|
448
|
-
**kwargs,
|
|
1617
|
+
**kwargs: Any,
|
|
449
1618
|
) -> None:
|
|
450
1619
|
"""Set specific adapter configuration values.
|
|
451
1620
|
|
|
452
1621
|
Args:
|
|
1622
|
+
----
|
|
453
1623
|
adapter: Adapter type to set as default
|
|
454
1624
|
api_key: API key/token
|
|
455
1625
|
project_id: Project ID
|
|
@@ -498,11 +1668,13 @@ def set_adapter_config(
|
|
|
498
1668
|
|
|
499
1669
|
console.print(f"[green]✓[/green] Updated {target_adapter} configuration")
|
|
500
1670
|
|
|
501
|
-
# Save config
|
|
1671
|
+
# Save config (always to project config for security)
|
|
1672
|
+
resolver.save_project_config(config)
|
|
1673
|
+
config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
|
|
1674
|
+
|
|
502
1675
|
if global_scope:
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
console.print(f"[dim]Saved to {config_path}[/dim]")
|
|
1676
|
+
console.print(
|
|
1677
|
+
"[yellow]Note: Global config deprecated for security. Saved to project config instead.[/yellow]"
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
console.print(f"[dim]Saved to {config_path}[/dim]")
|