gitflow-analytics 3.3.0__py3-none-any.whl → 3.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. gitflow_analytics/_version.py +1 -1
  2. gitflow_analytics/cli.py +517 -15
  3. gitflow_analytics/cli_wizards/__init__.py +10 -0
  4. gitflow_analytics/cli_wizards/install_wizard.py +1181 -0
  5. gitflow_analytics/cli_wizards/run_launcher.py +433 -0
  6. gitflow_analytics/config/__init__.py +3 -0
  7. gitflow_analytics/config/aliases.py +306 -0
  8. gitflow_analytics/config/loader.py +35 -1
  9. gitflow_analytics/config/schema.py +13 -0
  10. gitflow_analytics/constants.py +75 -0
  11. gitflow_analytics/core/cache.py +7 -3
  12. gitflow_analytics/core/data_fetcher.py +66 -30
  13. gitflow_analytics/core/git_timeout_wrapper.py +6 -4
  14. gitflow_analytics/core/progress.py +2 -4
  15. gitflow_analytics/core/subprocess_git.py +31 -5
  16. gitflow_analytics/identity_llm/analysis_pass.py +13 -3
  17. gitflow_analytics/identity_llm/analyzer.py +14 -2
  18. gitflow_analytics/identity_llm/models.py +7 -1
  19. gitflow_analytics/qualitative/classifiers/llm/openai_client.py +5 -3
  20. gitflow_analytics/security/config.py +6 -6
  21. gitflow_analytics/security/extractors/dependency_checker.py +14 -14
  22. gitflow_analytics/security/extractors/secret_detector.py +8 -14
  23. gitflow_analytics/security/extractors/vulnerability_scanner.py +9 -9
  24. gitflow_analytics/security/llm_analyzer.py +10 -10
  25. gitflow_analytics/security/security_analyzer.py +17 -17
  26. gitflow_analytics/tui/screens/analysis_progress_screen.py +1 -1
  27. gitflow_analytics/ui/progress_display.py +36 -29
  28. gitflow_analytics/verify_activity.py +23 -26
  29. {gitflow_analytics-3.3.0.dist-info → gitflow_analytics-3.5.2.dist-info}/METADATA +1 -1
  30. {gitflow_analytics-3.3.0.dist-info → gitflow_analytics-3.5.2.dist-info}/RECORD +34 -31
  31. gitflow_analytics/security/reports/__init__.py +0 -5
  32. gitflow_analytics/security/reports/security_report.py +0 -358
  33. {gitflow_analytics-3.3.0.dist-info → gitflow_analytics-3.5.2.dist-info}/WHEEL +0 -0
  34. {gitflow_analytics-3.3.0.dist-info → gitflow_analytics-3.5.2.dist-info}/entry_points.txt +0 -0
  35. {gitflow_analytics-3.3.0.dist-info → gitflow_analytics-3.5.2.dist-info}/licenses/LICENSE +0 -0
  36. {gitflow_analytics-3.3.0.dist-info → gitflow_analytics-3.5.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1181 @@
1
+ """Interactive installation wizard for GitFlow Analytics.
2
+
3
+ This module provides a user-friendly installation experience with credential validation
4
+ and comprehensive configuration generation.
5
+ """
6
+
7
+ import getpass
8
+ import logging
9
+ import os
10
+ import stat
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import click
19
+ import requests
20
+ import yaml
21
+ from github import Github
22
+ from github.GithubException import GithubException
23
+
24
+ from ..core.git_auth import verify_github_token
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class InstallWizard:
30
+ """Interactive installation wizard for GitFlow Analytics setup."""
31
+
32
+ # Installation profiles
33
+ PROFILES = {
34
+ "1": {
35
+ "name": "Standard",
36
+ "description": "GitHub + JIRA + AI (Full featured)",
37
+ "github": True,
38
+ "repositories": "manual",
39
+ "jira": True,
40
+ "ai": True,
41
+ "analysis": True,
42
+ },
43
+ "2": {
44
+ "name": "GitHub Only",
45
+ "description": "GitHub integration without PM tools",
46
+ "github": True,
47
+ "repositories": "manual",
48
+ "jira": False,
49
+ "ai": False,
50
+ "analysis": True,
51
+ },
52
+ "3": {
53
+ "name": "Organization Mode",
54
+ "description": "Auto-discover repos from GitHub org",
55
+ "github": True,
56
+ "repositories": "organization",
57
+ "jira": True,
58
+ "ai": True,
59
+ "analysis": True,
60
+ },
61
+ "4": {
62
+ "name": "Minimal",
63
+ "description": "Local repos only, no integrations",
64
+ "github": False,
65
+ "repositories": "local",
66
+ "jira": False,
67
+ "ai": False,
68
+ "analysis": True,
69
+ },
70
+ "5": {
71
+ "name": "Custom",
72
+ "description": "Configure everything manually",
73
+ "github": None, # Ask user
74
+ "repositories": None, # Ask user
75
+ "jira": None, # Ask user
76
+ "ai": None, # Ask user
77
+ "analysis": True,
78
+ },
79
+ }
80
+
81
+ def __init__(self, output_dir: Path, skip_validation: bool = False):
82
+ """Initialize the installation wizard.
83
+
84
+ Args:
85
+ output_dir: Directory where config files will be created
86
+ skip_validation: Skip credential validation (for testing)
87
+ """
88
+ self.output_dir = Path(output_dir).resolve()
89
+ self.skip_validation = skip_validation
90
+ self.config_data = {}
91
+ self.env_data = {}
92
+ self.profile = None # Selected installation profile
93
+
94
+ # Ensure output directory exists
95
+ self.output_dir.mkdir(parents=True, exist_ok=True)
96
+
97
+ def _is_interactive(self) -> bool:
98
+ """Check if running in interactive terminal.
99
+
100
+ Returns:
101
+ True if stdin and stdout are connected to a TTY
102
+ """
103
+ return sys.stdin.isatty() and sys.stdout.isatty()
104
+
105
+ def _get_password(self, prompt: str, field_name: str = "password") -> str:
106
+ """Get password input with non-interactive detection.
107
+
108
+ Args:
109
+ prompt: Prompt text to display
110
+ field_name: Field name for error messages
111
+
112
+ Returns:
113
+ Password string
114
+ """
115
+ if self._is_interactive():
116
+ return getpass.getpass(prompt)
117
+ else:
118
+ click.echo(f"⚠️ Non-interactive mode detected - {field_name} will be visible", err=True)
119
+ return click.prompt(prompt, hide_input=False)
120
+
121
+ def _select_profile(self) -> dict:
122
+ """Let user select installation profile."""
123
+ click.echo("\n📋 Installation Profiles")
124
+ click.echo("=" * 60 + "\n")
125
+
126
+ for key, profile in self.PROFILES.items():
127
+ click.echo(f" {key}. {profile['name']}")
128
+ click.echo(f" {profile['description']}")
129
+ click.echo()
130
+
131
+ profile_choice = click.prompt(
132
+ "Select installation profile",
133
+ type=click.Choice(list(self.PROFILES.keys())),
134
+ default="1",
135
+ )
136
+
137
+ selected = self.PROFILES[profile_choice].copy()
138
+ click.echo(f"\n✅ Selected: {selected['name']}\n")
139
+
140
+ return selected
141
+
142
+ def run(self) -> bool:
143
+ """Run the installation wizard.
144
+
145
+ Returns:
146
+ True if installation completed successfully, False otherwise
147
+ """
148
+ try:
149
+ click.echo("🚀 GitFlow Analytics Installation Wizard")
150
+ click.echo("=" * 50)
151
+ click.echo()
152
+
153
+ # Step 0: Select profile
154
+ self.profile = self._select_profile()
155
+
156
+ # Step 1: GitHub Setup (conditional based on profile)
157
+ if self.profile["github"] is not False:
158
+ if not self._setup_github():
159
+ return False
160
+ else:
161
+ # Minimal mode - no GitHub
162
+ pass
163
+
164
+ # Step 2: Repository Configuration (based on profile)
165
+ if self.profile["repositories"] == "organization":
166
+ # Organization mode - already handled in GitHub setup
167
+ pass
168
+ elif self.profile["repositories"] == "manual":
169
+ if not self._setup_repositories():
170
+ return False
171
+ elif self.profile["repositories"] == "local":
172
+ if not self._setup_local_repositories():
173
+ return False
174
+ elif self.profile["repositories"] is None and not self._setup_repositories():
175
+ # Custom mode - ask user
176
+ return False
177
+
178
+ # Step 3: JIRA Setup (conditional based on profile)
179
+ if self.profile["jira"]:
180
+ self._setup_jira()
181
+ elif self.profile["jira"] is None:
182
+ # Custom mode - ask user
183
+ self._setup_jira()
184
+
185
+ # Step 4: OpenRouter/ChatGPT Setup (conditional based on profile)
186
+ if self.profile["ai"]:
187
+ self._setup_ai()
188
+ elif self.profile["ai"] is None:
189
+ # Custom mode - ask user
190
+ self._setup_ai()
191
+
192
+ # Step 5: Analysis Configuration
193
+ if self.profile["analysis"]:
194
+ self._setup_analysis()
195
+
196
+ # Step 6: Generate Files
197
+ if not self._generate_files():
198
+ return False
199
+
200
+ # Step 7: Validation
201
+ if not self._validate_installation():
202
+ return False
203
+
204
+ # Success summary
205
+ self._display_success_summary()
206
+
207
+ return True
208
+
209
+ except KeyboardInterrupt:
210
+ click.echo("\n\n⚠️ Installation cancelled by user")
211
+ return False
212
+ except (EOFError, click.exceptions.Abort):
213
+ click.echo("\n\n⚠️ Installation cancelled (non-interactive mode or user abort)")
214
+ return False
215
+ except requests.exceptions.RequestException as e:
216
+ # Never expose raw exception - could contain credentials
217
+ error_type = type(e).__name__
218
+ click.echo(f"\n\n❌ Installation failed: Network error ({error_type})")
219
+ logger.error(f"Installation network error: {error_type}")
220
+ return False
221
+ except Exception as e:
222
+ click.echo("\n\n❌ Installation failed: Unexpected error occurred")
223
+ logger.error(f"Installation error type: {type(e).__name__}")
224
+ return False
225
+
226
+ def _setup_github(self) -> bool:
227
+ """Setup GitHub credentials with validation.
228
+
229
+ Returns:
230
+ True if setup successful, False otherwise
231
+ """
232
+ click.echo("📋 Step 1: GitHub Setup (REQUIRED)")
233
+ click.echo("-" * 50)
234
+ click.echo("GitHub Personal Access Token is required for repository access.")
235
+ click.echo("Generate token at: https://github.com/settings/tokens")
236
+ click.echo("\nRequired permissions:")
237
+ click.echo(" • repo (Full control of private repositories)")
238
+ click.echo(" • read:org (Read org and team membership)")
239
+ click.echo()
240
+
241
+ max_retries = 3
242
+ for attempt in range(max_retries):
243
+ if attempt > 0:
244
+ # Add exponential backoff to prevent rate limiting
245
+ delay = 2 ** (attempt - 1) # 1, 2, 4 seconds
246
+ click.echo(f"⏳ Waiting {delay} seconds before retry...")
247
+ time.sleep(delay)
248
+
249
+ token = self._get_password(
250
+ "Enter GitHub Personal Access Token: ", "GitHub token"
251
+ ).strip()
252
+
253
+ if not token:
254
+ click.echo("❌ Token cannot be empty")
255
+ continue
256
+
257
+ # Validate token
258
+ if not self.skip_validation:
259
+ click.echo("🔍 Validating GitHub token...")
260
+ success, username, error_msg = verify_github_token(token)
261
+
262
+ if success:
263
+ click.echo(f"✅ Token verified successfully! (user: {username})")
264
+ self.env_data["GITHUB_TOKEN"] = token
265
+ self.config_data["github"] = {"token": "${GITHUB_TOKEN}"}
266
+ return True
267
+ else:
268
+ click.echo(f"❌ Validation failed: {error_msg}")
269
+ if attempt < max_retries - 1:
270
+ retry = click.confirm("Try again?", default=True)
271
+ if not retry:
272
+ return False
273
+ else:
274
+ click.echo("❌ Maximum retry attempts reached")
275
+ return False
276
+ else:
277
+ # Skip validation mode
278
+ self.env_data["GITHUB_TOKEN"] = token
279
+ self.config_data["github"] = {"token": "${GITHUB_TOKEN}"}
280
+ return True
281
+
282
+ return False
283
+
284
+ def _setup_repositories(self) -> bool:
285
+ """Setup repository configuration.
286
+
287
+ Returns:
288
+ True if setup successful, False otherwise
289
+ """
290
+ click.echo("\n📋 Step 2: Repository Configuration")
291
+ click.echo("-" * 50)
292
+ click.echo("Choose how to configure repositories:")
293
+ click.echo(" A) Organization mode (auto-discover all repos)")
294
+ click.echo(" B) Manual mode (specify individual repos)")
295
+ click.echo()
296
+
297
+ mode = click.prompt(
298
+ "Select mode",
299
+ type=click.Choice(["A", "B", "a", "b"], case_sensitive=False),
300
+ default="A",
301
+ ).upper()
302
+
303
+ if mode == "A":
304
+ return self._setup_organization_mode()
305
+ else:
306
+ return self._setup_manual_repos()
307
+
308
+ def _setup_organization_mode(self) -> bool:
309
+ """Setup organization mode with validation.
310
+
311
+ Returns:
312
+ True if setup successful, False otherwise
313
+ """
314
+ click.echo("\n📦 Organization Mode")
315
+ click.echo("All non-archived repositories will be automatically discovered.")
316
+ click.echo()
317
+
318
+ org_name = click.prompt("Enter GitHub organization name", type=str).strip()
319
+
320
+ if not org_name:
321
+ click.echo("❌ Organization name cannot be empty")
322
+ return False
323
+
324
+ # Validate organization exists (if not skipping validation)
325
+ if not self.skip_validation:
326
+ click.echo(f"🔍 Validating organization '{org_name}'...")
327
+ try:
328
+ github = Github(self.env_data["GITHUB_TOKEN"])
329
+ org = github.get_organization(org_name)
330
+ repo_count = org.public_repos + org.total_private_repos
331
+ click.echo(f"✅ Organization found! (~{repo_count} total repositories)")
332
+ except GithubException as e:
333
+ # Never expose raw exception - could contain credentials
334
+ error_type = type(e).__name__
335
+ click.echo(f"❌ Cannot access organization: {error_type}")
336
+ logger.debug(f"Organization validation error: {error_type}")
337
+ retry = click.confirm("Continue anyway?", default=False)
338
+ if not retry:
339
+ return False
340
+ except Exception as e:
341
+ error_type = type(e).__name__
342
+ click.echo(f"❌ Unexpected error: {error_type}")
343
+ logger.error(f"Organization validation unexpected error: {error_type}")
344
+ retry = click.confirm("Continue anyway?", default=False)
345
+ if not retry:
346
+ return False
347
+
348
+ self.config_data["github"]["organization"] = org_name
349
+ return True
350
+
351
+ def _validate_directory_path(self, path: str, purpose: str) -> Optional[Path]:
352
+ """Validate directory path is safe and within expected boundaries.
353
+
354
+ Args:
355
+ path: User-provided path
356
+ purpose: Description of path purpose for error messages
357
+
358
+ Returns:
359
+ Validated Path object or None if invalid
360
+ """
361
+ try:
362
+ # Expand and resolve path
363
+ path_obj = Path(path).expanduser().resolve()
364
+
365
+ # Prevent absolute paths outside user's home or current directory
366
+ if path_obj.is_absolute():
367
+ home = Path.home()
368
+ cwd = Path.cwd()
369
+
370
+ # Check if path is within safe boundaries
371
+ try:
372
+ # Try relative_to for Python 3.9+
373
+ is_safe = path_obj.is_relative_to(home) or path_obj.is_relative_to(cwd)
374
+ except AttributeError:
375
+ # Fallback for Python 3.8
376
+ is_safe = str(path_obj).startswith(str(home)) or str(path_obj).startswith(
377
+ str(cwd)
378
+ )
379
+
380
+ if not is_safe:
381
+ click.echo(f"⚠️ {purpose} must be within home directory or current directory")
382
+ return None
383
+
384
+ return path_obj
385
+
386
+ except (ValueError, OSError) as e:
387
+ click.echo(f"⚠️ Invalid path for {purpose}: Path validation error")
388
+ logger.debug(f"Path validation error: {type(e).__name__}")
389
+ return None
390
+
391
+ def _setup_manual_repos(self) -> bool:
392
+ """Setup manual repository configuration.
393
+
394
+ Returns:
395
+ True if setup successful, False otherwise
396
+ """
397
+ click.echo("\n📦 Manual Repository Mode")
398
+ click.echo("You can specify one or more local repository paths.")
399
+ click.echo()
400
+
401
+ repositories = []
402
+ while True:
403
+ repo_path_str = click.prompt(
404
+ "Enter repository path (or press Enter to finish)",
405
+ type=str,
406
+ default="",
407
+ show_default=False,
408
+ ).strip()
409
+
410
+ if not repo_path_str:
411
+ if not repositories:
412
+ click.echo("❌ At least one repository is required")
413
+ continue
414
+ break
415
+
416
+ # Validate path is safe
417
+ path_obj = self._validate_directory_path(repo_path_str, "Repository path")
418
+ if path_obj is None:
419
+ continue # Re-prompt
420
+
421
+ if not path_obj.exists():
422
+ click.echo(f"⚠️ Path does not exist: {path_obj}")
423
+ if not click.confirm("Add anyway?", default=False):
424
+ continue
425
+
426
+ # Check if it's a git repository
427
+ if (path_obj / ".git").exists():
428
+ click.echo(f"✅ Valid git repository: {path_obj}")
429
+ else:
430
+ click.echo(f"⚠️ Not a git repository: {path_obj}")
431
+ if not click.confirm("Add anyway?", default=False):
432
+ continue
433
+
434
+ repositories.append({"path": str(path_obj)})
435
+ click.echo(f"Added repository #{len(repositories)}")
436
+
437
+ if not click.confirm("Add another repository?", default=False):
438
+ break
439
+
440
+ self.config_data["github"]["repositories"] = repositories
441
+ return True
442
+
443
+ def _setup_local_repositories(self) -> bool:
444
+ """Setup local repository paths (no GitHub).
445
+
446
+ Returns:
447
+ True if setup successful, False otherwise
448
+ """
449
+ click.echo("\n📦 Local Repository Mode")
450
+ click.echo("Specify local Git repository paths to analyze.")
451
+ click.echo()
452
+
453
+ repositories = []
454
+ while True:
455
+ repo_path_str = click.prompt(
456
+ "Enter repository path (or press Enter to finish)",
457
+ type=str,
458
+ default="",
459
+ show_default=False,
460
+ ).strip()
461
+
462
+ if not repo_path_str:
463
+ if not repositories:
464
+ click.echo("❌ At least one repository is required")
465
+ continue
466
+ break
467
+
468
+ # Validate path is safe
469
+ path_obj = self._validate_directory_path(repo_path_str, "Repository path")
470
+ if path_obj is None:
471
+ continue # Re-prompt
472
+
473
+ if not path_obj.exists():
474
+ click.echo(f"⚠️ Path does not exist: {path_obj}")
475
+ if not click.confirm("Add anyway?", default=False):
476
+ continue
477
+
478
+ # Check if it's a git repository
479
+ if (path_obj / ".git").exists():
480
+ click.echo(f"✅ Valid git repository: {path_obj}")
481
+ else:
482
+ click.echo(f"⚠️ Not a git repository: {path_obj}")
483
+ if not click.confirm("Add anyway?", default=False):
484
+ continue
485
+
486
+ repo_name = click.prompt("Repository name", default=path_obj.name)
487
+
488
+ repositories.append({"name": repo_name, "path": str(path_obj)})
489
+ click.echo(f"Added repository #{len(repositories)}\n")
490
+
491
+ if not click.confirm("Add another repository?", default=False):
492
+ break
493
+
494
+ # Store repositories directly without GitHub section
495
+ self.config_data["repositories"] = repositories
496
+ return True
497
+
498
+ def _setup_jira(self) -> None:
499
+ """Setup JIRA integration (optional)."""
500
+ click.echo("\n📋 Step 3: JIRA Setup (OPTIONAL)")
501
+ click.echo("-" * 50)
502
+
503
+ if not click.confirm("Enable JIRA integration?", default=False):
504
+ click.echo("⏭️ Skipping JIRA setup")
505
+ return
506
+
507
+ click.echo("\nJIRA Configuration:")
508
+ click.echo("You'll need:")
509
+ click.echo(" • JIRA instance URL (e.g., https://yourcompany.atlassian.net)")
510
+ click.echo(" • Email address for API authentication")
511
+ click.echo(
512
+ " • API token from: https://id.atlassian.com/manage-profile/security/api-tokens"
513
+ )
514
+ click.echo()
515
+
516
+ max_retries = 3
517
+ for attempt in range(max_retries):
518
+ if attempt > 0:
519
+ # Add exponential backoff to prevent rate limiting
520
+ delay = 2 ** (attempt - 1) # 1, 2, 4 seconds
521
+ click.echo(f"⏳ Waiting {delay} seconds before retry...")
522
+ time.sleep(delay)
523
+
524
+ base_url = click.prompt("JIRA base URL", type=str).strip()
525
+ access_user = click.prompt("JIRA email", type=str).strip()
526
+ access_token = self._get_password("JIRA API token: ", "JIRA token").strip()
527
+
528
+ if not all([base_url, access_user, access_token]):
529
+ click.echo("❌ All JIRA fields are required")
530
+ continue
531
+
532
+ # Normalize base_url
533
+ base_url = base_url.rstrip("/")
534
+
535
+ # Validate JIRA credentials
536
+ if not self.skip_validation:
537
+ click.echo("🔍 Validating JIRA credentials...")
538
+ if self._validate_jira(base_url, access_user, access_token):
539
+ click.echo("✅ JIRA credentials validated!")
540
+ self._store_jira_config(base_url, access_user, access_token)
541
+ self._discover_jira_fields(base_url, access_user, access_token)
542
+ return
543
+ else:
544
+ if attempt < max_retries - 1:
545
+ retry = click.confirm("JIRA validation failed. Try again?", default=True)
546
+ if not retry:
547
+ click.echo("⏭️ Skipping JIRA setup")
548
+ return
549
+ else:
550
+ click.echo("❌ Maximum retry attempts reached")
551
+ click.echo("⏭️ Skipping JIRA setup")
552
+ return
553
+ else:
554
+ # Skip validation mode
555
+ self._store_jira_config(base_url, access_user, access_token)
556
+ return
557
+
558
+ click.echo("⏭️ Skipping JIRA setup")
559
+
560
+ def _validate_jira(self, base_url: str, username: str, api_token: str) -> bool:
561
+ """Validate JIRA credentials.
562
+
563
+ Args:
564
+ base_url: JIRA instance URL
565
+ username: JIRA username/email
566
+ api_token: JIRA API token
567
+
568
+ Returns:
569
+ True if credentials are valid, False otherwise
570
+ """
571
+ # Suppress requests logging to prevent credential exposure
572
+ urllib3_logger = logging.getLogger("urllib3")
573
+ requests_logger = logging.getLogger("requests")
574
+ original_urllib3 = urllib3_logger.level
575
+ original_requests = requests_logger.level
576
+
577
+ urllib3_logger.setLevel(logging.WARNING)
578
+ requests_logger.setLevel(logging.WARNING)
579
+
580
+ try:
581
+ import base64
582
+
583
+ # Create authentication header
584
+ credentials = base64.b64encode(f"{username}:{api_token}".encode()).decode()
585
+ headers = {
586
+ "Authorization": f"Basic {credentials}",
587
+ "Accept": "application/json",
588
+ "Content-Type": "application/json",
589
+ }
590
+
591
+ # Test authentication by getting current user info
592
+ response = requests.get(
593
+ f"{base_url}/rest/api/3/myself",
594
+ headers=headers,
595
+ timeout=10,
596
+ verify=True, # Explicit SSL verification
597
+ )
598
+
599
+ if response.status_code == 200:
600
+ user_info = response.json()
601
+ click.echo(f" Authenticated as: {user_info.get('displayName', username)}")
602
+ return True
603
+ else:
604
+ click.echo(f" Authentication failed (status {response.status_code})")
605
+ return False
606
+
607
+ except requests.exceptions.RequestException as e:
608
+ # Never expose raw exception - could contain credentials
609
+ error_type = type(e).__name__
610
+ click.echo(f" Connection error: {error_type}")
611
+ logger.debug(f"JIRA connection error type: {error_type}")
612
+ return False
613
+ except Exception as e:
614
+ click.echo(" JIRA validation failed")
615
+ logger.error(f"JIRA validation error type: {type(e).__name__}")
616
+ return False
617
+ finally:
618
+ # Always restore logging levels
619
+ urllib3_logger.setLevel(original_urllib3)
620
+ requests_logger.setLevel(original_requests)
621
+
622
+ def _store_jira_config(self, base_url: str, username: str, api_token: str) -> None:
623
+ """Store JIRA configuration.
624
+
625
+ Args:
626
+ base_url: JIRA instance URL
627
+ username: JIRA username/email
628
+ api_token: JIRA API token
629
+ """
630
+ self.env_data["JIRA_BASE_URL"] = base_url
631
+ self.env_data["JIRA_ACCESS_USER"] = username
632
+ self.env_data["JIRA_ACCESS_TOKEN"] = api_token
633
+
634
+ if "pm" not in self.config_data:
635
+ self.config_data["pm"] = {}
636
+
637
+ self.config_data["pm"]["jira"] = {
638
+ "base_url": "${JIRA_BASE_URL}",
639
+ "username": "${JIRA_ACCESS_USER}",
640
+ "api_token": "${JIRA_ACCESS_TOKEN}",
641
+ }
642
+
643
+ def _discover_jira_fields(self, base_url: str, username: str, api_token: str) -> None:
644
+ """Discover story point fields in JIRA.
645
+
646
+ Args:
647
+ base_url: JIRA instance URL
648
+ username: JIRA username/email
649
+ api_token: JIRA API token
650
+ """
651
+ # Suppress requests logging to prevent credential exposure
652
+ urllib3_logger = logging.getLogger("urllib3")
653
+ requests_logger = logging.getLogger("requests")
654
+ original_urllib3 = urllib3_logger.level
655
+ original_requests = requests_logger.level
656
+
657
+ urllib3_logger.setLevel(logging.WARNING)
658
+ requests_logger.setLevel(logging.WARNING)
659
+
660
+ try:
661
+ import base64
662
+
663
+ click.echo("🔍 Discovering story point fields...")
664
+
665
+ credentials = base64.b64encode(f"{username}:{api_token}".encode()).decode()
666
+ headers = {
667
+ "Authorization": f"Basic {credentials}",
668
+ "Accept": "application/json",
669
+ }
670
+
671
+ response = requests.get(
672
+ f"{base_url}/rest/api/3/field",
673
+ headers=headers,
674
+ timeout=10,
675
+ verify=True, # Explicit SSL verification
676
+ )
677
+
678
+ if response.status_code != 200:
679
+ return
680
+
681
+ fields = response.json()
682
+ story_point_fields = []
683
+
684
+ # Look for fields with "story", "point", or "estimate" in name
685
+ for field in fields:
686
+ name = field.get("name", "").lower()
687
+ if any(term in name for term in ["story", "point", "estimate"]):
688
+ story_point_fields.append(field["id"])
689
+
690
+ if story_point_fields:
691
+ click.echo(f"✅ Found {len(story_point_fields)} potential story point field(s)")
692
+ self.config_data["pm"]["jira"]["story_point_fields"] = story_point_fields
693
+ else:
694
+ click.echo("⚠️ No story point fields detected")
695
+
696
+ except Exception as e:
697
+ logger.debug(f"JIRA field discovery error type: {type(e).__name__}")
698
+ finally:
699
+ # Always restore logging levels
700
+ urllib3_logger.setLevel(original_urllib3)
701
+ requests_logger.setLevel(original_requests)
702
+
703
+ def _setup_ai(self) -> None:
704
+ """Setup AI-powered insights (optional)."""
705
+ click.echo("\n📋 Step 4: AI-Powered Insights (OPTIONAL)")
706
+ click.echo("-" * 50)
707
+
708
+ if not click.confirm("Enable AI-powered qualitative analysis?", default=False):
709
+ click.echo("⏭️ Skipping AI setup")
710
+ return
711
+
712
+ click.echo("\nAI Configuration:")
713
+ click.echo("GitFlow Analytics supports:")
714
+ click.echo(" • OpenRouter (sk-or-...) - Recommended, supports multiple models")
715
+ click.echo(" • OpenAI (sk-...) - Direct OpenAI API access")
716
+ click.echo("\nGet API key from:")
717
+ click.echo(" • OpenRouter: https://openrouter.ai/keys")
718
+ click.echo(" • OpenAI: https://platform.openai.com/api-keys")
719
+ click.echo()
720
+
721
+ max_retries = 3
722
+ for attempt in range(max_retries):
723
+ if attempt > 0:
724
+ # Add exponential backoff to prevent rate limiting
725
+ delay = 2 ** (attempt - 1) # 1, 2, 4 seconds
726
+ click.echo(f"⏳ Waiting {delay} seconds before retry...")
727
+ time.sleep(delay)
728
+
729
+ api_key = self._get_password("Enter API key: ", "AI API key").strip()
730
+
731
+ if not api_key:
732
+ click.echo("❌ API key cannot be empty")
733
+ continue
734
+
735
+ # Detect provider
736
+ is_openrouter = api_key.startswith("sk-or-")
737
+ provider = "OpenRouter" if is_openrouter else "OpenAI"
738
+
739
+ # Validate API key
740
+ if not self.skip_validation:
741
+ click.echo(f"🔍 Validating {provider} API key...")
742
+ if self._validate_ai_key(api_key, is_openrouter):
743
+ click.echo(f"✅ {provider} API key validated!")
744
+ self._store_ai_config(api_key, is_openrouter)
745
+ return
746
+ else:
747
+ if attempt < max_retries - 1:
748
+ retry = click.confirm(
749
+ f"{provider} validation failed. Try again?", default=True
750
+ )
751
+ if not retry:
752
+ click.echo("⏭️ Skipping AI setup")
753
+ return
754
+ else:
755
+ click.echo("❌ Maximum retry attempts reached")
756
+ click.echo("⏭️ Skipping AI setup")
757
+ return
758
+ else:
759
+ # Skip validation mode
760
+ self._store_ai_config(api_key, is_openrouter)
761
+ return
762
+
763
+ click.echo("⏭️ Skipping AI setup")
764
+
765
+ def _validate_ai_key(self, api_key: str, is_openrouter: bool) -> bool:
766
+ """Validate AI API key with simple test request.
767
+
768
+ Args:
769
+ api_key: API key to validate
770
+ is_openrouter: True if OpenRouter key, False if OpenAI
771
+
772
+ Returns:
773
+ True if key is valid, False otherwise
774
+ """
775
+ # Suppress requests logging to prevent credential exposure
776
+ urllib3_logger = logging.getLogger("urllib3")
777
+ requests_logger = logging.getLogger("requests")
778
+ original_urllib3 = urllib3_logger.level
779
+ original_requests = requests_logger.level
780
+
781
+ urllib3_logger.setLevel(logging.WARNING)
782
+ requests_logger.setLevel(logging.WARNING)
783
+
784
+ try:
785
+ if is_openrouter:
786
+ # Test OpenRouter
787
+ url = "https://openrouter.ai/api/v1/models"
788
+ headers = {
789
+ "Authorization": f"Bearer {api_key}",
790
+ }
791
+ response = requests.get(url, headers=headers, timeout=10, verify=True)
792
+ return response.status_code == 200
793
+ else:
794
+ # Test OpenAI
795
+ url = "https://api.openai.com/v1/models"
796
+ headers = {
797
+ "Authorization": f"Bearer {api_key}",
798
+ }
799
+ response = requests.get(url, headers=headers, timeout=10, verify=True)
800
+ return response.status_code == 200
801
+
802
+ except requests.exceptions.RequestException as e:
803
+ # Never expose raw exception - could contain credentials
804
+ error_type = type(e).__name__
805
+ click.echo(f" Connection error: {error_type}")
806
+ logger.debug(f"AI API connection error type: {error_type}")
807
+ return False
808
+ except Exception as e:
809
+ click.echo(" AI API validation failed")
810
+ logger.error(f"AI API validation error type: {type(e).__name__}")
811
+ return False
812
+ finally:
813
+ # Always restore logging levels
814
+ urllib3_logger.setLevel(original_urllib3)
815
+ requests_logger.setLevel(original_requests)
816
+
817
+ def _store_ai_config(self, api_key: str, is_openrouter: bool) -> None:
818
+ """Store AI configuration.
819
+
820
+ Args:
821
+ api_key: API key
822
+ is_openrouter: True if OpenRouter key, False if OpenAI
823
+ """
824
+ if is_openrouter:
825
+ self.env_data["OPENROUTER_API_KEY"] = api_key
826
+ self.config_data["chatgpt"] = {
827
+ "api_key": "${OPENROUTER_API_KEY}",
828
+ }
829
+ else:
830
+ self.env_data["OPENAI_API_KEY"] = api_key
831
+ self.config_data["chatgpt"] = {
832
+ "api_key": "${OPENAI_API_KEY}",
833
+ }
834
+
835
+ def _setup_analysis(self) -> None:
836
+ """Setup analysis configuration."""
837
+ click.echo("\n📋 Step 5: Analysis Configuration")
838
+ click.echo("-" * 50)
839
+
840
+ period_weeks = click.prompt(
841
+ "Analysis period (weeks)",
842
+ type=int,
843
+ default=4,
844
+ )
845
+
846
+ # Validate output directory path
847
+ while True:
848
+ output_dir = click.prompt(
849
+ "Output directory for reports",
850
+ type=str,
851
+ default="./reports",
852
+ )
853
+ output_path = self._validate_directory_path(output_dir, "Output directory")
854
+ if output_path is not None:
855
+ output_dir = str(output_path)
856
+ break
857
+ click.echo("Please enter a valid directory path.")
858
+
859
+ # Validate cache directory path
860
+ while True:
861
+ cache_dir = click.prompt(
862
+ "Cache directory",
863
+ type=str,
864
+ default="./.gitflow-cache",
865
+ )
866
+ cache_path = self._validate_directory_path(cache_dir, "Cache directory")
867
+ if cache_path is not None:
868
+ cache_dir = str(cache_path)
869
+ break
870
+ click.echo("Please enter a valid directory path.")
871
+
872
+ if "analysis" not in self.config_data:
873
+ self.config_data["analysis"] = {}
874
+
875
+ self.config_data["analysis"]["period_weeks"] = period_weeks
876
+ self.config_data["analysis"]["output_directory"] = output_dir
877
+ self.config_data["analysis"]["cache_directory"] = cache_dir
878
+
879
+ # NEW: Aliases configuration
880
+ click.echo("\n🔗 Developer Identity Aliases")
881
+ click.echo("-" * 40 + "\n")
882
+
883
+ click.echo("Aliases consolidate multiple email addresses for the same developer.")
884
+ click.echo("You can use a shared aliases.yaml file across multiple configs.\n")
885
+
886
+ use_aliases = click.confirm("Configure aliases file?", default=True)
887
+
888
+ if use_aliases:
889
+ aliases_options = [
890
+ "1. Create new aliases.yaml in this directory",
891
+ "2. Use existing shared aliases file",
892
+ "3. Generate aliases using LLM (after installation)",
893
+ ]
894
+
895
+ click.echo("\nOptions:")
896
+ for option in aliases_options:
897
+ click.echo(f" {option}")
898
+
899
+ aliases_choice = click.prompt(
900
+ "\nSelect option", type=click.Choice(["1", "2", "3"]), default="1"
901
+ )
902
+
903
+ if aliases_choice == "1":
904
+ # Create new aliases file
905
+ aliases_path = "aliases.yaml"
906
+
907
+ # Ensure analysis.identity section exists
908
+ if "identity" not in self.config_data.get("analysis", {}):
909
+ if "analysis" not in self.config_data:
910
+ self.config_data["analysis"] = {}
911
+ self.config_data["analysis"]["identity"] = {}
912
+
913
+ self.config_data["analysis"]["identity"]["aliases_file"] = aliases_path
914
+
915
+ # Create empty aliases file
916
+ from ..config.aliases import AliasesManager
917
+
918
+ aliases_full_path = self.output_dir / aliases_path
919
+ aliases_mgr = AliasesManager(aliases_full_path)
920
+ aliases_mgr.save() # Creates empty file with comments
921
+
922
+ click.echo(f"\n✅ Created {aliases_path}")
923
+ click.echo(" Generate aliases after installation with:")
924
+ click.echo(" gitflow-analytics aliases -c config.yaml --apply\n")
925
+
926
+ elif aliases_choice == "2":
927
+ # Use existing file
928
+ aliases_path = click.prompt(
929
+ "Path to aliases.yaml (relative to config)", default="../shared/aliases.yaml"
930
+ )
931
+
932
+ # Ensure analysis.identity section exists
933
+ if "identity" not in self.config_data.get("analysis", {}):
934
+ if "analysis" not in self.config_data:
935
+ self.config_data["analysis"] = {}
936
+ self.config_data["analysis"]["identity"] = {}
937
+
938
+ self.config_data["analysis"]["identity"]["aliases_file"] = aliases_path
939
+
940
+ click.echo(f"\n✅ Configured to use: {aliases_path}\n")
941
+
942
+ else: # choice == "3"
943
+ # Will generate after installation
944
+ click.echo("\n💡 After installation, run:")
945
+ click.echo(" gitflow-analytics aliases -c config.yaml --apply")
946
+ click.echo(" This will analyze your repos and generate aliases automatically.\n")
947
+
948
+ def _clear_sensitive_data(self) -> None:
949
+ """Clear sensitive data from memory after use."""
950
+ sensitive_keys = ["TOKEN", "KEY", "PASSWORD", "SECRET"]
951
+
952
+ for key in list(self.env_data.keys()):
953
+ if any(sensitive in key.upper() for sensitive in sensitive_keys):
954
+ # Overwrite with random data before deletion
955
+ self.env_data[key] = "CLEARED_" + os.urandom(16).hex()
956
+ del self.env_data[key]
957
+
958
+ # Clear the dictionary
959
+ self.env_data.clear()
960
+
961
+ def _generate_files(self) -> bool:
962
+ """Generate configuration and environment files.
963
+
964
+ Returns:
965
+ True if files generated successfully, False otherwise
966
+ """
967
+ click.echo("\n📋 Step 6: Generating Configuration Files")
968
+ click.echo("-" * 50)
969
+
970
+ try:
971
+ # Generate config.yaml
972
+ config_path = self.output_dir / "config.yaml"
973
+ if config_path.exists() and not click.confirm(
974
+ f"⚠️ {config_path} already exists. Overwrite?", default=False
975
+ ):
976
+ click.echo("❌ Installation cancelled")
977
+ return False
978
+
979
+ with open(config_path, "w") as f:
980
+ yaml.safe_dump(
981
+ self.config_data,
982
+ f,
983
+ default_flow_style=False,
984
+ sort_keys=False,
985
+ )
986
+ click.echo(f"✅ Created: {config_path}")
987
+
988
+ # Generate .env file with atomic secure permissions
989
+ env_path = self.output_dir / ".env"
990
+ if env_path.exists() and not click.confirm(
991
+ f"⚠️ {env_path} already exists. Overwrite?", default=False
992
+ ):
993
+ click.echo("❌ Installation cancelled")
994
+ return False
995
+
996
+ # Atomically create file with secure permissions using umask
997
+ old_umask = os.umask(0o077) # Ensure only owner can read/write
998
+ try:
999
+ with open(env_path, "w") as f:
1000
+ f.write("# GitFlow Analytics Environment Variables\n")
1001
+ f.write(
1002
+ f"# Generated by installation wizard on {datetime.now().strftime('%Y-%m-%d')}\n"
1003
+ )
1004
+ f.write(
1005
+ "# WARNING: This file contains sensitive credentials - never commit to git\n\n"
1006
+ )
1007
+
1008
+ for key, value in self.env_data.items():
1009
+ f.write(f"{key}={value}\n")
1010
+ finally:
1011
+ # Always restore original umask
1012
+ os.umask(old_umask)
1013
+
1014
+ # Verify permissions are correct (redundant but defensive)
1015
+ env_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
1016
+
1017
+ # Verify actual permissions
1018
+ actual_perms = stat.S_IMODE(os.stat(env_path).st_mode)
1019
+ if actual_perms != 0o600:
1020
+ click.echo(f"⚠️ Warning: .env permissions are {oct(actual_perms)}, expected 0o600")
1021
+ return False
1022
+
1023
+ click.echo(f"✅ Created: {env_path} (permissions: 0600)")
1024
+
1025
+ # Update .gitignore if in git repository
1026
+ self._update_gitignore()
1027
+
1028
+ return True
1029
+
1030
+ except OSError as e:
1031
+ click.echo("❌ Failed to generate files: File system error")
1032
+ logger.error(f"File generation OS error: {type(e).__name__}")
1033
+ return False
1034
+ except Exception as e:
1035
+ click.echo("❌ Failed to generate files: Unexpected error occurred")
1036
+ logger.error(f"File generation error type: {type(e).__name__}")
1037
+ return False
1038
+ finally:
1039
+ # Always clear sensitive data from memory
1040
+ self._clear_sensitive_data()
1041
+
1042
+ def _update_gitignore(self) -> None:
1043
+ """Update .gitignore to include .env if in a git repository."""
1044
+ try:
1045
+ # Check if we're in a git repository
1046
+ result = subprocess.run(
1047
+ ["git", "rev-parse", "--git-dir"],
1048
+ cwd=self.output_dir,
1049
+ capture_output=True,
1050
+ text=True,
1051
+ check=False,
1052
+ )
1053
+
1054
+ if result.returncode != 0:
1055
+ # Not a git repository
1056
+ return
1057
+
1058
+ gitignore_path = self.output_dir / ".gitignore"
1059
+
1060
+ # Read existing .gitignore
1061
+ existing_patterns = set()
1062
+ if gitignore_path.exists():
1063
+ with open(gitignore_path) as f:
1064
+ existing_patterns = set(line.strip() for line in f if line.strip())
1065
+
1066
+ # Add .env pattern if not present
1067
+ if ".env" not in existing_patterns:
1068
+ with open(gitignore_path, "a") as f:
1069
+ if existing_patterns:
1070
+ f.write("\n")
1071
+ f.write("# GitFlow Analytics environment variables\n")
1072
+ f.write(".env\n")
1073
+ click.echo("✅ Updated .gitignore to exclude .env")
1074
+
1075
+ except Exception as e:
1076
+ logger.debug(f"Could not update .gitignore: {e}")
1077
+
1078
+ def _validate_installation(self) -> bool:
1079
+ """Validate the installation by testing the configuration.
1080
+
1081
+ Returns:
1082
+ True if validation successful, False otherwise
1083
+ """
1084
+ click.echo("\n📋 Step 7: Validating Installation")
1085
+ click.echo("-" * 50)
1086
+
1087
+ config_path = self.output_dir / "config.yaml"
1088
+
1089
+ if not config_path.exists():
1090
+ click.echo("❌ Configuration file not found")
1091
+ return False
1092
+
1093
+ click.echo("🔍 Testing configuration...")
1094
+
1095
+ try:
1096
+ # Test configuration loading
1097
+ from ..config import ConfigLoader
1098
+
1099
+ ConfigLoader.load(config_path)
1100
+ click.echo("✅ Configuration validated successfully")
1101
+
1102
+ # Offer to run first analysis
1103
+ if click.confirm("\nRun initial analysis now?", default=False):
1104
+ self._run_analysis(config_path)
1105
+
1106
+ return True
1107
+
1108
+ except Exception as e:
1109
+ click.echo(f"❌ Configuration validation failed: {e}", err=True)
1110
+ click.echo("You may need to adjust the configuration manually.")
1111
+ logger.error(f"Configuration validation error type: {type(e).__name__}")
1112
+ return True # Don't fail installation on validation error
1113
+
1114
+ def _run_analysis(self, config_path: Path) -> None:
1115
+ """Run initial analysis.
1116
+
1117
+ Args:
1118
+ config_path: Path to configuration file
1119
+ """
1120
+ try:
1121
+ import sys
1122
+
1123
+ click.echo("\n🚀 Running analysis...")
1124
+ click.echo("-" * 50)
1125
+
1126
+ # Use subprocess to run analysis
1127
+ result = subprocess.run(
1128
+ [
1129
+ sys.executable,
1130
+ "-m",
1131
+ "gitflow_analytics.cli",
1132
+ "analyze",
1133
+ "--config",
1134
+ str(config_path),
1135
+ ],
1136
+ cwd=self.output_dir,
1137
+ capture_output=False,
1138
+ )
1139
+
1140
+ if result.returncode == 0:
1141
+ click.echo("\n✅ Analysis completed successfully!")
1142
+ else:
1143
+ click.echo(f"\n⚠️ Analysis exited with code {result.returncode}")
1144
+
1145
+ except subprocess.SubprocessError as e:
1146
+ click.echo("\n❌ Failed to run analysis: Process error")
1147
+ logger.error(f"Analysis subprocess error type: {type(e).__name__}")
1148
+ except Exception as e:
1149
+ click.echo("\n❌ Failed to run analysis: Unexpected error occurred")
1150
+ logger.error(f"Analysis error type: {type(e).__name__}")
1151
+
1152
+ def _display_success_summary(self) -> None:
1153
+ """Display installation success summary."""
1154
+ click.echo("\n" + "=" * 50)
1155
+ click.echo("✅ Installation Complete!")
1156
+ click.echo("=" * 50)
1157
+
1158
+ config_path = self.output_dir / "config.yaml"
1159
+ env_path = self.output_dir / ".env"
1160
+
1161
+ click.echo("\n📁 Generated Files:")
1162
+ click.echo(f" • Configuration: {config_path}")
1163
+ click.echo(f" • Environment: {env_path}")
1164
+
1165
+ click.echo("\n🔐 Security Reminders:")
1166
+ click.echo(" • .env file contains sensitive credentials")
1167
+ click.echo(" • Permissions set to 0600 (owner read/write only)")
1168
+ click.echo(" • Never commit .env to version control")
1169
+
1170
+ click.echo("\n🚀 Next Steps:")
1171
+ click.echo(f" 1. Review configuration: {config_path}")
1172
+ click.echo(" 2. Run analysis:")
1173
+ click.echo(f" gitflow-analytics analyze --config {config_path}")
1174
+ click.echo(" 3. Check generated reports in: ./reports/")
1175
+
1176
+ click.echo("\n📚 Documentation:")
1177
+ click.echo(" • Configuration Guide: docs/guides/configuration.md")
1178
+ click.echo(" • Getting Started: docs/getting-started/README.md")
1179
+ click.echo(" • Repository: https://github.com/EWTN-Global/gitflow-analytics")
1180
+
1181
+ click.echo()