@misterhuydo/sentinel 1.0.0

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,174 @@
1
+ """
2
+ config_loader.py — Load and hot-reload all .properties config files.
3
+ """
4
+
5
+ import logging
6
+ import signal
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _parse_properties(path: str) -> dict[str, str]:
15
+ result = {}
16
+ with open(path, encoding="utf-8") as f:
17
+ for raw in f:
18
+ line = raw.strip()
19
+ if not line or line.startswith("#"):
20
+ continue
21
+ if "=" not in line:
22
+ continue
23
+ key, _, val = line.partition("=")
24
+ key = key.strip()
25
+ val = val.partition("#")[0].strip()
26
+ if key:
27
+ result[key] = val
28
+ return result
29
+
30
+
31
+ def _csv(val: str) -> list[str]:
32
+ return [v.strip() for v in val.split(",") if v.strip()]
33
+
34
+
35
+ # ── Typed config objects ──────────────────────────────────────────────────────
36
+
37
+ @dataclass
38
+ class SentinelConfig:
39
+ poll_interval_seconds: int = 120
40
+ smtp_host: str = ""
41
+ smtp_port: int = 587
42
+ smtp_user: str = ""
43
+ smtp_password: str = ""
44
+ report_recipients: list[str] = field(default_factory=list)
45
+ report_interval_hours: int = 6
46
+ state_db: str = "./sentinel.db"
47
+ workspace_dir: str = "./workspace"
48
+ claude_code_bin: str = "claude"
49
+ github_token: str = ""
50
+ fix_confidence_threshold: float = 0.7
51
+ log_retention_hours: int = 48
52
+
53
+
54
+ @dataclass
55
+ class LogSourceConfig:
56
+ name: str = "" # derived from filename stem
57
+ source_type: str = "ssh"
58
+ # SSH
59
+ key: str = ""
60
+ hosts: list[str] = field(default_factory=list)
61
+ logs: list[str] = field(default_factory=list)
62
+ remote_service_user: str = ""
63
+ grep_filter: str = ""
64
+ grep_exclude: str = ""
65
+ tail: Optional[int] = None
66
+ head: Optional[int] = None
67
+ # Cloudflare
68
+ cf_url: str = ""
69
+ cf_token: str = ""
70
+
71
+
72
+ @dataclass
73
+ class RepoConfig:
74
+ repo_name: str = "" # derived from filename stem
75
+ repo_url: str = ""
76
+ local_path: str = ""
77
+ branch: str = "main"
78
+ auto_publish: bool = False
79
+ cicd_type: str = ""
80
+ cicd_job_url: str = ""
81
+ cicd_token: str = ""
82
+
83
+
84
+ # ── Loader ────────────────────────────────────────────────────────────────────
85
+
86
+ class ConfigLoader:
87
+ def __init__(self, config_dir: str = "./config"):
88
+ self.config_dir = Path(config_dir)
89
+ self.sentinel: SentinelConfig = SentinelConfig()
90
+ self.log_sources: dict[str, LogSourceConfig] = {}
91
+ self.repos: dict[str, RepoConfig] = {}
92
+ self.load()
93
+ self._register_sighup()
94
+
95
+ def load(self):
96
+ self._load_sentinel()
97
+ self._load_log_sources()
98
+ self._load_repos()
99
+ logger.info(
100
+ "Config loaded: %d log-config(s), %d repo-config(s)",
101
+ len(self.log_sources), len(self.repos),
102
+ )
103
+
104
+ def _load_sentinel(self):
105
+ path = self.config_dir / "sentinel.properties"
106
+ if not path.exists():
107
+ logger.warning("sentinel.properties not found at %s", path)
108
+ return
109
+ d = _parse_properties(str(path))
110
+ c = SentinelConfig()
111
+ c.poll_interval_seconds = int(d.get("POLL_INTERVAL_SECONDS", 120))
112
+ c.smtp_host = d.get("SMTP_HOST", "")
113
+ c.smtp_port = int(d.get("SMTP_PORT", 587))
114
+ c.smtp_user = d.get("SMTP_USER", "")
115
+ c.smtp_password = d.get("SMTP_PASSWORD", "")
116
+ c.report_recipients = _csv(d.get("REPORT_RECIPIENTS", ""))
117
+ c.report_interval_hours = int(d.get("REPORT_INTERVAL_HOURS", 6))
118
+ c.state_db = d.get("STATE_DB", "./sentinel.db")
119
+ c.workspace_dir = d.get("WORKSPACE_DIR", "./workspace")
120
+ c.claude_code_bin = d.get("CLAUDE_CODE_BIN", "claude")
121
+ c.github_token = d.get("GITHUB_TOKEN", "")
122
+ c.fix_confidence_threshold = float(d.get("FIX_CONFIDENCE_THRESHOLD", 0.7))
123
+ c.log_retention_hours = int(d.get("LOG_RETENTION_HOURS", 48))
124
+ self.sentinel = c
125
+
126
+ def _load_log_sources(self):
127
+ sources_dir = self.config_dir / "log-configs"
128
+ if not sources_dir.exists():
129
+ return
130
+ self.log_sources = {}
131
+ for path in sorted(sources_dir.glob("*.properties")):
132
+ d = _parse_properties(str(path))
133
+ s = LogSourceConfig()
134
+ s.name = path.stem
135
+ s.source_type = d.get("SOURCE_TYPE", "ssh").lower()
136
+ s.key = d.get("KEY", "")
137
+ s.hosts = _csv(d.get("HOSTS", ""))
138
+ s.logs = _csv(d.get("LOGS", ""))
139
+ s.remote_service_user = d.get("REMOTE_SERVICE_USER", path.stem)
140
+ s.grep_filter = d.get("GREP_FILTER", "")
141
+ s.grep_exclude = d.get("GREP_EXCLUDE", "")
142
+ s.tail = int(d["TAIL"]) if "TAIL" in d else None
143
+ s.head = int(d["HEAD"]) if "HEAD" in d else None
144
+ s.cf_url = d.get("CF_URL", "")
145
+ s.cf_token = d.get("CF_TOKEN", "")
146
+ self.log_sources[s.name] = s
147
+
148
+ def _load_repos(self):
149
+ repos_dir = self.config_dir / "repo-configs"
150
+ if not repos_dir.exists():
151
+ return
152
+ self.repos = {}
153
+ for path in sorted(repos_dir.glob("*.properties")):
154
+ d = _parse_properties(str(path))
155
+ r = RepoConfig()
156
+ r.repo_name = path.stem
157
+ r.repo_url = d.get("REPO_URL", "")
158
+ r.local_path = d.get("LOCAL_PATH", "")
159
+ r.branch = d.get("BRANCH", "main")
160
+ r.auto_publish = d.get("AUTO_PUBLISH", "false").lower() == "true"
161
+ r.cicd_type = d.get("CICD_TYPE", "")
162
+ r.cicd_job_url = d.get("CICD_JOB_URL", "")
163
+ r.cicd_token = d.get("CICD_TOKEN", "")
164
+ self.repos[r.repo_name] = r
165
+
166
+ def _register_sighup(self):
167
+ try:
168
+ signal.signal(signal.SIGHUP, self._on_sighup)
169
+ except (OSError, AttributeError):
170
+ pass
171
+
172
+ def _on_sighup(self, *_):
173
+ logger.info("SIGHUP received — reloading config")
174
+ self.load()
@@ -0,0 +1,123 @@
1
+ """
2
+ fix_engine.py — Generate code fixes via Claude Code (headless).
3
+
4
+ Invokes: claude --print "<prompt>" 2>&1
5
+
6
+ Cairn MCP context is fetched automatically by Claude Code via its MCP tool
7
+ connection — Sentinel does not need to query or inject it explicitly.
8
+ """
9
+
10
+ import logging
11
+ import re
12
+ import subprocess
13
+ import textwrap
14
+ from pathlib import Path
15
+
16
+ from .config_loader import RepoConfig, SentinelConfig
17
+ from .log_parser import ErrorEvent
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ SUBPROCESS_TIMEOUT = 120
22
+ MAX_FILES_IN_PATCH = 5
23
+ MAX_LINES_IN_PATCH = 200
24
+
25
+ _DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
26
+ _DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
27
+
28
+
29
+ def _build_prompt(event: ErrorEvent, repo: RepoConfig, log_file: Path) -> str:
30
+ return textwrap.dedent(f"""\
31
+ You are fixing a production bug in the repository at {repo.local_path}.
32
+ Repository: {repo.repo_name}
33
+
34
+ LOG FILE: {log_file}
35
+ Read this file first. It contains the last 48h of logs from {event.source} —
36
+ use it to understand the frequency, surrounding context, and any warnings
37
+ that preceded this error.
38
+
39
+ ERROR fingerprint to fix (from {event.source}):
40
+ {event.full_text()}
41
+
42
+ Task:
43
+ 1. Read the log file above to understand what led up to this error.
44
+ 2. Use your available tools to explore the codebase and identify the root cause.
45
+ 3. Output ONLY a unified diff patch (git diff format) fixing the issue.
46
+ 4. Do not explain. Output only the patch.
47
+ 5. If you cannot determine a safe fix, output: SKIP: <reason>
48
+ """)
49
+
50
+
51
+ def _extract_patch(output: str) -> str | None:
52
+ m = _DIFF_BLOCK.search(output)
53
+ if m:
54
+ return m.group(1).strip()
55
+ if _DIFF_HEADER.search(output):
56
+ return output.strip()
57
+ return None
58
+
59
+
60
+ def _validate_patch(patch: str) -> tuple[bool, str]:
61
+ files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
62
+ lines_changed = len([
63
+ l for l in patch.splitlines()
64
+ if l.startswith(("+", "-")) and not l.startswith(("+++", "---"))
65
+ ])
66
+ if files_changed > MAX_FILES_IN_PATCH:
67
+ return False, f"Patch touches {files_changed} files (limit {MAX_FILES_IN_PATCH})"
68
+ if lines_changed > MAX_LINES_IN_PATCH:
69
+ return False, f"Patch changes {lines_changed} lines (limit {MAX_LINES_IN_PATCH})"
70
+ return True, ""
71
+
72
+
73
+ def generate_fix(
74
+ event: ErrorEvent,
75
+ repo: RepoConfig,
76
+ cfg: SentinelConfig,
77
+ patches_dir: Path,
78
+ ) -> tuple[str, Path | None]:
79
+ """
80
+ Generate a fix for the given error event.
81
+
82
+ Returns:
83
+ (status, patch_path)
84
+ status: "patch" | "skip" | "error"
85
+ """
86
+ log_file = Path(cfg.workspace_dir) / "fetched" / f"{event.source}.log"
87
+ prompt = _build_prompt(event, repo, log_file)
88
+
89
+ logger.info("Invoking Claude Code for %s (fp=%s)", event.source, event.fingerprint)
90
+ try:
91
+ result = subprocess.run(
92
+ [cfg.claude_code_bin, "--print", prompt],
93
+ capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT,
94
+ )
95
+ except subprocess.TimeoutExpired:
96
+ logger.error("Claude Code timed out for %s", event.fingerprint)
97
+ return "error", None
98
+ except FileNotFoundError:
99
+ logger.error("Claude Code binary not found at '%s'", cfg.claude_code_bin)
100
+ return "error", None
101
+
102
+ output = (result.stdout or "") + (result.stderr or "")
103
+
104
+ if output.strip().upper().startswith("SKIP:"):
105
+ reason = output.strip()[5:].strip()
106
+ logger.info("Claude skipped fix for %s: %s", event.fingerprint, reason)
107
+ return "skip", None
108
+
109
+ patch = _extract_patch(output)
110
+ if not patch:
111
+ logger.warning("No patch found in Claude output for %s", event.fingerprint)
112
+ return "error", None
113
+
114
+ ok, reason = _validate_patch(patch)
115
+ if not ok:
116
+ logger.warning("Patch rejected for %s: %s", event.fingerprint, reason)
117
+ return "skip", None
118
+
119
+ patches_dir.mkdir(parents=True, exist_ok=True)
120
+ patch_path = patches_dir / f"{event.fingerprint}.diff"
121
+ patch_path.write_text(patch, encoding="utf-8")
122
+ logger.info("Patch written to %s", patch_path)
123
+ return "patch", patch_path
@@ -0,0 +1,227 @@
1
+ """
2
+ git_manager.py — Git operations and GitHub PR creation.
3
+
4
+ Supports two modes (driven by repo.auto_publish):
5
+ True → apply fix directly to main branch and push
6
+ False → push to sentinel/fix-<fp> branch and open a GitHub PR
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ import requests
15
+
16
+ from .config_loader import RepoConfig, SentinelConfig
17
+ from .log_parser import ErrorEvent
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ GIT_TIMEOUT = 60
22
+ PR_BRANCH_PREFIX = "sentinel/fix-"
23
+
24
+ # Files that must never be modified by Sentinel
25
+ _PROTECTED_PATHS = {".github/", "Jenkinsfile", "pom.xml"}
26
+
27
+
28
+ def _git(args: list[str], cwd: str, env: dict | None = None, timeout: int = GIT_TIMEOUT) -> subprocess.CompletedProcess:
29
+ return subprocess.run(
30
+ ["git"] + args,
31
+ cwd=cwd,
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=timeout,
35
+ env=env or os.environ.copy(),
36
+ )
37
+
38
+
39
+ def _git_env(repo: RepoConfig) -> dict:
40
+ # GIT_SSH_COMMAND can be set externally for SSH-based repos
41
+ return os.environ.copy()
42
+
43
+
44
+ def _check_protected_paths(patch_path: Path) -> bool:
45
+ text = patch_path.read_text(encoding="utf-8", errors="replace")
46
+ for line in text.splitlines():
47
+ if line.startswith(("--- a/", "+++ b/")):
48
+ file_path = line[6:]
49
+ for protected in _PROTECTED_PATHS:
50
+ if file_path.startswith(protected) or file_path == protected.rstrip("/"):
51
+ logger.warning("Patch touches protected path %s — skipping", file_path)
52
+ return True
53
+ return False
54
+
55
+
56
+ def apply_and_commit(
57
+ event: ErrorEvent,
58
+ patch_path: Path,
59
+ repo: RepoConfig,
60
+ cfg: SentinelConfig,
61
+ ) -> tuple[str, str]:
62
+ """
63
+ Pull, apply patch, run tests, commit.
64
+
65
+ Returns:
66
+ (status, commit_hash)
67
+ status: "committed" | "failed"
68
+ """
69
+ env = _git_env(repo)
70
+ local_path = repo.local_path
71
+
72
+ if _check_protected_paths(patch_path):
73
+ return "failed", ""
74
+
75
+ r = _git(["pull", "--rebase", "origin", repo.branch], cwd=local_path, env=env)
76
+ if r.returncode != 0:
77
+ logger.error("git pull failed for %s:\n%s", repo.repo_name, r.stderr)
78
+ return "failed", ""
79
+
80
+ r = _git(["apply", "--check", str(patch_path)], cwd=local_path, env=env)
81
+ if r.returncode != 0:
82
+ logger.error("Patch dry-run failed for %s:\n%s", event.fingerprint, r.stderr)
83
+ return "failed", ""
84
+
85
+ r = _git(["apply", str(patch_path)], cwd=local_path, env=env)
86
+ if r.returncode != 0:
87
+ logger.error("git apply failed for %s:\n%s", event.fingerprint, r.stderr)
88
+ return "failed", ""
89
+
90
+ if not _run_tests(repo, local_path):
91
+ _git(["checkout", "."], cwd=local_path, env=env)
92
+ return "failed", ""
93
+
94
+ summary = event.short_summary()[:60]
95
+ commit_msg = f"fix(sentinel): {summary} [auto]\n\nError fingerprint: {event.fingerprint}\nSource: {event.source}"
96
+ r = _git(["commit", "-am", commit_msg], cwd=local_path, env=env)
97
+ if r.returncode != 0:
98
+ logger.error("git commit failed:\n%s", r.stderr)
99
+ _git(["checkout", "."], cwd=local_path, env=env)
100
+ return "failed", ""
101
+
102
+ commit_hash = _git(["rev-parse", "HEAD"], cwd=local_path, env=env).stdout.strip()
103
+ logger.info("Committed %s for %s", commit_hash[:8], event.fingerprint)
104
+
105
+ _append_changelog(repo, event, commit_hash)
106
+ return "committed", commit_hash
107
+
108
+
109
+ def _run_tests(repo: RepoConfig, local_path: str) -> bool:
110
+ """Run the repo's test suite. Return True if passing."""
111
+ if (Path(local_path) / "pom.xml").exists():
112
+ cmd = ["mvn", "test", "-q", "--no-transfer-progress"]
113
+ elif (Path(local_path) / "package.json").exists():
114
+ cmd = ["npm", "test", "--", "--watchAll=false"]
115
+ elif (Path(local_path) / "build.gradle").exists() or (Path(local_path) / "build.gradle.kts").exists():
116
+ cmd = ["./gradlew", "test", "-q"]
117
+ else:
118
+ logger.info("No recognised build file in %s — skipping tests", repo.repo_name)
119
+ return True
120
+
121
+ logger.info("Running tests for %s: %s", repo.repo_name, " ".join(cmd))
122
+ r = subprocess.run(cmd, cwd=local_path, capture_output=True, text=True, timeout=300)
123
+ if r.returncode != 0:
124
+ logger.error("Tests failed:\n%s", r.stdout[-2000:] + r.stderr[-1000:])
125
+ return False
126
+ logger.info("Tests passed")
127
+ return True
128
+
129
+
130
+ def _append_changelog(repo: RepoConfig, event: ErrorEvent, commit_hash: str):
131
+ changelog = Path(repo.local_path) / "CHANGELOG.md"
132
+ entry = (
133
+ f"\n## [{commit_hash[:8]}] Sentinel auto-fix\n"
134
+ f"- Source: {event.source}\n"
135
+ f"- Error: {event.short_summary()}\n"
136
+ f"- Fingerprint: `{event.fingerprint}`\n"
137
+ )
138
+ with open(changelog, "a", encoding="utf-8") as f:
139
+ f.write(entry)
140
+ env = _git_env(repo)
141
+ _git(["add", "CHANGELOG.md"], cwd=repo.local_path, env=env)
142
+ _git(["commit", "--amend", "--no-edit"], cwd=repo.local_path, env=env)
143
+
144
+
145
+ def publish(
146
+ event: ErrorEvent,
147
+ repo: RepoConfig,
148
+ cfg: SentinelConfig,
149
+ commit_hash: str,
150
+ ) -> tuple[str, str]:
151
+ """
152
+ Push the fix and (if AUTO_PUBLISH=false) open a PR.
153
+
154
+ Returns:
155
+ (branch, pr_url) — pr_url is "" when AUTO_PUBLISH=true
156
+ """
157
+ env = _git_env(repo)
158
+ local_path = repo.local_path
159
+
160
+ if repo.auto_publish:
161
+ r = _git(["push", "origin", repo.branch], cwd=local_path, env=env)
162
+ if r.returncode != 0:
163
+ logger.error("git push failed:\n%s", r.stderr)
164
+ return repo.branch, ""
165
+ else:
166
+ branch = f"{PR_BRANCH_PREFIX}{event.fingerprint[:8]}"
167
+ _git(["checkout", "-B", branch], cwd=local_path, env=env)
168
+ r = _git(["push", "-u", "origin", branch], cwd=local_path, env=env)
169
+ if r.returncode != 0:
170
+ logger.error("git push branch failed:\n%s", r.stderr)
171
+ _git(["checkout", repo.branch], cwd=local_path, env=env)
172
+ return branch, ""
173
+ _git(["checkout", repo.branch], cwd=local_path, env=env)
174
+ pr_url = _open_github_pr(event, repo, cfg, branch, commit_hash)
175
+ return branch, pr_url
176
+
177
+
178
+ def _open_github_pr(
179
+ event: ErrorEvent,
180
+ repo: RepoConfig,
181
+ cfg: SentinelConfig,
182
+ branch: str,
183
+ commit_hash: str,
184
+ ) -> str:
185
+ if not cfg.github_token:
186
+ logger.warning("GITHUB_TOKEN not set — cannot open PR")
187
+ return ""
188
+
189
+ repo_url = repo.repo_url
190
+ if repo_url.startswith("git@"):
191
+ owner_repo = repo_url.split(":")[-1].removesuffix(".git")
192
+ else:
193
+ owner_repo = "/".join(repo_url.rstrip("/").split("/")[-2:]).removesuffix(".git")
194
+
195
+ title = f"[Sentinel] fix({event.source}): {event.short_summary()[:60]}"
196
+ stack_preview = "\n".join(event.stack_trace[:8])
197
+ body = (
198
+ f"## Auto-generated fix by Sentinel\n\n"
199
+ f"**Error fingerprint**: `{event.fingerprint}` \n"
200
+ f"**Source**: `{event.source}` / `{event.log_file}` \n"
201
+ f"**First seen**: {event.timestamp}\n\n"
202
+ f"### Error\n```\n{event.message}\n{stack_preview}\n```\n\n"
203
+ f"### Commits in this fix\n"
204
+ f"| SHA (short) | Description |\n|---|---|\n"
205
+ f"| `{commit_hash[:8]}` | fix(sentinel): {event.short_summary()[:60]} |\n\n"
206
+ f"---\n"
207
+ f"_To apply: merge this PR. To reject: close it. "
208
+ f"Sentinel will not retry this fingerprint for 24 h._"
209
+ )
210
+
211
+ resp = requests.post(
212
+ f"https://api.github.com/repos/{owner_repo}/pulls",
213
+ json={"title": title, "body": body, "head": branch, "base": repo.branch},
214
+ headers={
215
+ "Authorization": f"Bearer {cfg.github_token}",
216
+ "Accept": "application/vnd.github+json",
217
+ },
218
+ timeout=30,
219
+ )
220
+
221
+ if resp.status_code == 201:
222
+ pr_url = resp.json().get("html_url", "")
223
+ logger.info("PR opened: %s", pr_url)
224
+ return pr_url
225
+ else:
226
+ logger.error("Failed to open PR (%s): %s", resp.status_code, resp.text[:300])
227
+ return ""