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.
@@ -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 interactive launcher.
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__ = ["InstallWizard", "InteractiveLauncher", "run_interactive_launcher"]
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"),