gitflow-analytics 1.3.11__py3-none-any.whl → 3.3.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.
Files changed (48) hide show
  1. gitflow_analytics/_version.py +1 -1
  2. gitflow_analytics/classification/batch_classifier.py +156 -4
  3. gitflow_analytics/cli.py +803 -135
  4. gitflow_analytics/config/loader.py +39 -1
  5. gitflow_analytics/config/schema.py +1 -0
  6. gitflow_analytics/core/cache.py +20 -0
  7. gitflow_analytics/core/data_fetcher.py +1051 -117
  8. gitflow_analytics/core/git_auth.py +169 -0
  9. gitflow_analytics/core/git_timeout_wrapper.py +347 -0
  10. gitflow_analytics/core/metrics_storage.py +12 -3
  11. gitflow_analytics/core/progress.py +219 -18
  12. gitflow_analytics/core/subprocess_git.py +145 -0
  13. gitflow_analytics/extractors/ml_tickets.py +3 -2
  14. gitflow_analytics/extractors/tickets.py +93 -8
  15. gitflow_analytics/integrations/jira_integration.py +1 -1
  16. gitflow_analytics/integrations/orchestrator.py +47 -29
  17. gitflow_analytics/metrics/branch_health.py +3 -2
  18. gitflow_analytics/models/database.py +72 -1
  19. gitflow_analytics/pm_framework/adapters/jira_adapter.py +12 -5
  20. gitflow_analytics/pm_framework/orchestrator.py +8 -3
  21. gitflow_analytics/qualitative/classifiers/llm/openai_client.py +24 -4
  22. gitflow_analytics/qualitative/classifiers/llm_commit_classifier.py +3 -1
  23. gitflow_analytics/qualitative/core/llm_fallback.py +34 -2
  24. gitflow_analytics/reports/narrative_writer.py +118 -74
  25. gitflow_analytics/security/__init__.py +11 -0
  26. gitflow_analytics/security/config.py +189 -0
  27. gitflow_analytics/security/extractors/__init__.py +7 -0
  28. gitflow_analytics/security/extractors/dependency_checker.py +379 -0
  29. gitflow_analytics/security/extractors/secret_detector.py +197 -0
  30. gitflow_analytics/security/extractors/vulnerability_scanner.py +333 -0
  31. gitflow_analytics/security/llm_analyzer.py +347 -0
  32. gitflow_analytics/security/reports/__init__.py +5 -0
  33. gitflow_analytics/security/reports/security_report.py +358 -0
  34. gitflow_analytics/security/security_analyzer.py +414 -0
  35. gitflow_analytics/tui/app.py +3 -1
  36. gitflow_analytics/tui/progress_adapter.py +313 -0
  37. gitflow_analytics/tui/screens/analysis_progress_screen.py +407 -46
  38. gitflow_analytics/tui/screens/results_screen.py +219 -206
  39. gitflow_analytics/ui/__init__.py +21 -0
  40. gitflow_analytics/ui/progress_display.py +1477 -0
  41. gitflow_analytics/verify_activity.py +697 -0
  42. {gitflow_analytics-1.3.11.dist-info → gitflow_analytics-3.3.0.dist-info}/METADATA +2 -1
  43. {gitflow_analytics-1.3.11.dist-info → gitflow_analytics-3.3.0.dist-info}/RECORD +47 -31
  44. gitflow_analytics/cli_rich.py +0 -503
  45. {gitflow_analytics-1.3.11.dist-info → gitflow_analytics-3.3.0.dist-info}/WHEEL +0 -0
  46. {gitflow_analytics-1.3.11.dist-info → gitflow_analytics-3.3.0.dist-info}/entry_points.txt +0 -0
  47. {gitflow_analytics-1.3.11.dist-info → gitflow_analytics-3.3.0.dist-info}/licenses/LICENSE +0 -0
  48. {gitflow_analytics-1.3.11.dist-info → gitflow_analytics-3.3.0.dist-info}/top_level.txt +0 -0
@@ -13,6 +13,7 @@ DESIGN DECISIONS:
13
13
  - Testable: Can be globally disabled for testing, with event capture capability
14
14
  - Nested support: Handles nested progress contexts with proper positioning
15
15
  - Consistent styling: All progress bars follow the same formatting rules
16
+ - Rich integration: Optional Rich library support for enhanced terminal UI
16
17
 
17
18
  USAGE:
18
19
  from gitflow_analytics.core.progress import get_progress_service
@@ -30,10 +31,24 @@ import sys
30
31
  import threading
31
32
  from contextlib import contextmanager
32
33
  from dataclasses import dataclass
33
- from typing import Any, Optional
34
+ from typing import Any, Dict, Optional
34
35
 
35
36
  from tqdm import tqdm
36
37
 
38
+ # Import UI components if available
39
+ try:
40
+ from ..ui.progress_display import (
41
+ RICH_AVAILABLE,
42
+ RepositoryInfo,
43
+ RepositoryStatus,
44
+ create_progress_display,
45
+ )
46
+
47
+ UI_AVAILABLE = True
48
+ except ImportError:
49
+ UI_AVAILABLE = False
50
+ RICH_AVAILABLE = False
51
+
37
52
 
38
53
  @dataclass
39
54
  class ProgressContext:
@@ -73,38 +88,76 @@ class ProgressService:
73
88
 
74
89
  This service provides a unified interface for creating and managing progress bars
75
90
  throughout the application. It supports nested progress contexts, global disable
76
- for testing, and event capture for verification.
91
+ for testing, event capture for verification, and optional Rich terminal UI.
77
92
  """
78
93
 
79
- def __init__(self):
80
- """Initialize the progress service."""
94
+ def __init__(self, display_style: str = "auto", version: str = "1.3.11"):
95
+ """Initialize the progress service.
96
+
97
+ Args:
98
+ display_style: Display style ("rich", "simple", or "auto")
99
+ version: Version string for display
100
+ """
81
101
  self._enabled = True
82
102
  self._lock = threading.Lock()
83
103
  self._active_contexts: list[ProgressContext] = []
84
104
  self._position_counter = 0
85
105
  self._capture_events = False
86
106
  self._captured_events: list[ProgressEvent] = []
107
+ self._display_style = display_style
108
+ self._version = version
109
+
110
+ # Rich display components
111
+ self._rich_display = None
112
+ self._repository_contexts: Dict[str, Any] = {}
113
+ self._use_rich = False
114
+
115
+ # Initialize display based on configuration
116
+ self._init_display()
87
117
 
88
118
  # Check environment for testing mode
119
+ # Note: If user explicitly requested rich mode, don't disable it
89
120
  self._check_testing_environment()
90
121
 
122
+ def _init_display(self):
123
+ """Initialize the appropriate display based on configuration."""
124
+ if UI_AVAILABLE and self._display_style in ("rich", "auto"):
125
+ try:
126
+ self._rich_display = create_progress_display(
127
+ style=self._display_style, version=self._version, update_frequency=0.5
128
+ )
129
+ self._use_rich = self._display_style == "rich" or (
130
+ self._display_style == "auto" and RICH_AVAILABLE
131
+ )
132
+ except Exception:
133
+ # Fall back to tqdm if Rich fails
134
+ self._use_rich = False
135
+ self._rich_display = None
136
+
91
137
  def _check_testing_environment(self):
92
138
  """Check if running in a testing environment and disable if needed.
93
139
 
94
140
  WHY: Progress bars interfere with test output and can cause issues in CI/CD.
95
141
  This automatically detects common testing scenarios and disables progress.
96
142
  """
143
+ # Don't auto-disable if user explicitly requested rich mode
144
+ explicit_rich = self._display_style == "rich"
145
+
97
146
  # Disable in pytest
98
147
  if "pytest" in sys.modules:
99
148
  self._enabled = False
149
+ self._use_rich = False
100
150
 
101
151
  # Disable if explicitly requested via environment
102
152
  if os.environ.get("GITFLOW_DISABLE_PROGRESS", "").lower() in ("1", "true", "yes"):
103
153
  self._enabled = False
154
+ self._use_rich = False
104
155
 
105
156
  # Disable if not in a TTY (e.g., CI/CD, piped output)
106
- if not sys.stdout.isatty():
157
+ # BUT: Keep enabled if user explicitly requested rich mode
158
+ if not sys.stdout.isatty() and not explicit_rich:
107
159
  self._enabled = False
160
+ self._use_rich = False
108
161
 
109
162
  def create_progress(
110
163
  self,
@@ -154,16 +207,21 @@ class ProgressService:
154
207
 
155
208
  # Create actual progress bar if enabled
156
209
  if self._enabled:
157
- context.progress_bar = tqdm(
158
- total=total,
159
- desc=description,
160
- unit=unit,
161
- position=position,
162
- leave=leave,
163
- # Consistent styling
164
- bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]",
165
- dynamic_ncols=True,
166
- )
210
+ if self._use_rich and self._rich_display:
211
+ # For Rich display, we don't create individual tqdm bars
212
+ # Instead, we'll manage everything through the Rich display
213
+ context.progress_bar = None
214
+ else:
215
+ context.progress_bar = tqdm(
216
+ total=total,
217
+ desc=description,
218
+ unit=unit,
219
+ position=position,
220
+ leave=leave,
221
+ # Consistent styling
222
+ bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]",
223
+ dynamic_ncols=True,
224
+ )
167
225
 
168
226
  self._active_contexts.append(context)
169
227
  return context
@@ -196,7 +254,18 @@ class ProgressService:
196
254
  )
197
255
 
198
256
  # Update actual progress bar if it exists
199
- if context.progress_bar:
257
+ if self._use_rich and self._rich_display:
258
+ # Update Rich display based on context type
259
+ if hasattr(context, "repository_name"):
260
+ # Repository-specific progress
261
+ speed = increment / 0.1 if increment > 0 else 0 # Simple speed calculation
262
+ self._rich_display.update_repository(
263
+ context.repository_name, context.current, speed
264
+ )
265
+ else:
266
+ # Overall progress
267
+ self._rich_display.update_overall(context.current, description)
268
+ elif context.progress_bar:
200
269
  context.progress_bar.update(increment)
201
270
  if description:
202
271
  context.progress_bar.set_description(description)
@@ -334,26 +403,158 @@ class ProgressService:
334
403
  with self._lock:
335
404
  self._captured_events = []
336
405
 
406
+ # Rich-specific methods
407
+ def start_rich_display(
408
+ self, total_items: int = 100, description: str = "Analyzing repositories"
409
+ ):
410
+ """Start the Rich display if available.
411
+
412
+ Args:
413
+ total_items: Total number of items to process
414
+ description: Description of the overall task
415
+ """
416
+ if self._use_rich and self._rich_display and self._enabled:
417
+ self._rich_display.start(total_items, description)
418
+
419
+ def stop_rich_display(self):
420
+ """Stop the Rich display if active."""
421
+ if self._use_rich and self._rich_display:
422
+ self._rich_display.stop()
423
+
424
+ def start_repository(self, repo_name: str, total_commits: int = 0):
425
+ """Start processing a repository with Rich display.
426
+
427
+ Args:
428
+ repo_name: Name of the repository
429
+ total_commits: Total number of commits to process
430
+ """
431
+ if self._use_rich and self._rich_display and self._enabled:
432
+ self._rich_display.start_repository(repo_name, total_commits)
433
+
434
+ def finish_repository(
435
+ self, repo_name: str, success: bool = True, error_message: Optional[str] = None
436
+ ):
437
+ """Finish processing a repository with Rich display.
438
+
439
+ Args:
440
+ repo_name: Name of the repository
441
+ success: Whether processing was successful
442
+ error_message: Error message if processing failed
443
+ """
444
+ if self._use_rich and self._rich_display and self._enabled:
445
+ self._rich_display.finish_repository(repo_name, success, error_message)
446
+
447
+ def update_statistics(self, **kwargs):
448
+ """Update Rich display statistics.
449
+
450
+ Args:
451
+ **kwargs: Statistics to update (total_commits, total_developers, etc.)
452
+ """
453
+ if self._use_rich and self._rich_display and self._enabled:
454
+ self._rich_display.update_statistics(**kwargs)
455
+
456
+ def initialize_repositories(self, repository_list: list):
457
+ """Initialize all repositories with pending status in Rich display.
458
+
459
+ Args:
460
+ repository_list: List of repositories to be processed.
461
+ Each item should have 'name' and optionally 'path' fields.
462
+ """
463
+ if self._use_rich and self._rich_display and self._enabled:
464
+ self._rich_display.initialize_repositories(repository_list)
465
+
466
+ def set_phase(self, phase: str):
467
+ """Set the current processing phase for Rich display.
468
+
469
+ Args:
470
+ phase: Description of the current phase
471
+ """
472
+ if self._use_rich and self._rich_display and self._enabled:
473
+ self._rich_display.set_phase(phase)
474
+
475
+ def create_repository_progress(
476
+ self, repo_name: str, total: int, description: str
477
+ ) -> ProgressContext:
478
+ """Create a progress context specifically for repository processing.
479
+
480
+ Args:
481
+ repo_name: Name of the repository
482
+ total: Total number of items to process
483
+ description: Description of the task
484
+
485
+ Returns:
486
+ ProgressContext with repository information
487
+ """
488
+ context = self.create_progress(total, description, unit="commits", nested=True)
489
+ # Add repository name to context for Rich display handling
490
+ # Note: We use object.__setattr__ to bypass dataclass frozen status if needed
491
+ object.__setattr__(context, "repository_name", repo_name)
492
+
493
+ if self._use_rich and self._rich_display and self._enabled:
494
+ self.start_repository(repo_name, total)
495
+
496
+ return context
497
+
337
498
 
338
499
  # Global singleton instance
339
500
  _progress_service: Optional[ProgressService] = None
340
501
  _service_lock = threading.Lock()
341
502
 
342
503
 
343
- def get_progress_service() -> ProgressService:
504
+ def get_progress_service(
505
+ display_style: Optional[str] = None, version: Optional[str] = None
506
+ ) -> ProgressService:
344
507
  """Get the global progress service instance.
345
508
 
509
+ Args:
510
+ display_style: Optional display style override ("rich", "simple", or "auto")
511
+ version: Optional version string for display
512
+
346
513
  Returns:
347
514
  The singleton ProgressService instance
348
515
 
349
516
  Thread-safe singleton pattern ensures only one progress service exists.
517
+ If display_style is provided and differs from current style, the service is reconfigured.
350
518
  """
351
519
  global _progress_service
352
520
 
521
+ # Check if we need to reconfigure an existing service
522
+ if _progress_service is not None and display_style is not None:
523
+ with _service_lock:
524
+ # If display style changed, reconfigure the service
525
+ if _progress_service._display_style != display_style:
526
+ # Close any active displays
527
+ if _progress_service._use_rich and _progress_service._rich_display:
528
+ _progress_service.stop_rich_display()
529
+
530
+ # Reconfigure with new display style
531
+ _progress_service._display_style = display_style
532
+ _progress_service._use_rich = False
533
+ _progress_service._rich_display = None
534
+
535
+ # Re-enable if user explicitly requested rich mode
536
+ if display_style == "rich":
537
+ _progress_service._enabled = True
538
+
539
+ # Reinitialize display
540
+ _progress_service._init_display()
541
+
353
542
  if _progress_service is None:
354
543
  with _service_lock:
355
544
  if _progress_service is None:
356
- _progress_service = ProgressService()
545
+ # Get display style from environment or use default
546
+ if display_style is None:
547
+ display_style = os.environ.get("GITFLOW_PROGRESS_STYLE", "auto")
548
+ if version is None:
549
+ # Try to get version from package
550
+ try:
551
+ from .._version import __version__
552
+
553
+ version = __version__
554
+ except ImportError:
555
+ version = "1.3.11"
556
+
557
+ _progress_service = ProgressService(display_style=display_style, version=version)
357
558
 
358
559
  return _progress_service
359
560
 
@@ -0,0 +1,145 @@
1
+ """Subprocess-based Git operations to avoid authentication prompts."""
2
+
3
+ import logging
4
+ import subprocess
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class SubprocessGit:
13
+ """Git operations using subprocess to avoid GitPython authentication issues."""
14
+
15
+ @staticmethod
16
+ def get_commits_in_range(
17
+ repo_path: Path, start_date: datetime, end_date: datetime, branch: str = "HEAD"
18
+ ) -> list[dict[str, Any]]:
19
+ """Get commits in date range using git log subprocess.
20
+
21
+ This avoids GitPython's potential authentication triggers by using
22
+ subprocess directly with environment variables that prevent prompts.
23
+ """
24
+ # Format dates for git log
25
+ start_str = start_date.strftime("%Y-%m-%d")
26
+ end_str = end_date.strftime("%Y-%m-%d")
27
+
28
+ # Build git log command with JSON-like format for easy parsing
29
+ cmd = [
30
+ "git",
31
+ "log",
32
+ f"--since={start_str}",
33
+ f"--until={end_str}",
34
+ "--all", # All branches
35
+ "--no-merges", # Skip merge commits
36
+ "--format=%H|%ae|%an|%at|%s", # hash|email|name|timestamp|subject
37
+ "--numstat", # Include file changes
38
+ ]
39
+
40
+ # Set environment to prevent any authentication prompts
41
+ env = {
42
+ "GIT_TERMINAL_PROMPT": "0",
43
+ "GIT_ASKPASS": "echo",
44
+ "SSH_ASKPASS": "echo",
45
+ "GCM_INTERACTIVE": "never",
46
+ "DISPLAY": "",
47
+ }
48
+
49
+ try:
50
+ result = subprocess.run(
51
+ cmd, cwd=repo_path, capture_output=True, text=True, env=env, timeout=30
52
+ )
53
+
54
+ if result.returncode != 0:
55
+ logger.warning(f"Git log failed: {result.stderr}")
56
+ return []
57
+
58
+ return SubprocessGit._parse_git_log(result.stdout)
59
+
60
+ except subprocess.TimeoutExpired:
61
+ logger.error("Git log timed out - likely authentication issue")
62
+ return []
63
+ except Exception as e:
64
+ logger.error(f"Error running git log: {e}")
65
+ return []
66
+
67
+ @staticmethod
68
+ def _parse_git_log(output: str) -> list[dict[str, Any]]:
69
+ """Parse git log output into commit dictionaries."""
70
+ commits = []
71
+ current_commit = None
72
+
73
+ for line in output.split("\n"):
74
+ if not line:
75
+ continue
76
+
77
+ if "|" in line and not line[0].isdigit():
78
+ # This is a commit line
79
+ if current_commit:
80
+ commits.append(current_commit)
81
+
82
+ parts = line.split("|")
83
+ if len(parts) >= 5:
84
+ current_commit = {
85
+ "hash": parts[0],
86
+ "author_email": parts[1],
87
+ "author_name": parts[2],
88
+ "timestamp": int(parts[3]),
89
+ "message": "|".join(parts[4:]), # Message might contain |
90
+ "files": [],
91
+ }
92
+ elif current_commit and line[0].isdigit():
93
+ # This is a numstat line (additions deletions filename)
94
+ parts = line.split("\t")
95
+ if len(parts) >= 3:
96
+ current_commit["files"].append(
97
+ {"additions": parts[0], "deletions": parts[1], "filename": parts[2]}
98
+ )
99
+
100
+ # Don't forget the last commit
101
+ if current_commit:
102
+ commits.append(current_commit)
103
+
104
+ return commits
105
+
106
+ @staticmethod
107
+ def check_remotes_safe(repo_path: Path) -> bool:
108
+ """Check if repository has remotes without triggering authentication."""
109
+ cmd = ["git", "remote", "-v"]
110
+
111
+ env = {
112
+ "GIT_TERMINAL_PROMPT": "0",
113
+ "GIT_ASKPASS": "echo",
114
+ }
115
+
116
+ try:
117
+ result = subprocess.run(
118
+ cmd, cwd=repo_path, capture_output=True, text=True, env=env, timeout=5
119
+ )
120
+ return bool(result.stdout.strip())
121
+ except:
122
+ return False
123
+
124
+ @staticmethod
125
+ def get_branches_safe(repo_path: Path) -> list[str]:
126
+ """Get list of branches without triggering authentication."""
127
+ branches = []
128
+
129
+ # Get local branches
130
+ cmd = ["git", "branch", "--format=%(refname:short)"]
131
+
132
+ try:
133
+ result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, timeout=5)
134
+
135
+ if result.returncode == 0:
136
+ branches = [b.strip() for b in result.stdout.split("\n") if b.strip()]
137
+
138
+ except:
139
+ pass
140
+
141
+ # Default to common branch names if none found
142
+ if not branches:
143
+ branches = ["main", "master", "develop"]
144
+
145
+ return branches
@@ -673,7 +673,7 @@ class MLTicketExtractor(TicketExtractor):
673
673
  return mapping.get(ml_category, "other")
674
674
 
675
675
  def analyze_ticket_coverage(
676
- self, commits: list[dict[str, Any]], prs: list[dict[str, Any]]
676
+ self, commits: list[dict[str, Any]], prs: list[dict[str, Any]], progress_display=None
677
677
  ) -> dict[str, Any]:
678
678
  """Enhanced ticket coverage analysis with ML categorization insights.
679
679
 
@@ -683,12 +683,13 @@ class MLTicketExtractor(TicketExtractor):
683
683
  Args:
684
684
  commits: List of commit data
685
685
  prs: List of PR data
686
+ progress_display: Optional progress display for showing analysis progress
686
687
 
687
688
  Returns:
688
689
  Enhanced analysis results with ML insights
689
690
  """
690
691
  # Get base analysis from parent
691
- base_analysis = super().analyze_ticket_coverage(commits, prs)
692
+ base_analysis = super().analyze_ticket_coverage(commits, prs, progress_display)
692
693
 
693
694
  if not self.enable_ml:
694
695
  # Add indicator that ML was not used