@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.
- package/bin/sentinel.js +39 -0
- package/lib/add.js +57 -0
- package/lib/generate.js +111 -0
- package/lib/init.js +206 -0
- package/package.json +21 -0
- package/python/requirements.txt +5 -0
- package/python/sentinel/__init__.py +0 -0
- package/python/sentinel/cairn_client.py +45 -0
- package/python/sentinel/cicd_trigger.py +66 -0
- package/python/sentinel/config_loader.py +174 -0
- package/python/sentinel/fix_engine.py +123 -0
- package/python/sentinel/git_manager.py +227 -0
- package/python/sentinel/log_fetcher.py +200 -0
- package/python/sentinel/log_parser.py +149 -0
- package/python/sentinel/main.py +223 -0
- package/python/sentinel/repo_router.py +24 -0
- package/python/sentinel/reporter.py +173 -0
- package/python/sentinel/state_store.py +164 -0
- package/templates/log-configs/_example.properties +47 -0
- package/templates/repo-configs/_example.properties +37 -0
- package/templates/sentinel.properties +31 -0
|
@@ -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 ""
|