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.
- gitflow_analytics/_version.py +1 -1
- gitflow_analytics/cli.py +164 -15
- gitflow_analytics/cli_wizards/__init__.py +10 -0
- gitflow_analytics/cli_wizards/install_wizard.py +936 -0
- gitflow_analytics/cli_wizards/run_launcher.py +343 -0
- gitflow_analytics/config/schema.py +12 -0
- gitflow_analytics/constants.py +75 -0
- gitflow_analytics/core/cache.py +7 -3
- gitflow_analytics/core/data_fetcher.py +66 -30
- gitflow_analytics/core/git_timeout_wrapper.py +6 -4
- gitflow_analytics/core/progress.py +2 -4
- gitflow_analytics/core/subprocess_git.py +31 -5
- gitflow_analytics/identity_llm/analysis_pass.py +13 -3
- gitflow_analytics/identity_llm/analyzer.py +14 -2
- gitflow_analytics/identity_llm/models.py +7 -1
- gitflow_analytics/qualitative/classifiers/llm/openai_client.py +5 -3
- gitflow_analytics/security/config.py +6 -6
- gitflow_analytics/security/extractors/dependency_checker.py +14 -14
- gitflow_analytics/security/extractors/secret_detector.py +8 -14
- gitflow_analytics/security/extractors/vulnerability_scanner.py +9 -9
- gitflow_analytics/security/llm_analyzer.py +10 -10
- gitflow_analytics/security/security_analyzer.py +17 -17
- gitflow_analytics/tui/screens/analysis_progress_screen.py +1 -1
- gitflow_analytics/ui/progress_display.py +36 -29
- gitflow_analytics/verify_activity.py +23 -26
- {gitflow_analytics-3.3.0.dist-info → gitflow_analytics-3.4.7.dist-info}/METADATA +1 -1
- {gitflow_analytics-3.3.0.dist-info → gitflow_analytics-3.4.7.dist-info}/RECORD +31 -29
- gitflow_analytics/security/reports/__init__.py +0 -5
- gitflow_analytics/security/reports/security_report.py +0 -358
- {gitflow_analytics-3.3.0.dist-info → gitflow_analytics-3.4.7.dist-info}/WHEEL +0 -0
- {gitflow_analytics-3.3.0.dist-info → gitflow_analytics-3.4.7.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-3.3.0.dist-info → gitflow_analytics-3.4.7.dist-info}/licenses/LICENSE +0 -0
- {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()
|