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.
Files changed (26) hide show
  1. gitflow_analytics/_version.py +1 -1
  2. gitflow_analytics/cli.py +853 -129
  3. gitflow_analytics/cli_wizards/__init__.py +9 -3
  4. gitflow_analytics/cli_wizards/menu.py +798 -0
  5. gitflow_analytics/config/loader.py +3 -1
  6. gitflow_analytics/config/profiles.py +1 -2
  7. gitflow_analytics/core/data_fetcher.py +0 -2
  8. gitflow_analytics/core/identity.py +2 -0
  9. gitflow_analytics/extractors/tickets.py +3 -1
  10. gitflow_analytics/integrations/github_integration.py +1 -1
  11. gitflow_analytics/integrations/jira_integration.py +1 -1
  12. gitflow_analytics/qualitative/chatgpt_analyzer.py +15 -15
  13. gitflow_analytics/qualitative/classifiers/llm/prompts.py +1 -1
  14. gitflow_analytics/qualitative/core/processor.py +1 -2
  15. gitflow_analytics/qualitative/enhanced_analyzer.py +24 -8
  16. gitflow_analytics/reports/narrative_writer.py +13 -9
  17. gitflow_analytics/security/reports/__init__.py +5 -0
  18. gitflow_analytics/security/reports/security_report.py +358 -0
  19. gitflow_analytics/ui/progress_display.py +14 -6
  20. gitflow_analytics/verify_activity.py +1 -1
  21. {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/METADATA +37 -1
  22. {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/RECORD +26 -23
  23. {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/WHEEL +0 -0
  24. {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/entry_points.txt +0 -0
  25. {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/licenses/LICENSE +0 -0
  26. {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)