mcp-ticketer 0.12.0__py3-none-any.whl → 2.2.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

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