@misterhuydo/sentinel 1.5.5 → 1.5.7

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.
Files changed (37) hide show
  1. package/.cairn/.hint-lock +1 -1
  2. package/.cairn/minify-map.json +7 -1
  3. package/.cairn/session.json +17 -5
  4. package/.cairn/views/23edf4_sentinel_boss.py +3664 -0
  5. package/.cairn/views/62a614_bundle.js +4 -1
  6. package/.cairn/views/7802b9_cicd_trigger.py +171 -0
  7. package/.cairn/views/ac3df4_repo_task_engine.py +351 -0
  8. package/lib/.cairn/minify-map.json +6 -0
  9. package/lib/.cairn/views/2a85cc_init.js +380 -0
  10. package/lib/.cairn/views/e26996_slack-setup.js +97 -0
  11. package/lib/.cairn/views/fb78ac_upgrade.js +36 -1
  12. package/lib/.cairn/views/fc4a1a_add.js +164 -51
  13. package/lib/init.js +54 -0
  14. package/lib/maven.js +212 -0
  15. package/lib/slack-setup.js +5 -0
  16. package/package.json +1 -1
  17. package/python/requirements.txt +1 -0
  18. package/python/sentinel/.cairn/.cairn-project +0 -0
  19. package/python/sentinel/.cairn/.hint-lock +1 -0
  20. package/python/sentinel/.cairn/session.json +9 -0
  21. package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
  22. package/python/sentinel/config_loader.py +29 -10
  23. package/python/sentinel/dependency_manager.py +9 -2
  24. package/python/sentinel/git_manager.py +23 -0
  25. package/python/sentinel/issue_watcher.py +7 -1
  26. package/python/sentinel/main.py +346 -7
  27. package/python/sentinel/notify.py +44 -12
  28. package/python/sentinel/repo_task_engine.py +37 -3
  29. package/python/sentinel/sentinel_boss.py +213 -6
  30. package/python/sentinel/slack_bot.py +15 -2
  31. package/python/sentinel/state_store.py +0 -1
  32. package/python/tests/__init__.py +0 -0
  33. package/python/tests/test_config_loader.py +138 -0
  34. package/python/tests/test_log_parser.py +62 -0
  35. package/python/tests/test_repo_router.py +73 -0
  36. package/python/tests/test_smoke.py +96 -0
  37. package/python/tests/test_state_store.py +128 -0
@@ -0,0 +1,138 @@
1
+ """Tests for config_loader."""
2
+
3
+ import textwrap
4
+
5
+ import pytest
6
+
7
+ from sentinel.config_loader import ConfigLoader
8
+
9
+
10
+ @pytest.fixture
11
+ def config_dir(tmp_path):
12
+ (tmp_path / "log-configs").mkdir()
13
+ (tmp_path / "repo-configs").mkdir()
14
+
15
+ (tmp_path / "sentinel.properties").write_text(textwrap.dedent("""\
16
+ POLL_INTERVAL_SECONDS=60
17
+ SMTP_HOST=smtp.example.com
18
+ SMTP_PORT=465
19
+ SMTP_USER=bot@example.com
20
+ SMTP_PASSWORD=secret
21
+ MAILS=admin@example.com, ops@example.com
22
+ REPORT_INTERVAL_HOURS=4
23
+ STATE_DB=./test.db
24
+ WORKSPACE_DIR=./workspace
25
+ CLAUDE_CODE_BIN=claude
26
+ GITHUB_TOKEN=ghp_test
27
+ FIX_CONFIDENCE_THRESHOLD=0.8
28
+ """))
29
+
30
+ (tmp_path / "log-configs" / "elprint-salescore.properties").write_text(textwrap.dedent("""\
31
+ SOURCE_TYPE=ssh
32
+ KEY=/path/to/key.pem
33
+ HOSTS=host1, host2
34
+ LOGS=logs/app.log, logs/alarm.log
35
+ REMOTE_SERVICE_USER=MyService
36
+ GREP_FILTER=ERROR|WARN
37
+ TAIL=200
38
+ """))
39
+
40
+ (tmp_path / "log-configs" / "my-worker.properties").write_text(textwrap.dedent("""\
41
+ SOURCE_TYPE=cloudflare
42
+ CF_URL=https://worker.example.com/logs
43
+ CF_TOKEN=cf_token_abc
44
+ """))
45
+
46
+ (tmp_path / "repo-configs" / "elprint-salescore.properties").write_text(textwrap.dedent("""\
47
+ REPO_URL=git@github.com:org/elprint-salescore.git
48
+ LOCAL_PATH=/repos/elprint-salescore
49
+ BRANCH=main
50
+ AUTO_PUBLISH=false
51
+ CICD_TYPE=jenkins
52
+ CICD_JOB_URL=https://jenkins.quadim.ai/job/elprint-salescore
53
+ CICD_TOKEN=tok123
54
+ """))
55
+
56
+ # Library — no log-config counterpart
57
+ (tmp_path / "repo-configs" / "elprint-commons.properties").write_text(textwrap.dedent("""\
58
+ REPO_URL=git@github.com:org/elprint-commons.git
59
+ LOCAL_PATH=/repos/elprint-commons
60
+ BRANCH=main
61
+ AUTO_PUBLISH=false
62
+ CICD_TYPE=
63
+ CICD_JOB_URL=
64
+ CICD_TOKEN=
65
+ """))
66
+
67
+ return tmp_path
68
+
69
+
70
+ def test_sentinel_config(config_dir):
71
+ loader = ConfigLoader(str(config_dir))
72
+ cfg = loader.sentinel
73
+ assert cfg.poll_interval_seconds == 60
74
+ assert cfg.smtp_host == "smtp.example.com"
75
+ assert cfg.mails == ["admin@example.com", "ops@example.com"]
76
+ assert cfg.fix_confidence_threshold == 0.8
77
+ assert cfg.github_token == "ghp_test"
78
+
79
+
80
+ def test_log_sources(config_dir):
81
+ loader = ConfigLoader(str(config_dir))
82
+ assert "elprint-salescore" in loader.log_sources
83
+ assert "my-worker" in loader.log_sources
84
+
85
+ ssh = loader.log_sources["elprint-salescore"]
86
+ assert ssh.source_type == "ssh"
87
+ assert ssh.hosts == ["host1", "host2"]
88
+ assert ssh.tail == 200
89
+ assert ssh.name == "elprint-salescore"
90
+
91
+ cf = loader.log_sources["my-worker"]
92
+ assert cf.source_type == "cloudflare"
93
+ assert cf.cf_url == "https://worker.example.com/logs"
94
+
95
+
96
+ def test_repos(config_dir):
97
+ loader = ConfigLoader(str(config_dir))
98
+ assert "elprint-salescore" in loader.repos
99
+ assert "elprint-commons" in loader.repos
100
+
101
+ r = loader.repos["elprint-salescore"]
102
+ assert r.repo_name == "elprint-salescore" # derived from stem
103
+ assert r.auto_publish is False
104
+ assert r.cicd_job_url == "https://jenkins.quadim.ai/job/elprint-salescore"
105
+ assert r.cicd_token == "tok123"
106
+
107
+ lib = loader.repos["elprint-commons"]
108
+ assert lib.cicd_job_url == "" # library, no CI/CD
109
+
110
+
111
+ def test_stem_linking(config_dir):
112
+ """Log-config and repo-config with same stem are linkable by key lookup."""
113
+ loader = ConfigLoader(str(config_dir))
114
+ # elprint-salescore exists in both — they link by stem
115
+ assert "elprint-salescore" in loader.log_sources
116
+ assert "elprint-salescore" in loader.repos
117
+ # elprint-commons has no log-config — that's fine
118
+ assert "elprint-commons" not in loader.log_sources
119
+ assert "elprint-commons" in loader.repos
120
+
121
+
122
+ def test_inline_comments_stripped(config_dir):
123
+ (config_dir / "repo-configs" / "test-svc.properties").write_text(
124
+ "REPO_URL=git@github.com:org/test.git # comment\nLOCAL_PATH=/repos/test\n"
125
+ )
126
+ loader = ConfigLoader(str(config_dir))
127
+ assert loader.repos["test-svc"].repo_url == "git@github.com:org/test.git"
128
+
129
+
130
+ def test_no_redundant_fields(config_dir):
131
+ """Confirm removed fields are not present on config objects."""
132
+ loader = ConfigLoader(str(config_dir))
133
+ repo = loader.repos["elprint-salescore"]
134
+ assert not hasattr(repo, "repo_name_field") # REPO_NAME property gone
135
+ assert not hasattr(repo, "package_prefixes") # PACKAGE_PREFIXES gone
136
+ assert not hasattr(repo, "cairn_mcp_enabled") # CAIRN_MCP_ENABLED gone
137
+ log = loader.log_sources["elprint-salescore"]
138
+ assert hasattr(log, "target_repo") # target_repo exists (used for routing)
@@ -0,0 +1,62 @@
1
+ """Tests for log_parser."""
2
+
3
+ import textwrap
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from sentinel.log_parser import parse_log_file, _fingerprint, _normalize_message
9
+
10
+
11
+ SAMPLE_LOG = textwrap.dedent("""\
12
+ 2024-01-15 12:00:00.000 INFO [main] com.example.App - Starting application
13
+ 2024-01-15 12:01:00.123 ERROR [http-nio-1] com.example.UserService - Failed to load user 42
14
+ \tat com.example.UserService.load(UserService.java:88)
15
+ \tat com.example.Controller.handle(Controller.java:55)
16
+ \t... 10 more
17
+ 2024-01-15 12:02:00.456 WARN [scheduler] com.example.Job - Job took too long
18
+ 2024-01-15 12:03:00.789 ERROR [http-nio-2] com.example.UserService - Failed to load user 99
19
+ \tat com.example.UserService.load(UserService.java:88)
20
+ \tat com.example.Controller.handle(Controller.java:55)
21
+ """)
22
+
23
+
24
+ @pytest.fixture
25
+ def log_file(tmp_path):
26
+ p = tmp_path / "app.log"
27
+ p.write_text(SAMPLE_LOG)
28
+ return p
29
+
30
+
31
+ def test_parse_events(log_file):
32
+ events = parse_log_file(log_file, "TEST")
33
+ # INFO is filtered out; we expect 2 ERRORs and 1 WARN
34
+ assert len(events) == 3
35
+ levels = {e.level for e in events}
36
+ assert "ERROR" in levels
37
+ assert "WARN" in levels
38
+
39
+
40
+ def test_stack_trace_attached(log_file):
41
+ events = parse_log_file(log_file, "TEST")
42
+ errors = [e for e in events if e.level == "ERROR"]
43
+ assert all(len(e.stack_trace) > 0 for e in errors)
44
+
45
+
46
+ def test_dedup_fingerprint(log_file):
47
+ events = parse_log_file(log_file, "TEST")
48
+ errors = [e for e in events if e.level == "ERROR"]
49
+ # Both errors have the same message pattern and stack → same fingerprint
50
+ assert errors[0].fingerprint == errors[1].fingerprint
51
+
52
+
53
+ def test_normalize_strips_ids():
54
+ msg = "Failed to load user 42 at 2024-01-15 12:00:00"
55
+ norm = _normalize_message(msg)
56
+ assert "42" not in norm
57
+ assert "TIMESTAMP" in norm
58
+
59
+
60
+ def test_source_name_attached(log_file):
61
+ events = parse_log_file(log_file, "MYSOURCE")
62
+ assert all(e.source == "MYSOURCE" for e in events)
@@ -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"}