mcp-ticketer 0.3.0__py3-none-any.whl → 2.2.9__py3-none-any.whl

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