@misterhuydo/sentinel 1.4.36 → 1.4.38
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/.cairn/.hint-lock +1 -1
- package/.cairn/session.json +2 -2
- package/package.json +1 -1
- package/python/sentinel/fix_engine.py +19 -0
- package/python/sentinel/main.py +59 -49
- package/python/sentinel/notify.py +33 -0
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-25T07:20:08.738Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-25T07:
|
|
3
|
-
"checkpoint_at": "2026-03-25T07:
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-25T07:35:25.214Z",
|
|
3
|
+
"checkpoint_at": "2026-03-25T07:35:25.219Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/package.json
CHANGED
|
@@ -29,6 +29,25 @@ _DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
|
|
|
29
29
|
_DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _extract_patch(output: str) -> str | None:
|
|
33
|
+
"""Extract a unified diff patch from Claude's output."""
|
|
34
|
+
# 1. Prefer a fenced ```diff or ```patch block
|
|
35
|
+
m = _DIFF_BLOCK.search(output)
|
|
36
|
+
if m:
|
|
37
|
+
patch = m.group(1).strip()
|
|
38
|
+
if _DIFF_HEADER.search(patch):
|
|
39
|
+
return patch
|
|
40
|
+
|
|
41
|
+
# 2. Fall back to raw diff lines anywhere in the output
|
|
42
|
+
if _DIFF_HEADER.search(output):
|
|
43
|
+
lines = output.splitlines()
|
|
44
|
+
for i, line in enumerate(lines):
|
|
45
|
+
if _DIFF_HEADER.match(line):
|
|
46
|
+
return "\n".join(lines[i:])
|
|
47
|
+
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
32
51
|
def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers: list[str] = None, synced_files: list = None) -> str:
|
|
33
52
|
if log_file and log_file.exists():
|
|
34
53
|
ctx = (
|
package/python/sentinel/main.py
CHANGED
|
@@ -29,7 +29,7 @@ from .log_parser import parse_all, scan_all_for_markers, ErrorEvent
|
|
|
29
29
|
from .issue_watcher import scan_issues, mark_done, IssueEvent
|
|
30
30
|
from .repo_router import route
|
|
31
31
|
from .reporter import build_and_send, send_fix_notification, send_failure_notification, send_confirmed_notification, send_regression_notification, send_startup_notification, send_upgrade_notification
|
|
32
|
-
from .notify import notify_fix_blocked
|
|
32
|
+
from .notify import notify_fix_blocked, notify_fix_applied
|
|
33
33
|
from .health_checker import evaluate_repos
|
|
34
34
|
from .state_store import StateStore
|
|
35
35
|
|
|
@@ -183,61 +183,71 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
183
183
|
)
|
|
184
184
|
return # Leave the file so admin can add the header
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
try:
|
|
187
|
+
patches_dir = Path(sentinel.workspace_dir) / "patches"
|
|
188
|
+
status, patch_path, marker = generate_fix(event, repo, sentinel, patches_dir, store)
|
|
189
|
+
|
|
190
|
+
if status != "patch" or patch_path is None:
|
|
191
|
+
store.record_fix(event.fingerprint, "skipped" if status in ("skip", "needs_human") else "failed",
|
|
192
|
+
repo_name=repo.repo_name)
|
|
193
|
+
# For user-submitted issues: always notify (person is waiting)
|
|
194
|
+
submitter_uid = getattr(event, "submitter_user_id", "")
|
|
195
|
+
reason_text = marker if status == "needs_human" else f"Claude Code returned {status.upper()}"
|
|
196
|
+
notify_fix_blocked(sentinel, event.source, event.message,
|
|
197
|
+
reason=reason_text, repo_name=repo.repo_name,
|
|
198
|
+
submitter_user_id=submitter_uid)
|
|
199
|
+
mark_done(event.issue_file)
|
|
200
|
+
return
|
|
188
201
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return
|
|
202
|
+
commit_status, commit_hash = apply_and_commit(event, patch_path, repo, sentinel)
|
|
203
|
+
if commit_status != "committed":
|
|
204
|
+
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
205
|
+
submitter_uid = getattr(event, "submitter_user_id", "")
|
|
206
|
+
notify_fix_blocked(sentinel, event.source, event.message,
|
|
207
|
+
reason="Patch was generated but commit/tests failed",
|
|
208
|
+
repo_name=repo.repo_name,
|
|
209
|
+
submitter_user_id=submitter_uid)
|
|
210
|
+
mark_done(event.issue_file)
|
|
211
|
+
return
|
|
200
212
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
213
|
+
branch, pr_url = publish(event, repo, sentinel, commit_hash)
|
|
214
|
+
store.record_fix(
|
|
215
|
+
event.fingerprint,
|
|
216
|
+
"applied" if repo.auto_publish else "pending",
|
|
217
|
+
patch_path=str(patch_path),
|
|
218
|
+
commit_hash=commit_hash,
|
|
219
|
+
branch=branch,
|
|
220
|
+
pr_url=pr_url,
|
|
221
|
+
repo_name=repo.repo_name,
|
|
222
|
+
sentinel_marker=marker,
|
|
223
|
+
)
|
|
224
|
+
send_fix_notification(sentinel, {
|
|
225
|
+
"source": event.source,
|
|
226
|
+
"severity": "ERROR",
|
|
227
|
+
"fingerprint": event.fingerprint,
|
|
228
|
+
"first_seen": event.timestamp,
|
|
229
|
+
"message": event.message,
|
|
230
|
+
"stack_trace": event.body,
|
|
231
|
+
"repo_name": repo.repo_name,
|
|
232
|
+
"commit_hash": commit_hash,
|
|
233
|
+
"branch": branch,
|
|
234
|
+
"pr_url": pr_url,
|
|
235
|
+
"auto_publish": repo.auto_publish,
|
|
236
|
+
"files_changed": [],
|
|
237
|
+
})
|
|
204
238
|
submitter_uid = getattr(event, "submitter_user_id", "")
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
repo_name=repo.repo_name,
|
|
239
|
+
notify_fix_applied(sentinel, event.source, event.message,
|
|
240
|
+
repo_name=repo.repo_name, branch=branch, pr_url=pr_url,
|
|
208
241
|
submitter_user_id=submitter_uid)
|
|
209
242
|
mark_done(event.issue_file)
|
|
210
|
-
return
|
|
211
243
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
event.fingerprint,
|
|
215
|
-
"applied" if repo.auto_publish else "pending",
|
|
216
|
-
patch_path=str(patch_path),
|
|
217
|
-
commit_hash=commit_hash,
|
|
218
|
-
branch=branch,
|
|
219
|
-
pr_url=pr_url,
|
|
220
|
-
repo_name=repo.repo_name,
|
|
221
|
-
sentinel_marker=marker,
|
|
222
|
-
)
|
|
223
|
-
send_fix_notification(sentinel, {
|
|
224
|
-
"source": event.source,
|
|
225
|
-
"severity": "ERROR",
|
|
226
|
-
"fingerprint": event.fingerprint,
|
|
227
|
-
"first_seen": event.timestamp,
|
|
228
|
-
"message": event.message,
|
|
229
|
-
"stack_trace": event.body,
|
|
230
|
-
"repo_name": repo.repo_name,
|
|
231
|
-
"commit_hash": commit_hash,
|
|
232
|
-
"branch": branch,
|
|
233
|
-
"pr_url": pr_url,
|
|
234
|
-
"auto_publish": repo.auto_publish,
|
|
235
|
-
"files_changed": [],
|
|
236
|
-
})
|
|
237
|
-
mark_done(event.issue_file)
|
|
244
|
+
if repo.auto_publish:
|
|
245
|
+
cicd_trigger(repo, store, event.fingerprint)
|
|
238
246
|
|
|
239
|
-
|
|
240
|
-
|
|
247
|
+
except Exception:
|
|
248
|
+
logger.exception("Unexpected error processing issue %s — archiving to prevent retry loop", event.source)
|
|
249
|
+
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
250
|
+
mark_done(event.issue_file)
|
|
241
251
|
|
|
242
252
|
|
|
243
253
|
async def poll_cycle(cfg_loader: ConfigLoader, store: StateStore):
|
|
@@ -228,6 +228,39 @@ def notify_fix_blocked(
|
|
|
228
228
|
logger.warning("notify_fix_blocked: email notification failed: %s", exc)
|
|
229
229
|
|
|
230
230
|
|
|
231
|
+
def notify_fix_applied(
|
|
232
|
+
cfg,
|
|
233
|
+
source: str,
|
|
234
|
+
message: str,
|
|
235
|
+
repo_name: str,
|
|
236
|
+
branch: str,
|
|
237
|
+
pr_url: str,
|
|
238
|
+
submitter_user_id: str = "",
|
|
239
|
+
) -> None:
|
|
240
|
+
"""
|
|
241
|
+
DM the submitter (if known) that their issue was fixed.
|
|
242
|
+
Falls back to posting in the Slack channel if no submitter.
|
|
243
|
+
"""
|
|
244
|
+
repo_line = f" in *{repo_name}*" if repo_name else ""
|
|
245
|
+
if pr_url:
|
|
246
|
+
action_line = f":arrow_right: <{pr_url}|Review PR>"
|
|
247
|
+
elif branch:
|
|
248
|
+
action_line = f":arrow_right: Pushed to `{branch}`"
|
|
249
|
+
else:
|
|
250
|
+
action_line = ""
|
|
251
|
+
|
|
252
|
+
slack_text = (
|
|
253
|
+
f":white_check_mark: *Fix applied{repo_line}*\n"
|
|
254
|
+
f"*Issue:* {message[:200]}\n"
|
|
255
|
+
+ (f"{action_line}\n" if action_line else "")
|
|
256
|
+
).rstrip()
|
|
257
|
+
|
|
258
|
+
if submitter_user_id:
|
|
259
|
+
slack_dm(cfg.slack_bot_token, submitter_user_id, slack_text)
|
|
260
|
+
else:
|
|
261
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel, slack_text)
|
|
262
|
+
|
|
263
|
+
|
|
231
264
|
def alert_if_rate_limited(
|
|
232
265
|
bot_token: str,
|
|
233
266
|
channel: str,
|