opspilot-ai 0.1.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.
@@ -0,0 +1,347 @@
1
+ """Deployment and Git history correlation for incident analysis."""
2
+
3
+ import subprocess
4
+ from datetime import datetime, timedelta
5
+ from typing import Dict, List, Optional
6
+ from pathlib import Path
7
+
8
+
9
+ def get_recent_commits(repo_path: str, since_hours: int = 24, limit: int = 20) -> List[Dict]:
10
+ """
11
+ Get recent Git commits with metadata.
12
+
13
+ Args:
14
+ repo_path: Path to Git repository
15
+ since_hours: Get commits from last N hours (default: 24)
16
+ limit: Max number of commits to retrieve (default: 20)
17
+
18
+ Returns:
19
+ List of commit dictionaries with hash, author, date, message
20
+ """
21
+ try:
22
+ since_time = datetime.now() - timedelta(hours=since_hours)
23
+ since_str = since_time.strftime("%Y-%m-%d %H:%M:%S")
24
+
25
+ # Git log format: hash|author|date|subject
26
+ cmd = [
27
+ "git", "-C", repo_path, "log",
28
+ f"--since={since_str}",
29
+ f"-{limit}",
30
+ "--pretty=format:%H|%an|%ai|%s"
31
+ ]
32
+
33
+ result = subprocess.run(
34
+ cmd,
35
+ capture_output=True,
36
+ text=True,
37
+ timeout=10
38
+ )
39
+
40
+ if result.returncode != 0:
41
+ print(f"Git log failed: {result.stderr}")
42
+ return []
43
+
44
+ commits = []
45
+ for line in result.stdout.strip().split("\n"):
46
+ if not line:
47
+ continue
48
+
49
+ parts = line.split("|", 3)
50
+ if len(parts) == 4:
51
+ commits.append({
52
+ "hash": parts[0][:8], # Short hash
53
+ "author": parts[1],
54
+ "date": parts[2],
55
+ "message": parts[3]
56
+ })
57
+
58
+ return commits
59
+
60
+ except subprocess.TimeoutExpired:
61
+ print("Git log timed out")
62
+ return []
63
+ except FileNotFoundError:
64
+ print("Git not found. Install from: https://git-scm.com/")
65
+ return []
66
+ except Exception as e:
67
+ print(f"Failed to get Git commits: {e}")
68
+ return []
69
+
70
+
71
+ def get_changed_files(repo_path: str, commit_hash: str) -> List[str]:
72
+ """
73
+ Get list of files changed in a specific commit.
74
+
75
+ Args:
76
+ repo_path: Path to Git repository
77
+ commit_hash: Commit hash
78
+
79
+ Returns:
80
+ List of file paths changed in the commit
81
+ """
82
+ try:
83
+ cmd = [
84
+ "git", "-C", repo_path, "diff-tree",
85
+ "--no-commit-id", "--name-only", "-r",
86
+ commit_hash
87
+ ]
88
+
89
+ result = subprocess.run(
90
+ cmd,
91
+ capture_output=True,
92
+ text=True,
93
+ timeout=10
94
+ )
95
+
96
+ if result.returncode == 0:
97
+ return [f.strip() for f in result.stdout.split("\n") if f.strip()]
98
+ else:
99
+ return []
100
+
101
+ except Exception:
102
+ return []
103
+
104
+
105
+ def correlate_with_error_timeline(commits: List[Dict], error_first_seen: str) -> Dict:
106
+ """
107
+ Correlate commits with error timeline.
108
+
109
+ Identifies commits that happened around the same time as errors appeared.
110
+
111
+ Args:
112
+ commits: List of commit dictionaries
113
+ error_first_seen: Timestamp when error was first seen
114
+
115
+ Returns:
116
+ Analysis dictionary with suspicious commits
117
+ """
118
+ try:
119
+ # Parse error timestamp (supports common formats)
120
+ error_time = None
121
+ for fmt in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"]:
122
+ try:
123
+ error_time = datetime.strptime(error_first_seen[:19], fmt)
124
+ break
125
+ except ValueError:
126
+ continue
127
+
128
+ if not error_time:
129
+ return {"correlation": "unknown", "reason": "Could not parse error timestamp"}
130
+
131
+ # Find commits within ±2 hours of error
132
+ suspicious_commits = []
133
+ for commit in commits:
134
+ try:
135
+ commit_time = datetime.strptime(commit["date"][:19], "%Y-%m-%d %H:%M:%S")
136
+ time_diff = abs((commit_time - error_time).total_seconds() / 3600)
137
+
138
+ if time_diff <= 2:
139
+ suspicious_commits.append({
140
+ **commit,
141
+ "time_diff_hours": round(time_diff, 1)
142
+ })
143
+ except ValueError:
144
+ continue
145
+
146
+ if suspicious_commits:
147
+ return {
148
+ "correlation": "strong",
149
+ "suspicious_commits": suspicious_commits,
150
+ "count": len(suspicious_commits),
151
+ "reason": f"Found {len(suspicious_commits)} commits within 2 hours of error"
152
+ }
153
+ else:
154
+ return {
155
+ "correlation": "weak",
156
+ "reason": "No commits near error occurrence time"
157
+ }
158
+
159
+ except Exception as e:
160
+ return {"correlation": "error", "reason": str(e)}
161
+
162
+
163
+ def analyze_deployment_impact(repo_path: str, since_hours: int = 24) -> Dict:
164
+ """
165
+ Analyze recent deployments and their potential impact.
166
+
167
+ Args:
168
+ repo_path: Path to Git repository
169
+ since_hours: Analyze last N hours (default: 24)
170
+
171
+ Returns:
172
+ Deployment analysis with commits, changed files, and risk assessment
173
+ """
174
+ commits = get_recent_commits(repo_path, since_hours)
175
+
176
+ if not commits:
177
+ return {
178
+ "has_recent_changes": False,
179
+ "commits": [],
180
+ "risk_level": "LOW",
181
+ "reason": f"No commits in the last {since_hours} hours"
182
+ }
183
+
184
+ # Categorize changed files
185
+ all_changed_files = []
186
+ high_risk_files = []
187
+ config_files = []
188
+
189
+ for commit in commits:
190
+ files = get_changed_files(repo_path, commit["hash"])
191
+ all_changed_files.extend(files)
192
+
193
+ # Identify high-risk changes
194
+ for file in files:
195
+ file_lower = file.lower()
196
+
197
+ # Config files
198
+ if any(x in file_lower for x in [".env", "config", "settings", "docker"]):
199
+ config_files.append({"file": file, "commit": commit["hash"]})
200
+
201
+ # Database migrations
202
+ if "migration" in file_lower or "schema" in file_lower:
203
+ high_risk_files.append({"file": file, "commit": commit["hash"], "reason": "Database change"})
204
+
205
+ # Critical services
206
+ if any(x in file_lower for x in ["auth", "payment", "api", "gateway"]):
207
+ high_risk_files.append({"file": file, "commit": commit["hash"], "reason": "Critical service"})
208
+
209
+ # Assess risk level
210
+ if high_risk_files:
211
+ risk_level = "HIGH"
212
+ reason = f"{len(high_risk_files)} high-risk files changed (migrations, auth, payments)"
213
+ elif config_files:
214
+ risk_level = "MEDIUM"
215
+ reason = f"{len(config_files)} configuration files changed"
216
+ elif len(commits) > 10:
217
+ risk_level = "MEDIUM"
218
+ reason = f"High deployment frequency ({len(commits)} commits)"
219
+ else:
220
+ risk_level = "LOW"
221
+ reason = f"{len(commits)} commits with standard changes"
222
+
223
+ return {
224
+ "has_recent_changes": True,
225
+ "commits": commits,
226
+ "total_commits": len(commits),
227
+ "changed_files_count": len(set(all_changed_files)),
228
+ "high_risk_files": high_risk_files,
229
+ "config_files": config_files,
230
+ "risk_level": risk_level,
231
+ "reason": reason
232
+ }
233
+
234
+
235
+ def get_blame_for_error(repo_path: str, file_path: str, line_number: int) -> Optional[Dict]:
236
+ """
237
+ Get Git blame information for a specific line.
238
+
239
+ Args:
240
+ repo_path: Path to Git repository
241
+ file_path: File path relative to repo root
242
+ line_number: Line number (1-indexed)
243
+
244
+ Returns:
245
+ Blame info with commit hash, author, date
246
+ """
247
+ try:
248
+ cmd = [
249
+ "git", "-C", repo_path, "blame",
250
+ "-L", f"{line_number},{line_number}",
251
+ "--porcelain",
252
+ file_path
253
+ ]
254
+
255
+ result = subprocess.run(
256
+ cmd,
257
+ capture_output=True,
258
+ text=True,
259
+ timeout=10
260
+ )
261
+
262
+ if result.returncode != 0:
263
+ return None
264
+
265
+ # Parse porcelain format
266
+ lines = result.stdout.split("\n")
267
+ if not lines:
268
+ return None
269
+
270
+ # First line: hash original_line final_line num_lines
271
+ parts = lines[0].split()
272
+ commit_hash = parts[0] if parts else "unknown"
273
+
274
+ # Extract author and date from subsequent lines
275
+ author = "unknown"
276
+ date = "unknown"
277
+
278
+ for line in lines[1:]:
279
+ if line.startswith("author "):
280
+ author = line[7:]
281
+ elif line.startswith("author-time "):
282
+ try:
283
+ timestamp = int(line[12:])
284
+ date = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
285
+ except ValueError:
286
+ pass
287
+
288
+ return {
289
+ "commit": commit_hash[:8],
290
+ "author": author,
291
+ "date": date,
292
+ "file": file_path,
293
+ "line": line_number
294
+ }
295
+
296
+ except Exception:
297
+ return None
298
+
299
+
300
+ def format_deployment_analysis(analysis: Dict) -> str:
301
+ """
302
+ Format deployment analysis as human-readable text.
303
+
304
+ Args:
305
+ analysis: Deployment analysis dictionary
306
+
307
+ Returns:
308
+ Formatted multi-line string
309
+ """
310
+ if not analysis.get("has_recent_changes"):
311
+ return "\nNo recent deployments detected.\n"
312
+
313
+ output = []
314
+
315
+ output.append("\n" + "=" * 60)
316
+ output.append("RECENT DEPLOYMENT ANALYSIS")
317
+ output.append("=" * 60)
318
+
319
+ risk_level = analysis.get("risk_level", "UNKNOWN")
320
+ risk_color_map = {
321
+ "HIGH": "RED",
322
+ "MEDIUM": "YELLOW",
323
+ "LOW": "GREEN"
324
+ }
325
+
326
+ output.append(f"\nRisk Level: {risk_level}")
327
+ output.append(f"Reason: {analysis.get('reason', 'N/A')}")
328
+ output.append(f"\nTotal Commits: {analysis.get('total_commits', 0)}")
329
+ output.append(f"Files Changed: {analysis.get('changed_files_count', 0)}")
330
+
331
+ if analysis.get("high_risk_files"):
332
+ output.append(f"\nHigh-Risk Changes ({len(analysis['high_risk_files'])}):")
333
+ for item in analysis["high_risk_files"][:5]: # Show first 5
334
+ output.append(f" - {item['file']} ({item['reason']}) [{item['commit']}]")
335
+
336
+ if analysis.get("config_files"):
337
+ output.append(f"\nConfiguration Changes ({len(analysis['config_files'])}):")
338
+ for item in analysis["config_files"][:5]:
339
+ output.append(f" - {item['file']} [{item['commit']}]")
340
+
341
+ if analysis.get("commits"):
342
+ output.append(f"\nRecent Commits:")
343
+ for commit in analysis["commits"][:5]:
344
+ output.append(f" [{commit['hash']}] {commit['message']}")
345
+ output.append(f" by {commit['author']} at {commit['date']}")
346
+
347
+ return "\n".join(output)
@@ -0,0 +1,14 @@
1
+ from pathlib import Path
2
+ from typing import List
3
+
4
+
5
+ def read_dependencies(project_root: str) -> List[str]:
6
+ root = Path(project_root)
7
+
8
+ if (root / "requirements.txt").exists():
9
+ return (root / "requirements.txt").read_text().splitlines()
10
+
11
+ if (root / "package.json").exists():
12
+ return ["node_project"]
13
+
14
+ return []
@@ -0,0 +1,17 @@
1
+ from pathlib import Path
2
+ from typing import Dict
3
+
4
+
5
+ def read_docker_files(project_root: str) -> Dict[str, str]:
6
+ root = Path(project_root)
7
+ docker_data = {}
8
+
9
+ dockerfile = root / "Dockerfile"
10
+ if dockerfile.exists():
11
+ docker_data["Dockerfile"] = dockerfile.read_text()
12
+
13
+ compose = root / "docker-compose.yml"
14
+ if compose.exists():
15
+ docker_data["docker-compose.yml"] = compose.read_text()
16
+
17
+ return docker_data
@@ -0,0 +1,17 @@
1
+ from pathlib import Path
2
+ from typing import Dict
3
+
4
+
5
+ def read_env(project_root: str) -> Dict[str, str]:
6
+ env_path = Path(project_root) / ".env"
7
+ env_vars = {}
8
+
9
+ if not env_path.exists():
10
+ return env_vars
11
+
12
+ for line in env_path.read_text().splitlines():
13
+ if line.strip() and not line.startswith("#") and "=" in line:
14
+ key, value = line.split("=", 1)
15
+ env_vars[key.strip()] = value.strip()
16
+
17
+ return env_vars
@@ -0,0 +1,16 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+
5
+ def read_logs(project_root: str) -> Optional[str]:
6
+ logs_dir = Path(project_root) / "logs"
7
+ if not logs_dir.exists():
8
+ return None
9
+
10
+ log_files = list(logs_dir.glob("*.log"))
11
+ if not log_files:
12
+ return None
13
+
14
+ # Read most recent log file
15
+ latest_log = max(log_files, key=lambda f: f.stat().st_mtime)
16
+ return latest_log.read_text(errors="ignore")[-5000:]
@@ -0,0 +1,262 @@
1
+ """Production log fetching from various sources."""
2
+
3
+ import requests
4
+ from pathlib import Path
5
+ from typing import Optional, List
6
+ import subprocess
7
+
8
+
9
+ def fetch_logs_from_url(url: str, timeout: int = 30) -> Optional[str]:
10
+ """
11
+ Fetch logs from HTTP/HTTPS URL.
12
+
13
+ Args:
14
+ url: Log file URL
15
+ timeout: Request timeout in seconds
16
+
17
+ Returns:
18
+ Log content as string, or None if failed
19
+ """
20
+ try:
21
+ response = requests.get(url, timeout=timeout)
22
+ response.raise_for_status()
23
+ return response.text
24
+ except requests.RequestException as e:
25
+ print(f"Failed to fetch logs from {url}: {e}")
26
+ return None
27
+
28
+
29
+ def fetch_logs_from_s3(bucket: str, key: str, profile: Optional[str] = None) -> Optional[str]:
30
+ """
31
+ Fetch logs from S3 bucket using AWS CLI.
32
+
33
+ Args:
34
+ bucket: S3 bucket name
35
+ key: Object key (file path in bucket)
36
+ profile: Optional AWS profile name
37
+
38
+ Returns:
39
+ Log content as string, or None if failed
40
+ """
41
+ try:
42
+ cmd = ["aws", "s3", "cp", f"s3://{bucket}/{key}", "-"]
43
+
44
+ if profile:
45
+ cmd.extend(["--profile", profile])
46
+
47
+ result = subprocess.run(
48
+ cmd,
49
+ capture_output=True,
50
+ text=True,
51
+ timeout=60
52
+ )
53
+
54
+ if result.returncode == 0:
55
+ return result.stdout
56
+ else:
57
+ print(f"Failed to fetch from S3: {result.stderr}")
58
+ return None
59
+
60
+ except subprocess.TimeoutExpired:
61
+ print(f"S3 fetch timed out for s3://{bucket}/{key}")
62
+ return None
63
+ except FileNotFoundError:
64
+ print("AWS CLI not found. Install from: https://aws.amazon.com/cli/")
65
+ return None
66
+
67
+
68
+ def fetch_logs_from_file(path: str) -> Optional[str]:
69
+ """
70
+ Read logs from local file.
71
+
72
+ Args:
73
+ path: Local file path
74
+
75
+ Returns:
76
+ Log content as string, or None if failed
77
+ """
78
+ try:
79
+ file_path = Path(path)
80
+ if not file_path.exists():
81
+ print(f"Log file not found: {path}")
82
+ return None
83
+
84
+ return file_path.read_text(encoding="utf-8", errors="ignore")
85
+ except Exception as e:
86
+ print(f"Failed to read log file {path}: {e}")
87
+ return None
88
+
89
+
90
+ def fetch_logs_from_kubectl(namespace: str, pod: str, container: Optional[str] = None,
91
+ tail: int = 1000) -> Optional[str]:
92
+ """
93
+ Fetch logs from Kubernetes pod using kubectl.
94
+
95
+ Args:
96
+ namespace: Kubernetes namespace
97
+ pod: Pod name
98
+ container: Optional container name (for multi-container pods)
99
+ tail: Number of lines to fetch (default: 1000)
100
+
101
+ Returns:
102
+ Log content as string, or None if failed
103
+ """
104
+ try:
105
+ cmd = ["kubectl", "logs", pod, "-n", namespace, f"--tail={tail}"]
106
+
107
+ if container:
108
+ cmd.extend(["-c", container])
109
+
110
+ result = subprocess.run(
111
+ cmd,
112
+ capture_output=True,
113
+ text=True,
114
+ timeout=30
115
+ )
116
+
117
+ if result.returncode == 0:
118
+ return result.stdout
119
+ else:
120
+ print(f"Failed to fetch kubectl logs: {result.stderr}")
121
+ return None
122
+
123
+ except subprocess.TimeoutExpired:
124
+ print(f"kubectl logs fetch timed out for {namespace}/{pod}")
125
+ return None
126
+ except FileNotFoundError:
127
+ print("kubectl not found. Install from: https://kubernetes.io/docs/tasks/tools/")
128
+ return None
129
+
130
+
131
+ def fetch_logs_from_cloudwatch(log_group: str, log_stream: str,
132
+ profile: Optional[str] = None,
133
+ limit: int = 1000) -> Optional[str]:
134
+ """
135
+ Fetch logs from AWS CloudWatch using AWS CLI.
136
+
137
+ Args:
138
+ log_group: CloudWatch log group name
139
+ log_stream: Log stream name
140
+ profile: Optional AWS profile name
141
+ limit: Max number of log events (default: 1000)
142
+
143
+ Returns:
144
+ Log content as string, or None if failed
145
+ """
146
+ try:
147
+ cmd = [
148
+ "aws", "logs", "get-log-events",
149
+ "--log-group-name", log_group,
150
+ "--log-stream-name", log_stream,
151
+ "--limit", str(limit),
152
+ "--output", "text",
153
+ "--query", "events[*].message"
154
+ ]
155
+
156
+ if profile:
157
+ cmd.extend(["--profile", profile])
158
+
159
+ result = subprocess.run(
160
+ cmd,
161
+ capture_output=True,
162
+ text=True,
163
+ timeout=60
164
+ )
165
+
166
+ if result.returncode == 0:
167
+ return result.stdout
168
+ else:
169
+ print(f"Failed to fetch CloudWatch logs: {result.stderr}")
170
+ return None
171
+
172
+ except subprocess.TimeoutExpired:
173
+ print(f"CloudWatch fetch timed out for {log_group}/{log_stream}")
174
+ return None
175
+ except FileNotFoundError:
176
+ print("AWS CLI not found. Install from: https://aws.amazon.com/cli/")
177
+ return None
178
+
179
+
180
+ def auto_detect_and_fetch(source: str) -> Optional[str]:
181
+ """
182
+ Auto-detect log source type and fetch accordingly.
183
+
184
+ Supports:
185
+ - HTTP/HTTPS URLs
186
+ - S3 paths (s3://bucket/key)
187
+ - Kubernetes (k8s://namespace/pod or k8s://namespace/pod/container)
188
+ - CloudWatch (cw://log-group/log-stream)
189
+ - Local files
190
+
191
+ Args:
192
+ source: Log source string
193
+
194
+ Returns:
195
+ Log content as string, or None if failed
196
+ """
197
+ source = source.strip()
198
+
199
+ # HTTP/HTTPS URL
200
+ if source.startswith(("http://", "https://")):
201
+ return fetch_logs_from_url(source)
202
+
203
+ # S3 path
204
+ if source.startswith("s3://"):
205
+ # Parse s3://bucket/key
206
+ parts = source[5:].split("/", 1)
207
+ if len(parts) == 2:
208
+ return fetch_logs_from_s3(parts[0], parts[1])
209
+ else:
210
+ print(f"Invalid S3 path format: {source}")
211
+ return None
212
+
213
+ # Kubernetes
214
+ if source.startswith("k8s://"):
215
+ # Parse k8s://namespace/pod or k8s://namespace/pod/container
216
+ parts = source[6:].split("/")
217
+ if len(parts) == 2:
218
+ return fetch_logs_from_kubectl(parts[0], parts[1])
219
+ elif len(parts) == 3:
220
+ return fetch_logs_from_kubectl(parts[0], parts[1], parts[2])
221
+ else:
222
+ print(f"Invalid k8s path format: {source}")
223
+ return None
224
+
225
+ # CloudWatch
226
+ if source.startswith("cw://"):
227
+ # Parse cw://log-group/log-stream
228
+ parts = source[5:].split("/", 1)
229
+ if len(parts) == 2:
230
+ return fetch_logs_from_cloudwatch(parts[0], parts[1])
231
+ else:
232
+ print(f"Invalid CloudWatch path format: {source}")
233
+ return None
234
+
235
+ # Local file (default)
236
+ return fetch_logs_from_file(source)
237
+
238
+
239
+ def fetch_multiple_sources(sources: List[str]) -> str:
240
+ """
241
+ Fetch logs from multiple sources and concatenate.
242
+
243
+ Args:
244
+ sources: List of log source strings
245
+
246
+ Returns:
247
+ Combined log content
248
+ """
249
+ all_logs = []
250
+
251
+ for source in sources:
252
+ print(f"Fetching logs from: {source}")
253
+ logs = auto_detect_and_fetch(source)
254
+ if logs:
255
+ all_logs.append(f"\n{'='*60}\n")
256
+ all_logs.append(f"SOURCE: {source}\n")
257
+ all_logs.append(f"{'='*60}\n")
258
+ all_logs.append(logs)
259
+ else:
260
+ print(f"Warning: Could not fetch logs from {source}")
261
+
262
+ return "\n".join(all_logs)
@@ -0,0 +1,19 @@
1
+ from pathlib import Path
2
+ from typing import List, Dict
3
+
4
+
5
+ def scan_project_tree(project_root: str, max_depth: int = 3) -> Dict[str, List[str]]:
6
+ root = Path(project_root)
7
+ structure = {}
8
+
9
+ for path in root.rglob("*"):
10
+ try:
11
+ depth = len(path.relative_to(root).parts)
12
+ except ValueError:
13
+ continue
14
+
15
+ if depth <= max_depth and path.is_file():
16
+ structure.setdefault(
17
+ str(path.parent.relative_to(root)), []).append(path.name)
18
+
19
+ return structure