@misterhuydo/sentinel 1.5.4 → 1.5.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/.cairn/.hint-lock +1 -1
- package/.cairn/minify-map.json +13 -1
- package/.cairn/session.json +2 -2
- package/.cairn/views/23edf4_sentinel_boss.py +3664 -0
- package/.cairn/views/5f5141_main.py +1067 -0
- package/.cairn/views/62a614_bundle.js +4 -1
- package/.cairn/views/7802b9_cicd_trigger.py +171 -0
- package/.cairn/views/ac3df4_repo_task_engine.py +351 -0
- package/lib/.cairn/minify-map.json +6 -0
- package/lib/.cairn/views/2a85cc_init.js +380 -0
- package/lib/.cairn/views/e26996_slack-setup.js +97 -0
- package/lib/.cairn/views/fb78ac_upgrade.js +36 -1
- package/lib/.cairn/views/fc4a1a_add.js +164 -51
- package/lib/init.js +54 -0
- package/lib/maven.js +212 -0
- package/lib/slack-setup.js +5 -0
- package/package.json +1 -1
- package/python/requirements.txt +1 -0
- package/python/sentinel/.cairn/.cairn-project +0 -0
- package/python/sentinel/.cairn/.hint-lock +1 -0
- package/python/sentinel/.cairn/session.json +9 -0
- package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
- package/python/sentinel/config_loader.py +29 -10
- package/python/sentinel/dependency_manager.py +9 -2
- package/python/sentinel/git_manager.py +23 -0
- package/python/sentinel/issue_watcher.py +7 -1
- package/python/sentinel/main.py +353 -8
- package/python/sentinel/notify.py +44 -12
- package/python/sentinel/repo_task_engine.py +49 -7
- package/python/sentinel/sentinel_boss.py +117 -3
- package/python/sentinel/slack_bot.py +15 -2
- package/python/sentinel/state_store.py +0 -1
- package/python/tests/__init__.py +0 -0
- package/python/tests/test_config_loader.py +138 -0
- package/python/tests/test_log_parser.py +62 -0
- package/python/tests/test_repo_router.py +73 -0
- package/python/tests/test_smoke.py +96 -0
- package/python/tests/test_state_store.py +128 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_repo_router.py — Unit tests for repo routing logic.
|
|
3
|
+
"""
|
|
4
|
+
import pytest
|
|
5
|
+
from sentinel.repo_router import route
|
|
6
|
+
from sentinel.log_parser import ErrorEvent
|
|
7
|
+
from sentinel.config_loader import RepoConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _make_event(source: str, fingerprint: str = "fp1") -> ErrorEvent:
|
|
11
|
+
return ErrorEvent(
|
|
12
|
+
source=source,
|
|
13
|
+
fingerprint=fingerprint,
|
|
14
|
+
level="ERROR",
|
|
15
|
+
timestamp="2026-01-01T00:00:00",
|
|
16
|
+
log_file="",
|
|
17
|
+
thread="",
|
|
18
|
+
logger_name="com.example.Test",
|
|
19
|
+
message="some error",
|
|
20
|
+
stack_trace=[],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_repo(name: str) -> RepoConfig:
|
|
25
|
+
r = RepoConfig()
|
|
26
|
+
r.repo_name = name
|
|
27
|
+
r.repo_url = f"git@github.com:org/{name}.git"
|
|
28
|
+
r.local_path = f"/repos/{name}"
|
|
29
|
+
return r
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── routing by source name ────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def test_routes_by_source_name():
|
|
35
|
+
repos = {"my-service": _make_repo("my-service")}
|
|
36
|
+
event = _make_event("my-service")
|
|
37
|
+
result = route(event, repos)
|
|
38
|
+
assert result is not None
|
|
39
|
+
assert result.repo_name == "my-service"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_returns_none_for_unknown_source(caplog):
|
|
43
|
+
repos = {"other-service": _make_repo("other-service")}
|
|
44
|
+
event = _make_event("unknown-source")
|
|
45
|
+
result = route(event, repos)
|
|
46
|
+
assert result is None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_returns_none_for_empty_repos():
|
|
50
|
+
event = _make_event("any-source")
|
|
51
|
+
result = route(event, {})
|
|
52
|
+
assert result is None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_exact_match_only():
|
|
56
|
+
repos = {
|
|
57
|
+
"service-a": _make_repo("service-a"),
|
|
58
|
+
"service-b": _make_repo("service-b"),
|
|
59
|
+
}
|
|
60
|
+
event = _make_event("service-a")
|
|
61
|
+
result = route(event, repos)
|
|
62
|
+
assert result.repo_name == "service-a"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_multiple_repos_correct_routing():
|
|
66
|
+
repos = {
|
|
67
|
+
"auth-service": _make_repo("auth-service"),
|
|
68
|
+
"order-service": _make_repo("order-service"),
|
|
69
|
+
}
|
|
70
|
+
for source, expected in [("auth-service", "auth-service"), ("order-service", "order-service")]:
|
|
71
|
+
result = route(_make_event(source), repos)
|
|
72
|
+
assert result is not None
|
|
73
|
+
assert result.repo_name == expected
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_smoke.py — Import-level smoke tests.
|
|
3
|
+
If any of these fail, a Patch fix broke a module import or removed a critical symbol.
|
|
4
|
+
"""
|
|
5
|
+
import textwrap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_import_config_loader():
|
|
9
|
+
from sentinel.config_loader import ConfigLoader, SentinelConfig, RepoConfig, LogSourceConfig
|
|
10
|
+
assert callable(ConfigLoader)
|
|
11
|
+
assert hasattr(SentinelConfig, "__dataclass_fields__")
|
|
12
|
+
assert hasattr(RepoConfig, "ssh_key_file") # property must exist
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_import_log_parser():
|
|
16
|
+
from sentinel.log_parser import parse_log_file, ErrorEvent, _fingerprint
|
|
17
|
+
assert callable(parse_log_file)
|
|
18
|
+
assert callable(_fingerprint)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_import_repo_router():
|
|
22
|
+
from sentinel.repo_router import route
|
|
23
|
+
assert callable(route)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_import_state_store():
|
|
27
|
+
from sentinel.state_store import StateStore
|
|
28
|
+
assert callable(StateStore)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_import_notify():
|
|
32
|
+
from sentinel.notify import slack_alert, notify_fix_applied
|
|
33
|
+
assert callable(slack_alert)
|
|
34
|
+
assert callable(notify_fix_applied)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_import_git_manager():
|
|
38
|
+
from sentinel.git_manager import _git_env
|
|
39
|
+
assert callable(_git_env)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_ssh_key_priority(tmp_path):
|
|
43
|
+
"""Deploy key takes priority over user key on RepoConfig.ssh_key_file."""
|
|
44
|
+
from sentinel.config_loader import ConfigLoader
|
|
45
|
+
|
|
46
|
+
(tmp_path / "log-configs").mkdir()
|
|
47
|
+
(tmp_path / "repo-configs").mkdir()
|
|
48
|
+
(tmp_path / "sentinel.properties").write_text(
|
|
49
|
+
"GIT_SSH_USER_KEY=~/.ssh/user.key\n"
|
|
50
|
+
)
|
|
51
|
+
(tmp_path / "repo-configs" / "my-repo.properties").write_text(textwrap.dedent("""\
|
|
52
|
+
REPO_URL=git@github.com:org/my-repo.git
|
|
53
|
+
GIT_SSH_DEPLOY_KEY=~/.ssh/deploy.key
|
|
54
|
+
"""))
|
|
55
|
+
|
|
56
|
+
loader = ConfigLoader(str(tmp_path))
|
|
57
|
+
repo = loader.repos["my-repo"]
|
|
58
|
+
assert "deploy.key" in repo.ssh_key_file # deploy key wins
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_ssh_user_key_fallback(tmp_path):
|
|
62
|
+
"""User key is used when no deploy key is set."""
|
|
63
|
+
from sentinel.config_loader import ConfigLoader
|
|
64
|
+
|
|
65
|
+
(tmp_path / "log-configs").mkdir()
|
|
66
|
+
(tmp_path / "repo-configs").mkdir()
|
|
67
|
+
(tmp_path / "sentinel.properties").write_text(
|
|
68
|
+
"GIT_SSH_USER_KEY=~/.ssh/user.key\n"
|
|
69
|
+
)
|
|
70
|
+
(tmp_path / "repo-configs" / "my-repo.properties").write_text(
|
|
71
|
+
"REPO_URL=git@github.com:org/my-repo.git\n"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
loader = ConfigLoader(str(tmp_path))
|
|
75
|
+
repo = loader.repos["my-repo"]
|
|
76
|
+
assert "user.key" in repo.ssh_key_file # falls back to user key
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_severity_critical_on_oom():
|
|
80
|
+
from sentinel.log_parser import ErrorEvent
|
|
81
|
+
e = ErrorEvent(
|
|
82
|
+
source="svc", log_file="", timestamp="2026-01-01T00:00:00",
|
|
83
|
+
level="ERROR", thread="main", logger_name="com.example.App",
|
|
84
|
+
message="java.lang.OutOfMemoryError: Java heap space",
|
|
85
|
+
)
|
|
86
|
+
assert e.severity == "CRITICAL"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_severity_infra_issue():
|
|
90
|
+
from sentinel.log_parser import ErrorEvent
|
|
91
|
+
e = ErrorEvent(
|
|
92
|
+
source="svc", log_file="", timestamp="2026-01-01T00:00:00",
|
|
93
|
+
level="ERROR", thread="main", logger_name="com.example.App",
|
|
94
|
+
message="ConnectException: Connection refused",
|
|
95
|
+
)
|
|
96
|
+
assert e.is_infra_issue is True
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_state_store.py — Unit tests for StateStore (in-memory SQLite).
|
|
3
|
+
"""
|
|
4
|
+
import pytest
|
|
5
|
+
from sentinel.state_store import StateStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def store(tmp_path):
|
|
10
|
+
return StateStore(str(tmp_path / "test.db"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── errors ────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
def test_record_and_get_error(store):
|
|
16
|
+
store.record_error("fp1", "source-A", "NullPointerException in Foo.bar")
|
|
17
|
+
errors = store.get_recent_errors(24)
|
|
18
|
+
assert len(errors) == 1
|
|
19
|
+
assert errors[0]["fingerprint"] == "fp1"
|
|
20
|
+
assert errors[0]["source"] == "source-A"
|
|
21
|
+
assert errors[0]["count"] == 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_error_count_increments(store):
|
|
25
|
+
store.record_error("fp1", "source-A", "some error")
|
|
26
|
+
store.record_error("fp1", "source-A", "some error")
|
|
27
|
+
errors = store.get_recent_errors(24)
|
|
28
|
+
assert errors[0]["count"] == 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_is_seen(store):
|
|
32
|
+
assert not store.seen("fp1")
|
|
33
|
+
store.record_error("fp1", "source-A", "msg")
|
|
34
|
+
assert store.seen("fp1")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── fixes ─────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def test_record_fix(store):
|
|
40
|
+
store.record_error("fp1", "src", "msg")
|
|
41
|
+
store.record_fix("fp1", "applied", patch_path="/tmp/x.diff",
|
|
42
|
+
commit_hash="abc123", branch="sentinel/fix-fp1", repo_name="my-repo")
|
|
43
|
+
fixes = store.get_recent_fixes(24)
|
|
44
|
+
assert len(fixes) == 1
|
|
45
|
+
assert fixes[0]["status"] == "applied"
|
|
46
|
+
assert fixes[0]["repo_name"] == "my-repo"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_get_open_prs(store):
|
|
50
|
+
store.record_error("fp1", "src", "msg")
|
|
51
|
+
store.record_fix("fp1", "pending", pr_url="https://github.com/org/repo/pull/1",
|
|
52
|
+
branch="sentinel/fix-fp1", repo_name="repo")
|
|
53
|
+
prs = store.get_open_prs()
|
|
54
|
+
assert len(prs) == 1
|
|
55
|
+
assert prs[0]["pr_url"] == "https://github.com/org/repo/pull/1"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── slack_users ───────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def test_upsert_and_get_user(store):
|
|
61
|
+
store.upsert_user("U123", "huy")
|
|
62
|
+
assert store.get_user_name("U123") == "huy"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_get_user_unknown_returns_id(store):
|
|
66
|
+
assert store.get_user_name("UNKNOWN") == "UNKNOWN"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_get_all_users(store):
|
|
70
|
+
store.upsert_user("U1", "alice")
|
|
71
|
+
store.upsert_user("U2", "bob")
|
|
72
|
+
users = store.get_all_users()
|
|
73
|
+
assert users == {"U1": "alice", "U2": "bob"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_upsert_user_updates_name(store):
|
|
77
|
+
store.upsert_user("U1", "old-name")
|
|
78
|
+
store.upsert_user("U1", "new-name")
|
|
79
|
+
assert store.get_user_name("U1") == "new-name"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── conversations ─────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def test_save_and_load_conversation(store):
|
|
85
|
+
history = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}]
|
|
86
|
+
store.save_conversation("U1", history)
|
|
87
|
+
loaded = store.load_conversation("U1")
|
|
88
|
+
assert loaded == history
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_load_conversation_empty(store):
|
|
92
|
+
assert store.load_conversation("U_NOBODY") == []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_clear_conversation(store):
|
|
96
|
+
store.save_conversation("U1", [{"role": "user", "content": "test"}])
|
|
97
|
+
store.save_conversation("U1", [])
|
|
98
|
+
assert store.load_conversation("U1") == []
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── admin ops ─────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def test_reset_fingerprint(store):
|
|
104
|
+
store.record_error("fp1", "src", "msg")
|
|
105
|
+
store.record_fix("fp1", "applied", branch="b", repo_name="r")
|
|
106
|
+
assert store.reset_fingerprint("fp1") is True
|
|
107
|
+
fixes = store.get_recent_fixes(24)
|
|
108
|
+
assert all(f["fingerprint"] != "fp1" for f in fixes)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_reset_fingerprint_not_found(store):
|
|
112
|
+
assert store.reset_fingerprint("nonexistent") is False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_get_all_errors(store):
|
|
116
|
+
store.record_error("fp1", "src", "msg1")
|
|
117
|
+
store.record_error("fp2", "src", "msg2")
|
|
118
|
+
errors = store.get_all_errors()
|
|
119
|
+
assert len(errors) == 2
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_get_all_user_stats(store):
|
|
123
|
+
store.upsert_user("U1", "alice")
|
|
124
|
+
store.upsert_user("U2", "bob")
|
|
125
|
+
stats = store.get_all_user_stats()
|
|
126
|
+
assert len(stats) == 2
|
|
127
|
+
ids = {s["user_id"] for s in stats}
|
|
128
|
+
assert ids == {"U1", "U2"}
|