@misterhuydo/sentinel 1.0.5 → 1.0.6
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/lib/generate.js +22 -3
- package/package.json +1 -1
- package/python/sentinel/__pycache__/fix_engine.cpython-313.pyc +0 -0
- package/python/sentinel/__pycache__/main.cpython-313.pyc +0 -0
- package/python/sentinel/__pycache__/reporter.cpython-313.pyc +0 -0
- package/python/sentinel/fix_engine.py +36 -30
- package/python/sentinel/issue_watcher.py +131 -0
- package/python/sentinel/main.py +136 -18
- package/python/sentinel/reporter.py +50 -0
package/lib/generate.js
CHANGED
|
@@ -73,7 +73,7 @@ if echo "$AUTH_OUT" | grep -Eqi "not logged in|/login"; then
|
|
|
73
73
|
exit 1
|
|
74
74
|
fi
|
|
75
75
|
|
|
76
|
-
mkdir -p "$DIR/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches"
|
|
76
|
+
mkdir -p "$DIR/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches" "$DIR/issues"
|
|
77
77
|
cd "$DIR"
|
|
78
78
|
PYTHONPATH="${codeDir}" "${pythonBin}" -m sentinel.main --config ./config \\
|
|
79
79
|
>> "$DIR/logs/sentinel.log" 2>&1 &
|
|
@@ -109,17 +109,36 @@ rm -f "$PID_FILE"
|
|
|
109
109
|
function generateWorkspaceScripts(workspace) {
|
|
110
110
|
// startAll.sh
|
|
111
111
|
fs.writeFileSync(path.join(workspace, 'startAll.sh'), `#!/usr/bin/env bash
|
|
112
|
-
# Start all Sentinel project instances
|
|
112
|
+
# Start all valid Sentinel project instances.
|
|
113
|
+
# A valid project must have config/repo-configs/*.properties with a GitHub REPO_URL.
|
|
113
114
|
WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
|
|
114
115
|
started=0
|
|
116
|
+
skipped=0
|
|
115
117
|
for project_dir in "$WORKSPACE"/*/; do
|
|
116
118
|
name=$(basename "$project_dir")
|
|
117
119
|
[[ "$name" == "code" ]] && continue
|
|
118
120
|
[[ -f "$project_dir/start.sh" ]] || continue
|
|
121
|
+
|
|
122
|
+
# Must have at least one repo-config with a valid GitHub REPO_URL
|
|
123
|
+
valid_repo=false
|
|
124
|
+
for props in "$project_dir/config/repo-configs/"*.properties 2>/dev/null; do
|
|
125
|
+
[[ -f "$props" ]] || continue
|
|
126
|
+
if grep -qE "^REPO_URL[[:space:]]*=[[:space:]]*(git@github\.com:|https://github\.com/)" "$props"; then
|
|
127
|
+
valid_repo=true
|
|
128
|
+
break
|
|
129
|
+
fi
|
|
130
|
+
done
|
|
131
|
+
|
|
132
|
+
if [[ "$valid_repo" == "false" ]]; then
|
|
133
|
+
echo "[sentinel] Skipping $name — no valid REPO_URL found in config/repo-configs/"
|
|
134
|
+
skipped=$((skipped + 1))
|
|
135
|
+
continue
|
|
136
|
+
fi
|
|
137
|
+
|
|
119
138
|
bash "$project_dir/start.sh"
|
|
120
139
|
started=$((started + 1))
|
|
121
140
|
done
|
|
122
|
-
echo "[sentinel] $started project(s) started"
|
|
141
|
+
echo "[sentinel] $started project(s) started, $skipped skipped"
|
|
123
142
|
`, { mode: 0o755 });
|
|
124
143
|
|
|
125
144
|
// stopAll.sh
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -26,36 +26,39 @@ _DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
|
|
|
26
26
|
_DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def _build_prompt(event
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
29
|
+
def _build_prompt(event, repo: RepoConfig, log_file) -> str:
|
|
30
|
+
if log_file and log_file.exists():
|
|
31
|
+
ctx = (
|
|
32
|
+
"LOG FILE: " + str(log_file) + "\n"
|
|
33
|
+
"Read this file first -- it contains the last 48h of logs from "
|
|
34
|
+
+ event.source + ".\n"
|
|
35
|
+
"Use it to understand frequency, context, and preceding warnings."
|
|
36
|
+
)
|
|
37
|
+
step1 = "Read the log file above to understand what led up to this error."
|
|
38
|
+
else:
|
|
39
|
+
ctx = (
|
|
40
|
+
"SOURCE: " + event.source + "\n"
|
|
41
|
+
"No rolling log file available. The full issue description is below."
|
|
42
|
+
)
|
|
43
|
+
step1 = "Use the issue description above as your primary context."
|
|
44
|
+
|
|
45
|
+
lines_out = [
|
|
46
|
+
f"You are fixing a production bug in the repository at {repo.local_path}.",
|
|
47
|
+
f"Repository: {repo.repo_name}",
|
|
48
|
+
"",
|
|
49
|
+
ctx,
|
|
50
|
+
"",
|
|
51
|
+
f"ISSUE TO FIX (from {event.source}):",
|
|
52
|
+
event.full_text(),
|
|
53
|
+
"",
|
|
54
|
+
"Task:",
|
|
55
|
+
f"1. {step1}",
|
|
56
|
+
"2. Use your available tools to explore the codebase and identify the root cause.",
|
|
57
|
+
"3. Output ONLY a unified diff patch (git diff format) fixing the issue.",
|
|
58
|
+
"4. Do not explain. Output only the patch.",
|
|
59
|
+
"5. If you cannot determine a safe fix, output: SKIP: <reason>",
|
|
60
|
+
]
|
|
61
|
+
return "\n".join(lines_out)
|
|
59
62
|
|
|
60
63
|
def _validate_patch(patch: str) -> tuple[bool, str]:
|
|
61
64
|
files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
|
|
@@ -83,7 +86,10 @@ def generate_fix(
|
|
|
83
86
|
(status, patch_path)
|
|
84
87
|
status: "patch" | "skip" | "error"
|
|
85
88
|
"""
|
|
89
|
+
# Issues have source like "issues/filename" — no rolling log file exists
|
|
86
90
|
log_file = Path(cfg.workspace_dir) / "fetched" / f"{event.source}.log"
|
|
91
|
+
if not log_file.exists():
|
|
92
|
+
log_file = None
|
|
87
93
|
prompt = _build_prompt(event, repo, log_file)
|
|
88
94
|
|
|
89
95
|
logger.info("Invoking Claude Code for %s (fp=%s)", event.source, event.fingerprint)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
issue_watcher.py — Scan the issues/ directory for manually-submitted bug reports.
|
|
3
|
+
|
|
4
|
+
Admins drop plain-text or markdown files into <project>/issues/.
|
|
5
|
+
Each file is treated as a fix request. Processed files are archived to issues/.done/.
|
|
6
|
+
|
|
7
|
+
File format (TARGET_REPO header is optional):
|
|
8
|
+
|
|
9
|
+
TARGET_REPO: my-repo-name
|
|
10
|
+
|
|
11
|
+
Short summary of the problem (becomes the email subject line)
|
|
12
|
+
|
|
13
|
+
Any details: customer feedback, stack traces, screenshots text, etc.
|
|
14
|
+
If TARGET_REPO is omitted and only one repo is configured, it is used automatically.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import logging
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_TARGET_REPO_PREFIX = "TARGET_REPO:"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class IssueEvent:
|
|
31
|
+
"""
|
|
32
|
+
A fix request sourced from the issues/ directory.
|
|
33
|
+
Implements the same interface as ErrorEvent so it can flow through
|
|
34
|
+
the same fix pipeline (_handle_error / generate_fix / git_manager).
|
|
35
|
+
"""
|
|
36
|
+
source: str # "issues/<filename>" — shown in emails and logs
|
|
37
|
+
issue_file: Path # full path, used for archiving after processing
|
|
38
|
+
message: str # first non-blank body line — used as subject summary
|
|
39
|
+
body: str # full file content (the issue description)
|
|
40
|
+
target_repo: str # explicit TARGET_REPO value, or "" for auto-select
|
|
41
|
+
fingerprint: str = ""
|
|
42
|
+
severity: str = "ERROR"
|
|
43
|
+
timestamp: str = ""
|
|
44
|
+
|
|
45
|
+
# Compatibility fields matching ErrorEvent interface
|
|
46
|
+
level: str = "ERROR"
|
|
47
|
+
thread: str = ""
|
|
48
|
+
logger_name: str = ""
|
|
49
|
+
stack_trace: list[str] = field(default_factory=list)
|
|
50
|
+
log_file: str = ""
|
|
51
|
+
|
|
52
|
+
def __post_init__(self):
|
|
53
|
+
if not self.fingerprint:
|
|
54
|
+
raw = f"issue:{self.source}:{self.message[:200]}"
|
|
55
|
+
self.fingerprint = hashlib.sha1(raw.encode()).hexdigest()[:16]
|
|
56
|
+
if not self.timestamp:
|
|
57
|
+
self.timestamp = datetime.now(timezone.utc).isoformat()
|
|
58
|
+
if not self.stack_trace:
|
|
59
|
+
self.stack_trace = self.body.splitlines()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_infra_issue(self) -> bool:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def short_summary(self) -> str:
|
|
66
|
+
return self.message[:120]
|
|
67
|
+
|
|
68
|
+
def full_text(self) -> str:
|
|
69
|
+
return self.body
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def scan_issues(project_dir: Path) -> list[IssueEvent]:
|
|
73
|
+
"""
|
|
74
|
+
Return all pending issue files from <project_dir>/issues/.
|
|
75
|
+
Files starting with '.' and files inside .done/ are skipped.
|
|
76
|
+
"""
|
|
77
|
+
issues_dir = project_dir / "issues"
|
|
78
|
+
if not issues_dir.exists():
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
events = []
|
|
82
|
+
for f in sorted(issues_dir.iterdir()):
|
|
83
|
+
if not f.is_file() or f.name.startswith("."):
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
content = f.read_text(encoding="utf-8", errors="replace").strip()
|
|
88
|
+
except OSError as e:
|
|
89
|
+
logger.error("Cannot read issue file %s: %s", f, e)
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if not content:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
lines = content.splitlines()
|
|
96
|
+
target_repo = ""
|
|
97
|
+
body_start = 0
|
|
98
|
+
|
|
99
|
+
# Parse optional TARGET_REPO: header (must be the first non-blank line)
|
|
100
|
+
for i, line in enumerate(lines):
|
|
101
|
+
stripped = line.strip()
|
|
102
|
+
if stripped.upper().startswith(_TARGET_REPO_PREFIX):
|
|
103
|
+
target_repo = stripped[len(_TARGET_REPO_PREFIX):].strip()
|
|
104
|
+
body_start = i + 1
|
|
105
|
+
elif stripped:
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
body = "\n".join(lines[body_start:]).strip() or content
|
|
109
|
+
message = next((l.strip() for l in lines[body_start:] if l.strip()), f.name)
|
|
110
|
+
|
|
111
|
+
events.append(IssueEvent(
|
|
112
|
+
source=f"issues/{f.name}",
|
|
113
|
+
issue_file=f,
|
|
114
|
+
message=message,
|
|
115
|
+
body=body,
|
|
116
|
+
target_repo=target_repo,
|
|
117
|
+
))
|
|
118
|
+
logger.info("Found issue: %s (target_repo=%r)", f.name, target_repo or "auto")
|
|
119
|
+
|
|
120
|
+
return events
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def mark_done(issue_file: Path) -> None:
|
|
124
|
+
"""Archive a processed issue to issues/.done/ regardless of outcome."""
|
|
125
|
+
done_dir = issue_file.parent / ".done"
|
|
126
|
+
done_dir.mkdir(exist_ok=True)
|
|
127
|
+
dest = done_dir / issue_file.name
|
|
128
|
+
if dest.exists():
|
|
129
|
+
dest = done_dir / f"{issue_file.stem}-{int(time.time())}{issue_file.suffix}"
|
|
130
|
+
issue_file.rename(dest)
|
|
131
|
+
logger.info("Issue archived: %s -> .done/%s", issue_file.name, dest.name)
|
package/python/sentinel/main.py
CHANGED
|
@@ -22,8 +22,9 @@ from .git_manager import apply_and_commit, publish
|
|
|
22
22
|
from .cicd_trigger import trigger as cicd_trigger
|
|
23
23
|
from .log_fetcher import fetch_all
|
|
24
24
|
from .log_parser import parse_all, ErrorEvent
|
|
25
|
+
from .issue_watcher import scan_issues, mark_done, IssueEvent
|
|
25
26
|
from .repo_router import route
|
|
26
|
-
from .reporter import build_and_send, send_fix_notification
|
|
27
|
+
from .reporter import build_and_send, send_fix_notification, send_failure_notification
|
|
27
28
|
from .state_store import StateStore
|
|
28
29
|
|
|
29
30
|
logging.basicConfig(
|
|
@@ -83,12 +84,27 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
83
84
|
status, patch_path = generate_fix(event, repo, sentinel, patches_dir)
|
|
84
85
|
|
|
85
86
|
if status != "patch" or patch_path is None:
|
|
86
|
-
|
|
87
|
+
outcome = "skipped" if status == "skip" else "failed"
|
|
88
|
+
store.record_fix(event.fingerprint, outcome, repo_name=repo.repo_name)
|
|
89
|
+
send_failure_notification(sentinel, {
|
|
90
|
+
"source": event.source,
|
|
91
|
+
"message": event.message,
|
|
92
|
+
"repo_name": repo.repo_name,
|
|
93
|
+
"reason": f"Claude Code returned {status.upper()}",
|
|
94
|
+
"body": event.full_text()[:500],
|
|
95
|
+
})
|
|
87
96
|
return
|
|
88
97
|
|
|
89
98
|
commit_status, commit_hash = apply_and_commit(event, patch_path, repo, sentinel)
|
|
90
99
|
if commit_status != "committed":
|
|
91
100
|
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
101
|
+
send_failure_notification(sentinel, {
|
|
102
|
+
"source": event.source,
|
|
103
|
+
"message": event.message,
|
|
104
|
+
"repo_name": repo.repo_name,
|
|
105
|
+
"reason": "patch generated but commit/tests failed",
|
|
106
|
+
"body": event.full_text()[:500],
|
|
107
|
+
})
|
|
92
108
|
return
|
|
93
109
|
|
|
94
110
|
branch, pr_url = publish(event, repo, sentinel, commit_hash)
|
|
@@ -123,28 +139,129 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
123
139
|
|
|
124
140
|
# ── Poll cycle ────────────────────────────────────────────────────────────────
|
|
125
141
|
|
|
126
|
-
async def poll_cycle(cfg_loader: ConfigLoader, store: StateStore):
|
|
127
|
-
global _report_requested
|
|
128
142
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
143
|
+
# ── Issue pipeline ────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: StateStore):
|
|
146
|
+
"""Process a single issue file from the issues/ directory."""
|
|
147
|
+
sentinel = cfg_loader.sentinel
|
|
148
|
+
|
|
149
|
+
if Path("SENTINEL_PAUSE").exists():
|
|
150
|
+
logger.info("SENTINEL_PAUSE present -- fix activity halted")
|
|
132
151
|
return
|
|
133
152
|
|
|
134
|
-
|
|
135
|
-
|
|
153
|
+
if store.fix_attempted_recently(event.fingerprint, hours=24):
|
|
154
|
+
logger.debug("Issue already processed recently: %s", event.source)
|
|
155
|
+
mark_done(event.issue_file)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Route: explicit TARGET_REPO in file > single-repo shortcut > warn and leave
|
|
159
|
+
if event.target_repo:
|
|
160
|
+
repo = cfg_loader.repos.get(event.target_repo)
|
|
161
|
+
if not repo:
|
|
162
|
+
logger.warning("TARGET_REPO %r not found in config -- leaving %s for admin",
|
|
163
|
+
event.target_repo, event.source)
|
|
164
|
+
return
|
|
165
|
+
elif len(cfg_loader.repos) == 1:
|
|
166
|
+
repo = next(iter(cfg_loader.repos.values()))
|
|
167
|
+
else:
|
|
168
|
+
logger.warning(
|
|
169
|
+
"Cannot auto-route %s -- add 'TARGET_REPO: <repo>' as first line in the file",
|
|
170
|
+
event.source,
|
|
171
|
+
)
|
|
172
|
+
return # Leave the file so admin can add the header
|
|
173
|
+
|
|
174
|
+
patches_dir = Path(sentinel.workspace_dir) / "patches"
|
|
175
|
+
status, patch_path = generate_fix(event, repo, sentinel, patches_dir)
|
|
176
|
+
|
|
177
|
+
if status != "patch" or patch_path is None:
|
|
178
|
+
store.record_fix(event.fingerprint, "skipped" if status == "skip" else "failed",
|
|
179
|
+
repo_name=repo.repo_name)
|
|
180
|
+
send_failure_notification(sentinel, {
|
|
181
|
+
"source": event.source,
|
|
182
|
+
"message": event.message,
|
|
183
|
+
"repo_name": repo.repo_name,
|
|
184
|
+
"reason": f"Claude Code returned {status.upper()}",
|
|
185
|
+
"body": event.body[:500],
|
|
186
|
+
})
|
|
187
|
+
mark_done(event.issue_file)
|
|
188
|
+
return
|
|
136
189
|
|
|
137
|
-
|
|
138
|
-
|
|
190
|
+
commit_status, commit_hash = apply_and_commit(event, patch_path, repo, sentinel)
|
|
191
|
+
if commit_status != "committed":
|
|
192
|
+
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
193
|
+
send_failure_notification(sentinel, {
|
|
194
|
+
"source": event.source,
|
|
195
|
+
"message": event.message,
|
|
196
|
+
"repo_name": repo.repo_name,
|
|
197
|
+
"reason": "patch generated but commit/tests failed",
|
|
198
|
+
"body": event.body[:500],
|
|
199
|
+
})
|
|
200
|
+
mark_done(event.issue_file)
|
|
201
|
+
return
|
|
139
202
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if
|
|
144
|
-
|
|
203
|
+
branch, pr_url = publish(event, repo, sentinel, commit_hash)
|
|
204
|
+
store.record_fix(
|
|
205
|
+
event.fingerprint,
|
|
206
|
+
"applied" if repo.auto_publish else "pending",
|
|
207
|
+
patch_path=str(patch_path),
|
|
208
|
+
commit_hash=commit_hash,
|
|
209
|
+
branch=branch,
|
|
210
|
+
pr_url=pr_url,
|
|
211
|
+
repo_name=repo.repo_name,
|
|
212
|
+
)
|
|
213
|
+
send_fix_notification(sentinel, {
|
|
214
|
+
"source": event.source,
|
|
215
|
+
"severity": "ERROR",
|
|
216
|
+
"fingerprint": event.fingerprint,
|
|
217
|
+
"first_seen": event.timestamp,
|
|
218
|
+
"message": event.message,
|
|
219
|
+
"stack_trace": event.body,
|
|
220
|
+
"repo_name": repo.repo_name,
|
|
221
|
+
"commit_hash": commit_hash,
|
|
222
|
+
"branch": branch,
|
|
223
|
+
"pr_url": pr_url,
|
|
224
|
+
"auto_publish": repo.auto_publish,
|
|
225
|
+
"files_changed": [],
|
|
226
|
+
})
|
|
227
|
+
mark_done(event.issue_file)
|
|
228
|
+
|
|
229
|
+
if repo.auto_publish:
|
|
230
|
+
cicd_trigger(repo, store, event.fingerprint)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def poll_cycle(cfg_loader: ConfigLoader, store: StateStore):
|
|
234
|
+
global _report_requested
|
|
235
|
+
|
|
236
|
+
# ── Log sources (optional) ────────────────────────────────────────────────
|
|
237
|
+
sources = list(cfg_loader.log_sources.values())
|
|
238
|
+
if sources:
|
|
239
|
+
logger.info("Fetching logs from %d source(s)...", len(sources))
|
|
240
|
+
fetched = await fetch_all(sources, cfg_loader.sentinel)
|
|
241
|
+
events = parse_all(fetched, cfg_loader.log_sources)
|
|
242
|
+
logger.info("Parsed %d error/warn events", len(events))
|
|
243
|
+
|
|
244
|
+
new_events = []
|
|
245
|
+
for event in events:
|
|
246
|
+
store.record_error(event.fingerprint, event.source, event.message)
|
|
247
|
+
if not store.fix_attempted_recently(event.fingerprint):
|
|
248
|
+
new_events.append(event)
|
|
249
|
+
|
|
250
|
+
if new_events:
|
|
251
|
+
logger.info("%d new log event(s) to process", len(new_events))
|
|
252
|
+
await asyncio.gather(
|
|
253
|
+
*[_handle_error(e, cfg_loader, store) for e in new_events],
|
|
254
|
+
return_exceptions=True,
|
|
255
|
+
)
|
|
145
256
|
|
|
146
|
-
|
|
147
|
-
|
|
257
|
+
# ── Issues directory (always checked) ────────────────────────────────────
|
|
258
|
+
issues = scan_issues(Path("."))
|
|
259
|
+
if issues:
|
|
260
|
+
logger.info("%d issue file(s) found in issues/", len(issues))
|
|
261
|
+
await asyncio.gather(
|
|
262
|
+
*[_handle_issue(e, cfg_loader, store) for e in issues],
|
|
263
|
+
return_exceptions=True,
|
|
264
|
+
)
|
|
148
265
|
|
|
149
266
|
if cfg_loader.sentinel.send_health and (_report_requested or _report_due(cfg_loader, store)):
|
|
150
267
|
_report_requested = False
|
|
@@ -216,6 +333,7 @@ def main():
|
|
|
216
333
|
Path("logs").mkdir(exist_ok=True)
|
|
217
334
|
Path("workspace/fetched").mkdir(parents=True, exist_ok=True)
|
|
218
335
|
Path("workspace/patches").mkdir(parents=True, exist_ok=True)
|
|
336
|
+
Path("issues").mkdir(exist_ok=True)
|
|
219
337
|
|
|
220
338
|
parser = argparse.ArgumentParser(description="Sentinel — Autonomous DevOps Agent")
|
|
221
339
|
parser.add_argument("--init", action="store_true", help="First-time setup")
|
|
@@ -136,3 +136,53 @@ def _age(ts_str: str) -> str:
|
|
|
136
136
|
return f"{int(delta.total_seconds() // 60)}m" if hours < 1 else f"{hours}h"
|
|
137
137
|
except Exception:
|
|
138
138
|
return "?"
|
|
139
|
+
|
|
140
|
+
def send_failure_notification(cfg: SentinelConfig, details: dict):
|
|
141
|
+
"""
|
|
142
|
+
Notify admins when Claude Code cannot fix a problem (from logs or issues/).
|
|
143
|
+
|
|
144
|
+
details dict keys: source, message, repo_name, reason, body
|
|
145
|
+
"""
|
|
146
|
+
if not cfg.mails:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
source = details.get('source', 'unknown')
|
|
150
|
+
repo_name = details.get('repo_name', 'unknown')
|
|
151
|
+
reason = details.get('reason', 'unknown')
|
|
152
|
+
message = details.get('message', '')
|
|
153
|
+
body = details.get('body', '')[:1000]
|
|
154
|
+
ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
|
155
|
+
|
|
156
|
+
subject = f'[Sentinel] UNRESOLVED ({source}): {message[:80]}'
|
|
157
|
+
|
|
158
|
+
ctx_html = f'<h3>Context</h3><pre>{body}</pre>' if body else ''
|
|
159
|
+
html = (
|
|
160
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
161
|
+
'<style>'
|
|
162
|
+
'body{font-family:Arial,sans-serif;font-size:14px;color:#222}'
|
|
163
|
+
'h2{color:#c62828}'
|
|
164
|
+
'h3{color:#444;border-bottom:1px solid #ddd;padding-bottom:4px}'
|
|
165
|
+
'table{border-collapse:collapse;width:100%;margin-bottom:16px}'
|
|
166
|
+
'th{background:#f1f3f4;text-align:left;padding:6px 10px}'
|
|
167
|
+
'td{padding:5px 10px;border-bottom:1px solid #eee;vertical-align:top}'
|
|
168
|
+
'.label{font-weight:bold;width:160px}'
|
|
169
|
+
'.mono{font-family:monospace;font-size:12px}'
|
|
170
|
+
'pre{background:#f8f8f8;border:1px solid #ddd;padding:10px;font-size:12px;white-space:pre-wrap}'
|
|
171
|
+
'</style></head><body>'
|
|
172
|
+
'<h2>⚠ Sentinel could not fix this issue</h2>'
|
|
173
|
+
f'<p><strong>{repo_name}</strong> · {ts}</p>'
|
|
174
|
+
'<h3>Details</h3>'
|
|
175
|
+
'<table>'
|
|
176
|
+
f'<tr><td class="label">Source</td><td class="mono">{source}</td></tr>'
|
|
177
|
+
f'<tr><td class="label">Repository</td><td class="mono">{repo_name}</td></tr>'
|
|
178
|
+
f'<tr><td class="label">Message</td><td class="mono">{message}</td></tr>'
|
|
179
|
+
f'<tr><td class="label">Reason</td><td>{reason}</td></tr>'
|
|
180
|
+
'</table>'
|
|
181
|
+
+ ctx_html +
|
|
182
|
+
'<hr><small>Sentinel — Autonomous DevOps Agent</small>'
|
|
183
|
+
'</body></html>'
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
_send_email(cfg, subject, html)
|
|
187
|
+
logger.info('Failure notification sent for %s', source)
|
|
188
|
+
|