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.
- gitflow_analytics/_version.py +1 -1
- gitflow_analytics/classification/batch_classifier.py +156 -4
- gitflow_analytics/cli.py +803 -135
- gitflow_analytics/config/loader.py +39 -1
- gitflow_analytics/config/schema.py +1 -0
- gitflow_analytics/core/cache.py +20 -0
- gitflow_analytics/core/data_fetcher.py +1051 -117
- gitflow_analytics/core/git_auth.py +169 -0
- gitflow_analytics/core/git_timeout_wrapper.py +347 -0
- gitflow_analytics/core/metrics_storage.py +12 -3
- gitflow_analytics/core/progress.py +219 -18
- gitflow_analytics/core/subprocess_git.py +145 -0
- gitflow_analytics/extractors/ml_tickets.py +3 -2
- gitflow_analytics/extractors/tickets.py +93 -8
- gitflow_analytics/integrations/jira_integration.py +1 -1
- gitflow_analytics/integrations/orchestrator.py +47 -29
- gitflow_analytics/metrics/branch_health.py +3 -2
- gitflow_analytics/models/database.py +72 -1
- gitflow_analytics/pm_framework/adapters/jira_adapter.py +12 -5
- gitflow_analytics/pm_framework/orchestrator.py +8 -3
- gitflow_analytics/qualitative/classifiers/llm/openai_client.py +24 -4
- gitflow_analytics/qualitative/classifiers/llm_commit_classifier.py +3 -1
- gitflow_analytics/qualitative/core/llm_fallback.py +34 -2
- gitflow_analytics/reports/narrative_writer.py +118 -74
- gitflow_analytics/security/__init__.py +11 -0
- gitflow_analytics/security/config.py +189 -0
- gitflow_analytics/security/extractors/__init__.py +7 -0
- gitflow_analytics/security/extractors/dependency_checker.py +379 -0
- gitflow_analytics/security/extractors/secret_detector.py +197 -0
- gitflow_analytics/security/extractors/vulnerability_scanner.py +333 -0
- gitflow_analytics/security/llm_analyzer.py +347 -0
- gitflow_analytics/security/reports/__init__.py +5 -0
- gitflow_analytics/security/reports/security_report.py +358 -0
- gitflow_analytics/security/security_analyzer.py +414 -0
- gitflow_analytics/tui/app.py +3 -1
- gitflow_analytics/tui/progress_adapter.py +313 -0
- gitflow_analytics/tui/screens/analysis_progress_screen.py +407 -46
- gitflow_analytics/tui/screens/results_screen.py +219 -206
- gitflow_analytics/ui/__init__.py +21 -0
- gitflow_analytics/ui/progress_display.py +1477 -0
- gitflow_analytics/verify_activity.py +697 -0
- {gitflow_analytics-1.3.11.dist-info → gitflow_analytics-3.3.0.dist-info}/METADATA +2 -1
- {gitflow_analytics-1.3.11.dist-info → gitflow_analytics-3.3.0.dist-info}/RECORD +47 -31
- gitflow_analytics/cli_rich.py +0 -503
- {gitflow_analytics-1.3.11.dist-info → gitflow_analytics-3.3.0.dist-info}/WHEEL +0 -0
- {gitflow_analytics-1.3.11.dist-info → gitflow_analytics-3.3.0.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-1.3.11.dist-info → gitflow_analytics-3.3.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
382
|
-
metrics["
|
|
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
|
-
|
|
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
|
|