mcp-ticketer 0.4.11__py3-none-any.whl → 2.0.1__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 +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +394 -9
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -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.py +836 -105
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +772 -1
- mcp_ticketer/adapters/linear/adapter.py +2293 -108
- mcp_ticketer/adapters/linear/client.py +146 -12
- mcp_ticketer/adapters/linear/mappers.py +105 -11
- mcp_ticketer/adapters/linear/queries.py +168 -1
- mcp_ticketer/adapters/linear/types.py +80 -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/cache/memory.py +3 -3
- mcp_ticketer/cli/adapter_diagnostics.py +4 -2
- mcp_ticketer/cli/auggie_configure.py +18 -6
- mcp_ticketer/cli/codex_configure.py +175 -60
- mcp_ticketer/cli/configure.py +884 -146
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +31 -28
- mcp_ticketer/cli/discover.py +293 -21
- mcp_ticketer/cli/gemini_configure.py +18 -6
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +99 -15
- mcp_ticketer/cli/main.py +109 -2055
- mcp_ticketer/cli/mcp_configure.py +673 -99
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +6 -6
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +13 -11
- mcp_ticketer/cli/ticket_commands.py +277 -36
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +45 -41
- mcp_ticketer/core/__init__.py +35 -1
- mcp_ticketer/core/adapter.py +170 -5
- mcp_ticketer/core/config.py +38 -31
- mcp_ticketer/core/env_discovery.py +33 -3
- mcp_ticketer/core/env_loader.py +7 -6
- mcp_ticketer/core/exceptions.py +10 -4
- mcp_ticketer/core/http_client.py +10 -10
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +32 -20
- mcp_ticketer/core/models.py +136 -1
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +148 -14
- mcp_ticketer/core/registry.py +1 -1
- mcp_ticketer/core/session_state.py +171 -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 +2 -2
- mcp_ticketer/mcp/server/__init__.py +2 -2
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +187 -93
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +37 -9
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
- 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 +1429 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
- 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/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 +180 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/health_monitor.py +1 -0
- mcp_ticketer/queue/manager.py +4 -4
- mcp_ticketer/queue/queue.py +3 -3
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +2 -2
- mcp_ticketer/queue/worker.py +15 -13
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
- mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
- mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
- mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
mcp_ticketer/cli/configure.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Interactive configuration wizard for MCP Ticketer."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
4
6
|
|
|
5
7
|
import typer
|
|
6
8
|
from rich.console import Console
|
|
@@ -21,6 +23,61 @@ from ..core.project_config import (
|
|
|
21
23
|
console = Console()
|
|
22
24
|
|
|
23
25
|
|
|
26
|
+
def _retry_setting(
|
|
27
|
+
setting_name: str,
|
|
28
|
+
prompt_func: Callable[[], Any],
|
|
29
|
+
validate_func: Callable[[Any], tuple[bool, str | None]],
|
|
30
|
+
max_retries: int = 3,
|
|
31
|
+
) -> Any:
|
|
32
|
+
"""Retry a configuration setting with validation.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
----
|
|
36
|
+
setting_name: Human-readable name of the setting
|
|
37
|
+
prompt_func: Function that prompts for the setting value
|
|
38
|
+
validate_func: Function that validates the value (returns tuple of success, error_msg)
|
|
39
|
+
max_retries: Maximum number of retry attempts
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
-------
|
|
43
|
+
Validated setting value
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
------
|
|
47
|
+
typer.Exit: If max retries exceeded
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
for attempt in range(1, max_retries + 1):
|
|
51
|
+
try:
|
|
52
|
+
value = prompt_func()
|
|
53
|
+
is_valid, error = validate_func(value)
|
|
54
|
+
|
|
55
|
+
if is_valid:
|
|
56
|
+
return value
|
|
57
|
+
console.print(f"[red]✗ {error}[/red]")
|
|
58
|
+
if attempt < max_retries:
|
|
59
|
+
console.print(
|
|
60
|
+
f"[yellow]Attempt {attempt}/{max_retries} - Please try again[/yellow]"
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
console.print(f"[red]Failed after {max_retries} attempts[/red]")
|
|
64
|
+
retry = Confirm.ask("Retry this setting?", default=True)
|
|
65
|
+
if retry:
|
|
66
|
+
# Extend attempts
|
|
67
|
+
max_retries += 3
|
|
68
|
+
console.print(
|
|
69
|
+
f"[yellow]Extending retries (new limit: {max_retries})[/yellow]"
|
|
70
|
+
)
|
|
71
|
+
continue
|
|
72
|
+
raise typer.Exit(1) from None
|
|
73
|
+
except KeyboardInterrupt:
|
|
74
|
+
console.print("\n[yellow]Configuration cancelled[/yellow]")
|
|
75
|
+
raise typer.Exit(0) from None
|
|
76
|
+
|
|
77
|
+
console.print(f"[red]Could not configure {setting_name}[/red]")
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
|
|
80
|
+
|
|
24
81
|
def configure_wizard() -> None:
|
|
25
82
|
"""Run interactive configuration wizard."""
|
|
26
83
|
console.print(
|
|
@@ -45,24 +102,25 @@ def configure_wizard() -> None:
|
|
|
45
102
|
|
|
46
103
|
# Step 2: Choose where to save
|
|
47
104
|
console.print("\n[bold]Step 2: Configuration Scope[/bold]")
|
|
48
|
-
console.print(
|
|
49
|
-
|
|
105
|
+
console.print(
|
|
106
|
+
"1. Project-specific (recommended): .mcp-ticketer/config.json in project root"
|
|
107
|
+
)
|
|
108
|
+
console.print("2. Legacy global (deprecated): saves to project config for security")
|
|
50
109
|
|
|
51
|
-
scope = Prompt.ask("Save configuration as", choices=["1", "2"], default="
|
|
110
|
+
scope = Prompt.ask("Save configuration as", choices=["1", "2"], default="1")
|
|
52
111
|
|
|
53
112
|
resolver = ConfigResolver()
|
|
54
113
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
114
|
+
# Always save to project config (global config removed for security)
|
|
115
|
+
resolver.save_project_config(config)
|
|
116
|
+
config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
|
|
117
|
+
|
|
118
|
+
if scope == "2":
|
|
58
119
|
console.print(
|
|
59
|
-
|
|
120
|
+
"[yellow]Note: Global config is deprecated for security. Saving to project config instead.[/yellow]"
|
|
60
121
|
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
resolver.save_project_config(config)
|
|
64
|
-
config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
|
|
65
|
-
console.print(f"\n[green]✓[/green] Configuration saved to {config_path}")
|
|
122
|
+
|
|
123
|
+
console.print(f"\n[green]✓[/green] Configuration saved to {config_path}")
|
|
66
124
|
|
|
67
125
|
# Show usage instructions
|
|
68
126
|
console.print("\n[bold]Usage:[/bold]")
|
|
@@ -95,173 +153,853 @@ def _configure_single_adapter() -> TicketerConfig:
|
|
|
95
153
|
adapter_type = adapter_type_map[adapter_choice]
|
|
96
154
|
|
|
97
155
|
# Configure the selected adapter
|
|
156
|
+
default_values: dict[str, str] = {}
|
|
98
157
|
if adapter_type == AdapterType.LINEAR:
|
|
99
|
-
adapter_config = _configure_linear()
|
|
158
|
+
adapter_config, default_values = _configure_linear(interactive=True)
|
|
100
159
|
elif adapter_type == AdapterType.JIRA:
|
|
101
|
-
adapter_config = _configure_jira()
|
|
160
|
+
adapter_config, default_values = _configure_jira(interactive=True)
|
|
102
161
|
elif adapter_type == AdapterType.GITHUB:
|
|
103
|
-
adapter_config = _configure_github()
|
|
162
|
+
adapter_config, default_values = _configure_github(interactive=True)
|
|
104
163
|
else:
|
|
105
|
-
adapter_config = _configure_aitrackdown()
|
|
164
|
+
adapter_config, default_values = _configure_aitrackdown(interactive=True)
|
|
106
165
|
|
|
107
|
-
# Create config
|
|
166
|
+
# Create config with default values
|
|
108
167
|
config = TicketerConfig(
|
|
109
168
|
default_adapter=adapter_type.value,
|
|
110
169
|
adapters={adapter_type.value: adapter_config},
|
|
170
|
+
default_user=default_values.get("default_user"),
|
|
171
|
+
default_project=default_values.get("default_project"),
|
|
172
|
+
default_epic=default_values.get("default_epic"),
|
|
173
|
+
default_tags=default_values.get("default_tags"),
|
|
111
174
|
)
|
|
112
175
|
|
|
113
176
|
return config
|
|
114
177
|
|
|
115
178
|
|
|
116
|
-
def _configure_linear(
|
|
117
|
-
|
|
118
|
-
|
|
179
|
+
def _configure_linear(
|
|
180
|
+
existing_config: dict[str, Any] | None = None,
|
|
181
|
+
interactive: bool = True,
|
|
182
|
+
api_key: str | None = None,
|
|
183
|
+
team_id: str | None = None,
|
|
184
|
+
team_key: str | None = None,
|
|
185
|
+
**kwargs: Any,
|
|
186
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
187
|
+
"""Configure Linear adapter with option to preserve existing settings.
|
|
119
188
|
|
|
120
|
-
|
|
121
|
-
api_key = os.getenv("LINEAR_API_KEY") or ""
|
|
122
|
-
if api_key:
|
|
123
|
-
console.print("[dim]Found LINEAR_API_KEY in environment[/dim]")
|
|
124
|
-
use_env = Confirm.ask("Use this API key?", default=True)
|
|
125
|
-
if not use_env:
|
|
126
|
-
api_key = ""
|
|
189
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
127
190
|
|
|
128
|
-
|
|
129
|
-
|
|
191
|
+
Args:
|
|
192
|
+
----
|
|
193
|
+
existing_config: Optional existing configuration to preserve/update
|
|
194
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
195
|
+
api_key: Pre-provided API key (optional, for programmatic mode)
|
|
196
|
+
team_id: Pre-provided team ID (optional, for programmatic mode)
|
|
197
|
+
team_key: Pre-provided team key (optional, for programmatic mode)
|
|
198
|
+
**kwargs: Additional configuration parameters
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
-------
|
|
202
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
203
|
+
- AdapterConfig: Configured Linear adapter configuration
|
|
204
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
130
205
|
|
|
131
|
-
|
|
132
|
-
|
|
206
|
+
"""
|
|
207
|
+
if interactive:
|
|
208
|
+
console.print("\n[bold cyan]Linear Configuration[/bold cyan]")
|
|
133
209
|
|
|
134
|
-
#
|
|
135
|
-
|
|
210
|
+
# Check if we have existing config
|
|
211
|
+
has_existing = (
|
|
212
|
+
existing_config is not None and existing_config.get("adapter") == "linear"
|
|
213
|
+
)
|
|
136
214
|
|
|
137
|
-
|
|
138
|
-
project_id = Prompt.ask("Project ID (optional)", default="")
|
|
215
|
+
config_dict: dict[str, Any] = {"adapter": AdapterType.LINEAR.value}
|
|
139
216
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
217
|
+
if has_existing and interactive:
|
|
218
|
+
preserve = Confirm.ask(
|
|
219
|
+
"Existing Linear configuration found. Preserve current settings?",
|
|
220
|
+
default=True,
|
|
221
|
+
)
|
|
222
|
+
if preserve:
|
|
223
|
+
console.print("[green]✓[/green] Keeping existing configuration")
|
|
224
|
+
config_dict = existing_config.copy()
|
|
225
|
+
|
|
226
|
+
# Allow updating specific fields
|
|
227
|
+
update_fields = Confirm.ask("Update specific fields?", default=False)
|
|
228
|
+
if not update_fields:
|
|
229
|
+
# Extract default values before returning
|
|
230
|
+
default_values = {}
|
|
231
|
+
if "user_email" in config_dict:
|
|
232
|
+
default_values["default_user"] = config_dict.pop("user_email")
|
|
233
|
+
if "default_epic" in config_dict:
|
|
234
|
+
default_values["default_epic"] = config_dict.pop("default_epic")
|
|
235
|
+
if "default_project" in config_dict:
|
|
236
|
+
default_values["default_project"] = config_dict.pop(
|
|
237
|
+
"default_project"
|
|
238
|
+
)
|
|
239
|
+
if "default_tags" in config_dict:
|
|
240
|
+
default_values["default_tags"] = config_dict.pop("default_tags")
|
|
241
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
242
|
+
|
|
243
|
+
console.print(
|
|
244
|
+
"[yellow]Enter new values or press Enter to keep current[/yellow]"
|
|
245
|
+
)
|
|
144
246
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if
|
|
150
|
-
|
|
247
|
+
# API Key (programmatic mode: use provided value or env, interactive: prompt)
|
|
248
|
+
current_key = config_dict.get("api_key", "") if has_existing else ""
|
|
249
|
+
final_api_key = api_key or os.getenv("LINEAR_API_KEY") or ""
|
|
250
|
+
|
|
251
|
+
if interactive:
|
|
252
|
+
# Interactive mode: prompt with retry
|
|
253
|
+
if final_api_key and not current_key:
|
|
254
|
+
console.print("[dim]Found LINEAR_API_KEY in environment[/dim]")
|
|
255
|
+
use_env = Confirm.ask("Use this API key?", default=True)
|
|
256
|
+
if use_env:
|
|
257
|
+
current_key = final_api_key
|
|
258
|
+
|
|
259
|
+
def prompt_api_key() -> str:
|
|
260
|
+
if current_key:
|
|
261
|
+
api_key_prompt = f"Linear API Key [current: {'*' * 8}...]"
|
|
262
|
+
return Prompt.ask(api_key_prompt, password=True, default=current_key)
|
|
263
|
+
return Prompt.ask("Linear API Key", password=True)
|
|
264
|
+
|
|
265
|
+
def validate_api_key(key: str) -> tuple[bool, str | None]:
|
|
266
|
+
if not key or len(key) < 10:
|
|
267
|
+
return False, "API key must be at least 10 characters"
|
|
268
|
+
return True, None
|
|
269
|
+
|
|
270
|
+
final_api_key = _retry_setting("API Key", prompt_api_key, validate_api_key)
|
|
271
|
+
elif not final_api_key:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
"Linear API key is required (provide api_key parameter or set LINEAR_API_KEY environment variable)"
|
|
274
|
+
)
|
|
151
275
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
276
|
+
config_dict["api_key"] = final_api_key
|
|
277
|
+
|
|
278
|
+
# Team Key/ID (programmatic mode: use provided values, interactive: prompt)
|
|
279
|
+
current_team_key = config_dict.get("team_key", "") if has_existing else ""
|
|
280
|
+
config_dict.get("team_id", "") if has_existing else ""
|
|
281
|
+
final_team_key = team_key or os.getenv("LINEAR_TEAM_KEY") or ""
|
|
282
|
+
final_team_id = team_id or os.getenv("LINEAR_TEAM_ID") or ""
|
|
283
|
+
|
|
284
|
+
if interactive:
|
|
285
|
+
# Interactive mode: prompt for team key (preferred over team_id)
|
|
286
|
+
def prompt_team_key() -> str:
|
|
287
|
+
if current_team_key:
|
|
288
|
+
team_key_prompt = f"Linear Team Key [current: {current_team_key}]"
|
|
289
|
+
return Prompt.ask(team_key_prompt, default=current_team_key)
|
|
290
|
+
return Prompt.ask("Linear Team Key (e.g., 'ENG', 'BTA')")
|
|
291
|
+
|
|
292
|
+
def validate_team_key(key: str) -> tuple[bool, str | None]:
|
|
293
|
+
if not key or len(key) < 2:
|
|
294
|
+
return False, "Team key must be at least 2 characters"
|
|
295
|
+
return True, None
|
|
296
|
+
|
|
297
|
+
final_team_key = _retry_setting("Team Key", prompt_team_key, validate_team_key)
|
|
298
|
+
config_dict["team_key"] = final_team_key
|
|
299
|
+
|
|
300
|
+
# Remove team_id if present (will be resolved from team_key)
|
|
301
|
+
if "team_id" in config_dict:
|
|
302
|
+
del config_dict["team_id"]
|
|
303
|
+
else:
|
|
304
|
+
# Programmatic mode: use whichever was provided
|
|
305
|
+
if final_team_key:
|
|
306
|
+
config_dict["team_key"] = final_team_key
|
|
307
|
+
if final_team_id:
|
|
308
|
+
config_dict["team_id"] = final_team_id
|
|
309
|
+
if not final_team_key and not final_team_id:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
"Linear requires either team_key or team_id (provide parameter or set LINEAR_TEAM_KEY/LINEAR_TEAM_ID environment variable)"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# User email configuration (optional, for default assignee) - only in interactive mode
|
|
315
|
+
if interactive:
|
|
316
|
+
current_user_email = config_dict.get("user_email", "") if has_existing else ""
|
|
317
|
+
|
|
318
|
+
def prompt_user_email() -> str:
|
|
319
|
+
if current_user_email:
|
|
320
|
+
user_email_prompt = (
|
|
321
|
+
f"Your Linear email (optional, for auto-assignment) "
|
|
322
|
+
f"[current: {current_user_email}]"
|
|
323
|
+
)
|
|
324
|
+
return Prompt.ask(user_email_prompt, default=current_user_email)
|
|
325
|
+
return Prompt.ask(
|
|
326
|
+
"Your Linear email (optional, for auto-assignment)", default=""
|
|
327
|
+
)
|
|
157
328
|
|
|
158
|
-
|
|
329
|
+
def validate_user_email(email: str) -> tuple[bool, str | None]:
|
|
330
|
+
if not email: # Optional field
|
|
331
|
+
return True, None
|
|
332
|
+
import re
|
|
159
333
|
|
|
334
|
+
email_pattern = re.compile(
|
|
335
|
+
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
336
|
+
)
|
|
337
|
+
if not email_pattern.match(email):
|
|
338
|
+
return False, f"Invalid email format: {email}"
|
|
339
|
+
return True, None
|
|
160
340
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
341
|
+
user_email = _retry_setting(
|
|
342
|
+
"User Email", prompt_user_email, validate_user_email
|
|
343
|
+
)
|
|
344
|
+
if user_email:
|
|
345
|
+
config_dict["user_email"] = user_email
|
|
346
|
+
console.print(f"[green]✓[/green] Will use {user_email} as default assignee")
|
|
164
347
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
server = Prompt.ask("JIRA Server URL (e.g., https://company.atlassian.net)")
|
|
348
|
+
# ============================================================
|
|
349
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
350
|
+
# ============================================================
|
|
169
351
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
352
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
353
|
+
console.print("Configure default values for ticket creation:")
|
|
354
|
+
|
|
355
|
+
# Default epic/project
|
|
356
|
+
current_default_epic = (
|
|
357
|
+
config_dict.get("default_epic", "") if has_existing else ""
|
|
358
|
+
)
|
|
174
359
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
360
|
+
def prompt_default_epic() -> str:
|
|
361
|
+
if current_default_epic:
|
|
362
|
+
return Prompt.ask(
|
|
363
|
+
f"Default epic/project ID (optional) [current: {current_default_epic}]",
|
|
364
|
+
default=current_default_epic,
|
|
365
|
+
)
|
|
366
|
+
return Prompt.ask(
|
|
367
|
+
"Default epic/project ID (optional, accepts project URLs or IDs like 'PROJ-123')",
|
|
368
|
+
default="",
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def validate_default_epic(epic_id: str) -> tuple[bool, str | None]:
|
|
372
|
+
if not epic_id: # Optional field
|
|
373
|
+
return True, None
|
|
374
|
+
# Basic validation - just check it's not empty when provided
|
|
375
|
+
if len(epic_id.strip()) < 2:
|
|
376
|
+
return False, "Epic/project ID must be at least 2 characters"
|
|
377
|
+
return True, None
|
|
378
|
+
|
|
379
|
+
default_epic = _retry_setting(
|
|
380
|
+
"Default Epic/Project", prompt_default_epic, validate_default_epic
|
|
381
|
+
)
|
|
382
|
+
if default_epic:
|
|
383
|
+
config_dict["default_epic"] = default_epic
|
|
384
|
+
config_dict["default_project"] = default_epic # Set both for compatibility
|
|
385
|
+
console.print(
|
|
386
|
+
f"[green]✓[/green] Will use '{default_epic}' as default epic/project"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Default tags
|
|
390
|
+
current_default_tags = (
|
|
391
|
+
config_dict.get("default_tags", []) if has_existing else []
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def prompt_default_tags() -> str:
|
|
395
|
+
if current_default_tags:
|
|
396
|
+
tags_str = ", ".join(current_default_tags)
|
|
397
|
+
return Prompt.ask(
|
|
398
|
+
f"Default tags (optional, comma-separated) [current: {tags_str}]",
|
|
399
|
+
default=tags_str,
|
|
400
|
+
)
|
|
401
|
+
return Prompt.ask(
|
|
402
|
+
"Default tags (optional, comma-separated, e.g., 'bug,urgent')",
|
|
403
|
+
default="",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def validate_default_tags(tags_input: str) -> tuple[bool, str | None]:
|
|
407
|
+
if not tags_input: # Optional field
|
|
408
|
+
return True, None
|
|
409
|
+
# Parse and validate tags
|
|
410
|
+
tags = [tag.strip() for tag in tags_input.split(",") if tag.strip()]
|
|
411
|
+
if not tags:
|
|
412
|
+
return False, "Please provide at least one tag or leave empty"
|
|
413
|
+
# Check each tag is reasonable
|
|
414
|
+
for tag in tags:
|
|
415
|
+
if len(tag) < 2:
|
|
416
|
+
return False, f"Tag '{tag}' must be at least 2 characters"
|
|
417
|
+
if len(tag) > 50:
|
|
418
|
+
return False, f"Tag '{tag}' is too long (max 50 characters)"
|
|
419
|
+
return True, None
|
|
420
|
+
|
|
421
|
+
default_tags_input = _retry_setting(
|
|
422
|
+
"Default Tags", prompt_default_tags, validate_default_tags
|
|
423
|
+
)
|
|
424
|
+
if default_tags_input:
|
|
425
|
+
default_tags = [
|
|
426
|
+
tag.strip() for tag in default_tags_input.split(",") if tag.strip()
|
|
427
|
+
]
|
|
428
|
+
config_dict["default_tags"] = default_tags
|
|
429
|
+
console.print(f"[green]✓[/green] Will use tags: {', '.join(default_tags)}")
|
|
430
|
+
|
|
431
|
+
# Validate with detailed error reporting
|
|
432
|
+
is_valid, error = ConfigValidator.validate_linear_config(config_dict)
|
|
433
|
+
|
|
434
|
+
if not is_valid:
|
|
435
|
+
console.print("\n[red]❌ Configuration Validation Failed[/red]")
|
|
436
|
+
console.print(f"[red]Error: {error}[/red]\n")
|
|
437
|
+
|
|
438
|
+
# Show which settings were problematic
|
|
439
|
+
console.print("[yellow]Problematic settings:[/yellow]")
|
|
440
|
+
if "api_key" not in config_dict or not config_dict["api_key"]:
|
|
441
|
+
console.print(" • [red]API Key[/red] - Missing or empty")
|
|
442
|
+
if "team_key" not in config_dict and "team_id" not in config_dict:
|
|
443
|
+
console.print(
|
|
444
|
+
" • [red]Team Key/ID[/red] - Neither team_key nor team_id provided"
|
|
445
|
+
)
|
|
446
|
+
if "user_email" in config_dict:
|
|
447
|
+
email = config_dict["user_email"]
|
|
448
|
+
import re
|
|
449
|
+
|
|
450
|
+
if not re.match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", email):
|
|
451
|
+
console.print(f" • [red]User Email[/red] - Invalid format: {email}")
|
|
452
|
+
|
|
453
|
+
# Offer to retry specific settings
|
|
454
|
+
console.print("\n[cyan]Options:[/cyan]")
|
|
455
|
+
console.print(" 1. Retry configuration from scratch")
|
|
456
|
+
console.print(" 2. Fix specific settings")
|
|
457
|
+
console.print(" 3. Exit")
|
|
458
|
+
|
|
459
|
+
choice = Prompt.ask("Choose an option", choices=["1", "2", "3"], default="2")
|
|
460
|
+
|
|
461
|
+
if choice == "1":
|
|
462
|
+
# Recursive retry
|
|
463
|
+
return _configure_linear(existing_config=None)
|
|
464
|
+
if choice == "2":
|
|
465
|
+
# Fix specific settings
|
|
466
|
+
return _configure_linear(existing_config=config_dict)
|
|
467
|
+
raise typer.Exit(1) from None
|
|
468
|
+
|
|
469
|
+
console.print("[green]✓ Configuration validated successfully[/green]")
|
|
470
|
+
|
|
471
|
+
# Extract default values to return separately (not part of AdapterConfig)
|
|
472
|
+
default_values = {}
|
|
473
|
+
if "user_email" in config_dict:
|
|
474
|
+
default_values["default_user"] = config_dict.pop("user_email")
|
|
475
|
+
if "default_epic" in config_dict:
|
|
476
|
+
default_values["default_epic"] = config_dict.pop("default_epic")
|
|
477
|
+
if "default_project" in config_dict:
|
|
478
|
+
default_values["default_project"] = config_dict.pop("default_project")
|
|
479
|
+
if "default_tags" in config_dict:
|
|
480
|
+
default_values["default_tags"] = config_dict.pop("default_tags")
|
|
481
|
+
|
|
482
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _configure_jira(
|
|
486
|
+
interactive: bool = True,
|
|
487
|
+
server: str | None = None,
|
|
488
|
+
email: str | None = None,
|
|
489
|
+
api_token: str | None = None,
|
|
490
|
+
project_key: str | None = None,
|
|
491
|
+
**kwargs: Any,
|
|
492
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
493
|
+
"""Configure JIRA adapter.
|
|
494
|
+
|
|
495
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
----
|
|
499
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
500
|
+
server: Pre-provided JIRA server URL (optional)
|
|
501
|
+
email: Pre-provided JIRA user email (optional)
|
|
502
|
+
api_token: Pre-provided JIRA API token (optional)
|
|
503
|
+
project_key: Pre-provided default project key (optional)
|
|
504
|
+
**kwargs: Additional configuration parameters
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
-------
|
|
508
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
509
|
+
- AdapterConfig: Configured JIRA adapter configuration
|
|
510
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
511
|
+
|
|
512
|
+
"""
|
|
513
|
+
if interactive:
|
|
514
|
+
console.print("\n[bold]Configure JIRA Integration:[/bold]")
|
|
515
|
+
|
|
516
|
+
# Server URL (programmatic mode: use provided value or env, interactive: prompt)
|
|
517
|
+
final_server = server or os.getenv("JIRA_SERVER") or ""
|
|
518
|
+
if interactive and not final_server:
|
|
519
|
+
final_server = Prompt.ask(
|
|
520
|
+
"JIRA Server URL (e.g., https://company.atlassian.net)"
|
|
521
|
+
)
|
|
522
|
+
elif not interactive and not final_server:
|
|
523
|
+
raise ValueError(
|
|
524
|
+
"JIRA server URL is required (provide server parameter or set JIRA_SERVER environment variable)"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# Email (programmatic mode: use provided value or env, interactive: prompt)
|
|
528
|
+
final_email = email or os.getenv("JIRA_EMAIL") or ""
|
|
529
|
+
if interactive and not final_email:
|
|
530
|
+
final_email = Prompt.ask("JIRA User Email")
|
|
531
|
+
elif not interactive and not final_email:
|
|
532
|
+
raise ValueError(
|
|
533
|
+
"JIRA email is required (provide email parameter or set JIRA_EMAIL environment variable)"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# API Token (programmatic mode: use provided value or env, interactive: prompt)
|
|
537
|
+
final_api_token = api_token or os.getenv("JIRA_API_TOKEN") or ""
|
|
538
|
+
if interactive and not final_api_token:
|
|
178
539
|
console.print(
|
|
179
540
|
"[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]"
|
|
180
541
|
)
|
|
181
|
-
|
|
542
|
+
final_api_token = Prompt.ask("JIRA API Token", password=True)
|
|
543
|
+
elif not interactive and not final_api_token:
|
|
544
|
+
raise ValueError(
|
|
545
|
+
"JIRA API token is required (provide api_token parameter or set JIRA_API_TOKEN environment variable)"
|
|
546
|
+
)
|
|
182
547
|
|
|
183
|
-
# Project Key
|
|
184
|
-
|
|
548
|
+
# Project Key (optional)
|
|
549
|
+
final_project_key = project_key or os.getenv("JIRA_PROJECT_KEY") or ""
|
|
550
|
+
if interactive and not final_project_key:
|
|
551
|
+
final_project_key = Prompt.ask(
|
|
552
|
+
"Default Project Key (optional, e.g., PROJ)", default=""
|
|
553
|
+
)
|
|
185
554
|
|
|
186
555
|
config_dict = {
|
|
187
556
|
"adapter": AdapterType.JIRA.value,
|
|
188
|
-
"server":
|
|
189
|
-
"email":
|
|
190
|
-
"api_token":
|
|
557
|
+
"server": final_server.rstrip("/"),
|
|
558
|
+
"email": final_email,
|
|
559
|
+
"api_token": final_api_token,
|
|
191
560
|
}
|
|
192
561
|
|
|
193
|
-
if
|
|
194
|
-
config_dict["project_key"] =
|
|
562
|
+
if final_project_key:
|
|
563
|
+
config_dict["project_key"] = final_project_key
|
|
195
564
|
|
|
196
565
|
# Validate
|
|
197
566
|
is_valid, error = ConfigValidator.validate_jira_config(config_dict)
|
|
198
567
|
if not is_valid:
|
|
199
|
-
|
|
200
|
-
|
|
568
|
+
if interactive:
|
|
569
|
+
console.print(f"[red]Configuration error: {error}[/red]")
|
|
570
|
+
raise typer.Exit(1) from None
|
|
571
|
+
raise ValueError(f"JIRA configuration validation failed: {error}")
|
|
572
|
+
|
|
573
|
+
# ============================================================
|
|
574
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
575
|
+
# ============================================================
|
|
576
|
+
default_values = {}
|
|
577
|
+
|
|
578
|
+
if interactive:
|
|
579
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
580
|
+
console.print("Configure default values for ticket creation:")
|
|
581
|
+
|
|
582
|
+
# Default user/assignee
|
|
583
|
+
user_input = Prompt.ask(
|
|
584
|
+
"Default assignee/user (optional, JIRA username or email)",
|
|
585
|
+
default="",
|
|
586
|
+
show_default=False,
|
|
587
|
+
)
|
|
588
|
+
if user_input:
|
|
589
|
+
default_values["default_user"] = user_input
|
|
590
|
+
console.print(
|
|
591
|
+
f"[green]✓[/green] Will use '{user_input}' as default assignee"
|
|
592
|
+
)
|
|
201
593
|
|
|
202
|
-
|
|
594
|
+
# Default epic/project
|
|
595
|
+
epic_input = Prompt.ask(
|
|
596
|
+
"Default epic/project ID (optional, e.g., 'PROJ-123')",
|
|
597
|
+
default="",
|
|
598
|
+
show_default=False,
|
|
599
|
+
)
|
|
600
|
+
if epic_input:
|
|
601
|
+
default_values["default_epic"] = epic_input
|
|
602
|
+
default_values["default_project"] = epic_input # Compatibility
|
|
603
|
+
console.print(
|
|
604
|
+
f"[green]✓[/green] Will use '{epic_input}' as default epic/project"
|
|
605
|
+
)
|
|
203
606
|
|
|
607
|
+
# Default tags
|
|
608
|
+
tags_input = Prompt.ask(
|
|
609
|
+
"Default tags/labels (optional, comma-separated, e.g., 'bug,urgent')",
|
|
610
|
+
default="",
|
|
611
|
+
show_default=False,
|
|
612
|
+
)
|
|
613
|
+
if tags_input:
|
|
614
|
+
tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
|
|
615
|
+
if tags_list:
|
|
616
|
+
default_values["default_tags"] = tags_list
|
|
617
|
+
console.print(f"[green]✓[/green] Will use tags: {', '.join(tags_list)}")
|
|
204
618
|
|
|
205
|
-
|
|
206
|
-
"""Configure GitHub adapter."""
|
|
207
|
-
console.print("\n[bold]Configure GitHub Integration:[/bold]")
|
|
619
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
208
620
|
|
|
209
|
-
# Token
|
|
210
|
-
token = os.getenv("GITHUB_TOKEN") or ""
|
|
211
|
-
if token:
|
|
212
|
-
console.print("[dim]Found GITHUB_TOKEN in environment[/dim]")
|
|
213
|
-
use_env = Confirm.ask("Use this token?", default=True)
|
|
214
|
-
if not use_env:
|
|
215
|
-
token = ""
|
|
216
621
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
622
|
+
def _configure_github(
|
|
623
|
+
interactive: bool = True,
|
|
624
|
+
token: str | None = None,
|
|
625
|
+
repo_url: str | None = None,
|
|
626
|
+
owner: str | None = None,
|
|
627
|
+
repo: str | None = None,
|
|
628
|
+
**kwargs: Any,
|
|
629
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
630
|
+
"""Configure GitHub adapter.
|
|
631
|
+
|
|
632
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
----
|
|
636
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
637
|
+
token: Pre-provided GitHub Personal Access Token (optional)
|
|
638
|
+
repo_url: Pre-provided GitHub repository URL (optional, preferred)
|
|
639
|
+
owner: Pre-provided repository owner (optional, fallback)
|
|
640
|
+
repo: Pre-provided repository name (optional, fallback)
|
|
641
|
+
**kwargs: Additional configuration parameters
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
-------
|
|
645
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
646
|
+
- AdapterConfig: Configured GitHub adapter configuration
|
|
647
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
648
|
+
|
|
649
|
+
"""
|
|
650
|
+
if interactive:
|
|
651
|
+
console.print("\n[bold]Configure GitHub Integration:[/bold]")
|
|
652
|
+
|
|
653
|
+
# Token (programmatic mode: use provided value or env, interactive: prompt)
|
|
654
|
+
final_token = token or os.getenv("GITHUB_TOKEN") or ""
|
|
655
|
+
if interactive:
|
|
656
|
+
if final_token:
|
|
657
|
+
console.print("[dim]Found GITHUB_TOKEN in environment[/dim]")
|
|
658
|
+
use_env = Confirm.ask("Use this token?", default=True)
|
|
659
|
+
if not use_env:
|
|
660
|
+
final_token = ""
|
|
661
|
+
|
|
662
|
+
if not final_token:
|
|
663
|
+
console.print(
|
|
664
|
+
"[dim]Create token at: https://github.com/settings/tokens/new[/dim]"
|
|
665
|
+
)
|
|
666
|
+
console.print(
|
|
667
|
+
"[dim]Required scopes: repo (or public_repo for public repos)[/dim]"
|
|
668
|
+
)
|
|
669
|
+
final_token = Prompt.ask("GitHub Personal Access Token", password=True)
|
|
670
|
+
elif not final_token:
|
|
671
|
+
raise ValueError(
|
|
672
|
+
"GitHub token is required (provide token parameter or set GITHUB_TOKEN environment variable)"
|
|
220
673
|
)
|
|
674
|
+
|
|
675
|
+
# Repository URL/Owner/Repo - Prioritize repo_url, fallback to owner/repo
|
|
676
|
+
# Programmatic mode: use repo_url or GITHUB_REPO_URL env, fallback to owner/repo
|
|
677
|
+
final_owner = ""
|
|
678
|
+
final_repo = ""
|
|
679
|
+
|
|
680
|
+
# Step 1: Try to get URL from parameter or environment
|
|
681
|
+
url_input = repo_url or os.getenv("GITHUB_REPO_URL") or ""
|
|
682
|
+
|
|
683
|
+
# Step 2: Parse URL if provided
|
|
684
|
+
if url_input:
|
|
685
|
+
from ..core.url_parser import parse_github_repo_url
|
|
686
|
+
|
|
687
|
+
parsed_owner, parsed_repo, error = parse_github_repo_url(url_input)
|
|
688
|
+
if parsed_owner and parsed_repo:
|
|
689
|
+
final_owner = parsed_owner
|
|
690
|
+
final_repo = parsed_repo
|
|
691
|
+
if interactive:
|
|
692
|
+
console.print(
|
|
693
|
+
f"[dim]✓ Extracted repository: {final_owner}/{final_repo}[/dim]"
|
|
694
|
+
)
|
|
695
|
+
else:
|
|
696
|
+
# URL parsing failed
|
|
697
|
+
if interactive:
|
|
698
|
+
console.print(f"[yellow]Warning: {error}[/yellow]")
|
|
699
|
+
else:
|
|
700
|
+
raise ValueError(f"Failed to parse GitHub repository URL: {error}")
|
|
701
|
+
|
|
702
|
+
# Step 3: Interactive mode - prompt for URL if not provided
|
|
703
|
+
if interactive and not final_owner and not final_repo:
|
|
221
704
|
console.print(
|
|
222
|
-
"[dim]
|
|
705
|
+
"[dim]Enter your GitHub repository URL (e.g., https://github.com/owner/repo)[/dim]"
|
|
223
706
|
)
|
|
224
|
-
token = Prompt.ask("GitHub Personal Access Token", password=True)
|
|
225
707
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
708
|
+
# Keep prompting until we get a valid URL
|
|
709
|
+
while not final_owner or not final_repo:
|
|
710
|
+
from ..core.url_parser import parse_github_repo_url
|
|
711
|
+
|
|
712
|
+
url_prompt = Prompt.ask(
|
|
713
|
+
"GitHub Repository URL",
|
|
714
|
+
default="https://github.com/",
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
parsed_owner, parsed_repo, error = parse_github_repo_url(url_prompt)
|
|
718
|
+
if parsed_owner and parsed_repo:
|
|
719
|
+
final_owner = parsed_owner
|
|
720
|
+
final_repo = parsed_repo
|
|
721
|
+
console.print(f"[dim]✓ Repository: {final_owner}/{final_repo}[/dim]")
|
|
722
|
+
break
|
|
723
|
+
else:
|
|
724
|
+
console.print(f"[red]Error: {error}[/red]")
|
|
725
|
+
console.print(
|
|
726
|
+
"[yellow]Please enter a valid GitHub repository URL[/yellow]"
|
|
727
|
+
)
|
|
230
728
|
|
|
231
|
-
#
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
729
|
+
# Step 4: Non-interactive fallback - use individual owner/repo parameters
|
|
730
|
+
if not final_owner or not final_repo:
|
|
731
|
+
fallback_owner = owner or os.getenv("GITHUB_OWNER") or ""
|
|
732
|
+
fallback_repo = repo or os.getenv("GITHUB_REPO") or ""
|
|
733
|
+
|
|
734
|
+
# In non-interactive mode, both must be provided if URL wasn't
|
|
735
|
+
if not interactive:
|
|
736
|
+
if not fallback_owner or not fallback_repo:
|
|
737
|
+
raise ValueError(
|
|
738
|
+
"GitHub repository is required. Provide either:\n"
|
|
739
|
+
" - repo_url parameter or GITHUB_REPO_URL environment variable, OR\n"
|
|
740
|
+
" - Both owner and repo parameters (or GITHUB_OWNER/GITHUB_REPO environment variables)"
|
|
741
|
+
)
|
|
742
|
+
final_owner = fallback_owner
|
|
743
|
+
final_repo = fallback_repo
|
|
744
|
+
else:
|
|
745
|
+
# Interactive mode with fallback values
|
|
746
|
+
if fallback_owner:
|
|
747
|
+
final_owner = fallback_owner
|
|
748
|
+
if fallback_repo:
|
|
749
|
+
final_repo = fallback_repo
|
|
235
750
|
|
|
236
751
|
config_dict = {
|
|
237
752
|
"adapter": AdapterType.GITHUB.value,
|
|
238
|
-
"token":
|
|
239
|
-
"owner":
|
|
240
|
-
"repo":
|
|
241
|
-
"project_id": f"{
|
|
753
|
+
"token": final_token,
|
|
754
|
+
"owner": final_owner,
|
|
755
|
+
"repo": final_repo,
|
|
756
|
+
"project_id": f"{final_owner}/{final_repo}", # Convenience field
|
|
242
757
|
}
|
|
243
758
|
|
|
244
759
|
# Validate
|
|
245
760
|
is_valid, error = ConfigValidator.validate_github_config(config_dict)
|
|
246
761
|
if not is_valid:
|
|
247
|
-
|
|
248
|
-
|
|
762
|
+
if interactive:
|
|
763
|
+
console.print(f"[red]Configuration error: {error}[/red]")
|
|
764
|
+
raise typer.Exit(1) from None
|
|
765
|
+
raise ValueError(f"GitHub configuration validation failed: {error}")
|
|
766
|
+
|
|
767
|
+
# ============================================================
|
|
768
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
769
|
+
# ============================================================
|
|
770
|
+
default_values = {}
|
|
771
|
+
|
|
772
|
+
if interactive:
|
|
773
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
774
|
+
console.print("Configure default values for ticket creation:")
|
|
775
|
+
|
|
776
|
+
# Default user/assignee
|
|
777
|
+
user_input = Prompt.ask(
|
|
778
|
+
"Default assignee/user (optional, GitHub username)",
|
|
779
|
+
default="",
|
|
780
|
+
show_default=False,
|
|
781
|
+
)
|
|
782
|
+
if user_input:
|
|
783
|
+
default_values["default_user"] = user_input
|
|
784
|
+
console.print(
|
|
785
|
+
f"[green]✓[/green] Will use '{user_input}' as default assignee"
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
# Default epic/project (milestone for GitHub)
|
|
789
|
+
epic_input = Prompt.ask(
|
|
790
|
+
"Default milestone/project (optional, e.g., 'v1.0' or milestone number)",
|
|
791
|
+
default="",
|
|
792
|
+
show_default=False,
|
|
793
|
+
)
|
|
794
|
+
if epic_input:
|
|
795
|
+
default_values["default_epic"] = epic_input
|
|
796
|
+
default_values["default_project"] = epic_input # Compatibility
|
|
797
|
+
console.print(
|
|
798
|
+
f"[green]✓[/green] Will use '{epic_input}' as default milestone/project"
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
# Default tags (labels for GitHub)
|
|
802
|
+
tags_input = Prompt.ask(
|
|
803
|
+
"Default labels (optional, comma-separated, e.g., 'bug,enhancement')",
|
|
804
|
+
default="",
|
|
805
|
+
show_default=False,
|
|
806
|
+
)
|
|
807
|
+
if tags_input:
|
|
808
|
+
tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
|
|
809
|
+
if tags_list:
|
|
810
|
+
default_values["default_tags"] = tags_list
|
|
811
|
+
console.print(
|
|
812
|
+
f"[green]✓[/green] Will use labels: {', '.join(tags_list)}"
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
249
816
|
|
|
250
|
-
return AdapterConfig.from_dict(config_dict)
|
|
251
817
|
|
|
818
|
+
def _configure_aitrackdown(
|
|
819
|
+
interactive: bool = True, base_path: str | None = None, **kwargs: Any
|
|
820
|
+
) -> tuple[AdapterConfig, dict[str, Any]]:
|
|
821
|
+
"""Configure AITrackdown adapter.
|
|
252
822
|
|
|
253
|
-
|
|
254
|
-
"""Configure AITrackdown adapter."""
|
|
255
|
-
console.print("\n[bold]Configure AITrackdown (File-based):[/bold]")
|
|
823
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
256
824
|
|
|
257
|
-
|
|
825
|
+
Args:
|
|
826
|
+
----
|
|
827
|
+
interactive: If True, prompt user for missing values (default: True)
|
|
828
|
+
base_path: Pre-provided base path for ticket storage (optional)
|
|
829
|
+
**kwargs: Additional configuration parameters
|
|
830
|
+
|
|
831
|
+
Returns:
|
|
832
|
+
-------
|
|
833
|
+
Tuple of (AdapterConfig, default_values_dict)
|
|
834
|
+
- AdapterConfig: Configured AITrackdown adapter configuration
|
|
835
|
+
- default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
|
|
836
|
+
|
|
837
|
+
"""
|
|
838
|
+
if interactive:
|
|
839
|
+
console.print("\n[bold]Configure AITrackdown (File-based):[/bold]")
|
|
840
|
+
|
|
841
|
+
# Base path (programmatic mode: use provided value or default, interactive: prompt)
|
|
842
|
+
final_base_path = base_path or ".aitrackdown"
|
|
843
|
+
if interactive:
|
|
844
|
+
final_base_path = Prompt.ask(
|
|
845
|
+
"Base path for ticket storage", default=".aitrackdown"
|
|
846
|
+
)
|
|
258
847
|
|
|
259
848
|
config_dict = {
|
|
260
849
|
"adapter": AdapterType.AITRACKDOWN.value,
|
|
261
|
-
"base_path":
|
|
850
|
+
"base_path": final_base_path,
|
|
262
851
|
}
|
|
263
852
|
|
|
264
|
-
|
|
853
|
+
# ============================================================
|
|
854
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
855
|
+
# ============================================================
|
|
856
|
+
default_values = {}
|
|
857
|
+
|
|
858
|
+
if interactive:
|
|
859
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
860
|
+
console.print("Configure default values for ticket creation:")
|
|
861
|
+
|
|
862
|
+
# Default user/assignee
|
|
863
|
+
user_input = Prompt.ask(
|
|
864
|
+
"Default assignee/user (optional)", default="", show_default=False
|
|
865
|
+
)
|
|
866
|
+
if user_input:
|
|
867
|
+
default_values["default_user"] = user_input
|
|
868
|
+
console.print(
|
|
869
|
+
f"[green]✓[/green] Will use '{user_input}' as default assignee"
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
# Default epic/project
|
|
873
|
+
epic_input = Prompt.ask(
|
|
874
|
+
"Default epic/project ID (optional)", default="", show_default=False
|
|
875
|
+
)
|
|
876
|
+
if epic_input:
|
|
877
|
+
default_values["default_epic"] = epic_input
|
|
878
|
+
default_values["default_project"] = epic_input # Compatibility
|
|
879
|
+
console.print(
|
|
880
|
+
f"[green]✓[/green] Will use '{epic_input}' as default epic/project"
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
# Default tags
|
|
884
|
+
tags_input = Prompt.ask(
|
|
885
|
+
"Default tags (optional, comma-separated)", default="", show_default=False
|
|
886
|
+
)
|
|
887
|
+
if tags_input:
|
|
888
|
+
tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
|
|
889
|
+
if tags_list:
|
|
890
|
+
default_values["default_tags"] = tags_list
|
|
891
|
+
console.print(f"[green]✓[/green] Will use tags: {', '.join(tags_list)}")
|
|
892
|
+
|
|
893
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def prompt_default_values(
|
|
897
|
+
adapter_type: str,
|
|
898
|
+
existing_values: dict[str, Any] | None = None,
|
|
899
|
+
) -> dict[str, Any]:
|
|
900
|
+
"""Prompt user for default values (for ticket creation).
|
|
901
|
+
|
|
902
|
+
This is a standalone function that can be called independently of adapter configuration.
|
|
903
|
+
Used when adapter credentials exist but default values need to be set or updated.
|
|
904
|
+
|
|
905
|
+
Args:
|
|
906
|
+
----
|
|
907
|
+
adapter_type: Type of adapter (linear, jira, github, aitrackdown)
|
|
908
|
+
existing_values: Optional existing default values to show as current values
|
|
909
|
+
|
|
910
|
+
Returns:
|
|
911
|
+
-------
|
|
912
|
+
Dictionary containing default_user, default_epic, default_project, default_tags
|
|
913
|
+
(only includes keys that were provided by the user)
|
|
914
|
+
|
|
915
|
+
"""
|
|
916
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
917
|
+
console.print("Configure default values for ticket creation:")
|
|
918
|
+
|
|
919
|
+
default_values = {}
|
|
920
|
+
existing_values = existing_values or {}
|
|
921
|
+
|
|
922
|
+
# Default user/assignee
|
|
923
|
+
current_user = existing_values.get("default_user", "")
|
|
924
|
+
if current_user:
|
|
925
|
+
user_input = Prompt.ask(
|
|
926
|
+
f"Default assignee/user (optional) [current: {current_user}]",
|
|
927
|
+
default=current_user,
|
|
928
|
+
show_default=False,
|
|
929
|
+
)
|
|
930
|
+
else:
|
|
931
|
+
user_input = Prompt.ask(
|
|
932
|
+
"Default assignee/user (optional)",
|
|
933
|
+
default="",
|
|
934
|
+
show_default=False,
|
|
935
|
+
)
|
|
936
|
+
if user_input:
|
|
937
|
+
default_values["default_user"] = user_input
|
|
938
|
+
console.print(f"[green]✓[/green] Will use '{user_input}' as default assignee")
|
|
939
|
+
|
|
940
|
+
# Default epic/project
|
|
941
|
+
current_epic = existing_values.get("default_epic") or existing_values.get(
|
|
942
|
+
"default_project", ""
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
# Adapter-specific messaging
|
|
946
|
+
if adapter_type == "github":
|
|
947
|
+
epic_label = "milestone/project"
|
|
948
|
+
epic_example = "e.g., 'v1.0' or milestone number"
|
|
949
|
+
elif adapter_type == "linear":
|
|
950
|
+
epic_label = "epic/project ID"
|
|
951
|
+
epic_example = "e.g., 'PROJ-123' or UUID"
|
|
952
|
+
else:
|
|
953
|
+
epic_label = "epic/project ID"
|
|
954
|
+
epic_example = "e.g., 'PROJ-123'"
|
|
955
|
+
|
|
956
|
+
if current_epic:
|
|
957
|
+
epic_input = Prompt.ask(
|
|
958
|
+
f"Default {epic_label} (optional) [current: {current_epic}]",
|
|
959
|
+
default=current_epic,
|
|
960
|
+
show_default=False,
|
|
961
|
+
)
|
|
962
|
+
else:
|
|
963
|
+
epic_input = Prompt.ask(
|
|
964
|
+
f"Default {epic_label} (optional, {epic_example})",
|
|
965
|
+
default="",
|
|
966
|
+
show_default=False,
|
|
967
|
+
)
|
|
968
|
+
if epic_input:
|
|
969
|
+
default_values["default_epic"] = epic_input
|
|
970
|
+
default_values["default_project"] = epic_input # Compatibility
|
|
971
|
+
console.print(
|
|
972
|
+
f"[green]✓[/green] Will use '{epic_input}' as default {epic_label}"
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
# Default tags
|
|
976
|
+
current_tags = existing_values.get("default_tags", [])
|
|
977
|
+
current_tags_str = ", ".join(current_tags) if current_tags else ""
|
|
978
|
+
|
|
979
|
+
# Adapter-specific messaging
|
|
980
|
+
tags_label = "labels" if adapter_type == "github" else "tags/labels"
|
|
981
|
+
|
|
982
|
+
if current_tags_str:
|
|
983
|
+
tags_input = Prompt.ask(
|
|
984
|
+
f"Default {tags_label} (optional, comma-separated) [current: {current_tags_str}]",
|
|
985
|
+
default=current_tags_str,
|
|
986
|
+
show_default=False,
|
|
987
|
+
)
|
|
988
|
+
else:
|
|
989
|
+
tags_input = Prompt.ask(
|
|
990
|
+
f"Default {tags_label} (optional, comma-separated, e.g., 'bug,urgent')",
|
|
991
|
+
default="",
|
|
992
|
+
show_default=False,
|
|
993
|
+
)
|
|
994
|
+
if tags_input:
|
|
995
|
+
tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
|
|
996
|
+
if tags_list:
|
|
997
|
+
default_values["default_tags"] = tags_list
|
|
998
|
+
console.print(
|
|
999
|
+
f"[green]✓[/green] Will use {tags_label}: {', '.join(tags_list)}"
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
return default_values
|
|
265
1003
|
|
|
266
1004
|
|
|
267
1005
|
def _configure_hybrid_mode() -> TicketerConfig:
|
|
@@ -295,24 +1033,29 @@ def _configure_hybrid_mode() -> TicketerConfig:
|
|
|
295
1033
|
|
|
296
1034
|
if len(selected_adapters) < 2:
|
|
297
1035
|
console.print("[red]Hybrid mode requires at least 2 adapters[/red]")
|
|
298
|
-
raise typer.Exit(1)
|
|
1036
|
+
raise typer.Exit(1) from None
|
|
299
1037
|
|
|
300
1038
|
# Configure each adapter
|
|
301
1039
|
adapters = {}
|
|
1040
|
+
default_values: dict[str, str] = {}
|
|
302
1041
|
for adapter_type in selected_adapters:
|
|
303
1042
|
console.print(f"\n[cyan]Configuring {adapter_type.value}...[/cyan]")
|
|
304
1043
|
|
|
305
1044
|
if adapter_type == AdapterType.LINEAR:
|
|
306
|
-
adapter_config = _configure_linear()
|
|
1045
|
+
adapter_config, adapter_defaults = _configure_linear(interactive=True)
|
|
307
1046
|
elif adapter_type == AdapterType.JIRA:
|
|
308
|
-
adapter_config = _configure_jira()
|
|
1047
|
+
adapter_config, adapter_defaults = _configure_jira(interactive=True)
|
|
309
1048
|
elif adapter_type == AdapterType.GITHUB:
|
|
310
|
-
adapter_config = _configure_github()
|
|
1049
|
+
adapter_config, adapter_defaults = _configure_github(interactive=True)
|
|
311
1050
|
else:
|
|
312
|
-
adapter_config = _configure_aitrackdown()
|
|
1051
|
+
adapter_config, adapter_defaults = _configure_aitrackdown(interactive=True)
|
|
313
1052
|
|
|
314
1053
|
adapters[adapter_type.value] = adapter_config
|
|
315
1054
|
|
|
1055
|
+
# Only save defaults from the first/primary adapter
|
|
1056
|
+
if not default_values:
|
|
1057
|
+
default_values = adapter_defaults
|
|
1058
|
+
|
|
316
1059
|
# Select primary adapter
|
|
317
1060
|
console.print("\n[bold]Select primary adapter (source of truth):[/bold]")
|
|
318
1061
|
for idx, adapter_type in enumerate(selected_adapters, 1):
|
|
@@ -352,9 +1095,15 @@ def _configure_hybrid_mode() -> TicketerConfig:
|
|
|
352
1095
|
sync_strategy=sync_strategy,
|
|
353
1096
|
)
|
|
354
1097
|
|
|
355
|
-
# Create full config
|
|
1098
|
+
# Create full config with default values
|
|
356
1099
|
config = TicketerConfig(
|
|
357
|
-
default_adapter=primary_adapter,
|
|
1100
|
+
default_adapter=primary_adapter,
|
|
1101
|
+
adapters=adapters,
|
|
1102
|
+
hybrid_mode=hybrid_config,
|
|
1103
|
+
default_user=default_values.get("default_user"),
|
|
1104
|
+
default_project=default_values.get("default_project"),
|
|
1105
|
+
default_epic=default_values.get("default_epic"),
|
|
1106
|
+
default_tags=default_values.get("default_tags"),
|
|
358
1107
|
)
|
|
359
1108
|
|
|
360
1109
|
return config
|
|
@@ -365,31 +1114,17 @@ def show_current_config() -> None:
|
|
|
365
1114
|
resolver = ConfigResolver()
|
|
366
1115
|
|
|
367
1116
|
# Try to load configs
|
|
368
|
-
global_config = resolver.load_global_config()
|
|
369
1117
|
project_config = resolver.load_project_config()
|
|
370
1118
|
|
|
371
1119
|
console.print("[bold]Current Configuration:[/bold]\n")
|
|
372
1120
|
|
|
373
|
-
#
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if global_config.adapters:
|
|
379
|
-
table = Table(title="Global Adapters")
|
|
380
|
-
table.add_column("Adapter", style="cyan")
|
|
381
|
-
table.add_column("Configured", style="green")
|
|
382
|
-
|
|
383
|
-
for name, config in global_config.adapters.items():
|
|
384
|
-
configured = "✓" if config.enabled else "✗"
|
|
385
|
-
table.add_row(name, configured)
|
|
386
|
-
|
|
387
|
-
console.print(table)
|
|
388
|
-
else:
|
|
389
|
-
console.print("[yellow]No global configuration found[/yellow]")
|
|
1121
|
+
# Note about global config deprecation
|
|
1122
|
+
console.print(
|
|
1123
|
+
"[dim]Note: Global config has been deprecated for security reasons.[/dim]"
|
|
1124
|
+
)
|
|
1125
|
+
console.print("[dim]All configuration is now project-specific only.[/dim]\n")
|
|
390
1126
|
|
|
391
1127
|
# Project config
|
|
392
|
-
console.print()
|
|
393
1128
|
project_config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
|
|
394
1129
|
if project_config_path.exists():
|
|
395
1130
|
console.print(f"[cyan]Project:[/cyan] {project_config_path}")
|
|
@@ -444,11 +1179,12 @@ def set_adapter_config(
|
|
|
444
1179
|
project_id: str | None = None,
|
|
445
1180
|
team_id: str | None = None,
|
|
446
1181
|
global_scope: bool = False,
|
|
447
|
-
**kwargs,
|
|
1182
|
+
**kwargs: Any,
|
|
448
1183
|
) -> None:
|
|
449
1184
|
"""Set specific adapter configuration values.
|
|
450
1185
|
|
|
451
1186
|
Args:
|
|
1187
|
+
----
|
|
452
1188
|
adapter: Adapter type to set as default
|
|
453
1189
|
api_key: API key/token
|
|
454
1190
|
project_id: Project ID
|
|
@@ -497,11 +1233,13 @@ def set_adapter_config(
|
|
|
497
1233
|
|
|
498
1234
|
console.print(f"[green]✓[/green] Updated {target_adapter} configuration")
|
|
499
1235
|
|
|
500
|
-
# Save config
|
|
1236
|
+
# Save config (always to project config for security)
|
|
1237
|
+
resolver.save_project_config(config)
|
|
1238
|
+
config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
|
|
1239
|
+
|
|
501
1240
|
if global_scope:
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
console.print(f"[dim]Saved to {config_path}[/dim]")
|
|
1241
|
+
console.print(
|
|
1242
|
+
"[yellow]Note: Global config deprecated for security. Saved to project config instead.[/yellow]"
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
console.print(f"[dim]Saved to {config_path}[/dim]")
|