gitflow-analytics 3.12.6__py3-none-any.whl → 3.13.5__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 +853 -129
- gitflow_analytics/cli_wizards/__init__.py +9 -3
- gitflow_analytics/cli_wizards/menu.py +798 -0
- gitflow_analytics/config/loader.py +3 -1
- gitflow_analytics/config/profiles.py +1 -2
- gitflow_analytics/core/data_fetcher.py +0 -2
- gitflow_analytics/core/identity.py +2 -0
- gitflow_analytics/extractors/tickets.py +3 -1
- gitflow_analytics/integrations/github_integration.py +1 -1
- gitflow_analytics/integrations/jira_integration.py +1 -1
- gitflow_analytics/qualitative/chatgpt_analyzer.py +15 -15
- gitflow_analytics/qualitative/classifiers/llm/prompts.py +1 -1
- gitflow_analytics/qualitative/core/processor.py +1 -2
- gitflow_analytics/qualitative/enhanced_analyzer.py +24 -8
- gitflow_analytics/reports/narrative_writer.py +13 -9
- gitflow_analytics/security/reports/__init__.py +5 -0
- gitflow_analytics/security/reports/security_report.py +358 -0
- gitflow_analytics/ui/progress_display.py +14 -6
- gitflow_analytics/verify_activity.py +1 -1
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/METADATA +37 -1
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/RECORD +26 -23
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/WHEEL +0 -0
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/licenses/LICENSE +0 -0
- {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
"""Interactive CLI menu system for gitflow-analytics.
|
|
2
|
+
|
|
3
|
+
This module provides an interactive menu interface that appears when the tool
|
|
4
|
+
is run without arguments, offering options for configuration, alias management,
|
|
5
|
+
analysis execution, and more.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validate_subprocess_path(path: Path) -> None:
|
|
24
|
+
"""Validate path is safe for subprocess execution.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
path: Path to validate
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValueError: If path contains dangerous characters or traversal patterns
|
|
31
|
+
"""
|
|
32
|
+
path_str = str(path.resolve())
|
|
33
|
+
|
|
34
|
+
# Check for shell metacharacters
|
|
35
|
+
dangerous_chars = [";", "&", "|", "$", "`", "\n", "\r", "<", ">"]
|
|
36
|
+
if any(char in path_str for char in dangerous_chars):
|
|
37
|
+
raise ValueError(f"Path contains invalid characters: {path}")
|
|
38
|
+
|
|
39
|
+
# No path traversal
|
|
40
|
+
if ".." in path.parts:
|
|
41
|
+
raise ValueError(f"Path cannot contain '..' traversal: {path}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _validate_editor_command(editor: str) -> None:
|
|
45
|
+
"""Validate editor command is safe.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
editor: Editor command from environment
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If editor contains spaces or dangerous characters
|
|
52
|
+
"""
|
|
53
|
+
# Editor should be a single command without arguments
|
|
54
|
+
if " " in editor:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"EDITOR environment variable contains spaces/arguments: {editor}. "
|
|
57
|
+
"Please set EDITOR to command name only (e.g., 'vim' not 'vim -x')"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Check for shell metacharacters
|
|
61
|
+
dangerous_chars = [";", "&", "|", "$", "`", "\n", "\r", "<", ">"]
|
|
62
|
+
if any(char in editor for char in dangerous_chars):
|
|
63
|
+
raise ValueError(f"EDITOR contains invalid characters: {editor}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _atomic_yaml_write(config_path: Path, config_data: dict) -> None:
|
|
67
|
+
"""Atomically write YAML config to prevent corruption.
|
|
68
|
+
|
|
69
|
+
Uses temp file + atomic rename pattern to ensure config is never
|
|
70
|
+
partially written or corrupted.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
config_path: Destination config file path
|
|
74
|
+
config_data: Configuration data to write
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
IOError: If write fails
|
|
78
|
+
"""
|
|
79
|
+
temp_fd = None
|
|
80
|
+
temp_path = None
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# Create temp file in same directory as target (required for atomic rename)
|
|
84
|
+
temp_fd, temp_path_str = tempfile.mkstemp(
|
|
85
|
+
dir=config_path.parent, prefix=f".{config_path.name}.", suffix=".tmp", text=True
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
temp_path = Path(temp_path_str)
|
|
89
|
+
|
|
90
|
+
# Write to temp file
|
|
91
|
+
with os.fdopen(temp_fd, "w") as f:
|
|
92
|
+
yaml.dump(config_data, f, default_flow_style=False, sort_keys=False)
|
|
93
|
+
temp_fd = None # fdopen closes the fd
|
|
94
|
+
|
|
95
|
+
# Atomic rename
|
|
96
|
+
os.replace(temp_path, config_path)
|
|
97
|
+
logger.debug(f"Atomically wrote config to {config_path}")
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
# Cleanup temp file on error
|
|
101
|
+
if temp_fd is not None:
|
|
102
|
+
with contextlib.suppress(Exception):
|
|
103
|
+
os.close(temp_fd)
|
|
104
|
+
|
|
105
|
+
if temp_path and temp_path.exists():
|
|
106
|
+
temp_path.unlink(missing_ok=True)
|
|
107
|
+
|
|
108
|
+
raise OSError(f"Failed to write config atomically: {e}") from e
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _run_subprocess_safely(cmd: list[str], operation_name: str, timeout: int = 300) -> bool:
|
|
112
|
+
"""Run subprocess with comprehensive error handling.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
cmd: Command list to execute (no shell=True)
|
|
116
|
+
operation_name: Human-readable operation name for error messages
|
|
117
|
+
timeout: Timeout in seconds (default: 300s for interactive operations)
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if subprocess succeeded (exit code 0), False otherwise
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
result = subprocess.run(
|
|
124
|
+
cmd,
|
|
125
|
+
shell=False,
|
|
126
|
+
check=False,
|
|
127
|
+
timeout=timeout,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if result.returncode == 0:
|
|
131
|
+
return True
|
|
132
|
+
elif result.returncode < 0:
|
|
133
|
+
# Process was terminated by signal
|
|
134
|
+
signal_num = -result.returncode
|
|
135
|
+
click.echo(
|
|
136
|
+
click.style(
|
|
137
|
+
f"\n⚠️ {operation_name} was terminated (signal {signal_num})", fg="yellow"
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
logger.warning(f"{operation_name} terminated by signal {signal_num}")
|
|
141
|
+
return False
|
|
142
|
+
else:
|
|
143
|
+
# Process exited with non-zero code
|
|
144
|
+
click.echo(
|
|
145
|
+
click.style(
|
|
146
|
+
f"\n⚠️ {operation_name} exited with code {result.returncode}", fg="yellow"
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
logger.warning(f"{operation_name} exited with code {result.returncode}")
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
except subprocess.TimeoutExpired:
|
|
153
|
+
click.echo(
|
|
154
|
+
click.style(f"\n❌ {operation_name} timed out after {timeout} seconds", fg="red"),
|
|
155
|
+
err=True,
|
|
156
|
+
)
|
|
157
|
+
logger.error(f"{operation_name} timeout after {timeout}s")
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
except FileNotFoundError as e:
|
|
161
|
+
click.echo(click.style(f"\n❌ Command not found: {e}", fg="red"), err=True)
|
|
162
|
+
logger.error(f"{operation_name} - command not found: {e}")
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
click.echo(click.style(f"\n❌ Error running {operation_name}: {e}", fg="red"), err=True)
|
|
167
|
+
logger.error(f"{operation_name} error: {type(e).__name__}: {e}")
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def find_or_prompt_config() -> Optional[Path]:
|
|
172
|
+
"""Find config.yaml or prompt user for path.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Path to config file, or None if user cancels.
|
|
176
|
+
"""
|
|
177
|
+
# Check for config.yaml in current directory
|
|
178
|
+
cwd_config = Path.cwd() / "config.yaml"
|
|
179
|
+
if cwd_config.exists():
|
|
180
|
+
click.echo(
|
|
181
|
+
click.style(f"✅ Found config.yaml in current directory: {cwd_config}", fg="green")
|
|
182
|
+
)
|
|
183
|
+
if click.confirm("Use this config?", default=True):
|
|
184
|
+
return cwd_config
|
|
185
|
+
|
|
186
|
+
# Prompt for config path
|
|
187
|
+
click.echo("\n" + "=" * 60)
|
|
188
|
+
click.echo(click.style("Configuration File Required", fg="yellow", bold=True))
|
|
189
|
+
click.echo("=" * 60)
|
|
190
|
+
click.echo("\nPlease provide the path to your config.yaml file:")
|
|
191
|
+
click.echo(" • Enter absolute path: /path/to/config.yaml")
|
|
192
|
+
click.echo(" • Enter relative path: ./config.yaml")
|
|
193
|
+
click.echo(" • Press Ctrl+C to exit\n")
|
|
194
|
+
|
|
195
|
+
while True:
|
|
196
|
+
try:
|
|
197
|
+
config_path_str = click.prompt("Config path", type=str)
|
|
198
|
+
config_path = Path(config_path_str).expanduser().resolve()
|
|
199
|
+
|
|
200
|
+
if config_path.exists() and config_path.is_file():
|
|
201
|
+
return config_path
|
|
202
|
+
else:
|
|
203
|
+
click.echo(click.style(f"❌ File not found: {config_path}", fg="red"))
|
|
204
|
+
if not click.confirm("Try again?", default=True):
|
|
205
|
+
return None
|
|
206
|
+
except click.Abort:
|
|
207
|
+
click.echo("\n\n👋 Exiting menu.")
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def validate_config(config_path: Path) -> bool:
|
|
212
|
+
"""Validate configuration file with helpful error messages.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
config_path: Path to config.yaml file
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
True if config is valid, False otherwise.
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
from gitflow_analytics.config.loader import ConfigLoader
|
|
222
|
+
|
|
223
|
+
ConfigLoader.load(config_path)
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
except yaml.YAMLError as e:
|
|
227
|
+
click.echo(click.style("\n❌ YAML Syntax Error", fg="red", bold=True), err=True)
|
|
228
|
+
|
|
229
|
+
# Show location if available
|
|
230
|
+
if hasattr(e, "problem_mark"):
|
|
231
|
+
mark = e.problem_mark
|
|
232
|
+
click.echo(f" Location: Line {mark.line + 1}, Column {mark.column + 1}", err=True)
|
|
233
|
+
|
|
234
|
+
# Show problematic line with context
|
|
235
|
+
try:
|
|
236
|
+
with open(config_path) as f:
|
|
237
|
+
lines = f.readlines()
|
|
238
|
+
|
|
239
|
+
if 0 <= mark.line < len(lines):
|
|
240
|
+
click.echo("\n Context:", err=True)
|
|
241
|
+
|
|
242
|
+
# Line before
|
|
243
|
+
if mark.line > 0:
|
|
244
|
+
click.echo(f" {mark.line}: {lines[mark.line - 1].rstrip()}", err=True)
|
|
245
|
+
|
|
246
|
+
# Problematic line (highlighted)
|
|
247
|
+
click.echo(
|
|
248
|
+
click.style(f" {mark.line + 1}: {lines[mark.line].rstrip()}", fg="red"),
|
|
249
|
+
err=True,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Pointer to column
|
|
253
|
+
pointer = " " * (len(str(mark.line + 1)) + 2 + mark.column) + "^"
|
|
254
|
+
click.echo(click.style(pointer, fg="red"), err=True)
|
|
255
|
+
|
|
256
|
+
# Line after
|
|
257
|
+
if mark.line + 1 < len(lines):
|
|
258
|
+
click.echo(f" {mark.line + 2}: {lines[mark.line + 1].rstrip()}", err=True)
|
|
259
|
+
except Exception:
|
|
260
|
+
# If we can't read file, just skip context
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
# Show problem description
|
|
264
|
+
if hasattr(e, "problem"):
|
|
265
|
+
click.echo(f"\n Problem: {e.problem}", err=True)
|
|
266
|
+
|
|
267
|
+
# Add helpful tips
|
|
268
|
+
click.echo("\n💡 Common YAML issues:", err=True)
|
|
269
|
+
click.echo(" • Check for unmatched quotes or brackets", err=True)
|
|
270
|
+
click.echo(" • Ensure proper indentation (use spaces, not tabs)", err=True)
|
|
271
|
+
click.echo(" • Verify colons have space after them (key: value)", err=True)
|
|
272
|
+
click.echo(" • Check for special characters that need quoting", err=True)
|
|
273
|
+
|
|
274
|
+
logger.error(f"YAML syntax error in config: {e}")
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
click.echo(click.style(f"❌ Configuration error: {e}", fg="red"), err=True)
|
|
279
|
+
logger.error(f"Config validation failed: {e}")
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def edit_configuration(config_path: Path) -> bool:
|
|
284
|
+
"""Open config.yaml in user's editor.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
config_path: Path to config.yaml file
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
True if edit succeeded and config is valid, False otherwise.
|
|
291
|
+
"""
|
|
292
|
+
# Get editor from environment, fallback to vi
|
|
293
|
+
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi"))
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
# Validate editor command
|
|
297
|
+
_validate_editor_command(editor)
|
|
298
|
+
except ValueError as e:
|
|
299
|
+
click.echo(click.style(f"❌ {e}", fg="red"), err=True)
|
|
300
|
+
logger.error(f"Editor validation failed: {e}")
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
# Validate config path
|
|
305
|
+
_validate_subprocess_path(config_path)
|
|
306
|
+
except ValueError as e:
|
|
307
|
+
click.echo(click.style(f"❌ Invalid config path: {e}", fg="red"), err=True)
|
|
308
|
+
logger.error(f"Config path validation failed: {e}")
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
click.echo(f"\n📝 Opening {config_path} with {editor}...")
|
|
312
|
+
|
|
313
|
+
# Run editor with timeout (5 minutes default for interactive editing)
|
|
314
|
+
success = _run_subprocess_safely(
|
|
315
|
+
[editor, str(config_path)], operation_name="Editor", timeout=300
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if not success:
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
# Validate after edit
|
|
322
|
+
click.echo("\n🔍 Validating configuration...")
|
|
323
|
+
if validate_config(config_path):
|
|
324
|
+
click.echo(click.style("✅ Configuration is valid!", fg="green"))
|
|
325
|
+
return True
|
|
326
|
+
else:
|
|
327
|
+
click.echo(
|
|
328
|
+
click.style(
|
|
329
|
+
"⚠️ Configuration has errors. Please fix before running analysis.",
|
|
330
|
+
fg="yellow",
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def fix_aliases(config_path: Path) -> bool:
|
|
337
|
+
"""Launch interactive alias creator.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
config_path: Path to config.yaml file
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
True if alias creation succeeded, False otherwise.
|
|
344
|
+
"""
|
|
345
|
+
click.echo(
|
|
346
|
+
"\n" + click.style("🔧 Launching Interactive Alias Creator...", fg="cyan", bold=True) + "\n"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
# Validate config path
|
|
351
|
+
_validate_subprocess_path(config_path)
|
|
352
|
+
except ValueError as e:
|
|
353
|
+
click.echo(click.style(f"❌ Invalid config path: {e}", fg="red"), err=True)
|
|
354
|
+
logger.error(f"Config path validation failed: {e}")
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
cmd = [
|
|
358
|
+
sys.executable,
|
|
359
|
+
"-m",
|
|
360
|
+
"gitflow_analytics.cli",
|
|
361
|
+
"create-alias-interactive",
|
|
362
|
+
"-c",
|
|
363
|
+
str(config_path),
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
# Run with 5 minute timeout for interactive session
|
|
367
|
+
success = _run_subprocess_safely(cmd, operation_name="Alias creator", timeout=300)
|
|
368
|
+
|
|
369
|
+
if success:
|
|
370
|
+
click.echo(click.style("\n✅ Alias creation completed!", fg="green"))
|
|
371
|
+
|
|
372
|
+
return success
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def get_current_weeks(config_path: Path) -> int:
|
|
376
|
+
"""Get current weeks setting from config.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
config_path: Path to config.yaml file
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Current weeks setting, or 12 as default.
|
|
383
|
+
"""
|
|
384
|
+
try:
|
|
385
|
+
with open(config_path) as f:
|
|
386
|
+
config_data = yaml.safe_load(f)
|
|
387
|
+
return config_data.get("analysis", {}).get("weeks_back", 12)
|
|
388
|
+
except Exception as e:
|
|
389
|
+
logger.warning(f"Could not read weeks from config: {e}")
|
|
390
|
+
return 12
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def repull_data(config_path: Path) -> bool:
|
|
394
|
+
"""Re-run analysis with optional cache clear.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
config_path: Path to config.yaml file
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
True if analysis succeeded, False otherwise.
|
|
401
|
+
"""
|
|
402
|
+
click.echo("\n" + "=" * 60)
|
|
403
|
+
click.echo(click.style("Re-pull Data (Re-run Analysis)", fg="cyan", bold=True))
|
|
404
|
+
click.echo("=" * 60 + "\n")
|
|
405
|
+
|
|
406
|
+
# Ask about clearing cache
|
|
407
|
+
clear_cache = click.confirm("🗑️ Clear cache before re-pull?", default=True)
|
|
408
|
+
|
|
409
|
+
# Ask about number of weeks
|
|
410
|
+
current_weeks = get_current_weeks(config_path)
|
|
411
|
+
use_current = click.confirm(f"📅 Use current setting ({current_weeks} weeks)?", default=True)
|
|
412
|
+
|
|
413
|
+
if use_current:
|
|
414
|
+
weeks = current_weeks
|
|
415
|
+
else:
|
|
416
|
+
weeks = click.prompt(
|
|
417
|
+
"Number of weeks to analyze",
|
|
418
|
+
type=click.IntRange(1, 52),
|
|
419
|
+
default=current_weeks,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
# Validate config path
|
|
424
|
+
_validate_subprocess_path(config_path)
|
|
425
|
+
except ValueError as e:
|
|
426
|
+
click.echo(click.style(f"❌ Invalid config path: {e}", fg="red"), err=True)
|
|
427
|
+
logger.error(f"Config path validation failed: {e}")
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
# Build command
|
|
431
|
+
cmd = [
|
|
432
|
+
sys.executable,
|
|
433
|
+
"-m",
|
|
434
|
+
"gitflow_analytics.cli",
|
|
435
|
+
"analyze",
|
|
436
|
+
"-c",
|
|
437
|
+
str(config_path),
|
|
438
|
+
"--weeks",
|
|
439
|
+
str(weeks),
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
if clear_cache:
|
|
443
|
+
cmd.append("--clear-cache")
|
|
444
|
+
|
|
445
|
+
# Display what will be run
|
|
446
|
+
click.echo("\n🚀 Running analysis...")
|
|
447
|
+
click.echo(f" Config: {config_path}")
|
|
448
|
+
click.echo(f" Weeks: {weeks}")
|
|
449
|
+
click.echo(f" Clear cache: {'Yes' if clear_cache else 'No'}\n")
|
|
450
|
+
|
|
451
|
+
# Run with 10 minute timeout for analysis
|
|
452
|
+
success = _run_subprocess_safely(cmd, operation_name="Analysis", timeout=600)
|
|
453
|
+
|
|
454
|
+
if success:
|
|
455
|
+
click.echo(click.style("\n✅ Analysis completed successfully!", fg="green"))
|
|
456
|
+
|
|
457
|
+
return success
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def set_weeks(config_path: Path) -> bool:
|
|
461
|
+
"""Update analysis.weeks_back in config.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
config_path: Path to config.yaml file
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
True if config update succeeded, False otherwise.
|
|
468
|
+
"""
|
|
469
|
+
click.echo("\n" + "=" * 60)
|
|
470
|
+
click.echo(click.style("Set Number of Weeks", fg="cyan", bold=True))
|
|
471
|
+
click.echo("=" * 60 + "\n")
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
# Load current config
|
|
475
|
+
with open(config_path) as f:
|
|
476
|
+
config_data = yaml.safe_load(f)
|
|
477
|
+
|
|
478
|
+
# Get current value
|
|
479
|
+
current = config_data.get("analysis", {}).get("weeks_back", 12)
|
|
480
|
+
click.echo(f"Current setting: {current} weeks\n")
|
|
481
|
+
|
|
482
|
+
# Prompt for new value
|
|
483
|
+
weeks = click.prompt(
|
|
484
|
+
"Number of weeks to analyze",
|
|
485
|
+
type=click.IntRange(1, 52),
|
|
486
|
+
default=current,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Update config
|
|
490
|
+
if "analysis" not in config_data:
|
|
491
|
+
config_data["analysis"] = {}
|
|
492
|
+
config_data["analysis"]["weeks_back"] = weeks
|
|
493
|
+
|
|
494
|
+
# Write back to file atomically
|
|
495
|
+
_atomic_yaml_write(config_path, config_data)
|
|
496
|
+
|
|
497
|
+
click.echo(click.style(f"\n✅ Set to {weeks} weeks", fg="green"))
|
|
498
|
+
click.echo(f"💾 Saved to {config_path}")
|
|
499
|
+
|
|
500
|
+
# Validate config after modification
|
|
501
|
+
if not validate_config(config_path):
|
|
502
|
+
click.echo(
|
|
503
|
+
click.style(
|
|
504
|
+
"\n⚠️ Warning: Config may have validation issues after update.",
|
|
505
|
+
fg="yellow",
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
return False
|
|
509
|
+
|
|
510
|
+
return True
|
|
511
|
+
|
|
512
|
+
except Exception as e:
|
|
513
|
+
click.echo(click.style(f"\n❌ Error updating config: {e}", fg="red"), err=True)
|
|
514
|
+
logger.error(f"Config update error: {type(e).__name__}: {e}")
|
|
515
|
+
return False
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def run_full_analysis(config_path: Path) -> bool:
|
|
519
|
+
"""Launch full analysis with current config settings.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
config_path: Path to config.yaml file
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
True if analysis succeeded, False otherwise.
|
|
526
|
+
"""
|
|
527
|
+
click.echo("\n" + "=" * 60)
|
|
528
|
+
click.echo(click.style("Run Full Analysis", fg="cyan", bold=True))
|
|
529
|
+
click.echo("=" * 60 + "\n")
|
|
530
|
+
|
|
531
|
+
# Get current weeks setting
|
|
532
|
+
weeks = get_current_weeks(config_path)
|
|
533
|
+
click.echo("📊 Running analysis with current settings:")
|
|
534
|
+
click.echo(f" Config: {config_path}")
|
|
535
|
+
click.echo(f" Weeks: {weeks}\n")
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
# Validate config path
|
|
539
|
+
_validate_subprocess_path(config_path)
|
|
540
|
+
except ValueError as e:
|
|
541
|
+
click.echo(click.style(f"❌ Invalid config path: {e}", fg="red"), err=True)
|
|
542
|
+
logger.error(f"Config path validation failed: {e}")
|
|
543
|
+
return False
|
|
544
|
+
|
|
545
|
+
# Build command
|
|
546
|
+
cmd = [
|
|
547
|
+
sys.executable,
|
|
548
|
+
"-m",
|
|
549
|
+
"gitflow_analytics.cli",
|
|
550
|
+
"analyze",
|
|
551
|
+
"-c",
|
|
552
|
+
str(config_path),
|
|
553
|
+
]
|
|
554
|
+
|
|
555
|
+
# Run with 10 minute timeout for analysis
|
|
556
|
+
success = _run_subprocess_safely(cmd, operation_name="Analysis", timeout=600)
|
|
557
|
+
|
|
558
|
+
if success:
|
|
559
|
+
click.echo(click.style("\n✅ Analysis completed successfully!", fg="green"))
|
|
560
|
+
|
|
561
|
+
return success
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def rename_developer_alias(config_path: Path) -> bool:
|
|
565
|
+
"""Interactive interface for renaming developer aliases.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
config_path: Path to config.yaml file
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
True if rename succeeded, False otherwise.
|
|
572
|
+
"""
|
|
573
|
+
click.echo("\n" + "=" * 60)
|
|
574
|
+
click.echo(click.style("Rename Developer Alias", fg="cyan", bold=True))
|
|
575
|
+
click.echo("=" * 60 + "\n")
|
|
576
|
+
|
|
577
|
+
click.echo("Update a developer's canonical display name in reports.")
|
|
578
|
+
click.echo("This updates the configuration file and optionally the cache.\n")
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
# Load config to get manual_mappings
|
|
582
|
+
with open(config_path) as f:
|
|
583
|
+
config_data = yaml.safe_load(f)
|
|
584
|
+
|
|
585
|
+
# Navigate to manual_mappings
|
|
586
|
+
manual_mappings = (
|
|
587
|
+
config_data.get("analysis", {}).get("identity", {}).get("manual_mappings", [])
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
if not manual_mappings:
|
|
591
|
+
click.echo(
|
|
592
|
+
click.style(
|
|
593
|
+
"❌ No manual_mappings found in config. Please add developers first.", fg="red"
|
|
594
|
+
),
|
|
595
|
+
err=True,
|
|
596
|
+
)
|
|
597
|
+
return False
|
|
598
|
+
|
|
599
|
+
# Display numbered list of developers
|
|
600
|
+
click.echo(click.style("Current Developers:", fg="cyan", bold=True))
|
|
601
|
+
click.echo()
|
|
602
|
+
|
|
603
|
+
developer_names = []
|
|
604
|
+
for idx, mapping in enumerate(manual_mappings, 1):
|
|
605
|
+
name = mapping.get("name", "Unknown")
|
|
606
|
+
email = mapping.get("primary_email", "N/A")
|
|
607
|
+
alias_count = len(mapping.get("aliases", []))
|
|
608
|
+
|
|
609
|
+
developer_names.append(name)
|
|
610
|
+
click.echo(f" {idx}. {click.style(name, fg='green')}")
|
|
611
|
+
click.echo(f" Email: {email}")
|
|
612
|
+
click.echo(f" Aliases: {alias_count} email(s)")
|
|
613
|
+
click.echo()
|
|
614
|
+
|
|
615
|
+
# Prompt for selection
|
|
616
|
+
try:
|
|
617
|
+
selection = click.prompt(
|
|
618
|
+
"Select developer number to rename (or 0 to cancel)",
|
|
619
|
+
type=click.IntRange(0, len(developer_names)),
|
|
620
|
+
)
|
|
621
|
+
except click.Abort:
|
|
622
|
+
click.echo(click.style("\n❌ Cancelled", fg="yellow"))
|
|
623
|
+
return False
|
|
624
|
+
|
|
625
|
+
if selection == 0:
|
|
626
|
+
click.echo(click.style("\n❌ Cancelled", fg="yellow"))
|
|
627
|
+
return False
|
|
628
|
+
|
|
629
|
+
# Get selected developer name
|
|
630
|
+
old_name = developer_names[selection - 1]
|
|
631
|
+
click.echo(f"\n📝 Selected: {click.style(old_name, fg='green')}")
|
|
632
|
+
|
|
633
|
+
# Prompt for new name
|
|
634
|
+
new_name = click.prompt("Enter new canonical name", type=str)
|
|
635
|
+
|
|
636
|
+
# Validate new name
|
|
637
|
+
new_name = new_name.strip()
|
|
638
|
+
if not new_name:
|
|
639
|
+
click.echo(click.style("❌ New name cannot be empty", fg="red"), err=True)
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
if new_name == old_name:
|
|
643
|
+
click.echo(click.style("❌ New name is identical to current name", fg="yellow"))
|
|
644
|
+
return False
|
|
645
|
+
|
|
646
|
+
# Ask about cache update
|
|
647
|
+
update_cache = click.confirm("\nAlso update database cache?", default=True)
|
|
648
|
+
|
|
649
|
+
# Show what will be done
|
|
650
|
+
click.echo("\n" + "=" * 60)
|
|
651
|
+
click.echo(click.style("Summary", fg="yellow", bold=True))
|
|
652
|
+
click.echo("=" * 60)
|
|
653
|
+
click.echo(f" Old name: {old_name}")
|
|
654
|
+
click.echo(f" New name: {new_name}")
|
|
655
|
+
click.echo(f" Update cache: {'Yes' if update_cache else 'No'}")
|
|
656
|
+
click.echo()
|
|
657
|
+
|
|
658
|
+
# Confirm
|
|
659
|
+
if not click.confirm("Proceed with rename?", default=True):
|
|
660
|
+
click.echo(click.style("\n❌ Cancelled", fg="yellow"))
|
|
661
|
+
return False
|
|
662
|
+
|
|
663
|
+
except Exception as e:
|
|
664
|
+
click.echo(click.style(f"❌ Error reading config: {e}", fg="red"), err=True)
|
|
665
|
+
logger.error(f"Config read error: {type(e).__name__}: {e}")
|
|
666
|
+
return False
|
|
667
|
+
|
|
668
|
+
try:
|
|
669
|
+
# Validate config path
|
|
670
|
+
_validate_subprocess_path(config_path)
|
|
671
|
+
except ValueError as e:
|
|
672
|
+
click.echo(click.style(f"❌ Invalid config path: {e}", fg="red"), err=True)
|
|
673
|
+
logger.error(f"Config path validation failed: {e}")
|
|
674
|
+
return False
|
|
675
|
+
|
|
676
|
+
# Build command
|
|
677
|
+
cmd = [
|
|
678
|
+
sys.executable,
|
|
679
|
+
"-m",
|
|
680
|
+
"gitflow_analytics.cli",
|
|
681
|
+
"alias-rename",
|
|
682
|
+
"-c",
|
|
683
|
+
str(config_path),
|
|
684
|
+
"--old-name",
|
|
685
|
+
old_name,
|
|
686
|
+
"--new-name",
|
|
687
|
+
new_name,
|
|
688
|
+
]
|
|
689
|
+
|
|
690
|
+
if update_cache:
|
|
691
|
+
cmd.append("--update-cache")
|
|
692
|
+
|
|
693
|
+
# Run with timeout
|
|
694
|
+
success = _run_subprocess_safely(cmd, operation_name="Alias Rename", timeout=60)
|
|
695
|
+
|
|
696
|
+
if success:
|
|
697
|
+
click.echo(click.style("\n✅ Rename completed successfully!", fg="green"))
|
|
698
|
+
click.echo(f"Future reports will show '{new_name}' instead of '{old_name}'")
|
|
699
|
+
|
|
700
|
+
return success
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def show_main_menu(config_path: Optional[Path] = None) -> None:
|
|
704
|
+
"""Display main interactive menu.
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
config_path: Optional path to config.yaml file. If not provided,
|
|
708
|
+
will attempt to find or prompt for it.
|
|
709
|
+
"""
|
|
710
|
+
# If no config, find or prompt for it
|
|
711
|
+
if not config_path:
|
|
712
|
+
config_path = find_or_prompt_config()
|
|
713
|
+
if not config_path:
|
|
714
|
+
click.echo(click.style("\n❌ No config file provided. Exiting.", fg="red"))
|
|
715
|
+
sys.exit(1)
|
|
716
|
+
|
|
717
|
+
# Validate config exists
|
|
718
|
+
if not config_path.exists():
|
|
719
|
+
click.echo(click.style(f"\n❌ Config file not found: {config_path}", fg="red"))
|
|
720
|
+
sys.exit(1)
|
|
721
|
+
|
|
722
|
+
# Main menu loop
|
|
723
|
+
while True:
|
|
724
|
+
try:
|
|
725
|
+
# Display menu header
|
|
726
|
+
click.echo("\n" + "=" * 60)
|
|
727
|
+
click.echo(click.style("GitFlow Analytics - Interactive Menu", fg="cyan", bold=True))
|
|
728
|
+
click.echo("=" * 60)
|
|
729
|
+
click.echo(f"\nConfig: {click.style(str(config_path), fg='green')}\n")
|
|
730
|
+
|
|
731
|
+
# Display menu options
|
|
732
|
+
click.echo(click.style("Choose an option:", fg="white", bold=True))
|
|
733
|
+
click.echo(" 1. Edit Configuration")
|
|
734
|
+
click.echo(" 2. Fix Developer Aliases")
|
|
735
|
+
click.echo(" 3. Re-pull Data (Re-run Analysis)")
|
|
736
|
+
click.echo(" 4. Set Number of Weeks")
|
|
737
|
+
click.echo(" 5. Run Full Analysis")
|
|
738
|
+
click.echo(" 6. Rename Developer Alias")
|
|
739
|
+
click.echo(" 0. Exit")
|
|
740
|
+
|
|
741
|
+
# Get user choice
|
|
742
|
+
click.echo()
|
|
743
|
+
choice = click.prompt(
|
|
744
|
+
click.style("Enter your choice", fg="yellow"),
|
|
745
|
+
type=click.Choice(["0", "1", "2", "3", "4", "5", "6"], case_sensitive=False),
|
|
746
|
+
show_choices=False,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
# Handle choice
|
|
750
|
+
success = True
|
|
751
|
+
|
|
752
|
+
if choice == "0":
|
|
753
|
+
click.echo(click.style("\n👋 Goodbye!", fg="green"))
|
|
754
|
+
break
|
|
755
|
+
elif choice == "1":
|
|
756
|
+
success = edit_configuration(config_path)
|
|
757
|
+
elif choice == "2":
|
|
758
|
+
success = fix_aliases(config_path)
|
|
759
|
+
elif choice == "3":
|
|
760
|
+
success = repull_data(config_path)
|
|
761
|
+
elif choice == "4":
|
|
762
|
+
success = set_weeks(config_path)
|
|
763
|
+
elif choice == "5":
|
|
764
|
+
success = run_full_analysis(config_path)
|
|
765
|
+
elif choice == "6":
|
|
766
|
+
success = rename_developer_alias(config_path)
|
|
767
|
+
|
|
768
|
+
# Show warning if operation failed
|
|
769
|
+
if not success and choice != "0":
|
|
770
|
+
click.echo(
|
|
771
|
+
click.style(
|
|
772
|
+
"\n⚠️ Operation did not complete successfully. Check messages above.",
|
|
773
|
+
fg="yellow",
|
|
774
|
+
)
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
# Pause before showing menu again
|
|
778
|
+
if choice != "0":
|
|
779
|
+
click.echo()
|
|
780
|
+
click.prompt(
|
|
781
|
+
click.style("Press Enter to continue", fg="cyan"),
|
|
782
|
+
default="",
|
|
783
|
+
show_default=False,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
except click.Abort:
|
|
787
|
+
click.echo(click.style("\n\n👋 Interrupted. Exiting.", fg="yellow"))
|
|
788
|
+
sys.exit(0)
|
|
789
|
+
except KeyboardInterrupt:
|
|
790
|
+
click.echo(click.style("\n\n👋 Interrupted. Exiting.", fg="yellow"))
|
|
791
|
+
sys.exit(0)
|
|
792
|
+
except Exception as e:
|
|
793
|
+
click.echo(click.style(f"\n❌ Error: {e}", fg="red"), err=True)
|
|
794
|
+
logger.error(f"Menu error: {type(e).__name__}: {e}")
|
|
795
|
+
|
|
796
|
+
# Ask if user wants to continue
|
|
797
|
+
if not click.confirm("Continue?", default=True):
|
|
798
|
+
sys.exit(1)
|