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
@@ -0,0 +1,169 @@
1
+ """Git authentication setup and validation for GitHub operations."""
2
+
3
+ import logging
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from github import Github
8
+ from github.GithubException import BadCredentialsException, GithubException
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def verify_github_token(token: str, timeout: int = 10) -> tuple[bool, str, str]:
14
+ """Verify GitHub token is valid and return authenticated username.
15
+
16
+ Args:
17
+ token: GitHub personal access token
18
+ timeout: API request timeout in seconds (default: 10)
19
+
20
+ Returns:
21
+ Tuple of (success, username, error_message)
22
+ - success: True if token is valid
23
+ - username: GitHub username if successful, empty string otherwise
24
+ - error_message: Error description if failed, empty string otherwise
25
+ """
26
+ if not token:
27
+ return False, "", "GitHub token is empty"
28
+
29
+ try:
30
+ github = Github(token, timeout=timeout)
31
+ user = github.get_user()
32
+ username = user.login
33
+ logger.info(f"GitHub token verified successfully for user: {username}")
34
+ return True, username, ""
35
+ except BadCredentialsException:
36
+ error_msg = "GitHub token is invalid or expired"
37
+ logger.error(error_msg)
38
+ return False, "", error_msg
39
+ except GithubException as e:
40
+ error_msg = (
41
+ f"GitHub API error: {e.data.get('message', str(e)) if hasattr(e, 'data') else str(e)}"
42
+ )
43
+ logger.error(error_msg)
44
+ return False, "", error_msg
45
+ except Exception as e:
46
+ error_msg = f"Unexpected error verifying GitHub token: {str(e)}"
47
+ logger.error(error_msg)
48
+ return False, "", error_msg
49
+
50
+
51
+ def setup_git_credentials(token: str, username: str = "git") -> bool:
52
+ """Configure git to use GitHub token for HTTPS authentication.
53
+
54
+ This function sets up the git credential helper to store credentials
55
+ and adds the GitHub token to ~/.git-credentials.
56
+
57
+ Args:
58
+ token: GitHub personal access token
59
+ username: Username for git authentication (default: "git")
60
+
61
+ Returns:
62
+ True if setup successful, False otherwise
63
+ """
64
+ try:
65
+ # Configure git to use credential helper store
66
+ subprocess.run(
67
+ ["git", "config", "--global", "credential.helper", "store"],
68
+ check=True,
69
+ capture_output=True,
70
+ text=True,
71
+ )
72
+ logger.debug("Configured git credential helper to 'store'")
73
+
74
+ # Add credentials to ~/.git-credentials
75
+ credentials_file = Path.home() / ".git-credentials"
76
+ credential_line = f"https://{username}:{token}@github.com\n"
77
+
78
+ # Read existing credentials
79
+ existing_credentials = []
80
+ if credentials_file.exists():
81
+ with open(credentials_file) as f:
82
+ existing_credentials = f.readlines()
83
+
84
+ # Check if GitHub credential already exists
85
+ github_creds = [line for line in existing_credentials if "github.com" in line]
86
+ if github_creds:
87
+ # Remove old GitHub credentials
88
+ existing_credentials = [
89
+ line for line in existing_credentials if "github.com" not in line
90
+ ]
91
+ logger.debug("Replaced existing GitHub credentials")
92
+
93
+ # Add new credential
94
+ existing_credentials.append(credential_line)
95
+
96
+ # Write back to file with proper permissions
97
+ credentials_file.touch(mode=0o600, exist_ok=True)
98
+ with open(credentials_file, "w") as f:
99
+ f.writelines(existing_credentials)
100
+
101
+ logger.info("Git credentials configured successfully")
102
+ return True
103
+
104
+ except subprocess.CalledProcessError as e:
105
+ logger.error(f"Failed to configure git credential helper: {e.stderr}")
106
+ return False
107
+ except OSError as e:
108
+ logger.error(f"Failed to write git credentials file: {e}")
109
+ return False
110
+ except Exception as e:
111
+ logger.error(f"Unexpected error setting up git credentials: {e}")
112
+ return False
113
+
114
+
115
+ def preflight_git_authentication(config: dict) -> bool:
116
+ """Run pre-flight checks for git authentication and setup credentials.
117
+
118
+ This function verifies the GitHub token and configures git credentials
119
+ before any git operations are performed.
120
+
121
+ Args:
122
+ config: Configuration dictionary containing github.token
123
+
124
+ Returns:
125
+ True if authentication is ready, False if setup failed
126
+ """
127
+ # Extract GitHub token from config
128
+ github_config = config.get("github", {})
129
+ token = github_config.get("token")
130
+
131
+ if not token:
132
+ logger.error("❌ GITHUB_TOKEN not found in configuration")
133
+ print("❌ GITHUB_TOKEN not found in config. Add to .env file or config.yaml")
134
+ print(" Example .env file:")
135
+ print(" GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx")
136
+ print("\n Or in config.yaml:")
137
+ print(" github:")
138
+ print(" token: ${GITHUB_TOKEN}")
139
+ return False
140
+
141
+ # Verify token is valid
142
+ success, username, error_msg = verify_github_token(token)
143
+ if not success:
144
+ logger.error(f"❌ GitHub token validation failed: {error_msg}")
145
+ if "invalid or expired" in error_msg.lower():
146
+ print("❌ GitHub token invalid or expired. Generate new token at:")
147
+ print(" https://github.com/settings/tokens")
148
+ print("\n Required permissions:")
149
+ print(" - repo (Full control of private repositories)")
150
+ print(" - read:org (Read org and team membership)")
151
+ elif "api error" in error_msg.lower():
152
+ print(f"❌ Cannot access GitHub API: {error_msg}")
153
+ print(" Check your network connection and GitHub API status:")
154
+ print(" https://www.githubstatus.com/")
155
+ else:
156
+ print(f"❌ GitHub authentication failed: {error_msg}")
157
+ return False
158
+
159
+ # Setup git credentials
160
+ if not setup_git_credentials(token, username="git"):
161
+ logger.error("❌ Failed to setup git credentials")
162
+ print("❌ Failed to configure git credentials")
163
+ print(" Try manually running:")
164
+ print(" git config --global credential.helper store")
165
+ return False
166
+
167
+ logger.info(f"✅ GitHub authentication configured successfully (user: {username})")
168
+ print(f"✅ GitHub authentication configured successfully (user: {username})")
169
+ return True
@@ -0,0 +1,347 @@
1
+ """Git operation wrapper with timeout protection.
2
+
3
+ This module provides timeout-protected git operations to prevent hanging
4
+ when repositories require authentication or have network issues.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import subprocess
10
+ import threading
11
+ import time
12
+ from contextlib import contextmanager
13
+ from pathlib import Path
14
+ from typing import Callable, Optional, TypeVar
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ class GitOperationTimeout(Exception):
22
+ """Raised when a git operation exceeds its timeout."""
23
+
24
+ pass
25
+
26
+
27
+ class GitTimeoutWrapper:
28
+ """Wrapper for git operations with timeout protection."""
29
+
30
+ def __init__(self, default_timeout: int = 30):
31
+ """Initialize the git timeout wrapper.
32
+
33
+ Args:
34
+ default_timeout: Default timeout in seconds for git operations
35
+ """
36
+ self.default_timeout = default_timeout
37
+ self._operation_stack = [] # Track nested operations for debugging
38
+
39
+ @contextmanager
40
+ def operation_tracker(self, operation_name: str, repo_path: Optional[Path] = None):
41
+ """Track the current git operation for debugging and heartbeat logging.
42
+
43
+ Args:
44
+ operation_name: Name of the operation being performed
45
+ repo_path: Optional repository path for context
46
+ """
47
+ operation_info = {
48
+ "name": operation_name,
49
+ "repo_path": str(repo_path) if repo_path else None,
50
+ "start_time": time.time(),
51
+ "thread_id": threading.current_thread().ident,
52
+ }
53
+
54
+ self._operation_stack.append(operation_info)
55
+ logger.info(
56
+ f"🚀 Starting operation: {operation_name} {f'for {repo_path}' if repo_path else ''}"
57
+ )
58
+
59
+ try:
60
+ yield operation_info
61
+ finally:
62
+ if self._operation_stack and self._operation_stack[-1] == operation_info:
63
+ self._operation_stack.pop()
64
+ elapsed = time.time() - operation_info["start_time"]
65
+ logger.info(f"✅ Completed operation: {operation_name} in {elapsed:.1f}s")
66
+
67
+ def get_current_operation(self) -> Optional[dict]:
68
+ """Get the currently running operation for this thread."""
69
+ thread_id = threading.current_thread().ident
70
+ for op in reversed(self._operation_stack):
71
+ if op.get("thread_id") == thread_id:
72
+ return op
73
+ return None
74
+
75
+ def run_with_timeout(
76
+ self,
77
+ func: Callable[..., T],
78
+ args: tuple = (),
79
+ kwargs: dict = None,
80
+ timeout: Optional[int] = None,
81
+ operation_name: str = "git_operation",
82
+ ) -> T:
83
+ """Run a function with timeout protection using threading.
84
+
85
+ Args:
86
+ func: Function to run
87
+ args: Positional arguments for the function
88
+ kwargs: Keyword arguments for the function
89
+ timeout: Timeout in seconds (uses default if not specified)
90
+ operation_name: Name of the operation for logging
91
+
92
+ Returns:
93
+ The result of the function
94
+
95
+ Raises:
96
+ GitOperationTimeout: If the operation times out
97
+ """
98
+ timeout = timeout or self.default_timeout
99
+ kwargs = kwargs or {}
100
+ result = [None]
101
+ exception = [None]
102
+
103
+ def target():
104
+ try:
105
+ result[0] = func(*args, **kwargs)
106
+ except Exception as e:
107
+ exception[0] = e
108
+
109
+ thread = threading.Thread(target=target, daemon=True)
110
+ thread.start()
111
+ thread.join(timeout)
112
+
113
+ if thread.is_alive():
114
+ # Thread is still running after timeout
115
+ logger.error(f"⏱️ Operation '{operation_name}' timed out after {timeout}s")
116
+ # Note: We can't actually kill the thread in Python, it will continue running
117
+ # but we'll raise an exception to prevent waiting for it
118
+ raise GitOperationTimeout(f"Operation '{operation_name}' timed out after {timeout}s")
119
+
120
+ if exception[0]:
121
+ raise exception[0]
122
+
123
+ return result[0]
124
+
125
+ def run_git_command(
126
+ self,
127
+ cmd: list[str],
128
+ cwd: Optional[Path] = None,
129
+ timeout: Optional[int] = None,
130
+ capture_output: bool = True,
131
+ check: bool = True,
132
+ ) -> subprocess.CompletedProcess:
133
+ """Run a git command with timeout protection.
134
+
135
+ Args:
136
+ cmd: Command to run as list of strings
137
+ cwd: Working directory for the command
138
+ timeout: Timeout in seconds (uses default if not specified)
139
+ capture_output: Whether to capture stdout/stderr
140
+ check: Whether to raise exception on non-zero return code
141
+
142
+ Returns:
143
+ CompletedProcess instance with the result
144
+
145
+ Raises:
146
+ GitOperationTimeout: If the command times out
147
+ subprocess.CalledProcessError: If check=True and command fails
148
+ """
149
+ timeout = timeout or self.default_timeout
150
+
151
+ # Set environment to prevent authentication prompts
152
+ env = os.environ.copy()
153
+ env.update(
154
+ {
155
+ "GIT_TERMINAL_PROMPT": "0",
156
+ "GIT_ASKPASS": "/bin/echo",
157
+ "SSH_ASKPASS": "/bin/echo",
158
+ "GCM_INTERACTIVE": "never",
159
+ "GIT_SSH_COMMAND": "ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o PasswordAuthentication=no",
160
+ "DISPLAY": "",
161
+ "GIT_CREDENTIAL_HELPER": "",
162
+ "GCM_PROVIDER": "none",
163
+ }
164
+ )
165
+
166
+ operation_name = " ".join(cmd[:2]) # e.g., "git fetch"
167
+
168
+ with self.operation_tracker(operation_name, cwd):
169
+ try:
170
+ result = subprocess.run(
171
+ cmd,
172
+ cwd=cwd,
173
+ env=env,
174
+ capture_output=capture_output,
175
+ text=True,
176
+ timeout=timeout,
177
+ check=check,
178
+ )
179
+ return result
180
+
181
+ except subprocess.TimeoutExpired as e:
182
+ logger.error(f"⏱️ Git command timed out after {timeout}s: {' '.join(cmd)}")
183
+ raise GitOperationTimeout(
184
+ f"Git command '{operation_name}' timed out after {timeout}s"
185
+ ) from e
186
+
187
+ except subprocess.CalledProcessError as e:
188
+ # Check if it's an authentication error
189
+ error_str = (e.stderr or "").lower()
190
+ if any(
191
+ x in error_str
192
+ for x in ["authentication", "permission denied", "401", "403", "password"]
193
+ ):
194
+ logger.error(f"🔐 Authentication failed for git command: {operation_name}")
195
+ logger.error(f" Error details: {e.stderr}")
196
+ raise
197
+
198
+ def fetch_with_timeout(self, repo_path: Path, timeout: int = 30) -> bool:
199
+ """Fetch from remote with timeout protection.
200
+
201
+ Args:
202
+ repo_path: Path to the repository
203
+ timeout: Timeout in seconds
204
+
205
+ Returns:
206
+ True if fetch succeeded, False otherwise
207
+ """
208
+ try:
209
+ self.run_git_command(
210
+ ["git", "fetch", "--all"], cwd=repo_path, timeout=timeout, check=True
211
+ )
212
+ logger.info(f"✅ Fetch succeeded for {repo_path.name}")
213
+ return True
214
+ except (GitOperationTimeout, subprocess.CalledProcessError) as e:
215
+ # Extract detailed error information
216
+ error_detail = ""
217
+ if isinstance(e, subprocess.CalledProcessError) and e.stderr:
218
+ error_detail = e.stderr.strip()
219
+ # Check for authentication-specific errors
220
+ if (
221
+ "could not read Username" in error_detail
222
+ or "could not read Password" in error_detail
223
+ ):
224
+ logger.error(
225
+ f"🔐 Authentication required for {repo_path.name}. "
226
+ f"Repository uses HTTPS but no credentials configured. "
227
+ f"Consider: (1) Configure git credential helper, "
228
+ f"(2) Use SSH URLs instead, or (3) Set GITHUB_TOKEN in environment."
229
+ )
230
+ elif "Authentication failed" in error_detail or "403" in error_detail:
231
+ logger.error(
232
+ f"🔐 Authentication failed for {repo_path.name}. "
233
+ f"Check that your GitHub token has proper permissions."
234
+ )
235
+ else:
236
+ logger.warning(f"Git fetch failed for {repo_path.name}: {error_detail}")
237
+ else:
238
+ logger.warning(f"Git fetch failed for {repo_path.name}: {e}")
239
+ return False
240
+
241
+ def pull_with_timeout(self, repo_path: Path, timeout: int = 30) -> bool:
242
+ """Pull from remote with timeout protection.
243
+
244
+ Args:
245
+ repo_path: Path to the repository
246
+ timeout: Timeout in seconds
247
+
248
+ Returns:
249
+ True if pull succeeded, False otherwise
250
+ """
251
+ try:
252
+ self.run_git_command(["git", "pull"], cwd=repo_path, timeout=timeout, check=True)
253
+ return True
254
+ except (GitOperationTimeout, subprocess.CalledProcessError) as e:
255
+ logger.warning(f"Git pull failed for {repo_path}: {e}")
256
+ return False
257
+
258
+ def clone_with_timeout(
259
+ self, clone_url: str, target_path: Path, branch: Optional[str] = None, timeout: int = 60
260
+ ) -> bool:
261
+ """Clone a repository with timeout protection.
262
+
263
+ Args:
264
+ clone_url: URL of the repository to clone
265
+ target_path: Target path for the cloned repository
266
+ branch: Optional branch to checkout
267
+ timeout: Timeout in seconds (default 60s for cloning)
268
+
269
+ Returns:
270
+ True if clone succeeded, False otherwise
271
+ """
272
+ cmd = ["git", "clone", "--config", "credential.helper="]
273
+ if branch:
274
+ cmd.extend(["-b", branch])
275
+ cmd.extend([clone_url, str(target_path)])
276
+
277
+ try:
278
+ self.run_git_command(cmd, timeout=timeout, check=True)
279
+ return True
280
+ except (GitOperationTimeout, subprocess.CalledProcessError) as e:
281
+ logger.warning(f"Git clone failed for {clone_url}: {e}")
282
+ return False
283
+
284
+
285
+ class HeartbeatLogger:
286
+ """Provides heartbeat logging for long-running operations."""
287
+
288
+ def __init__(self, interval: int = 5):
289
+ """Initialize heartbeat logger.
290
+
291
+ Args:
292
+ interval: Interval in seconds between heartbeat logs
293
+ """
294
+ self.interval = interval
295
+ self._stop_event = threading.Event()
296
+ self._thread = None
297
+ self._wrapper = GitTimeoutWrapper()
298
+
299
+ def start(self):
300
+ """Start the heartbeat logging thread."""
301
+ if self._thread and self._thread.is_alive():
302
+ return
303
+
304
+ self._stop_event.clear()
305
+ self._thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
306
+ self._thread.start()
307
+
308
+ def stop(self):
309
+ """Stop the heartbeat logging thread."""
310
+ self._stop_event.set()
311
+ if self._thread:
312
+ self._thread.join(timeout=1)
313
+
314
+ def _heartbeat_loop(self):
315
+ """Main heartbeat loop that logs current operations."""
316
+ last_log_time = 0
317
+
318
+ while not self._stop_event.is_set():
319
+ current_time = time.time()
320
+
321
+ if current_time - last_log_time >= self.interval:
322
+ operation = self._wrapper.get_current_operation()
323
+ if operation:
324
+ elapsed = current_time - operation["start_time"]
325
+ repo_info = f"for {operation['repo_path']} " if operation["repo_path"] else ""
326
+ logger.info(
327
+ f"💓 Heartbeat: Still running '{operation['name']}' "
328
+ f"{repo_info}"
329
+ f"(elapsed: {elapsed:.1f}s)"
330
+ )
331
+ last_log_time = current_time
332
+
333
+ # Sleep in small increments to be responsive to stop event
334
+ self._stop_event.wait(0.5)
335
+
336
+ def __enter__(self):
337
+ """Context manager entry."""
338
+ self.start()
339
+ return self
340
+
341
+ def __exit__(self, exc_type, exc_val, exc_tb):
342
+ """Context manager exit."""
343
+ self.stop()
344
+
345
+
346
+ # Global instance for convenience
347
+ git_wrapper = GitTimeoutWrapper()
@@ -378,8 +378,9 @@ class DailyMetricsStorage:
378
378
  else:
379
379
  # Fallback for unexpected types
380
380
  metrics["files_changed"] += 0
381
- metrics["lines_added"] += commit.get("insertions", 0)
382
- metrics["lines_deleted"] += commit.get("deletions", 0)
381
+ # Use filtered values if available, fallback to raw values
382
+ metrics["lines_added"] += commit.get("filtered_insertions", commit.get("insertions", 0))
383
+ metrics["lines_deleted"] += commit.get("filtered_deletions", commit.get("deletions", 0))
383
384
  metrics["story_points"] += commit.get("story_points", 0) or 0
384
385
 
385
386
  # Classification counts
@@ -394,7 +395,15 @@ class DailyMetricsStorage:
394
395
  ticket_refs = commit.get("ticket_references", [])
395
396
  if ticket_refs:
396
397
  metrics["tracked_commits"] += 1
397
- metrics["unique_tickets"].update(ticket_refs)
398
+ # Extract ticket IDs from ticket reference objects
399
+ # ticket_refs can be either [{"id": "PROJ-123", "platform": "jira"}] or ["PROJ-123"]
400
+ ticket_ids = []
401
+ for ref in ticket_refs:
402
+ if isinstance(ref, dict):
403
+ ticket_ids.append(ref.get("id", str(ref)))
404
+ else:
405
+ ticket_ids.append(str(ref))
406
+ metrics["unique_tickets"].update(ticket_ids)
398
407
  else:
399
408
  metrics["untracked_commits"] += 1
400
409