mcp-ticketer 0.4.11__py3-none-any.whl → 2.0.1__py3-none-any.whl

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

Potentially problematic release.


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

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