mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (109) 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 +796 -46
  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 +879 -129
  11. mcp_ticketer/adapters/hybrid.py +11 -11
  12. mcp_ticketer/adapters/jira.py +973 -73
  13. mcp_ticketer/adapters/linear/__init__.py +24 -0
  14. mcp_ticketer/adapters/linear/adapter.py +2732 -0
  15. mcp_ticketer/adapters/linear/client.py +344 -0
  16. mcp_ticketer/adapters/linear/mappers.py +420 -0
  17. mcp_ticketer/adapters/linear/queries.py +479 -0
  18. mcp_ticketer/adapters/linear/types.py +360 -0
  19. mcp_ticketer/adapters/linear.py +10 -2315
  20. mcp_ticketer/analysis/__init__.py +23 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/similarity.py +224 -0
  23. mcp_ticketer/analysis/staleness.py +266 -0
  24. mcp_ticketer/cache/memory.py +9 -8
  25. mcp_ticketer/cli/adapter_diagnostics.py +421 -0
  26. mcp_ticketer/cli/auggie_configure.py +116 -15
  27. mcp_ticketer/cli/codex_configure.py +274 -82
  28. mcp_ticketer/cli/configure.py +888 -151
  29. mcp_ticketer/cli/diagnostics.py +400 -157
  30. mcp_ticketer/cli/discover.py +297 -26
  31. mcp_ticketer/cli/gemini_configure.py +119 -26
  32. mcp_ticketer/cli/init_command.py +880 -0
  33. mcp_ticketer/cli/instruction_commands.py +435 -0
  34. mcp_ticketer/cli/linear_commands.py +616 -0
  35. mcp_ticketer/cli/main.py +203 -1165
  36. mcp_ticketer/cli/mcp_configure.py +474 -90
  37. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  38. mcp_ticketer/cli/migrate_config.py +12 -8
  39. mcp_ticketer/cli/platform_commands.py +123 -0
  40. mcp_ticketer/cli/platform_detection.py +418 -0
  41. mcp_ticketer/cli/platform_installer.py +513 -0
  42. mcp_ticketer/cli/python_detection.py +126 -0
  43. mcp_ticketer/cli/queue_commands.py +15 -15
  44. mcp_ticketer/cli/setup_command.py +639 -0
  45. mcp_ticketer/cli/simple_health.py +90 -65
  46. mcp_ticketer/cli/ticket_commands.py +1013 -0
  47. mcp_ticketer/cli/update_checker.py +313 -0
  48. mcp_ticketer/cli/utils.py +114 -66
  49. mcp_ticketer/core/__init__.py +24 -1
  50. mcp_ticketer/core/adapter.py +250 -16
  51. mcp_ticketer/core/config.py +145 -37
  52. mcp_ticketer/core/env_discovery.py +101 -22
  53. mcp_ticketer/core/env_loader.py +349 -0
  54. mcp_ticketer/core/exceptions.py +160 -0
  55. mcp_ticketer/core/http_client.py +26 -26
  56. mcp_ticketer/core/instructions.py +405 -0
  57. mcp_ticketer/core/label_manager.py +732 -0
  58. mcp_ticketer/core/mappers.py +42 -30
  59. mcp_ticketer/core/models.py +280 -28
  60. mcp_ticketer/core/onepassword_secrets.py +379 -0
  61. mcp_ticketer/core/project_config.py +183 -49
  62. mcp_ticketer/core/registry.py +3 -3
  63. mcp_ticketer/core/session_state.py +171 -0
  64. mcp_ticketer/core/state_matcher.py +592 -0
  65. mcp_ticketer/core/url_parser.py +425 -0
  66. mcp_ticketer/core/validators.py +69 -0
  67. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  68. mcp_ticketer/mcp/__init__.py +29 -1
  69. mcp_ticketer/mcp/__main__.py +60 -0
  70. mcp_ticketer/mcp/server/__init__.py +25 -0
  71. mcp_ticketer/mcp/server/__main__.py +60 -0
  72. mcp_ticketer/mcp/server/constants.py +58 -0
  73. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  74. mcp_ticketer/mcp/server/dto.py +195 -0
  75. mcp_ticketer/mcp/server/main.py +1343 -0
  76. mcp_ticketer/mcp/server/response_builder.py +206 -0
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +56 -0
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
  90. mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
  91. mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
  92. mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
  93. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
  94. mcp_ticketer/queue/__init__.py +1 -0
  95. mcp_ticketer/queue/health_monitor.py +168 -136
  96. mcp_ticketer/queue/manager.py +95 -25
  97. mcp_ticketer/queue/queue.py +40 -21
  98. mcp_ticketer/queue/run_worker.py +6 -1
  99. mcp_ticketer/queue/ticket_registry.py +213 -155
  100. mcp_ticketer/queue/worker.py +109 -49
  101. mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
  102. mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
  103. mcp_ticketer/mcp/server.py +0 -1895
  104. mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
  105. mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
  106. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
  107. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
  108. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
  109. {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,8 @@
1
1
  """Interactive configuration wizard for MCP Ticketer."""
2
2
 
3
3
  import os
4
- from typing import Optional
4
+ from collections.abc import Callable
5
+ from typing import Any
5
6
 
6
7
  import typer
7
8
  from rich.console import Console
@@ -22,6 +23,61 @@ from ..core.project_config import (
22
23
  console = Console()
23
24
 
24
25
 
26
+ def _retry_setting(
27
+ setting_name: str,
28
+ prompt_func: Callable[[], Any],
29
+ validate_func: Callable[[Any], tuple[bool, str | None]],
30
+ max_retries: int = 3,
31
+ ) -> Any:
32
+ """Retry a configuration setting with validation.
33
+
34
+ Args:
35
+ ----
36
+ setting_name: Human-readable name of the setting
37
+ prompt_func: Function that prompts for the setting value
38
+ validate_func: Function that validates the value (returns tuple of success, error_msg)
39
+ max_retries: Maximum number of retry attempts
40
+
41
+ Returns:
42
+ -------
43
+ Validated setting value
44
+
45
+ Raises:
46
+ ------
47
+ typer.Exit: If max retries exceeded
48
+
49
+ """
50
+ for attempt in range(1, max_retries + 1):
51
+ try:
52
+ value = prompt_func()
53
+ is_valid, error = validate_func(value)
54
+
55
+ if is_valid:
56
+ return value
57
+ console.print(f"[red]✗ {error}[/red]")
58
+ if attempt < max_retries:
59
+ console.print(
60
+ f"[yellow]Attempt {attempt}/{max_retries} - Please try again[/yellow]"
61
+ )
62
+ else:
63
+ console.print(f"[red]Failed after {max_retries} attempts[/red]")
64
+ retry = Confirm.ask("Retry this setting?", default=True)
65
+ if retry:
66
+ # Extend attempts
67
+ max_retries += 3
68
+ console.print(
69
+ f"[yellow]Extending retries (new limit: {max_retries})[/yellow]"
70
+ )
71
+ continue
72
+ raise typer.Exit(1) from None
73
+ except KeyboardInterrupt:
74
+ console.print("\n[yellow]Configuration cancelled[/yellow]")
75
+ raise typer.Exit(0) from None
76
+
77
+ console.print(f"[red]Could not configure {setting_name}[/red]")
78
+ raise typer.Exit(1)
79
+
80
+
25
81
  def configure_wizard() -> None:
26
82
  """Run interactive configuration wizard."""
27
83
  console.print(
@@ -46,24 +102,25 @@ def configure_wizard() -> None:
46
102
 
47
103
  # Step 2: Choose where to save
48
104
  console.print("\n[bold]Step 2: Configuration Scope[/bold]")
49
- console.print("1. Global (all projects): ~/.mcp-ticketer/config.json")
50
- 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")
51
109
 
52
- scope = Prompt.ask("Save configuration as", choices=["1", "2"], default="2")
110
+ scope = Prompt.ask("Save configuration as", choices=["1", "2"], default="1")
53
111
 
54
112
  resolver = ConfigResolver()
55
113
 
56
- if scope == "1":
57
- # Save global
58
- 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":
59
119
  console.print(
60
- 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]"
61
121
  )
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}")
122
+
123
+ console.print(f"\n[green]✓[/green] Configuration saved to {config_path}")
67
124
 
68
125
  # Show usage instructions
69
126
  console.print("\n[bold]Usage:[/bold]")
@@ -96,173 +153,853 @@ def _configure_single_adapter() -> TicketerConfig:
96
153
  adapter_type = adapter_type_map[adapter_choice]
97
154
 
98
155
  # Configure the selected adapter
156
+ default_values: dict[str, str] = {}
99
157
  if adapter_type == AdapterType.LINEAR:
100
- adapter_config = _configure_linear()
158
+ adapter_config, default_values = _configure_linear(interactive=True)
101
159
  elif adapter_type == AdapterType.JIRA:
102
- adapter_config = _configure_jira()
160
+ adapter_config, default_values = _configure_jira(interactive=True)
103
161
  elif adapter_type == AdapterType.GITHUB:
104
- adapter_config = _configure_github()
162
+ adapter_config, default_values = _configure_github(interactive=True)
105
163
  else:
106
- adapter_config = _configure_aitrackdown()
164
+ adapter_config, default_values = _configure_aitrackdown(interactive=True)
107
165
 
108
- # Create config
166
+ # Create config with default values
109
167
  config = TicketerConfig(
110
168
  default_adapter=adapter_type.value,
111
169
  adapters={adapter_type.value: adapter_config},
170
+ default_user=default_values.get("default_user"),
171
+ default_project=default_values.get("default_project"),
172
+ default_epic=default_values.get("default_epic"),
173
+ default_tags=default_values.get("default_tags"),
112
174
  )
113
175
 
114
176
  return config
115
177
 
116
178
 
117
- def _configure_linear() -> AdapterConfig:
118
- """Configure Linear adapter."""
119
- 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.
120
188
 
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 = ""
189
+ Supports both interactive (wizard) and programmatic (init command) modes.
128
190
 
129
- if not api_key:
130
- 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
131
205
 
132
- # Team ID
133
- 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]")
134
209
 
135
- # Team Key
136
- 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
+ )
137
214
 
138
- # Project ID
139
- project_id = Prompt.ask("Project ID (optional)", default="")
215
+ config_dict: dict[str, Any] = {"adapter": AdapterType.LINEAR.value}
140
216
 
141
- config_dict = {
142
- "adapter": AdapterType.LINEAR.value,
143
- "api_key": api_key,
144
- }
217
+ if has_existing and interactive:
218
+ preserve = Confirm.ask(
219
+ "Existing Linear configuration found. Preserve current settings?",
220
+ default=True,
221
+ )
222
+ if preserve:
223
+ console.print("[green]✓[/green] Keeping existing configuration")
224
+ config_dict = existing_config.copy()
225
+
226
+ # Allow updating specific fields
227
+ update_fields = Confirm.ask("Update specific fields?", default=False)
228
+ if not update_fields:
229
+ # Extract default values before returning
230
+ default_values = {}
231
+ if "user_email" in config_dict:
232
+ default_values["default_user"] = config_dict.pop("user_email")
233
+ if "default_epic" in config_dict:
234
+ default_values["default_epic"] = config_dict.pop("default_epic")
235
+ if "default_project" in config_dict:
236
+ default_values["default_project"] = config_dict.pop(
237
+ "default_project"
238
+ )
239
+ if "default_tags" in config_dict:
240
+ default_values["default_tags"] = config_dict.pop("default_tags")
241
+ return AdapterConfig.from_dict(config_dict), default_values
242
+
243
+ console.print(
244
+ "[yellow]Enter new values or press Enter to keep current[/yellow]"
245
+ )
145
246
 
146
- 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
247
+ # API Key (programmatic mode: use provided value or env, interactive: prompt)
248
+ current_key = config_dict.get("api_key", "") if has_existing else ""
249
+ final_api_key = api_key or os.getenv("LINEAR_API_KEY") or ""
250
+
251
+ if interactive:
252
+ # Interactive mode: prompt with retry
253
+ if final_api_key and not current_key:
254
+ console.print("[dim]Found LINEAR_API_KEY in environment[/dim]")
255
+ use_env = Confirm.ask("Use this API key?", default=True)
256
+ if use_env:
257
+ current_key = final_api_key
258
+
259
+ def prompt_api_key() -> str:
260
+ if current_key:
261
+ api_key_prompt = f"Linear API Key [current: {'*' * 8}...]"
262
+ return Prompt.ask(api_key_prompt, password=True, default=current_key)
263
+ return Prompt.ask("Linear API Key", password=True)
264
+
265
+ def validate_api_key(key: str) -> tuple[bool, str | None]:
266
+ if not key or len(key) < 10:
267
+ return False, "API key must be at least 10 characters"
268
+ return True, None
269
+
270
+ final_api_key = _retry_setting("API Key", prompt_api_key, validate_api_key)
271
+ elif not final_api_key:
272
+ raise ValueError(
273
+ "Linear API key is required (provide api_key parameter or set LINEAR_API_KEY environment variable)"
274
+ )
152
275
 
153
- # Validate
154
- is_valid, error = ConfigValidator.validate_linear_config(config_dict)
155
- if not is_valid:
156
- console.print(f"[red]Configuration error: {error}[/red]")
157
- 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
+ )
328
+
329
+ def validate_user_email(email: str) -> tuple[bool, str | None]:
330
+ if not email: # Optional field
331
+ return True, None
332
+ import re
333
+
334
+ email_pattern = re.compile(
335
+ r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
336
+ )
337
+ if not email_pattern.match(email):
338
+ return False, f"Invalid email format: {email}"
339
+ return True, None
340
+
341
+ user_email = _retry_setting(
342
+ "User Email", prompt_user_email, validate_user_email
343
+ )
344
+ if user_email:
345
+ config_dict["user_email"] = user_email
346
+ console.print(f"[green]✓[/green] Will use {user_email} as default assignee")
158
347
 
159
- return AdapterConfig.from_dict(config_dict)
348
+ # ============================================================
349
+ # DEFAULT VALUES SECTION (for ticket creation)
350
+ # ============================================================
160
351
 
352
+ console.print("\n[bold cyan]Default Values (Optional)[/bold cyan]")
353
+ console.print("Configure default values for ticket creation:")
161
354
 
162
- def _configure_jira() -> AdapterConfig:
163
- """Configure JIRA adapter."""
164
- console.print("\n[bold]Configure JIRA Integration:[/bold]")
355
+ # Default epic/project
356
+ current_default_epic = (
357
+ config_dict.get("default_epic", "") if has_existing else ""
358
+ )
359
+
360
+ def prompt_default_epic() -> str:
361
+ if current_default_epic:
362
+ return Prompt.ask(
363
+ f"Default epic/project ID (optional) [current: {current_default_epic}]",
364
+ default=current_default_epic,
365
+ )
366
+ return Prompt.ask(
367
+ "Default epic/project ID (optional, accepts project URLs or IDs like 'PROJ-123')",
368
+ default="",
369
+ )
370
+
371
+ def validate_default_epic(epic_id: str) -> tuple[bool, str | None]:
372
+ if not epic_id: # Optional field
373
+ return True, None
374
+ # Basic validation - just check it's not empty when provided
375
+ if len(epic_id.strip()) < 2:
376
+ return False, "Epic/project ID must be at least 2 characters"
377
+ return True, None
378
+
379
+ default_epic = _retry_setting(
380
+ "Default Epic/Project", prompt_default_epic, validate_default_epic
381
+ )
382
+ if default_epic:
383
+ config_dict["default_epic"] = default_epic
384
+ config_dict["default_project"] = default_epic # Set both for compatibility
385
+ console.print(
386
+ f"[green]✓[/green] Will use '{default_epic}' as default epic/project"
387
+ )
388
+
389
+ # Default tags
390
+ current_default_tags = (
391
+ config_dict.get("default_tags", []) if has_existing else []
392
+ )
393
+
394
+ def prompt_default_tags() -> str:
395
+ if current_default_tags:
396
+ tags_str = ", ".join(current_default_tags)
397
+ return Prompt.ask(
398
+ f"Default tags (optional, comma-separated) [current: {tags_str}]",
399
+ default=tags_str,
400
+ )
401
+ return Prompt.ask(
402
+ "Default tags (optional, comma-separated, e.g., 'bug,urgent')",
403
+ default="",
404
+ )
405
+
406
+ def validate_default_tags(tags_input: str) -> tuple[bool, str | None]:
407
+ if not tags_input: # Optional field
408
+ return True, None
409
+ # Parse and validate tags
410
+ tags = [tag.strip() for tag in tags_input.split(",") if tag.strip()]
411
+ if not tags:
412
+ return False, "Please provide at least one tag or leave empty"
413
+ # Check each tag is reasonable
414
+ for tag in tags:
415
+ if len(tag) < 2:
416
+ return False, f"Tag '{tag}' must be at least 2 characters"
417
+ if len(tag) > 50:
418
+ return False, f"Tag '{tag}' is too long (max 50 characters)"
419
+ return True, None
420
+
421
+ default_tags_input = _retry_setting(
422
+ "Default Tags", prompt_default_tags, validate_default_tags
423
+ )
424
+ if default_tags_input:
425
+ default_tags = [
426
+ tag.strip() for tag in default_tags_input.split(",") if tag.strip()
427
+ ]
428
+ config_dict["default_tags"] = default_tags
429
+ console.print(f"[green]✓[/green] Will use tags: {', '.join(default_tags)}")
430
+
431
+ # Validate with detailed error reporting
432
+ is_valid, error = ConfigValidator.validate_linear_config(config_dict)
433
+
434
+ if not is_valid:
435
+ console.print("\n[red]❌ Configuration Validation Failed[/red]")
436
+ console.print(f"[red]Error: {error}[/red]\n")
437
+
438
+ # Show which settings were problematic
439
+ console.print("[yellow]Problematic settings:[/yellow]")
440
+ if "api_key" not in config_dict or not config_dict["api_key"]:
441
+ console.print(" • [red]API Key[/red] - Missing or empty")
442
+ if "team_key" not in config_dict and "team_id" not in config_dict:
443
+ console.print(
444
+ " • [red]Team Key/ID[/red] - Neither team_key nor team_id provided"
445
+ )
446
+ if "user_email" in config_dict:
447
+ email = config_dict["user_email"]
448
+ import re
449
+
450
+ if not re.match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", email):
451
+ console.print(f" • [red]User Email[/red] - Invalid format: {email}")
452
+
453
+ # Offer to retry specific settings
454
+ console.print("\n[cyan]Options:[/cyan]")
455
+ console.print(" 1. Retry configuration from scratch")
456
+ console.print(" 2. Fix specific settings")
457
+ console.print(" 3. Exit")
458
+
459
+ choice = Prompt.ask("Choose an option", choices=["1", "2", "3"], default="2")
460
+
461
+ if choice == "1":
462
+ # Recursive retry
463
+ return _configure_linear(existing_config=None)
464
+ if choice == "2":
465
+ # Fix specific settings
466
+ return _configure_linear(existing_config=config_dict)
467
+ raise typer.Exit(1) from None
468
+
469
+ console.print("[green]✓ Configuration validated successfully[/green]")
470
+
471
+ # Extract default values to return separately (not part of AdapterConfig)
472
+ default_values = {}
473
+ if "user_email" in config_dict:
474
+ default_values["default_user"] = config_dict.pop("user_email")
475
+ if "default_epic" in config_dict:
476
+ default_values["default_epic"] = config_dict.pop("default_epic")
477
+ if "default_project" in config_dict:
478
+ default_values["default_project"] = config_dict.pop("default_project")
479
+ if "default_tags" in config_dict:
480
+ default_values["default_tags"] = config_dict.pop("default_tags")
481
+
482
+ return AdapterConfig.from_dict(config_dict), default_values
483
+
484
+
485
+ def _configure_jira(
486
+ interactive: bool = True,
487
+ server: str | None = None,
488
+ email: str | None = None,
489
+ api_token: str | None = None,
490
+ project_key: str | None = None,
491
+ **kwargs: Any,
492
+ ) -> tuple[AdapterConfig, dict[str, Any]]:
493
+ """Configure JIRA adapter.
494
+
495
+ Supports both interactive (wizard) and programmatic (init command) modes.
496
+
497
+ Args:
498
+ ----
499
+ interactive: If True, prompt user for missing values (default: True)
500
+ server: Pre-provided JIRA server URL (optional)
501
+ email: Pre-provided JIRA user email (optional)
502
+ api_token: Pre-provided JIRA API token (optional)
503
+ project_key: Pre-provided default project key (optional)
504
+ **kwargs: Additional configuration parameters
505
+
506
+ Returns:
507
+ -------
508
+ Tuple of (AdapterConfig, default_values_dict)
509
+ - AdapterConfig: Configured JIRA adapter configuration
510
+ - default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
165
511
 
166
- # 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)")
512
+ """
513
+ if interactive:
514
+ console.print("\n[bold]Configure JIRA Integration:[/bold]")
515
+
516
+ # Server URL (programmatic mode: use provided value or env, interactive: prompt)
517
+ final_server = server or os.getenv("JIRA_SERVER") or ""
518
+ if interactive and not final_server:
519
+ final_server = Prompt.ask(
520
+ "JIRA Server URL (e.g., https://company.atlassian.net)"
521
+ )
522
+ elif not interactive and not final_server:
523
+ raise ValueError(
524
+ "JIRA server URL is required (provide server parameter or set JIRA_SERVER environment variable)"
525
+ )
170
526
 
171
- # Email
172
- email = os.getenv("JIRA_EMAIL") or ""
173
- if not email:
174
- email = Prompt.ask("JIRA User Email")
527
+ # Email (programmatic mode: use provided value or env, interactive: prompt)
528
+ final_email = email or os.getenv("JIRA_EMAIL") or ""
529
+ if interactive and not final_email:
530
+ final_email = Prompt.ask("JIRA User Email")
531
+ elif not interactive and not final_email:
532
+ raise ValueError(
533
+ "JIRA email is required (provide email parameter or set JIRA_EMAIL environment variable)"
534
+ )
175
535
 
176
- # API Token
177
- api_token = os.getenv("JIRA_API_TOKEN") or ""
178
- if not api_token:
536
+ # API Token (programmatic mode: use provided value or env, interactive: prompt)
537
+ final_api_token = api_token or os.getenv("JIRA_API_TOKEN") or ""
538
+ if interactive and not final_api_token:
179
539
  console.print(
180
540
  "[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]"
181
541
  )
182
- 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
+ )
183
547
 
184
- # Project Key
185
- 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
+ )
186
554
 
187
555
  config_dict = {
188
556
  "adapter": AdapterType.JIRA.value,
189
- "server": server.rstrip("/"),
190
- "email": email,
191
- "api_token": api_token,
557
+ "server": final_server.rstrip("/"),
558
+ "email": final_email,
559
+ "api_token": final_api_token,
192
560
  }
193
561
 
194
- if project_key:
195
- config_dict["project_key"] = project_key
562
+ if final_project_key:
563
+ config_dict["project_key"] = final_project_key
196
564
 
197
565
  # Validate
198
566
  is_valid, error = ConfigValidator.validate_jira_config(config_dict)
199
567
  if not is_valid:
200
- console.print(f"[red]Configuration error: {error}[/red]")
201
- 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
+ )
593
+
594
+ # Default epic/project
595
+ epic_input = Prompt.ask(
596
+ "Default epic/project ID (optional, e.g., 'PROJ-123')",
597
+ default="",
598
+ show_default=False,
599
+ )
600
+ if epic_input:
601
+ default_values["default_epic"] = epic_input
602
+ default_values["default_project"] = epic_input # Compatibility
603
+ console.print(
604
+ f"[green]✓[/green] Will use '{epic_input}' as default epic/project"
605
+ )
202
606
 
203
- return AdapterConfig.from_dict(config_dict)
607
+ # Default tags
608
+ tags_input = Prompt.ask(
609
+ "Default tags/labels (optional, comma-separated, e.g., 'bug,urgent')",
610
+ default="",
611
+ show_default=False,
612
+ )
613
+ if tags_input:
614
+ tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
615
+ if tags_list:
616
+ default_values["default_tags"] = tags_list
617
+ console.print(f"[green]✓[/green] Will use tags: {', '.join(tags_list)}")
204
618
 
619
+ return AdapterConfig.from_dict(config_dict), default_values
205
620
 
206
- def _configure_github() -> AdapterConfig:
207
- """Configure GitHub adapter."""
208
- console.print("\n[bold]Configure GitHub Integration:[/bold]")
209
621
 
210
- # 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 = ""
622
+ def _configure_github(
623
+ interactive: bool = True,
624
+ token: str | None = None,
625
+ repo_url: str | None = None,
626
+ owner: str | None = None,
627
+ repo: str | None = None,
628
+ **kwargs: Any,
629
+ ) -> tuple[AdapterConfig, dict[str, Any]]:
630
+ """Configure GitHub adapter.
217
631
 
218
- if not token:
219
- console.print(
220
- "[dim]Create token at: https://github.com/settings/tokens/new[/dim]"
632
+ Supports both interactive (wizard) and programmatic (init command) modes.
633
+
634
+ Args:
635
+ ----
636
+ interactive: If True, prompt user for missing values (default: True)
637
+ token: Pre-provided GitHub Personal Access Token (optional)
638
+ repo_url: Pre-provided GitHub repository URL (optional, preferred)
639
+ owner: Pre-provided repository owner (optional, fallback)
640
+ repo: Pre-provided repository name (optional, fallback)
641
+ **kwargs: Additional configuration parameters
642
+
643
+ Returns:
644
+ -------
645
+ Tuple of (AdapterConfig, default_values_dict)
646
+ - AdapterConfig: Configured GitHub adapter configuration
647
+ - default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
648
+
649
+ """
650
+ if interactive:
651
+ console.print("\n[bold]Configure GitHub Integration:[/bold]")
652
+
653
+ # Token (programmatic mode: use provided value or env, interactive: prompt)
654
+ final_token = token or os.getenv("GITHUB_TOKEN") or ""
655
+ if interactive:
656
+ if final_token:
657
+ console.print("[dim]Found GITHUB_TOKEN in environment[/dim]")
658
+ use_env = Confirm.ask("Use this token?", default=True)
659
+ if not use_env:
660
+ final_token = ""
661
+
662
+ if not final_token:
663
+ console.print(
664
+ "[dim]Create token at: https://github.com/settings/tokens/new[/dim]"
665
+ )
666
+ console.print(
667
+ "[dim]Required scopes: repo (or public_repo for public repos)[/dim]"
668
+ )
669
+ final_token = Prompt.ask("GitHub Personal Access Token", password=True)
670
+ elif not final_token:
671
+ raise ValueError(
672
+ "GitHub token is required (provide token parameter or set GITHUB_TOKEN environment variable)"
221
673
  )
674
+
675
+ # Repository URL/Owner/Repo - Prioritize repo_url, fallback to owner/repo
676
+ # Programmatic mode: use repo_url or GITHUB_REPO_URL env, fallback to owner/repo
677
+ final_owner = ""
678
+ final_repo = ""
679
+
680
+ # Step 1: Try to get URL from parameter or environment
681
+ url_input = repo_url or os.getenv("GITHUB_REPO_URL") or ""
682
+
683
+ # Step 2: Parse URL if provided
684
+ if url_input:
685
+ from ..core.url_parser import parse_github_repo_url
686
+
687
+ parsed_owner, parsed_repo, error = parse_github_repo_url(url_input)
688
+ if parsed_owner and parsed_repo:
689
+ final_owner = parsed_owner
690
+ final_repo = parsed_repo
691
+ if interactive:
692
+ console.print(
693
+ f"[dim]✓ Extracted repository: {final_owner}/{final_repo}[/dim]"
694
+ )
695
+ else:
696
+ # URL parsing failed
697
+ if interactive:
698
+ console.print(f"[yellow]Warning: {error}[/yellow]")
699
+ else:
700
+ raise ValueError(f"Failed to parse GitHub repository URL: {error}")
701
+
702
+ # Step 3: Interactive mode - prompt for URL if not provided
703
+ if interactive and not final_owner and not final_repo:
222
704
  console.print(
223
- "[dim]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]"
224
706
  )
225
- token = Prompt.ask("GitHub Personal Access Token", password=True)
226
707
 
227
- # Repository Owner
228
- owner = os.getenv("GITHUB_OWNER") or ""
229
- if not owner:
230
- 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
+ )
231
728
 
232
- # Repository Name
233
- repo = os.getenv("GITHUB_REPO") or ""
234
- if not repo:
235
- 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
236
750
 
237
751
  config_dict = {
238
752
  "adapter": AdapterType.GITHUB.value,
239
- "token": token,
240
- "owner": owner,
241
- "repo": repo,
242
- "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
243
757
  }
244
758
 
245
759
  # Validate
246
760
  is_valid, error = ConfigValidator.validate_github_config(config_dict)
247
761
  if not is_valid:
248
- console.print(f"[red]Configuration error: {error}[/red]")
249
- 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
816
+
250
817
 
251
- return AdapterConfig.from_dict(config_dict)
818
+ def _configure_aitrackdown(
819
+ interactive: bool = True, base_path: str | None = None, **kwargs: Any
820
+ ) -> tuple[AdapterConfig, dict[str, Any]]:
821
+ """Configure AITrackdown adapter.
252
822
 
823
+ Supports both interactive (wizard) and programmatic (init command) modes.
253
824
 
254
- def _configure_aitrackdown() -> AdapterConfig:
255
- """Configure AITrackdown adapter."""
256
- console.print("\n[bold]Configure AITrackdown (File-based):[/bold]")
825
+ Args:
826
+ ----
827
+ interactive: If True, prompt user for missing values (default: True)
828
+ base_path: Pre-provided base path for ticket storage (optional)
829
+ **kwargs: Additional configuration parameters
257
830
 
258
- base_path = Prompt.ask("Base path for ticket storage", default=".aitrackdown")
831
+ Returns:
832
+ -------
833
+ Tuple of (AdapterConfig, default_values_dict)
834
+ - AdapterConfig: Configured AITrackdown adapter configuration
835
+ - default_values_dict: Dictionary containing default_user, default_epic, default_project, default_tags
836
+
837
+ """
838
+ if interactive:
839
+ console.print("\n[bold]Configure AITrackdown (File-based):[/bold]")
840
+
841
+ # Base path (programmatic mode: use provided value or default, interactive: prompt)
842
+ final_base_path = base_path or ".aitrackdown"
843
+ if interactive:
844
+ final_base_path = Prompt.ask(
845
+ "Base path for ticket storage", default=".aitrackdown"
846
+ )
259
847
 
260
848
  config_dict = {
261
849
  "adapter": AdapterType.AITRACKDOWN.value,
262
- "base_path": base_path,
850
+ "base_path": final_base_path,
263
851
  }
264
852
 
265
- 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
266
1003
 
267
1004
 
268
1005
  def _configure_hybrid_mode() -> TicketerConfig:
@@ -296,24 +1033,29 @@ def _configure_hybrid_mode() -> TicketerConfig:
296
1033
 
297
1034
  if len(selected_adapters) < 2:
298
1035
  console.print("[red]Hybrid mode requires at least 2 adapters[/red]")
299
- raise typer.Exit(1)
1036
+ raise typer.Exit(1) from None
300
1037
 
301
1038
  # Configure each adapter
302
1039
  adapters = {}
1040
+ default_values: dict[str, str] = {}
303
1041
  for adapter_type in selected_adapters:
304
1042
  console.print(f"\n[cyan]Configuring {adapter_type.value}...[/cyan]")
305
1043
 
306
1044
  if adapter_type == AdapterType.LINEAR:
307
- adapter_config = _configure_linear()
1045
+ adapter_config, adapter_defaults = _configure_linear(interactive=True)
308
1046
  elif adapter_type == AdapterType.JIRA:
309
- adapter_config = _configure_jira()
1047
+ adapter_config, adapter_defaults = _configure_jira(interactive=True)
310
1048
  elif adapter_type == AdapterType.GITHUB:
311
- adapter_config = _configure_github()
1049
+ adapter_config, adapter_defaults = _configure_github(interactive=True)
312
1050
  else:
313
- adapter_config = _configure_aitrackdown()
1051
+ adapter_config, adapter_defaults = _configure_aitrackdown(interactive=True)
314
1052
 
315
1053
  adapters[adapter_type.value] = adapter_config
316
1054
 
1055
+ # Only save defaults from the first/primary adapter
1056
+ if not default_values:
1057
+ default_values = adapter_defaults
1058
+
317
1059
  # Select primary adapter
318
1060
  console.print("\n[bold]Select primary adapter (source of truth):[/bold]")
319
1061
  for idx, adapter_type in enumerate(selected_adapters, 1):
@@ -353,9 +1095,15 @@ def _configure_hybrid_mode() -> TicketerConfig:
353
1095
  sync_strategy=sync_strategy,
354
1096
  )
355
1097
 
356
- # Create full config
1098
+ # Create full config with default values
357
1099
  config = TicketerConfig(
358
- default_adapter=primary_adapter, 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"),
359
1107
  )
360
1108
 
361
1109
  return config
@@ -366,31 +1114,17 @@ def show_current_config() -> None:
366
1114
  resolver = ConfigResolver()
367
1115
 
368
1116
  # Try to load configs
369
- global_config = resolver.load_global_config()
370
1117
  project_config = resolver.load_project_config()
371
1118
 
372
1119
  console.print("[bold]Current Configuration:[/bold]\n")
373
1120
 
374
- # 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]")
1121
+ # Note about global config deprecation
1122
+ console.print(
1123
+ "[dim]Note: Global config has been deprecated for security reasons.[/dim]"
1124
+ )
1125
+ console.print("[dim]All configuration is now project-specific only.[/dim]\n")
391
1126
 
392
1127
  # Project config
393
- console.print()
394
1128
  project_config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
395
1129
  if project_config_path.exists():
396
1130
  console.print(f"[cyan]Project:[/cyan] {project_config_path}")
@@ -440,16 +1174,17 @@ def show_current_config() -> None:
440
1174
 
441
1175
 
442
1176
  def set_adapter_config(
443
- adapter: Optional[str] = None,
444
- api_key: Optional[str] = None,
445
- project_id: Optional[str] = None,
446
- team_id: Optional[str] = None,
1177
+ adapter: str | None = None,
1178
+ api_key: str | None = None,
1179
+ project_id: str | None = None,
1180
+ team_id: str | None = None,
447
1181
  global_scope: bool = False,
448
- **kwargs,
1182
+ **kwargs: Any,
449
1183
  ) -> None:
450
1184
  """Set specific adapter configuration values.
451
1185
 
452
1186
  Args:
1187
+ ----
453
1188
  adapter: Adapter type to set as default
454
1189
  api_key: API key/token
455
1190
  project_id: Project ID
@@ -498,11 +1233,13 @@ def set_adapter_config(
498
1233
 
499
1234
  console.print(f"[green]✓[/green] Updated {target_adapter} configuration")
500
1235
 
501
- # Save config
1236
+ # Save config (always to project config for security)
1237
+ resolver.save_project_config(config)
1238
+ config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
1239
+
502
1240
  if global_scope:
503
- 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]")
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]")