mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__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 +796 -46
- 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 +879 -129
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +973 -73
- mcp_ticketer/adapters/linear/__init__.py +24 -0
- mcp_ticketer/adapters/linear/adapter.py +2732 -0
- mcp_ticketer/adapters/linear/client.py +344 -0
- mcp_ticketer/adapters/linear/mappers.py +420 -0
- mcp_ticketer/adapters/linear/queries.py +479 -0
- mcp_ticketer/adapters/linear/types.py +360 -0
- mcp_ticketer/adapters/linear.py +10 -2315
- mcp_ticketer/analysis/__init__.py +23 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +421 -0
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +888 -151
- mcp_ticketer/cli/diagnostics.py +400 -157
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +616 -0
- mcp_ticketer/cli/main.py +203 -1165
- mcp_ticketer/cli/mcp_configure.py +474 -90
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +418 -0
- mcp_ticketer/cli/platform_installer.py +513 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +90 -65
- mcp_ticketer/cli/ticket_commands.py +1013 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +114 -66
- mcp_ticketer/core/__init__.py +24 -1
- mcp_ticketer/core/adapter.py +250 -16
- mcp_ticketer/core/config.py +145 -37
- mcp_ticketer/core/env_discovery.py +101 -22
- mcp_ticketer/core/env_loader.py +349 -0
- mcp_ticketer/core/exceptions.py +160 -0
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/models.py +280 -28
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +183 -49
- mcp_ticketer/core/registry.py +3 -3
- 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 +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +56 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +95 -25
- mcp_ticketer/queue/queue.py +40 -21
- mcp_ticketer/queue/run_worker.py +6 -1
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +109 -49
- mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
- mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
- mcp_ticketer/mcp/server.py +0 -1895
- mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
- mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +0 -0
mcp_ticketer/cli/configure.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Interactive configuration wizard for MCP Ticketer."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
from
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
5
6
|
|
|
6
7
|
import typer
|
|
7
8
|
from rich.console import Console
|
|
@@ -22,6 +23,61 @@ from ..core.project_config import (
|
|
|
22
23
|
console = Console()
|
|
23
24
|
|
|
24
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
|
+
|
|
25
81
|
def configure_wizard() -> None:
|
|
26
82
|
"""Run interactive configuration wizard."""
|
|
27
83
|
console.print(
|
|
@@ -46,24 +102,25 @@ def configure_wizard() -> None:
|
|
|
46
102
|
|
|
47
103
|
# Step 2: Choose where to save
|
|
48
104
|
console.print("\n[bold]Step 2: Configuration Scope[/bold]")
|
|
49
|
-
console.print(
|
|
50
|
-
|
|
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")
|
|
51
109
|
|
|
52
|
-
scope = Prompt.ask("Save configuration as", choices=["1", "2"], default="
|
|
110
|
+
scope = Prompt.ask("Save configuration as", choices=["1", "2"], default="1")
|
|
53
111
|
|
|
54
112
|
resolver = ConfigResolver()
|
|
55
113
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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":
|
|
59
119
|
console.print(
|
|
60
|
-
|
|
120
|
+
"[yellow]Note: Global config is deprecated for security. Saving to project config instead.[/yellow]"
|
|
61
121
|
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
resolver.save_project_config(config)
|
|
65
|
-
config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
|
|
66
|
-
console.print(f"\n[green]✓[/green] Configuration saved to {config_path}")
|
|
122
|
+
|
|
123
|
+
console.print(f"\n[green]✓[/green] Configuration saved to {config_path}")
|
|
67
124
|
|
|
68
125
|
# Show usage instructions
|
|
69
126
|
console.print("\n[bold]Usage:[/bold]")
|
|
@@ -96,173 +153,853 @@ def _configure_single_adapter() -> TicketerConfig:
|
|
|
96
153
|
adapter_type = adapter_type_map[adapter_choice]
|
|
97
154
|
|
|
98
155
|
# Configure the selected adapter
|
|
156
|
+
default_values: dict[str, str] = {}
|
|
99
157
|
if adapter_type == AdapterType.LINEAR:
|
|
100
|
-
adapter_config = _configure_linear()
|
|
158
|
+
adapter_config, default_values = _configure_linear(interactive=True)
|
|
101
159
|
elif adapter_type == AdapterType.JIRA:
|
|
102
|
-
adapter_config = _configure_jira()
|
|
160
|
+
adapter_config, default_values = _configure_jira(interactive=True)
|
|
103
161
|
elif adapter_type == AdapterType.GITHUB:
|
|
104
|
-
adapter_config = _configure_github()
|
|
162
|
+
adapter_config, default_values = _configure_github(interactive=True)
|
|
105
163
|
else:
|
|
106
|
-
adapter_config = _configure_aitrackdown()
|
|
164
|
+
adapter_config, default_values = _configure_aitrackdown(interactive=True)
|
|
107
165
|
|
|
108
|
-
# Create config
|
|
166
|
+
# Create config with default values
|
|
109
167
|
config = TicketerConfig(
|
|
110
168
|
default_adapter=adapter_type.value,
|
|
111
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"),
|
|
112
174
|
)
|
|
113
175
|
|
|
114
176
|
return config
|
|
115
177
|
|
|
116
178
|
|
|
117
|
-
def _configure_linear(
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
120
188
|
|
|
121
|
-
|
|
122
|
-
api_key = os.getenv("LINEAR_API_KEY") or ""
|
|
123
|
-
if api_key:
|
|
124
|
-
console.print("[dim]Found LINEAR_API_KEY in environment[/dim]")
|
|
125
|
-
use_env = Confirm.ask("Use this API key?", default=True)
|
|
126
|
-
if not use_env:
|
|
127
|
-
api_key = ""
|
|
189
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
128
190
|
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
131
205
|
|
|
132
|
-
|
|
133
|
-
|
|
206
|
+
"""
|
|
207
|
+
if interactive:
|
|
208
|
+
console.print("\n[bold cyan]Linear Configuration[/bold cyan]")
|
|
134
209
|
|
|
135
|
-
#
|
|
136
|
-
|
|
210
|
+
# Check if we have existing config
|
|
211
|
+
has_existing = (
|
|
212
|
+
existing_config is not None and existing_config.get("adapter") == "linear"
|
|
213
|
+
)
|
|
137
214
|
|
|
138
|
-
|
|
139
|
-
project_id = Prompt.ask("Project ID (optional)", default="")
|
|
215
|
+
config_dict: dict[str, Any] = {"adapter": AdapterType.LINEAR.value}
|
|
140
216
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
)
|
|
145
246
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if
|
|
151
|
-
|
|
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
|
+
)
|
|
152
275
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
)
|
|
328
|
+
|
|
329
|
+
def validate_user_email(email: str) -> tuple[bool, str | None]:
|
|
330
|
+
if not email: # Optional field
|
|
331
|
+
return True, None
|
|
332
|
+
import re
|
|
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
|
|
340
|
+
|
|
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")
|
|
158
347
|
|
|
159
|
-
|
|
348
|
+
# ============================================================
|
|
349
|
+
# DEFAULT VALUES SECTION (for ticket creation)
|
|
350
|
+
# ============================================================
|
|
160
351
|
|
|
352
|
+
console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
|
|
353
|
+
console.print("Configure default values for ticket creation:")
|
|
161
354
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
355
|
+
# Default epic/project
|
|
356
|
+
current_default_epic = (
|
|
357
|
+
config_dict.get("default_epic", "") if has_existing else ""
|
|
358
|
+
)
|
|
359
|
+
|
|
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
|
|
165
511
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
)
|
|
170
526
|
|
|
171
|
-
# Email
|
|
172
|
-
|
|
173
|
-
if not
|
|
174
|
-
|
|
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
|
+
)
|
|
175
535
|
|
|
176
|
-
# API Token
|
|
177
|
-
|
|
178
|
-
if not
|
|
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:
|
|
179
539
|
console.print(
|
|
180
540
|
"[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]"
|
|
181
541
|
)
|
|
182
|
-
|
|
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
|
+
)
|
|
183
547
|
|
|
184
|
-
# Project Key
|
|
185
|
-
|
|
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
|
+
)
|
|
186
554
|
|
|
187
555
|
config_dict = {
|
|
188
556
|
"adapter": AdapterType.JIRA.value,
|
|
189
|
-
"server":
|
|
190
|
-
"email":
|
|
191
|
-
"api_token":
|
|
557
|
+
"server": final_server.rstrip("/"),
|
|
558
|
+
"email": final_email,
|
|
559
|
+
"api_token": final_api_token,
|
|
192
560
|
}
|
|
193
561
|
|
|
194
|
-
if
|
|
195
|
-
config_dict["project_key"] =
|
|
562
|
+
if final_project_key:
|
|
563
|
+
config_dict["project_key"] = final_project_key
|
|
196
564
|
|
|
197
565
|
# Validate
|
|
198
566
|
is_valid, error = ConfigValidator.validate_jira_config(config_dict)
|
|
199
567
|
if not is_valid:
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
)
|
|
593
|
+
|
|
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
|
+
)
|
|
202
606
|
|
|
203
|
-
|
|
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
|
|
|
619
|
+
return AdapterConfig.from_dict(config_dict), default_values
|
|
205
620
|
|
|
206
|
-
def _configure_github() -> AdapterConfig:
|
|
207
|
-
"""Configure GitHub adapter."""
|
|
208
|
-
console.print("\n[bold]Configure GitHub Integration:[/bold]")
|
|
209
621
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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.
|
|
217
631
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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)"
|
|
221
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:
|
|
222
704
|
console.print(
|
|
223
|
-
"[dim]
|
|
705
|
+
"[dim]Enter your GitHub repository URL (e.g., https://github.com/owner/repo)[/dim]"
|
|
224
706
|
)
|
|
225
|
-
token = Prompt.ask("GitHub Personal Access Token", password=True)
|
|
226
707
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
+
)
|
|
231
728
|
|
|
232
|
-
#
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
236
750
|
|
|
237
751
|
config_dict = {
|
|
238
752
|
"adapter": AdapterType.GITHUB.value,
|
|
239
|
-
"token":
|
|
240
|
-
"owner":
|
|
241
|
-
"repo":
|
|
242
|
-
"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
|
|
243
757
|
}
|
|
244
758
|
|
|
245
759
|
# Validate
|
|
246
760
|
is_valid, error = ConfigValidator.validate_github_config(config_dict)
|
|
247
761
|
if not is_valid:
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
816
|
+
|
|
250
817
|
|
|
251
|
-
|
|
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
|
|
|
823
|
+
Supports both interactive (wizard) and programmatic (init command) modes.
|
|
253
824
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
257
830
|
|
|
258
|
-
|
|
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
|
+
)
|
|
259
847
|
|
|
260
848
|
config_dict = {
|
|
261
849
|
"adapter": AdapterType.AITRACKDOWN.value,
|
|
262
|
-
"base_path":
|
|
850
|
+
"base_path": final_base_path,
|
|
263
851
|
}
|
|
264
852
|
|
|
265
|
-
|
|
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
|
|
266
1003
|
|
|
267
1004
|
|
|
268
1005
|
def _configure_hybrid_mode() -> TicketerConfig:
|
|
@@ -296,24 +1033,29 @@ def _configure_hybrid_mode() -> TicketerConfig:
|
|
|
296
1033
|
|
|
297
1034
|
if len(selected_adapters) < 2:
|
|
298
1035
|
console.print("[red]Hybrid mode requires at least 2 adapters[/red]")
|
|
299
|
-
raise typer.Exit(1)
|
|
1036
|
+
raise typer.Exit(1) from None
|
|
300
1037
|
|
|
301
1038
|
# Configure each adapter
|
|
302
1039
|
adapters = {}
|
|
1040
|
+
default_values: dict[str, str] = {}
|
|
303
1041
|
for adapter_type in selected_adapters:
|
|
304
1042
|
console.print(f"\n[cyan]Configuring {adapter_type.value}...[/cyan]")
|
|
305
1043
|
|
|
306
1044
|
if adapter_type == AdapterType.LINEAR:
|
|
307
|
-
adapter_config = _configure_linear()
|
|
1045
|
+
adapter_config, adapter_defaults = _configure_linear(interactive=True)
|
|
308
1046
|
elif adapter_type == AdapterType.JIRA:
|
|
309
|
-
adapter_config = _configure_jira()
|
|
1047
|
+
adapter_config, adapter_defaults = _configure_jira(interactive=True)
|
|
310
1048
|
elif adapter_type == AdapterType.GITHUB:
|
|
311
|
-
adapter_config = _configure_github()
|
|
1049
|
+
adapter_config, adapter_defaults = _configure_github(interactive=True)
|
|
312
1050
|
else:
|
|
313
|
-
adapter_config = _configure_aitrackdown()
|
|
1051
|
+
adapter_config, adapter_defaults = _configure_aitrackdown(interactive=True)
|
|
314
1052
|
|
|
315
1053
|
adapters[adapter_type.value] = adapter_config
|
|
316
1054
|
|
|
1055
|
+
# Only save defaults from the first/primary adapter
|
|
1056
|
+
if not default_values:
|
|
1057
|
+
default_values = adapter_defaults
|
|
1058
|
+
|
|
317
1059
|
# Select primary adapter
|
|
318
1060
|
console.print("\n[bold]Select primary adapter (source of truth):[/bold]")
|
|
319
1061
|
for idx, adapter_type in enumerate(selected_adapters, 1):
|
|
@@ -353,9 +1095,15 @@ def _configure_hybrid_mode() -> TicketerConfig:
|
|
|
353
1095
|
sync_strategy=sync_strategy,
|
|
354
1096
|
)
|
|
355
1097
|
|
|
356
|
-
# Create full config
|
|
1098
|
+
# Create full config with default values
|
|
357
1099
|
config = TicketerConfig(
|
|
358
|
-
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"),
|
|
359
1107
|
)
|
|
360
1108
|
|
|
361
1109
|
return config
|
|
@@ -366,31 +1114,17 @@ def show_current_config() -> None:
|
|
|
366
1114
|
resolver = ConfigResolver()
|
|
367
1115
|
|
|
368
1116
|
# Try to load configs
|
|
369
|
-
global_config = resolver.load_global_config()
|
|
370
1117
|
project_config = resolver.load_project_config()
|
|
371
1118
|
|
|
372
1119
|
console.print("[bold]Current Configuration:[/bold]\n")
|
|
373
1120
|
|
|
374
|
-
#
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if global_config.adapters:
|
|
380
|
-
table = Table(title="Global Adapters")
|
|
381
|
-
table.add_column("Adapter", style="cyan")
|
|
382
|
-
table.add_column("Configured", style="green")
|
|
383
|
-
|
|
384
|
-
for name, config in global_config.adapters.items():
|
|
385
|
-
configured = "✓" if config.enabled else "✗"
|
|
386
|
-
table.add_row(name, configured)
|
|
387
|
-
|
|
388
|
-
console.print(table)
|
|
389
|
-
else:
|
|
390
|
-
console.print("[yellow]No global configuration found[/yellow]")
|
|
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")
|
|
391
1126
|
|
|
392
1127
|
# Project config
|
|
393
|
-
console.print()
|
|
394
1128
|
project_config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
|
|
395
1129
|
if project_config_path.exists():
|
|
396
1130
|
console.print(f"[cyan]Project:[/cyan] {project_config_path}")
|
|
@@ -440,16 +1174,17 @@ def show_current_config() -> None:
|
|
|
440
1174
|
|
|
441
1175
|
|
|
442
1176
|
def set_adapter_config(
|
|
443
|
-
adapter:
|
|
444
|
-
api_key:
|
|
445
|
-
project_id:
|
|
446
|
-
team_id:
|
|
1177
|
+
adapter: str | None = None,
|
|
1178
|
+
api_key: str | None = None,
|
|
1179
|
+
project_id: str | None = None,
|
|
1180
|
+
team_id: str | None = None,
|
|
447
1181
|
global_scope: bool = False,
|
|
448
|
-
**kwargs,
|
|
1182
|
+
**kwargs: Any,
|
|
449
1183
|
) -> None:
|
|
450
1184
|
"""Set specific adapter configuration values.
|
|
451
1185
|
|
|
452
1186
|
Args:
|
|
1187
|
+
----
|
|
453
1188
|
adapter: Adapter type to set as default
|
|
454
1189
|
api_key: API key/token
|
|
455
1190
|
project_id: Project ID
|
|
@@ -498,11 +1233,13 @@ def set_adapter_config(
|
|
|
498
1233
|
|
|
499
1234
|
console.print(f"[green]✓[/green] Updated {target_adapter} configuration")
|
|
500
1235
|
|
|
501
|
-
# 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
|
+
|
|
502
1240
|
if global_scope:
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
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]")
|