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