@misterhuydo/sentinel 1.6.12 → 1.6.14
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/__init__.py +1 -1
- package/python/sentinel/__pycache__/config_loader.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/fix_engine.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/git_manager.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/slack_bot.cpython-311.pyc +0 -0
- package/python/sentinel/config_loader.py +2 -0
- package/python/sentinel/git_manager.py +6 -6
- package/python/sentinel/main.py +5 -1
- package/python/sentinel/sentinel_boss.py +21 -0
- package/python/sentinel/slack_bot.py +41 -0
- package/python/tests/test_bot_noise_filter.py +51 -0
- package/python/tests/test_config_loader.py +15 -0
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-04-
|
|
1
|
+
2026-04-28T09:31:55.053Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-04-
|
|
3
|
-
"checkpoint_at": "2026-04-
|
|
2
|
+
"message": "Auto-checkpoint at 2026-04-28T09:29:24.499Z",
|
|
3
|
+
"checkpoint_at": "2026-04-28T09:29:24.501Z",
|
|
4
4
|
"active_files": [
|
|
5
5
|
"J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js",
|
|
6
6
|
"J:\\Projects\\Sentinel\\cli\\lib\\test.js"
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.6.
|
|
1
|
+
__version__ = "1.6.14"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -85,6 +85,7 @@ class SentinelConfig:
|
|
|
85
85
|
auto_commit: bool = False # project-level default: push directly to main (no PR); repos can override
|
|
86
86
|
auto_release: bool = False # project-level default: trigger CI/CD pipeline after push; repos can override
|
|
87
87
|
auto_raise_issues: bool = False # if True, auto-fix detected log errors without waiting for human to raise an issue
|
|
88
|
+
bot_watcher_noise_pattern: str = "" # regex; matching bot-alert text is dropped before queueing as an issue
|
|
88
89
|
|
|
89
90
|
|
|
90
91
|
@dataclass
|
|
@@ -284,6 +285,7 @@ class ConfigLoader:
|
|
|
284
285
|
c.auto_release = d["AUTO_RELEASE"].lower() == "true"
|
|
285
286
|
if "AUTO_RAISE_ISSUES" in d:
|
|
286
287
|
c.auto_raise_issues = d["AUTO_RAISE_ISSUES"].lower() == "true"
|
|
288
|
+
c.bot_watcher_noise_pattern = d.get("BOT_WATCHER_NOISE_PATTERN", "").strip()
|
|
287
289
|
self.sentinel = c
|
|
288
290
|
|
|
289
291
|
def _load_log_sources(self):
|
|
@@ -477,7 +477,7 @@ def apply_and_commit(
|
|
|
477
477
|
logger.error("git pull failed for %s:\n%s", repo.repo_name, r.stderr)
|
|
478
478
|
return "failed", ""
|
|
479
479
|
|
|
480
|
-
r = _git(["apply", "--check", "--recount", "--ignore-whitespace", str(patch_path)], cwd=local_path, env=env)
|
|
480
|
+
r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
|
|
481
481
|
if r.returncode != 0:
|
|
482
482
|
try:
|
|
483
483
|
original = patch_path.read_text(encoding="utf-8", errors="replace")
|
|
@@ -487,12 +487,12 @@ def apply_and_commit(
|
|
|
487
487
|
repaired = None
|
|
488
488
|
if repaired and repaired != original:
|
|
489
489
|
patch_path.write_text(repaired, encoding="utf-8")
|
|
490
|
-
r = _git(["apply", "--check", "--recount", "--ignore-whitespace", str(patch_path)], cwd=local_path, env=env)
|
|
490
|
+
r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
|
|
491
491
|
if r.returncode != 0:
|
|
492
492
|
logger.error("Patch dry-run failed for %s:\n%s", event.fingerprint, r.stderr)
|
|
493
493
|
return "failed", ""
|
|
494
494
|
|
|
495
|
-
r = _git(["apply", "--recount", "--ignore-whitespace", str(patch_path)], cwd=local_path, env=env)
|
|
495
|
+
r = _git(["apply", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
|
|
496
496
|
if r.returncode != 0:
|
|
497
497
|
logger.error("git apply failed for %s:\n%s", event.fingerprint, r.stderr)
|
|
498
498
|
return "failed", ""
|
|
@@ -601,7 +601,7 @@ def apply_and_commit_multi(
|
|
|
601
601
|
dry_run_failures.append(f"{name}: git pull failed: {r.stderr.strip()[:200]}")
|
|
602
602
|
continue
|
|
603
603
|
# Dry-run
|
|
604
|
-
r = _git(["apply", "--check", "--recount", "--ignore-whitespace", str(sub_path)],
|
|
604
|
+
r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
|
|
605
605
|
cwd=repo.local_path, env=env)
|
|
606
606
|
if r.returncode != 0:
|
|
607
607
|
# Try repairing the patch by splicing in any intermediate file lines
|
|
@@ -616,7 +616,7 @@ def apply_and_commit_multi(
|
|
|
616
616
|
if repaired and repaired != original:
|
|
617
617
|
sub_path.write_text(repaired, encoding="utf-8")
|
|
618
618
|
r = _git(
|
|
619
|
-
["apply", "--check", "--recount", "--ignore-whitespace", str(sub_path)],
|
|
619
|
+
["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
|
|
620
620
|
cwd=repo.local_path, env=env,
|
|
621
621
|
)
|
|
622
622
|
if r.returncode == 0:
|
|
@@ -653,7 +653,7 @@ def apply_and_commit_multi(
|
|
|
653
653
|
"reason": "", "sub_patch_path": sub_path,
|
|
654
654
|
}
|
|
655
655
|
|
|
656
|
-
r = _git(["apply", "--recount", "--ignore-whitespace", str(sub_path)],
|
|
656
|
+
r = _git(["apply", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
|
|
657
657
|
cwd=repo.local_path, env=env)
|
|
658
658
|
if r.returncode != 0:
|
|
659
659
|
entry["reason"] = f"apply failed: {r.stderr.strip()[:200]}"
|
package/python/sentinel/main.py
CHANGED
|
@@ -563,7 +563,11 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
563
563
|
return
|
|
564
564
|
|
|
565
565
|
if store.fix_attempted_recently(event.fingerprint, hours=24):
|
|
566
|
-
logger.
|
|
566
|
+
logger.info(
|
|
567
|
+
"Issue %s skipped — fingerprint %s attempted in last 24h "
|
|
568
|
+
"(use Boss `retry_issue` to clear the prior row and re-attempt)",
|
|
569
|
+
event.source, event.fingerprint,
|
|
570
|
+
)
|
|
567
571
|
mark_done(event.issue_file)
|
|
568
572
|
return
|
|
569
573
|
|
|
@@ -2632,6 +2632,27 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2632
2632
|
except Exception as _e:
|
|
2633
2633
|
logger.debug("retry_issue: state_store guard failed (non-fatal): %s", _e)
|
|
2634
2634
|
|
|
2635
|
+
# Clear the 24h dedupe so _handle_issue actually re-runs the fix.
|
|
2636
|
+
# fix_attempted_recently() ignores status='skipped', so flipping any
|
|
2637
|
+
# recent 'failed' row for this fingerprint is enough.
|
|
2638
|
+
try:
|
|
2639
|
+
with store._conn() as _c:
|
|
2640
|
+
_n = _c.execute(
|
|
2641
|
+
"UPDATE fixes SET status='skipped' "
|
|
2642
|
+
"WHERE fingerprint=? AND status='failed' "
|
|
2643
|
+
"AND timestamp >= datetime('now', '-24 hours')",
|
|
2644
|
+
(_fp,),
|
|
2645
|
+
).rowcount
|
|
2646
|
+
_c.commit()
|
|
2647
|
+
if _n:
|
|
2648
|
+
logger.info(
|
|
2649
|
+
"Boss retry_issue: cleared %d prior failed row(s) for fingerprint %s "
|
|
2650
|
+
"so the retry won't be deduped",
|
|
2651
|
+
_n, _fp,
|
|
2652
|
+
)
|
|
2653
|
+
except Exception as _e:
|
|
2654
|
+
logger.debug("retry_issue: clearing prior failed rows failed (non-fatal): %s", _e)
|
|
2655
|
+
|
|
2635
2656
|
# Re-submit as a fresh issue file
|
|
2636
2657
|
issues_dir = project_dir / "issues"
|
|
2637
2658
|
issues_dir.mkdir(exist_ok=True)
|
|
@@ -55,6 +55,30 @@ class _Session:
|
|
|
55
55
|
_sessions: dict[str, _Session] = {}
|
|
56
56
|
_sessions_lock = asyncio.Lock()
|
|
57
57
|
|
|
58
|
+
# Cached compiled regex for bot-watcher noise filter — recompiled when the
|
|
59
|
+
# config value changes (so SIGHUP reload picks up edits without a restart).
|
|
60
|
+
_noise_filter_cache: tuple[str, "object | None"] = ("", None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_noise_filter(pattern: str):
|
|
64
|
+
"""Return a compiled re.Pattern for the noise filter, or None if disabled/invalid."""
|
|
65
|
+
global _noise_filter_cache
|
|
66
|
+
cached_pattern, cached_re = _noise_filter_cache
|
|
67
|
+
if pattern == cached_pattern:
|
|
68
|
+
return cached_re
|
|
69
|
+
if not pattern:
|
|
70
|
+
_noise_filter_cache = ("", None)
|
|
71
|
+
return None
|
|
72
|
+
import re as _re
|
|
73
|
+
try:
|
|
74
|
+
compiled = _re.compile(pattern, _re.IGNORECASE)
|
|
75
|
+
except _re.error as e:
|
|
76
|
+
logger.warning("BOT_WATCHER_NOISE_PATTERN is not a valid regex (%s) — filter disabled", e)
|
|
77
|
+
_noise_filter_cache = (pattern, None)
|
|
78
|
+
return None
|
|
79
|
+
_noise_filter_cache = (pattern, compiled)
|
|
80
|
+
return compiled
|
|
81
|
+
|
|
58
82
|
|
|
59
83
|
async def _get_or_create_session(user_id: str, user_name: str, channel: str) -> _Session:
|
|
60
84
|
async with _sessions_lock:
|
|
@@ -272,6 +296,23 @@ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
|
|
|
272
296
|
if not text:
|
|
273
297
|
return
|
|
274
298
|
|
|
299
|
+
# Drop operational-noise alerts (e.g. wrong-PIN attempts, single transient
|
|
300
|
+
# SMS failures) before they enter the issues queue. Configurable via
|
|
301
|
+
# BOT_WATCHER_NOISE_PATTERN in sentinel.properties (regex, alternatives
|
|
302
|
+
# joined with `|`). Empty = disabled.
|
|
303
|
+
noise_re = _get_noise_filter(getattr(cfg_loader.sentinel, "bot_watcher_noise_pattern", ""))
|
|
304
|
+
if noise_re is not None and noise_re.search(text):
|
|
305
|
+
logger.info(
|
|
306
|
+
"Bot watcher: dropping noise-pattern match from %s (preview: %r)",
|
|
307
|
+
bot_id, text[:120].replace("\n", " "),
|
|
308
|
+
)
|
|
309
|
+
if ts:
|
|
310
|
+
try:
|
|
311
|
+
await client.reactions_add(channel=channel, timestamp=ts, name="mute")
|
|
312
|
+
except Exception:
|
|
313
|
+
pass # reactions:write scope may not be granted — non-fatal
|
|
314
|
+
return
|
|
315
|
+
|
|
275
316
|
# Find the project this bot is registered to
|
|
276
317
|
# Match on either the B-prefixed bot_id or the U-prefixed user ID
|
|
277
318
|
_user_id = event.get("user", "")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Tests for the bot-watcher noise-pattern filter (slack_bot._get_noise_filter)."""
|
|
2
|
+
|
|
3
|
+
from sentinel import slack_bot
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _reset_cache():
|
|
7
|
+
slack_bot._noise_filter_cache = ("", None)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_empty_pattern_disables_filter():
|
|
11
|
+
_reset_cache()
|
|
12
|
+
assert slack_bot._get_noise_filter("") is None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_invalid_regex_disables_filter(caplog):
|
|
16
|
+
_reset_cache()
|
|
17
|
+
import logging
|
|
18
|
+
with caplog.at_level(logging.WARNING):
|
|
19
|
+
assert slack_bot._get_noise_filter("(unbalanced") is None
|
|
20
|
+
assert any("not a valid regex" in r.message for r in caplog.records)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_pattern_compiles_and_caches():
|
|
24
|
+
_reset_cache()
|
|
25
|
+
p1 = slack_bot._get_noise_filter("Illegal pin logon|SMS delivery FAILED")
|
|
26
|
+
assert p1 is not None
|
|
27
|
+
p2 = slack_bot._get_noise_filter("Illegal pin logon|SMS delivery FAILED")
|
|
28
|
+
assert p1 is p2 # cache hit returns the same compiled object
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_pattern_change_recompiles():
|
|
32
|
+
_reset_cache()
|
|
33
|
+
p1 = slack_bot._get_noise_filter("foo")
|
|
34
|
+
p2 = slack_bot._get_noise_filter("bar")
|
|
35
|
+
assert p1 is not p2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_match_is_case_insensitive():
|
|
39
|
+
_reset_cache()
|
|
40
|
+
pat = slack_bot._get_noise_filter("Illegal pin logon")
|
|
41
|
+
assert pat.search("ILLEGAL PIN LOGON ATTEMPTED")
|
|
42
|
+
assert pat.search("user typed wrong PIN — Illegal Pin Logon attempted")
|
|
43
|
+
assert not pat.search("normal startup message")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_alternatives_match_either():
|
|
47
|
+
_reset_cache()
|
|
48
|
+
pat = slack_bot._get_noise_filter("Illegal pin logon|1 SMS delivery FAILED")
|
|
49
|
+
assert pat.search("Illegal pin logon attempted.")
|
|
50
|
+
assert pat.search(":no_entry: 1 SMS delivery FAILED in the last 5 minutes")
|
|
51
|
+
assert not pat.search("MsGraphMailSender threw exception")
|
|
@@ -74,6 +74,21 @@ def test_sentinel_config(config_dir):
|
|
|
74
74
|
assert cfg.github_token == "ghp_test"
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def test_bot_watcher_noise_pattern_default_empty(config_dir):
|
|
78
|
+
cfg = ConfigLoader(str(config_dir)).sentinel
|
|
79
|
+
assert cfg.bot_watcher_noise_pattern == ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_bot_watcher_noise_pattern_loaded(tmp_path):
|
|
83
|
+
(tmp_path / "log-configs").mkdir()
|
|
84
|
+
(tmp_path / "repo-configs").mkdir()
|
|
85
|
+
(tmp_path / "sentinel.properties").write_text(
|
|
86
|
+
"BOT_WATCHER_NOISE_PATTERN=Illegal pin logon|1 SMS delivery FAILED\n"
|
|
87
|
+
)
|
|
88
|
+
cfg = ConfigLoader(str(tmp_path)).sentinel
|
|
89
|
+
assert cfg.bot_watcher_noise_pattern == "Illegal pin logon|1 SMS delivery FAILED"
|
|
90
|
+
|
|
91
|
+
|
|
77
92
|
def test_log_sources(config_dir):
|
|
78
93
|
loader = ConfigLoader(str(config_dir))
|
|
79
94
|
assert "elprint-salescore" in loader.log_sources
|