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