@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.
- package/.cairn/.hint-lock +1 -1
- package/.cairn/minify-map.json +7 -1
- package/.cairn/session.json +17 -5
- package/.cairn/views/23edf4_sentinel_boss.py +3664 -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 +346 -7
- package/python/sentinel/notify.py +44 -12
- package/python/sentinel/repo_task_engine.py +37 -3
- package/python/sentinel/sentinel_boss.py +213 -6
- 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,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"}
|