mcp-ticketer 0.12.0__py3-none-any.whl → 2.2.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mcp-ticketer might be problematic. Click here for more details.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/aitrackdown.py +507 -6
- mcp_ticketer/adapters/asana/adapter.py +229 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/adapter.py +2730 -139
- mcp_ticketer/adapters/linear/client.py +175 -3
- mcp_ticketer/adapters/linear/mappers.py +203 -8
- mcp_ticketer/adapters/linear/queries.py +280 -3
- mcp_ticketer/adapters/linear/types.py +120 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cli/adapter_diagnostics.py +3 -1
- mcp_ticketer/cli/auggie_configure.py +17 -5
- mcp_ticketer/cli/codex_configure.py +97 -61
- mcp_ticketer/cli/configure.py +1288 -105
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +13 -12
- mcp_ticketer/cli/discover.py +5 -0
- mcp_ticketer/cli/gemini_configure.py +17 -5
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +267 -3175
- mcp_ticketer/cli/mcp_configure.py +821 -119
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +795 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +705 -103
- mcp_ticketer/cli/utils.py +113 -0
- mcp_ticketer/core/__init__.py +56 -6
- mcp_ticketer/core/adapter.py +533 -2
- mcp_ticketer/core/config.py +21 -21
- mcp_ticketer/core/exceptions.py +7 -1
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +31 -19
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +480 -0
- mcp_ticketer/core/onepassword_secrets.py +1 -1
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +132 -14
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +625 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/mcp/server/__main__.py +2 -1
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +33 -11
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
- mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +209 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- mcp_ticketer/queue/queue.py +68 -0
- mcp_ticketer/queue/worker.py +1 -1
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1574
- mcp_ticketer/adapters/jira.py +0 -1258
- mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
- mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
- mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
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
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
4
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,260 @@ 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("Re-enter credentials and try again?", default=True)
|
|
210
|
+
if not retry:
|
|
211
|
+
console.print(
|
|
212
|
+
"[yellow]Skipping validation. Configuration saved but may not work.[/yellow]"
|
|
213
|
+
)
|
|
214
|
+
return True # Allow saving unvalidated config
|
|
215
|
+
else:
|
|
216
|
+
console.print("[red]Max retries exceeded[/red]")
|
|
217
|
+
final_choice = Confirm.ask(
|
|
218
|
+
"Save configuration anyway? (it may not work)", default=False
|
|
219
|
+
)
|
|
220
|
+
if final_choice:
|
|
221
|
+
console.print("[yellow]Configuration saved without validation[/yellow]")
|
|
222
|
+
return True
|
|
223
|
+
raise typer.Exit(1) from None
|
|
224
|
+
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _retry_setting(
|
|
229
|
+
setting_name: str,
|
|
230
|
+
prompt_func: Callable[[], Any],
|
|
231
|
+
validate_func: Callable[[Any], tuple[bool, str | None]],
|
|
232
|
+
max_retries: int = 3,
|
|
233
|
+
) -> Any:
|
|
234
|
+
"""Retry a configuration setting with validation.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
----
|
|
238
|
+
setting_name: Human-readable name of the setting
|
|
239
|
+
prompt_func: Function that prompts for the setting value
|
|
240
|
+
validate_func: Function that validates the value (returns tuple of success, error_msg)
|
|
241
|
+
max_retries: Maximum number of retry attempts
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
-------
|
|
245
|
+
Validated setting value
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
------
|
|
249
|
+
typer.Exit: If max retries exceeded
|
|
250
|
+
|
|
251
|
+
"""
|
|
252
|
+
for attempt in range(1, max_retries + 1):
|
|
253
|
+
try:
|
|
254
|
+
value = prompt_func()
|
|
255
|
+
is_valid, error = validate_func(value)
|
|
256
|
+
|
|
257
|
+
if is_valid:
|
|
258
|
+
return value
|
|
259
|
+
console.print(f"[red]✗ {error}[/red]")
|
|
260
|
+
if attempt < max_retries:
|
|
261
|
+
console.print(
|
|
262
|
+
f"[yellow]Attempt {attempt}/{max_retries} - Please try again[/yellow]"
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
console.print(f"[red]Failed after {max_retries} attempts[/red]")
|
|
266
|
+
retry = Confirm.ask("Retry this setting?", default=True)
|
|
267
|
+
if retry:
|
|
268
|
+
# Extend attempts
|
|
269
|
+
max_retries += 3
|
|
270
|
+
console.print(
|
|
271
|
+
f"[yellow]Extending retries (new limit: {max_retries})[/yellow]"
|
|
272
|
+
)
|
|
273
|
+
continue
|
|
274
|
+
raise typer.Exit(1) from None
|
|
275
|
+
except KeyboardInterrupt:
|
|
276
|
+
console.print("\n[yellow]Configuration cancelled[/yellow]")
|
|
277
|
+
raise typer.Exit(0) from None
|
|
278
|
+
|
|
279
|
+
console.print(f"[red]Could not configure {setting_name}[/red]")
|
|
280
|
+
raise typer.Exit(1)
|
|
281
|
+
|
|
282
|
+
|
|
25
283
|
def configure_wizard() -> None:
|
|
26
284
|
"""Run interactive configuration wizard."""
|
|
27
285
|
console.print(
|
|
@@ -97,173 +355,1086 @@ def _configure_single_adapter() -> TicketerConfig:
|
|
|
97
355
|
adapter_type = adapter_type_map[adapter_choice]
|
|
98
356
|
|
|
99
357
|
# Configure the selected adapter
|
|
358
|
+
default_values: dict[str, str] = {}
|
|
100
359
|
if adapter_type == AdapterType.LINEAR:
|
|
101
|
-
adapter_config = _configure_linear()
|
|
360
|
+
adapter_config, default_values = _configure_linear(interactive=True)
|
|
102
361
|
elif adapter_type == AdapterType.JIRA:
|
|
103
|
-
adapter_config = _configure_jira()
|
|
362
|
+
adapter_config, default_values = _configure_jira(interactive=True)
|
|
104
363
|
elif adapter_type == AdapterType.GITHUB:
|
|
105
|
-
adapter_config = _configure_github()
|
|
364
|
+
adapter_config, default_values = _configure_github(interactive=True)
|
|
106
365
|
else:
|
|
107
|
-
adapter_config = _configure_aitrackdown()
|
|
366
|
+
adapter_config, default_values = _configure_aitrackdown(interactive=True)
|
|
108
367
|
|
|
109
|
-
# Create config
|
|
368
|
+
# Create config with default values
|
|
110
369
|
config = TicketerConfig(
|
|
111
370
|
default_adapter=adapter_type.value,
|
|
112
371
|
adapters={adapter_type.value: adapter_config},
|
|
372
|
+
default_user=default_values.get("default_user"),
|
|
373
|
+
default_project=default_values.get("default_project"),
|
|
374
|
+
default_epic=default_values.get("default_epic"),
|
|
375
|
+
default_tags=default_values.get("default_tags"),
|
|
113
376
|
)
|
|
114
377
|
|
|
115
378
|
return config
|
|
116
379
|
|
|
117
380
|
|
|
118
|
-
def _configure_linear(
|
|
119
|
-
|
|
120
|
-
|
|
381
|
+
def _configure_linear(
|
|
382
|
+
existing_config: dict[str, Any] | None = None,
|
|
383
|
+
interactive: bool = True,
|
|
384
|
+
api_key: str | None = None,
|
|
385
|
+
team_id: str | None = None,
|
|
386
|
+
team_key: str | None = None,
|
|
387
|
+
**kwargs: Any,
|
|
388
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
389
|
+
"""Configure Linear adapter with option to preserve existing settings.
|
|
121
390
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
391
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
----
|
|
395
|
+
existing_config: Optional existing configuration to preserve/update
|
|
396
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
397
|
+
api_key: Pre-provided API key (optional, for programmatic mode)
|
|
398
|
+
team_id: Pre-provided team ID (optional, for programmatic mode)
|
|
399
|
+
team_key: Pre-provided team key (optional, for programmatic mode)
|
|
400
|
+
**kwargs: Additional configuration parameters
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
-------
|
|
404
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
405
|
+
- AdapterConfig: Configured Linear adapter configuration
|
|
406
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
407
|
+
|
|
408
|
+
"""
|
|
409
|
+
if interactive:
|
|
410
|
+
console.print("\n[bold cyan]Linear Configuration[/bold cyan]")
|
|
129
411
|
|
|
130
|
-
if
|
|
131
|
-
|
|
412
|
+
# Load existing configuration if available
|
|
413
|
+
if existing_config is None and interactive:
|
|
414
|
+
existing_config = _load_existing_adapter_config("linear")
|
|
132
415
|
|
|
133
|
-
#
|
|
134
|
-
|
|
416
|
+
# Check if we have existing config
|
|
417
|
+
has_existing = (
|
|
418
|
+
existing_config is not None and existing_config.get("adapter") == "linear"
|
|
419
|
+
)
|
|
135
420
|
|
|
136
|
-
|
|
137
|
-
team_key = Prompt.ask("Team Key (optional, e.g., ENG)", default="")
|
|
421
|
+
config_dict: dict[str, Any] = {"adapter": AdapterType.LINEAR.value}
|
|
138
422
|
|
|
139
|
-
|
|
140
|
-
|
|
423
|
+
if has_existing and interactive:
|
|
424
|
+
preserve = Confirm.ask(
|
|
425
|
+
"Existing Linear configuration found. Preserve current settings?",
|
|
426
|
+
default=True,
|
|
427
|
+
)
|
|
428
|
+
if preserve:
|
|
429
|
+
console.print("[green]✓[/green] Keeping existing configuration")
|
|
430
|
+
config_dict = existing_config.copy()
|
|
431
|
+
|
|
432
|
+
# Allow updating specific fields
|
|
433
|
+
update_fields = Confirm.ask("Update specific fields?", default=False)
|
|
434
|
+
if not update_fields:
|
|
435
|
+
# Extract default values before returning
|
|
436
|
+
default_values = {}
|
|
437
|
+
if "user_email" in config_dict:
|
|
438
|
+
default_values["default_user"] = config_dict.pop("user_email")
|
|
439
|
+
if "default_epic" in config_dict:
|
|
440
|
+
default_values["default_epic"] = config_dict.pop("default_epic")
|
|
441
|
+
if "default_project" in config_dict:
|
|
442
|
+
default_values["default_project"] = config_dict.pop(
|
|
443
|
+
"default_project"
|
|
444
|
+
)
|
|
445
|
+
if "default_tags" in config_dict:
|
|
446
|
+
default_values["default_tags"] = config_dict.pop("default_tags")
|
|
447
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
448
|
+
|
|
449
|
+
console.print(
|
|
450
|
+
"[yellow]Enter new values or press Enter to keep current[/yellow]"
|
|
451
|
+
)
|
|
141
452
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
453
|
+
# API Key with validation loop
|
|
454
|
+
current_key = config_dict.get("api_key", "") if has_existing else ""
|
|
455
|
+
final_api_key = api_key or os.getenv("LINEAR_API_KEY") or ""
|
|
456
|
+
|
|
457
|
+
if interactive:
|
|
458
|
+
# Interactive mode: prompt with retry
|
|
459
|
+
if final_api_key and not current_key:
|
|
460
|
+
console.print("[dim]Found LINEAR_API_KEY in environment[/dim]")
|
|
461
|
+
use_env = Confirm.ask("Use this API key?", default=True)
|
|
462
|
+
if use_env:
|
|
463
|
+
current_key = final_api_key
|
|
464
|
+
|
|
465
|
+
# Validation loop for API key
|
|
466
|
+
api_key_validated = False
|
|
467
|
+
while not api_key_validated:
|
|
468
|
+
|
|
469
|
+
def prompt_api_key() -> str:
|
|
470
|
+
if current_key:
|
|
471
|
+
masked = _mask_sensitive_value(current_key)
|
|
472
|
+
api_key_prompt = f"Linear API Key [current: {masked}]"
|
|
473
|
+
return Prompt.ask(
|
|
474
|
+
api_key_prompt, password=True, default=current_key
|
|
475
|
+
)
|
|
476
|
+
return Prompt.ask("Linear API Key", password=True)
|
|
477
|
+
|
|
478
|
+
def validate_api_key(key: str) -> tuple[bool, str | None]:
|
|
479
|
+
if not key or len(key) < 10:
|
|
480
|
+
return False, "API key must be at least 10 characters"
|
|
481
|
+
return True, None
|
|
482
|
+
|
|
483
|
+
final_api_key = _retry_setting("API Key", prompt_api_key, validate_api_key)
|
|
484
|
+
|
|
485
|
+
# Validate API key with real API call
|
|
486
|
+
try:
|
|
487
|
+
api_key_validated = _validate_api_credentials(
|
|
488
|
+
"linear", {"api_key": final_api_key}
|
|
489
|
+
)
|
|
490
|
+
except typer.Exit:
|
|
491
|
+
# User cancelled, propagate the exit
|
|
492
|
+
raise
|
|
493
|
+
except Exception as e:
|
|
494
|
+
console.print(f"[red]Validation error: {e}[/red]")
|
|
495
|
+
retry = Confirm.ask("Re-enter API key?", default=True)
|
|
496
|
+
if not retry:
|
|
497
|
+
raise typer.Exit(1) from None
|
|
498
|
+
|
|
499
|
+
elif not final_api_key:
|
|
500
|
+
raise ValueError(
|
|
501
|
+
"Linear API key is required (provide api_key parameter or set LINEAR_API_KEY environment variable)"
|
|
502
|
+
)
|
|
146
503
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if
|
|
152
|
-
|
|
504
|
+
config_dict["api_key"] = final_api_key
|
|
505
|
+
|
|
506
|
+
# Team Key/ID (programmatic mode: use provided values, interactive: prompt)
|
|
507
|
+
current_team_key = config_dict.get("team_key", "") if has_existing else ""
|
|
508
|
+
config_dict.get("team_id", "") if has_existing else ""
|
|
509
|
+
final_team_key = team_key or os.getenv("LINEAR_TEAM_KEY") or ""
|
|
510
|
+
final_team_id = team_id or os.getenv("LINEAR_TEAM_ID") or ""
|
|
511
|
+
|
|
512
|
+
if interactive:
|
|
513
|
+
# Interactive mode: prompt for team key (preferred over team_id)
|
|
514
|
+
def prompt_team_key() -> str:
|
|
515
|
+
if current_team_key:
|
|
516
|
+
team_key_prompt = f"Linear Team Key [current: {current_team_key}]"
|
|
517
|
+
return Prompt.ask(team_key_prompt, default=current_team_key)
|
|
518
|
+
return Prompt.ask("Linear Team Key (e.g., 'ENG', 'BTA')")
|
|
519
|
+
|
|
520
|
+
def validate_team_key(key: str) -> tuple[bool, str | None]:
|
|
521
|
+
if not key or len(key) < 2:
|
|
522
|
+
return False, "Team key must be at least 2 characters"
|
|
523
|
+
return True, None
|
|
524
|
+
|
|
525
|
+
final_team_key = _retry_setting("Team Key", prompt_team_key, validate_team_key)
|
|
526
|
+
config_dict["team_key"] = final_team_key
|
|
527
|
+
|
|
528
|
+
# Remove team_id if present (will be resolved from team_key)
|
|
529
|
+
if "team_id" in config_dict:
|
|
530
|
+
del config_dict["team_id"]
|
|
531
|
+
else:
|
|
532
|
+
# Programmatic mode: use whichever was provided
|
|
533
|
+
if final_team_key:
|
|
534
|
+
config_dict["team_key"] = final_team_key
|
|
535
|
+
if final_team_id:
|
|
536
|
+
config_dict["team_id"] = final_team_id
|
|
537
|
+
if not final_team_key and not final_team_id:
|
|
538
|
+
raise ValueError(
|
|
539
|
+
"Linear requires either team_key or team_id (provide parameter or set LINEAR_TEAM_KEY/LINEAR_TEAM_ID environment variable)"
|
|
540
|
+
)
|
|
153
541
|
|
|
154
|
-
#
|
|
542
|
+
# User email configuration (optional, for default assignee) - only in interactive mode
|
|
543
|
+
if interactive:
|
|
544
|
+
current_user_email = config_dict.get("user_email", "") if has_existing else ""
|
|
545
|
+
|
|
546
|
+
def prompt_user_email() -> str:
|
|
547
|
+
if current_user_email:
|
|
548
|
+
user_email_prompt = (
|
|
549
|
+
f"Your Linear email (optional, for auto-assignment) "
|
|
550
|
+
f"[current: {current_user_email}]"
|
|
551
|
+
)
|
|
552
|
+
return Prompt.ask(user_email_prompt, default=current_user_email)
|
|
553
|
+
return Prompt.ask(
|
|
554
|
+
"Your Linear email (optional, for auto-assignment)", default=""
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def validate_user_email(email: str) -> tuple[bool, str | None]:
|
|
558
|
+
if not email: # Optional field
|
|
559
|
+
return True, None
|
|
560
|
+
import re
|
|
561
|
+
|
|
562
|
+
email_pattern = re.compile(
|
|
563
|
+
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
564
|
+
)
|
|
565
|
+
if not email_pattern.match(email):
|
|
566
|
+
return False, f"Invalid email format: {email}"
|
|
567
|
+
return True, None
|
|
568
|
+
|
|
569
|
+
user_email = _retry_setting(
|
|
570
|
+
"User Email", prompt_user_email, validate_user_email
|
|
571
|
+
)
|
|
572
|
+
if user_email:
|
|
573
|
+
config_dict["user_email"] = user_email
|
|
574
|
+
console.print(f"[green]✓[/green] Will use {user_email} as default assignee")
|
|
575
|
+
|
|
576
|
+
# ============================================================
|
|
577
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
578
|
+
# ============================================================
|
|
579
|
+
|
|
580
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
581
|
+
console.print("Configure default values for ticket creation:")
|
|
582
|
+
|
|
583
|
+
# Default epic/project
|
|
584
|
+
current_default_epic = (
|
|
585
|
+
config_dict.get("default_epic", "") if has_existing else ""
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
def prompt_default_epic() -> str:
|
|
589
|
+
if current_default_epic:
|
|
590
|
+
return Prompt.ask(
|
|
591
|
+
f"Default epic/project ID (optional) [current: {current_default_epic}]",
|
|
592
|
+
default=current_default_epic,
|
|
593
|
+
)
|
|
594
|
+
return Prompt.ask(
|
|
595
|
+
"Default epic/project ID (optional, accepts project URLs or IDs like 'PROJ-123')",
|
|
596
|
+
default="",
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
def validate_default_epic(epic_id: str) -> tuple[bool, str | None]:
|
|
600
|
+
if not epic_id: # Optional field
|
|
601
|
+
return True, None
|
|
602
|
+
# Basic validation - just check it's not empty when provided
|
|
603
|
+
if len(epic_id.strip()) < 2:
|
|
604
|
+
return False, "Epic/project ID must be at least 2 characters"
|
|
605
|
+
return True, None
|
|
606
|
+
|
|
607
|
+
default_epic = _retry_setting(
|
|
608
|
+
"Default Epic/Project", prompt_default_epic, validate_default_epic
|
|
609
|
+
)
|
|
610
|
+
if default_epic:
|
|
611
|
+
config_dict["default_epic"] = default_epic
|
|
612
|
+
config_dict["default_project"] = default_epic # Set both for compatibility
|
|
613
|
+
console.print(
|
|
614
|
+
f"[green]✓[/green] Will use '{default_epic}' as default epic/project"
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
# Default tags
|
|
618
|
+
current_default_tags = (
|
|
619
|
+
config_dict.get("default_tags", []) if has_existing else []
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def prompt_default_tags() -> str:
|
|
623
|
+
if current_default_tags:
|
|
624
|
+
tags_str = ", ".join(current_default_tags)
|
|
625
|
+
return Prompt.ask(
|
|
626
|
+
f"Default tags (optional, comma-separated) [current: {tags_str}]",
|
|
627
|
+
default=tags_str,
|
|
628
|
+
)
|
|
629
|
+
return Prompt.ask(
|
|
630
|
+
"Default tags (optional, comma-separated, e.g., 'bug,urgent')",
|
|
631
|
+
default="",
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
def validate_default_tags(tags_input: str) -> tuple[bool, str | None]:
|
|
635
|
+
if not tags_input: # Optional field
|
|
636
|
+
return True, None
|
|
637
|
+
# Parse and validate tags
|
|
638
|
+
tags = [tag.strip() for tag in tags_input.split(",") if tag.strip()]
|
|
639
|
+
if not tags:
|
|
640
|
+
return False, "Please provide at least one tag or leave empty"
|
|
641
|
+
# Check each tag is reasonable
|
|
642
|
+
for tag in tags:
|
|
643
|
+
if len(tag) < 2:
|
|
644
|
+
return False, f"Tag '{tag}' must be at least 2 characters"
|
|
645
|
+
if len(tag) > 50:
|
|
646
|
+
return False, f"Tag '{tag}' is too long (max 50 characters)"
|
|
647
|
+
return True, None
|
|
648
|
+
|
|
649
|
+
default_tags_input = _retry_setting(
|
|
650
|
+
"Default Tags", prompt_default_tags, validate_default_tags
|
|
651
|
+
)
|
|
652
|
+
if default_tags_input:
|
|
653
|
+
default_tags = [
|
|
654
|
+
tag.strip() for tag in default_tags_input.split(",") if tag.strip()
|
|
655
|
+
]
|
|
656
|
+
config_dict["default_tags"] = default_tags
|
|
657
|
+
console.print(f"[green]✓[/green] Will use tags: {', '.join(default_tags)}")
|
|
658
|
+
|
|
659
|
+
# Validate with detailed error reporting
|
|
155
660
|
is_valid, error = ConfigValidator.validate_linear_config(config_dict)
|
|
661
|
+
|
|
156
662
|
if not is_valid:
|
|
157
|
-
console.print(
|
|
663
|
+
console.print("\n[red]❌ Configuration Validation Failed[/red]")
|
|
664
|
+
console.print(f"[red]Error: {error}[/red]\n")
|
|
665
|
+
|
|
666
|
+
# Show which settings were problematic
|
|
667
|
+
console.print("[yellow]Problematic settings:[/yellow]")
|
|
668
|
+
if "api_key" not in config_dict or not config_dict["api_key"]:
|
|
669
|
+
console.print(" • [red]API Key[/red] - Missing or empty")
|
|
670
|
+
if "team_key" not in config_dict and "team_id" not in config_dict:
|
|
671
|
+
console.print(
|
|
672
|
+
" • [red]Team Key/ID[/red] - Neither team_key nor team_id provided"
|
|
673
|
+
)
|
|
674
|
+
if "user_email" in config_dict:
|
|
675
|
+
email = config_dict["user_email"]
|
|
676
|
+
import re
|
|
677
|
+
|
|
678
|
+
if not re.match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", email):
|
|
679
|
+
console.print(f" • [red]User Email[/red] - Invalid format: {email}")
|
|
680
|
+
|
|
681
|
+
# Offer to retry specific settings
|
|
682
|
+
console.print("\n[cyan]Options:[/cyan]")
|
|
683
|
+
console.print(" 1. Retry configuration from scratch")
|
|
684
|
+
console.print(" 2. Fix specific settings")
|
|
685
|
+
console.print(" 3. Exit")
|
|
686
|
+
|
|
687
|
+
choice = Prompt.ask("Choose an option", choices=["1", "2", "3"], default="2")
|
|
688
|
+
|
|
689
|
+
if choice == "1":
|
|
690
|
+
# Recursive retry
|
|
691
|
+
return _configure_linear(existing_config=None)
|
|
692
|
+
if choice == "2":
|
|
693
|
+
# Fix specific settings
|
|
694
|
+
return _configure_linear(existing_config=config_dict)
|
|
158
695
|
raise typer.Exit(1) from None
|
|
159
696
|
|
|
160
|
-
|
|
697
|
+
console.print("[green]✓ Configuration validated successfully[/green]")
|
|
161
698
|
|
|
699
|
+
# Extract default values to return separately (not part of AdapterConfig)
|
|
700
|
+
default_values = {}
|
|
701
|
+
if "user_email" in config_dict:
|
|
702
|
+
default_values["default_user"] = config_dict.pop("user_email")
|
|
703
|
+
if "default_epic" in config_dict:
|
|
704
|
+
default_values["default_epic"] = config_dict.pop("default_epic")
|
|
705
|
+
if "default_project" in config_dict:
|
|
706
|
+
default_values["default_project"] = config_dict.pop("default_project")
|
|
707
|
+
if "default_tags" in config_dict:
|
|
708
|
+
default_values["default_tags"] = config_dict.pop("default_tags")
|
|
162
709
|
|
|
163
|
-
|
|
164
|
-
"""Configure JIRA adapter."""
|
|
165
|
-
console.print("\n[bold]Configure JIRA Integration:[/bold]")
|
|
710
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
166
711
|
|
|
167
|
-
# Server URL
|
|
168
|
-
server = os.getenv("JIRA_SERVER") or ""
|
|
169
|
-
if not server:
|
|
170
|
-
server = Prompt.ask("JIRA Server URL (e.g., https://company.atlassian.net)")
|
|
171
712
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
713
|
+
def _configure_jira(
|
|
714
|
+
interactive: bool = True,
|
|
715
|
+
server: str | None = None,
|
|
716
|
+
email: str | None = None,
|
|
717
|
+
api_token: str | None = None,
|
|
718
|
+
project_key: str | None = None,
|
|
719
|
+
**kwargs: Any,
|
|
720
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
721
|
+
"""Configure JIRA adapter.
|
|
176
722
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
723
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
----
|
|
727
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
728
|
+
server: Pre-provided JIRA server URL (optional)
|
|
729
|
+
email: Pre-provided JIRA user email (optional)
|
|
730
|
+
api_token: Pre-provided JIRA API token (optional)
|
|
731
|
+
project_key: Pre-provided default project key (optional)
|
|
732
|
+
**kwargs: Additional configuration parameters
|
|
733
|
+
|
|
734
|
+
Returns:
|
|
735
|
+
-------
|
|
736
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
737
|
+
- AdapterConfig: Configured JIRA adapter configuration
|
|
738
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
739
|
+
|
|
740
|
+
"""
|
|
741
|
+
if interactive:
|
|
742
|
+
console.print("\n[bold]Configure JIRA Integration:[/bold]")
|
|
743
|
+
|
|
744
|
+
# Load existing configuration if available
|
|
745
|
+
existing_config = _load_existing_adapter_config("jira") if interactive else None
|
|
746
|
+
has_existing = existing_config is not None
|
|
747
|
+
|
|
748
|
+
# Server URL (with existing value as default)
|
|
749
|
+
existing_server = existing_config.get("server", "") if has_existing else ""
|
|
750
|
+
final_server = server or os.getenv("JIRA_SERVER") or ""
|
|
751
|
+
|
|
752
|
+
if interactive:
|
|
753
|
+
if not final_server and existing_server:
|
|
754
|
+
final_server = Prompt.ask(
|
|
755
|
+
f"JIRA Server URL [current: {existing_server}]",
|
|
756
|
+
default=existing_server,
|
|
757
|
+
)
|
|
758
|
+
elif not final_server:
|
|
759
|
+
final_server = Prompt.ask(
|
|
760
|
+
"JIRA Server URL (e.g., https://company.atlassian.net)"
|
|
761
|
+
)
|
|
762
|
+
elif not interactive and not final_server:
|
|
763
|
+
raise ValueError(
|
|
764
|
+
"JIRA server URL is required (provide server parameter or set JIRA_SERVER environment variable)"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
# Email (with existing value as default)
|
|
768
|
+
existing_email = existing_config.get("email", "") if has_existing else ""
|
|
769
|
+
final_email = email or os.getenv("JIRA_EMAIL") or ""
|
|
770
|
+
|
|
771
|
+
if interactive:
|
|
772
|
+
if not final_email and existing_email:
|
|
773
|
+
final_email = Prompt.ask(
|
|
774
|
+
f"JIRA User Email [current: {existing_email}]",
|
|
775
|
+
default=existing_email,
|
|
776
|
+
)
|
|
777
|
+
elif not final_email:
|
|
778
|
+
final_email = Prompt.ask("JIRA User Email")
|
|
779
|
+
elif not interactive and not final_email:
|
|
780
|
+
raise ValueError(
|
|
781
|
+
"JIRA email is required (provide email parameter or set JIRA_EMAIL environment variable)"
|
|
182
782
|
)
|
|
183
|
-
api_token = Prompt.ask("JIRA API Token", password=True)
|
|
184
783
|
|
|
185
|
-
#
|
|
186
|
-
|
|
784
|
+
# API Token with validation loop
|
|
785
|
+
existing_token = existing_config.get("api_token", "") if has_existing else ""
|
|
786
|
+
final_api_token = api_token or os.getenv("JIRA_API_TOKEN") or ""
|
|
787
|
+
|
|
788
|
+
if interactive:
|
|
789
|
+
# Validation loop for JIRA credentials
|
|
790
|
+
jira_validated = False
|
|
791
|
+
while not jira_validated:
|
|
792
|
+
if not final_api_token and existing_token:
|
|
793
|
+
masked = _mask_sensitive_value(existing_token)
|
|
794
|
+
console.print(
|
|
795
|
+
"[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]"
|
|
796
|
+
)
|
|
797
|
+
final_api_token = Prompt.ask(
|
|
798
|
+
f"JIRA API Token [current: {masked}]",
|
|
799
|
+
password=True,
|
|
800
|
+
default=existing_token,
|
|
801
|
+
)
|
|
802
|
+
elif not final_api_token:
|
|
803
|
+
console.print(
|
|
804
|
+
"[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]"
|
|
805
|
+
)
|
|
806
|
+
final_api_token = Prompt.ask("JIRA API Token", password=True)
|
|
807
|
+
|
|
808
|
+
# Validate JIRA credentials with real API call
|
|
809
|
+
try:
|
|
810
|
+
jira_validated = _validate_api_credentials(
|
|
811
|
+
"jira",
|
|
812
|
+
{
|
|
813
|
+
"server": final_server,
|
|
814
|
+
"email": final_email,
|
|
815
|
+
"api_token": final_api_token,
|
|
816
|
+
},
|
|
817
|
+
)
|
|
818
|
+
except typer.Exit:
|
|
819
|
+
# User cancelled, propagate the exit
|
|
820
|
+
raise
|
|
821
|
+
except Exception as e:
|
|
822
|
+
console.print(f"[red]Validation error: {e}[/red]")
|
|
823
|
+
retry = Confirm.ask("Re-enter credentials?", default=True)
|
|
824
|
+
if not retry:
|
|
825
|
+
raise typer.Exit(1) from None
|
|
826
|
+
# Reset to prompt again
|
|
827
|
+
final_api_token = ""
|
|
828
|
+
|
|
829
|
+
elif not interactive and not final_api_token:
|
|
830
|
+
raise ValueError(
|
|
831
|
+
"JIRA API token is required (provide api_token parameter or set JIRA_API_TOKEN environment variable)"
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
# Project Key (optional, with existing value as default)
|
|
835
|
+
existing_project_key = (
|
|
836
|
+
existing_config.get("project_key", "") if has_existing else ""
|
|
837
|
+
)
|
|
838
|
+
final_project_key = project_key or os.getenv("JIRA_PROJECT_KEY") or ""
|
|
839
|
+
|
|
840
|
+
if interactive:
|
|
841
|
+
if not final_project_key and existing_project_key:
|
|
842
|
+
final_project_key = Prompt.ask(
|
|
843
|
+
f"Default Project Key (optional, e.g., PROJ) [current: {existing_project_key}]",
|
|
844
|
+
default=existing_project_key,
|
|
845
|
+
)
|
|
846
|
+
elif not final_project_key:
|
|
847
|
+
final_project_key = Prompt.ask(
|
|
848
|
+
"Default Project Key (optional, e.g., PROJ)", default=""
|
|
849
|
+
)
|
|
187
850
|
|
|
188
851
|
config_dict = {
|
|
189
852
|
"adapter": AdapterType.JIRA.value,
|
|
190
|
-
"server":
|
|
191
|
-
"email":
|
|
192
|
-
"api_token":
|
|
853
|
+
"server": final_server.rstrip("/"),
|
|
854
|
+
"email": final_email,
|
|
855
|
+
"api_token": final_api_token,
|
|
193
856
|
}
|
|
194
857
|
|
|
195
|
-
if
|
|
196
|
-
config_dict["project_key"] =
|
|
858
|
+
if final_project_key:
|
|
859
|
+
config_dict["project_key"] = final_project_key
|
|
197
860
|
|
|
198
861
|
# Validate
|
|
199
862
|
is_valid, error = ConfigValidator.validate_jira_config(config_dict)
|
|
200
863
|
if not is_valid:
|
|
201
|
-
|
|
202
|
-
|
|
864
|
+
if interactive:
|
|
865
|
+
console.print(f"[red]Configuration error: {error}[/red]")
|
|
866
|
+
raise typer.Exit(1) from None
|
|
867
|
+
raise ValueError(f"JIRA configuration validation failed: {error}")
|
|
868
|
+
|
|
869
|
+
# ============================================================
|
|
870
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
871
|
+
# ============================================================
|
|
872
|
+
default_values = {}
|
|
873
|
+
|
|
874
|
+
if interactive:
|
|
875
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
876
|
+
console.print("Configure default values for ticket creation:")
|
|
877
|
+
|
|
878
|
+
# Default user/assignee
|
|
879
|
+
existing_user = existing_config.get("user_email", "") if has_existing else ""
|
|
880
|
+
if existing_user:
|
|
881
|
+
user_input = Prompt.ask(
|
|
882
|
+
f"Default assignee/user (optional, JIRA username or email) [current: {existing_user}]",
|
|
883
|
+
default=existing_user,
|
|
884
|
+
)
|
|
885
|
+
else:
|
|
886
|
+
user_input = Prompt.ask(
|
|
887
|
+
"Default assignee/user (optional, JIRA username or email)",
|
|
888
|
+
default="",
|
|
889
|
+
show_default=False,
|
|
890
|
+
)
|
|
891
|
+
if user_input:
|
|
892
|
+
default_values["default_user"] = user_input
|
|
893
|
+
console.print(
|
|
894
|
+
f"[green]✓[/green] Will use '{user_input}' as default assignee"
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
# Default epic/project
|
|
898
|
+
existing_epic = existing_config.get("default_epic", "") if has_existing else ""
|
|
899
|
+
if existing_epic:
|
|
900
|
+
epic_input = Prompt.ask(
|
|
901
|
+
f"Default epic/project ID (optional, e.g., 'PROJ-123') [current: {existing_epic}]",
|
|
902
|
+
default=existing_epic,
|
|
903
|
+
)
|
|
904
|
+
else:
|
|
905
|
+
epic_input = Prompt.ask(
|
|
906
|
+
"Default epic/project ID (optional, e.g., 'PROJ-123')",
|
|
907
|
+
default="",
|
|
908
|
+
show_default=False,
|
|
909
|
+
)
|
|
910
|
+
if epic_input:
|
|
911
|
+
default_values["default_epic"] = epic_input
|
|
912
|
+
default_values["default_project"] = epic_input # Compatibility
|
|
913
|
+
console.print(
|
|
914
|
+
f"[green]✓[/green] Will use '{epic_input}' as default epic/project"
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
# Default tags
|
|
918
|
+
existing_tags = existing_config.get("default_tags", []) if has_existing else []
|
|
919
|
+
existing_tags_str = ", ".join(existing_tags) if existing_tags else ""
|
|
920
|
+
if existing_tags_str:
|
|
921
|
+
tags_input = Prompt.ask(
|
|
922
|
+
f"Default tags/labels (optional, comma-separated, e.g., 'bug,urgent') [current: {existing_tags_str}]",
|
|
923
|
+
default=existing_tags_str,
|
|
924
|
+
)
|
|
925
|
+
else:
|
|
926
|
+
tags_input = Prompt.ask(
|
|
927
|
+
"Default tags/labels (optional, comma-separated, e.g., 'bug,urgent')",
|
|
928
|
+
default="",
|
|
929
|
+
show_default=False,
|
|
930
|
+
)
|
|
931
|
+
if tags_input:
|
|
932
|
+
tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
|
|
933
|
+
if tags_list:
|
|
934
|
+
default_values["default_tags"] = tags_list
|
|
935
|
+
console.print(f"[green]✓[/green] Will use tags: {', '.join(tags_list)}")
|
|
203
936
|
|
|
204
|
-
return AdapterConfig.from_dict(config_dict)
|
|
937
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
205
938
|
|
|
206
939
|
|
|
207
|
-
def _configure_github(
|
|
208
|
-
|
|
209
|
-
|
|
940
|
+
def _configure_github(
|
|
941
|
+
interactive: bool = True,
|
|
942
|
+
token: str | None = None,
|
|
943
|
+
repo_url: str | None = None,
|
|
944
|
+
owner: str | None = None,
|
|
945
|
+
repo: str | None = None,
|
|
946
|
+
**kwargs: Any,
|
|
947
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
948
|
+
"""Configure GitHub adapter.
|
|
210
949
|
|
|
211
|
-
|
|
212
|
-
token = os.getenv("GITHUB_TOKEN") or ""
|
|
213
|
-
if token:
|
|
214
|
-
console.print("[dim]Found GITHUB_TOKEN in environment[/dim]")
|
|
215
|
-
use_env = Confirm.ask("Use this token?", default=True)
|
|
216
|
-
if not use_env:
|
|
217
|
-
token = ""
|
|
950
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
218
951
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
952
|
+
Args:
|
|
953
|
+
----
|
|
954
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
955
|
+
token: Pre-provided GitHub Personal Access Token (optional)
|
|
956
|
+
repo_url: Pre-provided GitHub repository URL (optional, preferred)
|
|
957
|
+
owner: Pre-provided repository owner (optional, fallback)
|
|
958
|
+
repo: Pre-provided repository name (optional, fallback)
|
|
959
|
+
**kwargs: Additional configuration parameters
|
|
960
|
+
|
|
961
|
+
Returns:
|
|
962
|
+
-------
|
|
963
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
964
|
+
- AdapterConfig: Configured GitHub adapter configuration
|
|
965
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
966
|
+
|
|
967
|
+
"""
|
|
968
|
+
if interactive:
|
|
969
|
+
console.print("\n[bold]Configure GitHub Integration:[/bold]")
|
|
970
|
+
|
|
971
|
+
# Load existing configuration if available
|
|
972
|
+
existing_config = _load_existing_adapter_config("github") if interactive else None
|
|
973
|
+
has_existing = existing_config is not None
|
|
974
|
+
|
|
975
|
+
# Token with validation loop
|
|
976
|
+
existing_token = existing_config.get("token", "") if has_existing else ""
|
|
977
|
+
final_token = token or os.getenv("GITHUB_TOKEN") or ""
|
|
978
|
+
|
|
979
|
+
if interactive:
|
|
980
|
+
github_validated = False
|
|
981
|
+
while not github_validated:
|
|
982
|
+
if final_token and not existing_token:
|
|
983
|
+
console.print("[dim]Found GITHUB_TOKEN in environment[/dim]")
|
|
984
|
+
use_env = Confirm.ask("Use this token?", default=True)
|
|
985
|
+
if not use_env:
|
|
986
|
+
final_token = ""
|
|
987
|
+
|
|
988
|
+
if not final_token:
|
|
989
|
+
if existing_token:
|
|
990
|
+
# Show masked existing token as default
|
|
991
|
+
masked = _mask_sensitive_value(existing_token)
|
|
992
|
+
console.print(
|
|
993
|
+
"[dim]Create token at: https://github.com/settings/tokens/new[/dim]"
|
|
994
|
+
)
|
|
995
|
+
console.print(
|
|
996
|
+
"[dim]Required scopes: repo (or public_repo for public repos)[/dim]"
|
|
997
|
+
)
|
|
998
|
+
final_token = Prompt.ask(
|
|
999
|
+
f"GitHub Personal Access Token [current: {masked}]",
|
|
1000
|
+
password=True,
|
|
1001
|
+
default=existing_token,
|
|
1002
|
+
)
|
|
1003
|
+
else:
|
|
1004
|
+
console.print(
|
|
1005
|
+
"[dim]Create token at: https://github.com/settings/tokens/new[/dim]"
|
|
1006
|
+
)
|
|
1007
|
+
console.print(
|
|
1008
|
+
"[dim]Required scopes: repo (or public_repo for public repos)[/dim]"
|
|
1009
|
+
)
|
|
1010
|
+
final_token = Prompt.ask(
|
|
1011
|
+
"GitHub Personal Access Token", password=True
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
# Validate GitHub token with real API call
|
|
1015
|
+
try:
|
|
1016
|
+
github_validated = _validate_api_credentials(
|
|
1017
|
+
"github", {"token": final_token}
|
|
1018
|
+
)
|
|
1019
|
+
except typer.Exit:
|
|
1020
|
+
# User cancelled, propagate the exit
|
|
1021
|
+
raise
|
|
1022
|
+
except Exception as e:
|
|
1023
|
+
console.print(f"[red]Validation error: {e}[/red]")
|
|
1024
|
+
retry = Confirm.ask("Re-enter token?", default=True)
|
|
1025
|
+
if not retry:
|
|
1026
|
+
raise typer.Exit(1) from None
|
|
1027
|
+
# Reset to prompt again
|
|
1028
|
+
final_token = ""
|
|
1029
|
+
|
|
1030
|
+
elif not final_token:
|
|
1031
|
+
raise ValueError(
|
|
1032
|
+
"GitHub token is required (provide token parameter or set GITHUB_TOKEN environment variable)"
|
|
222
1033
|
)
|
|
1034
|
+
|
|
1035
|
+
# Repository URL/Owner/Repo - Prioritize repo_url, fallback to owner/repo
|
|
1036
|
+
existing_owner = existing_config.get("owner", "") if has_existing else ""
|
|
1037
|
+
existing_repo = existing_config.get("repo", "") if has_existing else ""
|
|
1038
|
+
|
|
1039
|
+
final_owner = ""
|
|
1040
|
+
final_repo = ""
|
|
1041
|
+
|
|
1042
|
+
# Step 1: Try to get URL from parameter or environment
|
|
1043
|
+
url_input = repo_url or os.getenv("GITHUB_REPO_URL") or ""
|
|
1044
|
+
|
|
1045
|
+
# Step 2: Parse URL if provided
|
|
1046
|
+
if url_input:
|
|
1047
|
+
from ..core.url_parser import parse_github_repo_url
|
|
1048
|
+
|
|
1049
|
+
parsed_owner, parsed_repo, error = parse_github_repo_url(url_input)
|
|
1050
|
+
if parsed_owner and parsed_repo:
|
|
1051
|
+
final_owner = parsed_owner
|
|
1052
|
+
final_repo = parsed_repo
|
|
1053
|
+
if interactive:
|
|
1054
|
+
console.print(
|
|
1055
|
+
f"[dim]✓ Extracted repository: {final_owner}/{final_repo}[/dim]"
|
|
1056
|
+
)
|
|
1057
|
+
else:
|
|
1058
|
+
# URL parsing failed
|
|
1059
|
+
if interactive:
|
|
1060
|
+
console.print(f"[yellow]Warning: {error}[/yellow]")
|
|
1061
|
+
else:
|
|
1062
|
+
raise ValueError(f"Failed to parse GitHub repository URL: {error}")
|
|
1063
|
+
|
|
1064
|
+
# Step 3: Interactive mode - prompt for URL if not provided
|
|
1065
|
+
if interactive and not final_owner and not final_repo:
|
|
223
1066
|
console.print(
|
|
224
|
-
"[dim]
|
|
1067
|
+
"[dim]Enter your GitHub repository URL (e.g., https://github.com/owner/repo)[/dim]"
|
|
225
1068
|
)
|
|
226
|
-
token = Prompt.ask("GitHub Personal Access Token", password=True)
|
|
227
1069
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
1070
|
+
# Show existing as default if available
|
|
1071
|
+
if existing_owner and existing_repo:
|
|
1072
|
+
existing_url = f"https://github.com/{existing_owner}/{existing_repo}"
|
|
1073
|
+
console.print(f"[dim]Current repository: {existing_url}[/dim]")
|
|
1074
|
+
|
|
1075
|
+
# Keep prompting until we get a valid URL
|
|
1076
|
+
while not final_owner or not final_repo:
|
|
1077
|
+
from ..core.url_parser import parse_github_repo_url
|
|
1078
|
+
|
|
1079
|
+
default_prompt = (
|
|
1080
|
+
f"https://github.com/{existing_owner}/{existing_repo}"
|
|
1081
|
+
if existing_owner and existing_repo
|
|
1082
|
+
else "https://github.com/"
|
|
1083
|
+
)
|
|
232
1084
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
1085
|
+
url_prompt = Prompt.ask(
|
|
1086
|
+
"GitHub Repository URL",
|
|
1087
|
+
default=default_prompt,
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
parsed_owner, parsed_repo, error = parse_github_repo_url(url_prompt)
|
|
1091
|
+
if parsed_owner and parsed_repo:
|
|
1092
|
+
final_owner = parsed_owner
|
|
1093
|
+
final_repo = parsed_repo
|
|
1094
|
+
console.print(f"[dim]✓ Repository: {final_owner}/{final_repo}[/dim]")
|
|
1095
|
+
break
|
|
1096
|
+
else:
|
|
1097
|
+
console.print(f"[red]Error: {error}[/red]")
|
|
1098
|
+
console.print(
|
|
1099
|
+
"[yellow]Please enter a valid GitHub repository URL[/yellow]"
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
# Step 4: Non-interactive fallback - use individual owner/repo parameters
|
|
1103
|
+
if not final_owner or not final_repo:
|
|
1104
|
+
fallback_owner = owner or os.getenv("GITHUB_OWNER") or ""
|
|
1105
|
+
fallback_repo = repo or os.getenv("GITHUB_REPO") or ""
|
|
1106
|
+
|
|
1107
|
+
# In non-interactive mode, both must be provided if URL wasn't
|
|
1108
|
+
if not interactive:
|
|
1109
|
+
if not fallback_owner or not fallback_repo:
|
|
1110
|
+
raise ValueError(
|
|
1111
|
+
"GitHub repository is required. Provide either:\n"
|
|
1112
|
+
" - repo_url parameter or GITHUB_REPO_URL environment variable, OR\n"
|
|
1113
|
+
" - Both owner and repo parameters (or GITHUB_OWNER/GITHUB_REPO environment variables)"
|
|
1114
|
+
)
|
|
1115
|
+
final_owner = fallback_owner
|
|
1116
|
+
final_repo = fallback_repo
|
|
1117
|
+
else:
|
|
1118
|
+
# Interactive mode with fallback values
|
|
1119
|
+
if fallback_owner:
|
|
1120
|
+
final_owner = fallback_owner
|
|
1121
|
+
if fallback_repo:
|
|
1122
|
+
final_repo = fallback_repo
|
|
237
1123
|
|
|
238
1124
|
config_dict = {
|
|
239
1125
|
"adapter": AdapterType.GITHUB.value,
|
|
240
|
-
"token":
|
|
241
|
-
"owner":
|
|
242
|
-
"repo":
|
|
243
|
-
"project_id": f"{
|
|
1126
|
+
"token": final_token,
|
|
1127
|
+
"owner": final_owner,
|
|
1128
|
+
"repo": final_repo,
|
|
1129
|
+
"project_id": f"{final_owner}/{final_repo}", # Convenience field
|
|
244
1130
|
}
|
|
245
1131
|
|
|
246
1132
|
# Validate
|
|
247
1133
|
is_valid, error = ConfigValidator.validate_github_config(config_dict)
|
|
248
1134
|
if not is_valid:
|
|
249
|
-
|
|
250
|
-
|
|
1135
|
+
if interactive:
|
|
1136
|
+
console.print(f"[red]Configuration error: {error}[/red]")
|
|
1137
|
+
raise typer.Exit(1) from None
|
|
1138
|
+
raise ValueError(f"GitHub configuration validation failed: {error}")
|
|
1139
|
+
|
|
1140
|
+
# ============================================================
|
|
1141
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
1142
|
+
# ============================================================
|
|
1143
|
+
default_values = {}
|
|
1144
|
+
|
|
1145
|
+
if interactive:
|
|
1146
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
1147
|
+
console.print("Configure default values for ticket creation:")
|
|
1148
|
+
|
|
1149
|
+
# Default user/assignee
|
|
1150
|
+
existing_user = existing_config.get("user_email", "") if has_existing else ""
|
|
1151
|
+
if existing_user:
|
|
1152
|
+
user_input = Prompt.ask(
|
|
1153
|
+
f"Default assignee/user (optional, GitHub username) [current: {existing_user}]",
|
|
1154
|
+
default=existing_user,
|
|
1155
|
+
)
|
|
1156
|
+
else:
|
|
1157
|
+
user_input = Prompt.ask(
|
|
1158
|
+
"Default assignee/user (optional, GitHub username)",
|
|
1159
|
+
default="",
|
|
1160
|
+
show_default=False,
|
|
1161
|
+
)
|
|
1162
|
+
if user_input:
|
|
1163
|
+
default_values["default_user"] = user_input
|
|
1164
|
+
console.print(
|
|
1165
|
+
f"[green]✓[/green] Will use '{user_input}' as default assignee"
|
|
1166
|
+
)
|
|
251
1167
|
|
|
252
|
-
|
|
1168
|
+
# Default epic/project (milestone for GitHub)
|
|
1169
|
+
existing_epic = existing_config.get("default_epic", "") if has_existing else ""
|
|
1170
|
+
if existing_epic:
|
|
1171
|
+
epic_input = Prompt.ask(
|
|
1172
|
+
f"Default milestone/project (optional, e.g., 'v1.0' or milestone number) [current: {existing_epic}]",
|
|
1173
|
+
default=existing_epic,
|
|
1174
|
+
)
|
|
1175
|
+
else:
|
|
1176
|
+
epic_input = Prompt.ask(
|
|
1177
|
+
"Default milestone/project (optional, e.g., 'v1.0' or milestone number)",
|
|
1178
|
+
default="",
|
|
1179
|
+
show_default=False,
|
|
1180
|
+
)
|
|
1181
|
+
if epic_input:
|
|
1182
|
+
default_values["default_epic"] = epic_input
|
|
1183
|
+
default_values["default_project"] = epic_input # Compatibility
|
|
1184
|
+
console.print(
|
|
1185
|
+
f"[green]✓[/green] Will use '{epic_input}' as default milestone/project"
|
|
1186
|
+
)
|
|
253
1187
|
|
|
1188
|
+
# Default tags (labels for GitHub)
|
|
1189
|
+
existing_tags = existing_config.get("default_tags", []) if has_existing else []
|
|
1190
|
+
existing_tags_str = ", ".join(existing_tags) if existing_tags else ""
|
|
1191
|
+
if existing_tags_str:
|
|
1192
|
+
tags_input = Prompt.ask(
|
|
1193
|
+
f"Default labels (optional, comma-separated, e.g., 'bug,enhancement') [current: {existing_tags_str}]",
|
|
1194
|
+
default=existing_tags_str,
|
|
1195
|
+
)
|
|
1196
|
+
else:
|
|
1197
|
+
tags_input = Prompt.ask(
|
|
1198
|
+
"Default labels (optional, comma-separated, e.g., 'bug,enhancement')",
|
|
1199
|
+
default="",
|
|
1200
|
+
show_default=False,
|
|
1201
|
+
)
|
|
1202
|
+
if tags_input:
|
|
1203
|
+
tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
|
|
1204
|
+
if tags_list:
|
|
1205
|
+
default_values["default_tags"] = tags_list
|
|
1206
|
+
console.print(
|
|
1207
|
+
f"[green]✓[/green] Will use labels: {', '.join(tags_list)}"
|
|
1208
|
+
)
|
|
254
1209
|
|
|
255
|
-
|
|
256
|
-
"""Configure AITrackdown adapter."""
|
|
257
|
-
console.print("\n[bold]Configure AITrackdown (File-based):[/bold]")
|
|
1210
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
258
1211
|
|
|
259
|
-
|
|
1212
|
+
|
|
1213
|
+
def _configure_aitrackdown(
|
|
1214
|
+
interactive: bool = True, base_path: str | None = None, **kwargs: Any
|
|
1215
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
1216
|
+
"""Configure AITrackdown adapter.
|
|
1217
|
+
|
|
1218
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
1219
|
+
|
|
1220
|
+
Args:
|
|
1221
|
+
----
|
|
1222
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
1223
|
+
base_path: Pre-provided base path for ticket storage (optional)
|
|
1224
|
+
**kwargs: Additional configuration parameters
|
|
1225
|
+
|
|
1226
|
+
Returns:
|
|
1227
|
+
-------
|
|
1228
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
1229
|
+
- AdapterConfig: Configured AITrackdown adapter configuration
|
|
1230
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
1231
|
+
|
|
1232
|
+
"""
|
|
1233
|
+
if interactive:
|
|
1234
|
+
console.print("\n[bold]Configure AITrackdown (File-based):[/bold]")
|
|
1235
|
+
|
|
1236
|
+
# Load existing configuration if available
|
|
1237
|
+
existing_config = (
|
|
1238
|
+
_load_existing_adapter_config("aitrackdown") if interactive else None
|
|
1239
|
+
)
|
|
1240
|
+
has_existing = existing_config is not None
|
|
1241
|
+
|
|
1242
|
+
# Base path with existing value as default
|
|
1243
|
+
existing_base_path = (
|
|
1244
|
+
existing_config.get("base_path", ".aitrackdown") if has_existing else ""
|
|
1245
|
+
)
|
|
1246
|
+
final_base_path = base_path or existing_base_path or ".aitrackdown"
|
|
1247
|
+
|
|
1248
|
+
if interactive:
|
|
1249
|
+
if existing_base_path:
|
|
1250
|
+
final_base_path = Prompt.ask(
|
|
1251
|
+
f"Base path for ticket storage [current: {existing_base_path}]",
|
|
1252
|
+
default=existing_base_path,
|
|
1253
|
+
)
|
|
1254
|
+
else:
|
|
1255
|
+
final_base_path = Prompt.ask(
|
|
1256
|
+
"Base path for ticket storage", default=".aitrackdown"
|
|
1257
|
+
)
|
|
260
1258
|
|
|
261
1259
|
config_dict = {
|
|
262
1260
|
"adapter": AdapterType.AITRACKDOWN.value,
|
|
263
|
-
"base_path":
|
|
1261
|
+
"base_path": final_base_path,
|
|
264
1262
|
}
|
|
265
1263
|
|
|
266
|
-
|
|
1264
|
+
# ============================================================
|
|
1265
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
1266
|
+
# ============================================================
|
|
1267
|
+
default_values = {}
|
|
1268
|
+
|
|
1269
|
+
if interactive:
|
|
1270
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
1271
|
+
console.print("Configure default values for ticket creation:")
|
|
1272
|
+
|
|
1273
|
+
# Default user/assignee
|
|
1274
|
+
existing_user = existing_config.get("user_email", "") if has_existing else ""
|
|
1275
|
+
if existing_user:
|
|
1276
|
+
user_input = Prompt.ask(
|
|
1277
|
+
f"Default assignee/user (optional) [current: {existing_user}]",
|
|
1278
|
+
default=existing_user,
|
|
1279
|
+
)
|
|
1280
|
+
else:
|
|
1281
|
+
user_input = Prompt.ask(
|
|
1282
|
+
"Default assignee/user (optional)", default="", show_default=False
|
|
1283
|
+
)
|
|
1284
|
+
if user_input:
|
|
1285
|
+
default_values["default_user"] = user_input
|
|
1286
|
+
console.print(
|
|
1287
|
+
f"[green]✓[/green] Will use '{user_input}' as default assignee"
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
# Default epic/project
|
|
1291
|
+
existing_epic = existing_config.get("default_epic", "") if has_existing else ""
|
|
1292
|
+
if existing_epic:
|
|
1293
|
+
epic_input = Prompt.ask(
|
|
1294
|
+
f"Default epic/project ID (optional) [current: {existing_epic}]",
|
|
1295
|
+
default=existing_epic,
|
|
1296
|
+
)
|
|
1297
|
+
else:
|
|
1298
|
+
epic_input = Prompt.ask(
|
|
1299
|
+
"Default epic/project ID (optional)", default="", show_default=False
|
|
1300
|
+
)
|
|
1301
|
+
if epic_input:
|
|
1302
|
+
default_values["default_epic"] = epic_input
|
|
1303
|
+
default_values["default_project"] = epic_input # Compatibility
|
|
1304
|
+
console.print(
|
|
1305
|
+
f"[green]✓[/green] Will use '{epic_input}' as default epic/project"
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
# Default tags
|
|
1309
|
+
existing_tags = existing_config.get("default_tags", []) if has_existing else []
|
|
1310
|
+
existing_tags_str = ", ".join(existing_tags) if existing_tags else ""
|
|
1311
|
+
if existing_tags_str:
|
|
1312
|
+
tags_input = Prompt.ask(
|
|
1313
|
+
f"Default tags (optional, comma-separated) [current: {existing_tags_str}]",
|
|
1314
|
+
default=existing_tags_str,
|
|
1315
|
+
)
|
|
1316
|
+
else:
|
|
1317
|
+
tags_input = Prompt.ask(
|
|
1318
|
+
"Default tags (optional, comma-separated)",
|
|
1319
|
+
default="",
|
|
1320
|
+
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
|
|
267
1438
|
|
|
268
1439
|
|
|
269
1440
|
def _configure_hybrid_mode() -> TicketerConfig:
|
|
@@ -301,20 +1472,25 @@ def _configure_hybrid_mode() -> TicketerConfig:
|
|
|
301
1472
|
|
|
302
1473
|
# Configure each adapter
|
|
303
1474
|
adapters = {}
|
|
1475
|
+
default_values: dict[str, str] = {}
|
|
304
1476
|
for adapter_type in selected_adapters:
|
|
305
1477
|
console.print(f"\n[cyan]Configuring {adapter_type.value}...[/cyan]")
|
|
306
1478
|
|
|
307
1479
|
if adapter_type == AdapterType.LINEAR:
|
|
308
|
-
adapter_config = _configure_linear()
|
|
1480
|
+
adapter_config, adapter_defaults = _configure_linear(interactive=True)
|
|
309
1481
|
elif adapter_type == AdapterType.JIRA:
|
|
310
|
-
adapter_config = _configure_jira()
|
|
1482
|
+
adapter_config, adapter_defaults = _configure_jira(interactive=True)
|
|
311
1483
|
elif adapter_type == AdapterType.GITHUB:
|
|
312
|
-
adapter_config = _configure_github()
|
|
1484
|
+
adapter_config, adapter_defaults = _configure_github(interactive=True)
|
|
313
1485
|
else:
|
|
314
|
-
adapter_config = _configure_aitrackdown()
|
|
1486
|
+
adapter_config, adapter_defaults = _configure_aitrackdown(interactive=True)
|
|
315
1487
|
|
|
316
1488
|
adapters[adapter_type.value] = adapter_config
|
|
317
1489
|
|
|
1490
|
+
# Only save defaults from the first/primary adapter
|
|
1491
|
+
if not default_values:
|
|
1492
|
+
default_values = adapter_defaults
|
|
1493
|
+
|
|
318
1494
|
# Select primary adapter
|
|
319
1495
|
console.print("\n[bold]Select primary adapter (source of truth):[/bold]")
|
|
320
1496
|
for idx, adapter_type in enumerate(selected_adapters, 1):
|
|
@@ -354,9 +1530,15 @@ def _configure_hybrid_mode() -> TicketerConfig:
|
|
|
354
1530
|
sync_strategy=sync_strategy,
|
|
355
1531
|
)
|
|
356
1532
|
|
|
357
|
-
# Create full config
|
|
1533
|
+
# Create full config with default values
|
|
358
1534
|
config = TicketerConfig(
|
|
359
|
-
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"),
|
|
360
1542
|
)
|
|
361
1543
|
|
|
362
1544
|
return config
|
|
@@ -437,6 +1619,7 @@ def set_adapter_config(
|
|
|
437
1619
|
"""Set specific adapter configuration values.
|
|
438
1620
|
|
|
439
1621
|
Args:
|
|
1622
|
+
----
|
|
440
1623
|
adapter: Adapter type to set as default
|
|
441
1624
|
api_key: API key/token
|
|
442
1625
|
project_id: Project ID
|