@misterhuydo/sentinel 1.5.63 → 1.6.0
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/session.json +2 -2
- package/package.json +1 -1
- package/python/scripts/__pycache__/fix_ask_codebase_context.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_ask_codebase_stdin.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_chain_slack.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_fstring.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_knowledge_cache.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_knowledge_cache_staleness.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_merge_confirm.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_permission_messages.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_check_head_detect.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_msg_newlines.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_tracking_boss.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_tracking_db.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_tracking_main.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_project_isolation.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_system_prompt.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_two_bugs.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/patch_chain_release.cpython-311.pyc +0 -0
- package/python/sentinel/__init__.py +1 -1
- package/python/sentinel/__pycache__/__init__.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/cairn_client.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/cicd_trigger.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/config_loader.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/dependency_manager.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/dev_watcher.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__/health_checker.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/issue_watcher.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/log_fetcher.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/log_parser.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/log_syncer.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/notify.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/repo_router.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/repo_task_engine.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/reporter.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/sentinel_dev.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/slack_bot.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/state_store.cpython-311.pyc +0 -0
- package/python/sentinel/cairn_client.py +30 -11
- package/python/sentinel/fix_engine.py +182 -43
- package/python/sentinel/git_manager.py +335 -0
- package/python/sentinel/main.py +189 -5
- package/python/sentinel/state_store.py +121 -0
- package/python/tests/test_cairn_client.py +72 -0
- package/python/tests/test_fix_engine_json.py +95 -0
- package/python/tests/test_fix_engine_prompt.py +93 -0
- package/python/tests/test_multi_repo_apply.py +254 -0
- package/python/tests/test_multi_repo_publish.py +175 -0
- package/python/tests/test_patch_parser.py +250 -0
- package/python/tests/test_project_lock.py +85 -0
- package/python/tests/test_state_store.py +87 -0
|
@@ -87,6 +87,30 @@ class StateStore:
|
|
|
87
87
|
fingerprint TEXT, -- error/issue fingerprint that triggered it
|
|
88
88
|
committed_at TEXT NOT NULL
|
|
89
89
|
);
|
|
90
|
+
|
|
91
|
+
-- One fingerprint can produce changes in multiple repos (cross-repo fix).
|
|
92
|
+
-- Each row tracks the per-repo branch / commit / PR for one such fingerprint.
|
|
93
|
+
CREATE TABLE IF NOT EXISTS fix_repos (
|
|
94
|
+
fingerprint TEXT NOT NULL,
|
|
95
|
+
repo_name TEXT NOT NULL,
|
|
96
|
+
branch TEXT,
|
|
97
|
+
commit_hash TEXT,
|
|
98
|
+
pr_url TEXT,
|
|
99
|
+
pr_state TEXT, -- open|merged|closed|failed
|
|
100
|
+
apply_order INTEGER DEFAULT 0,
|
|
101
|
+
timestamp TEXT NOT NULL,
|
|
102
|
+
PRIMARY KEY (fingerprint, repo_name)
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
-- Long-lived `claude --print --resume <id>` sessions, one per project.
|
|
106
|
+
-- Lets cross-task prompt cache hit and gives Claude continuous context.
|
|
107
|
+
CREATE TABLE IF NOT EXISTS claude_sessions (
|
|
108
|
+
project_name TEXT PRIMARY KEY,
|
|
109
|
+
session_id TEXT NOT NULL,
|
|
110
|
+
last_used TEXT NOT NULL,
|
|
111
|
+
total_cost_usd REAL NOT NULL DEFAULT 0,
|
|
112
|
+
turn_count INTEGER NOT NULL DEFAULT 0
|
|
113
|
+
);
|
|
90
114
|
""")
|
|
91
115
|
self._migrate()
|
|
92
116
|
logger.debug("StateStore initialised at %s", self.db_path)
|
|
@@ -276,6 +300,103 @@ class StateStore:
|
|
|
276
300
|
).fetchone()
|
|
277
301
|
return row is not None
|
|
278
302
|
|
|
303
|
+
# ── Multi-repo fix tracking (one fingerprint → many repos) ────────────────
|
|
304
|
+
|
|
305
|
+
def record_fix_repo(
|
|
306
|
+
self,
|
|
307
|
+
fingerprint: str,
|
|
308
|
+
repo_name: str,
|
|
309
|
+
branch: str = "",
|
|
310
|
+
commit_hash: str = "",
|
|
311
|
+
pr_url: str = "",
|
|
312
|
+
pr_state: str = "",
|
|
313
|
+
apply_order: int = 0,
|
|
314
|
+
) -> None:
|
|
315
|
+
"""Record (or replace) the per-repo branch/commit/PR for a multi-repo fix."""
|
|
316
|
+
with self._conn() as conn:
|
|
317
|
+
conn.execute(
|
|
318
|
+
"INSERT INTO fix_repos (fingerprint, repo_name, branch, commit_hash, "
|
|
319
|
+
"pr_url, pr_state, apply_order, timestamp) VALUES (?,?,?,?,?,?,?,?) "
|
|
320
|
+
"ON CONFLICT(fingerprint, repo_name) DO UPDATE SET "
|
|
321
|
+
"branch=excluded.branch, commit_hash=excluded.commit_hash, "
|
|
322
|
+
"pr_url=excluded.pr_url, pr_state=excluded.pr_state, "
|
|
323
|
+
"apply_order=excluded.apply_order, timestamp=excluded.timestamp",
|
|
324
|
+
(fingerprint, repo_name, branch, commit_hash,
|
|
325
|
+
pr_url, pr_state, apply_order, _now()),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def update_fix_repo_state(
|
|
329
|
+
self,
|
|
330
|
+
fingerprint: str,
|
|
331
|
+
repo_name: str,
|
|
332
|
+
pr_state: str = "",
|
|
333
|
+
commit_hash: str = "",
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Patch pr_state and/or commit_hash on an existing fix_repos row."""
|
|
336
|
+
sets, args = [], []
|
|
337
|
+
if pr_state:
|
|
338
|
+
sets.append("pr_state=?"); args.append(pr_state)
|
|
339
|
+
if commit_hash:
|
|
340
|
+
sets.append("commit_hash=?"); args.append(commit_hash)
|
|
341
|
+
if not sets:
|
|
342
|
+
return
|
|
343
|
+
args.extend([fingerprint, repo_name])
|
|
344
|
+
with self._conn() as conn:
|
|
345
|
+
conn.execute(
|
|
346
|
+
f"UPDATE fix_repos SET {', '.join(sets)} "
|
|
347
|
+
"WHERE fingerprint=? AND repo_name=?", args,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def get_fix_repos(self, fingerprint: str) -> list[dict]:
|
|
351
|
+
"""Return all per-repo rows for a fingerprint, ordered by apply_order then repo_name."""
|
|
352
|
+
with self._conn() as conn:
|
|
353
|
+
rows = conn.execute(
|
|
354
|
+
"SELECT * FROM fix_repos WHERE fingerprint=? "
|
|
355
|
+
"ORDER BY apply_order, repo_name",
|
|
356
|
+
(fingerprint,),
|
|
357
|
+
).fetchall()
|
|
358
|
+
return [dict(r) for r in rows]
|
|
359
|
+
|
|
360
|
+
# ── Per-project Claude session tracking (for `claude --resume <id>`) ──────
|
|
361
|
+
|
|
362
|
+
def get_claude_session(self, project_name: str) -> dict | None:
|
|
363
|
+
"""Return the saved {session_id, last_used, total_cost_usd, turn_count} or None."""
|
|
364
|
+
with self._conn() as conn:
|
|
365
|
+
row = conn.execute(
|
|
366
|
+
"SELECT session_id, last_used, total_cost_usd, turn_count "
|
|
367
|
+
"FROM claude_sessions WHERE project_name=?",
|
|
368
|
+
(project_name,),
|
|
369
|
+
).fetchone()
|
|
370
|
+
return dict(row) if row else None
|
|
371
|
+
|
|
372
|
+
def set_claude_session(
|
|
373
|
+
self,
|
|
374
|
+
project_name: str,
|
|
375
|
+
session_id: str,
|
|
376
|
+
cost_delta: float = 0.0,
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Upsert the session id; accumulates cost + turn count across calls."""
|
|
379
|
+
with self._conn() as conn:
|
|
380
|
+
conn.execute(
|
|
381
|
+
"INSERT INTO claude_sessions "
|
|
382
|
+
"(project_name, session_id, last_used, total_cost_usd, turn_count) "
|
|
383
|
+
"VALUES (?, ?, ?, ?, 1) "
|
|
384
|
+
"ON CONFLICT(project_name) DO UPDATE SET "
|
|
385
|
+
"session_id=excluded.session_id, "
|
|
386
|
+
"last_used=excluded.last_used, "
|
|
387
|
+
"total_cost_usd=total_cost_usd + excluded.total_cost_usd, "
|
|
388
|
+
"turn_count=turn_count + 1",
|
|
389
|
+
(project_name, session_id, _now(), cost_delta),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def clear_claude_session(self, project_name: str) -> None:
|
|
393
|
+
"""Drop the saved session id for a project (forces a fresh session next call)."""
|
|
394
|
+
with self._conn() as conn:
|
|
395
|
+
conn.execute(
|
|
396
|
+
"DELETE FROM claude_sessions WHERE project_name=?",
|
|
397
|
+
(project_name,),
|
|
398
|
+
)
|
|
399
|
+
|
|
279
400
|
def mark_marker_seen(self, marker: str) -> dict | None:
|
|
280
401
|
"""Record that a SENTINEL marker appeared in production logs."""
|
|
281
402
|
with self._conn() as conn:
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_cairn_client.py — Unit tests for cairn federation init.
|
|
3
|
+
"""
|
|
4
|
+
from unittest.mock import patch, MagicMock
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from sentinel.cairn_client import init_project_root, index_repo
|
|
8
|
+
from sentinel.config_loader import RepoConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _fake_completed(rc=0, stdout="", stderr=""):
|
|
12
|
+
m = MagicMock()
|
|
13
|
+
m.returncode = rc
|
|
14
|
+
m.stdout = stdout
|
|
15
|
+
m.stderr = stderr
|
|
16
|
+
return m
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── init_project_root ─────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
def test_init_project_root_skips_when_marker_exists(tmp_path):
|
|
22
|
+
(tmp_path / ".cairn").mkdir()
|
|
23
|
+
(tmp_path / ".cairn" / ".cairn-project").touch()
|
|
24
|
+
with patch("sentinel.cairn_client.subprocess.run") as run:
|
|
25
|
+
ok = init_project_root(str(tmp_path))
|
|
26
|
+
assert ok is True
|
|
27
|
+
run.assert_not_called()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_init_project_root_runs_cairn_install_when_missing(tmp_path):
|
|
31
|
+
with patch("sentinel.cairn_client.subprocess.run",
|
|
32
|
+
return_value=_fake_completed(rc=0)) as run:
|
|
33
|
+
ok = init_project_root(str(tmp_path))
|
|
34
|
+
assert ok is True
|
|
35
|
+
run.assert_called_once()
|
|
36
|
+
args, kwargs = run.call_args
|
|
37
|
+
assert args[0] == ["cairn", "install"]
|
|
38
|
+
assert kwargs["cwd"] == str(tmp_path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_init_project_root_returns_false_on_install_failure(tmp_path):
|
|
42
|
+
with patch("sentinel.cairn_client.subprocess.run",
|
|
43
|
+
return_value=_fake_completed(rc=1, stderr="boom")):
|
|
44
|
+
ok = init_project_root(str(tmp_path))
|
|
45
|
+
assert ok is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_init_project_root_returns_false_on_subprocess_exception(tmp_path):
|
|
49
|
+
with patch("sentinel.cairn_client.subprocess.run",
|
|
50
|
+
side_effect=FileNotFoundError("cairn not on PATH")):
|
|
51
|
+
ok = init_project_root(str(tmp_path))
|
|
52
|
+
assert ok is False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_init_project_root_creates_nothing_when_dir_missing(tmp_path):
|
|
56
|
+
missing = tmp_path / "does-not-exist"
|
|
57
|
+
with patch("sentinel.cairn_client.subprocess.run") as run:
|
|
58
|
+
ok = init_project_root(str(missing))
|
|
59
|
+
assert ok is False
|
|
60
|
+
run.assert_not_called()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── index_repo (existing behaviour — guard against regression) ────────────────
|
|
64
|
+
|
|
65
|
+
def test_index_repo_skips_when_marker_exists(tmp_path):
|
|
66
|
+
(tmp_path / ".cairn").mkdir()
|
|
67
|
+
(tmp_path / ".cairn" / ".cairn-project").touch()
|
|
68
|
+
repo = RepoConfig(repo_name="r", local_path=str(tmp_path), branch="main")
|
|
69
|
+
with patch("sentinel.cairn_client.subprocess.run") as run:
|
|
70
|
+
ok = index_repo(repo)
|
|
71
|
+
assert ok is True
|
|
72
|
+
run.assert_not_called()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_fix_engine_json.py — Unit tests for _parse_claude_json().
|
|
3
|
+
|
|
4
|
+
Parses the single-object JSON emitted by `claude --print --output-format json`.
|
|
5
|
+
Critical: must extract session_id (for resume), cost (for budget tracking),
|
|
6
|
+
and the result text (which contains the patch).
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from sentinel.fix_engine import _parse_claude_json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _wrap(result_text: str, session_id: str = "abc-123",
|
|
15
|
+
cost: float = 0.05, is_error: bool = False) -> str:
|
|
16
|
+
return json.dumps({
|
|
17
|
+
"type": "result",
|
|
18
|
+
"subtype": "success" if not is_error else "error",
|
|
19
|
+
"is_error": is_error,
|
|
20
|
+
"result": result_text,
|
|
21
|
+
"session_id": session_id,
|
|
22
|
+
"total_cost_usd": cost,
|
|
23
|
+
"duration_ms": 1234,
|
|
24
|
+
"stop_reason": "end_turn",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── happy path ────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
def test_extracts_result_session_cost():
|
|
31
|
+
raw = _wrap("Here is the patch:\n```diff\n...```", "sess-1", 0.07)
|
|
32
|
+
parsed = _parse_claude_json(raw)
|
|
33
|
+
assert parsed["session_id"] == "sess-1"
|
|
34
|
+
assert parsed["total_cost_usd"] == pytest.approx(0.07)
|
|
35
|
+
assert "patch" in parsed["result"]
|
|
36
|
+
assert parsed["is_error"] is False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_handles_zero_cost():
|
|
40
|
+
raw = _wrap("ok", "sess", 0.0)
|
|
41
|
+
parsed = _parse_claude_json(raw)
|
|
42
|
+
assert parsed["total_cost_usd"] == 0.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_carries_is_error_flag():
|
|
46
|
+
raw = _wrap("err msg", "sess", 0.01, is_error=True)
|
|
47
|
+
parsed = _parse_claude_json(raw)
|
|
48
|
+
assert parsed["is_error"] is True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── tolerant inputs ───────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def test_strips_leading_trailing_whitespace():
|
|
54
|
+
raw = " \n" + _wrap("ok", "s", 0.0) + "\n "
|
|
55
|
+
parsed = _parse_claude_json(raw)
|
|
56
|
+
assert parsed["session_id"] == "s"
|
|
57
|
+
assert parsed["result"] == "ok"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_finds_json_after_stderr_garbage():
|
|
61
|
+
"""Claude sometimes prints debug lines before the JSON object."""
|
|
62
|
+
raw = (
|
|
63
|
+
"Some stderr line\n"
|
|
64
|
+
"Another warning\n"
|
|
65
|
+
+ _wrap("payload", "s", 0.0)
|
|
66
|
+
)
|
|
67
|
+
parsed = _parse_claude_json(raw)
|
|
68
|
+
assert parsed["session_id"] == "s"
|
|
69
|
+
assert parsed["result"] == "payload"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_returns_empty_on_unparseable():
|
|
73
|
+
parsed = _parse_claude_json("not json at all")
|
|
74
|
+
assert parsed["session_id"] == ""
|
|
75
|
+
assert parsed["result"] == ""
|
|
76
|
+
assert parsed["total_cost_usd"] == 0.0
|
|
77
|
+
# When unparseable, surface as an error so caller doesn't silently swallow it
|
|
78
|
+
assert parsed["is_error"] is True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_returns_empty_on_empty_string():
|
|
82
|
+
parsed = _parse_claude_json("")
|
|
83
|
+
assert parsed["session_id"] == ""
|
|
84
|
+
assert parsed["result"] == ""
|
|
85
|
+
assert parsed["is_error"] is True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_handles_missing_fields_gracefully():
|
|
89
|
+
"""If claude omits some fields, extract what's there and default the rest."""
|
|
90
|
+
raw = json.dumps({"type": "result", "result": "x", "session_id": "s"})
|
|
91
|
+
parsed = _parse_claude_json(raw)
|
|
92
|
+
assert parsed["session_id"] == "s"
|
|
93
|
+
assert parsed["result"] == "x"
|
|
94
|
+
assert parsed["total_cost_usd"] == 0.0
|
|
95
|
+
assert parsed["is_error"] is False
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_fix_engine_prompt.py — Spot-checks for the multi-repo prompt builder.
|
|
3
|
+
|
|
4
|
+
The prompt is mostly free text — these tests assert *presence* of key
|
|
5
|
+
instructions, not exact wording, so they tolerate cosmetic edits.
|
|
6
|
+
"""
|
|
7
|
+
from sentinel.fix_engine import _build_prompt
|
|
8
|
+
from sentinel.config_loader import RepoConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _mk_event():
|
|
12
|
+
"""Minimal ErrorEvent-like duck for the prompt builder."""
|
|
13
|
+
class E:
|
|
14
|
+
source = "MyService"
|
|
15
|
+
severity = "ERROR"
|
|
16
|
+
message = "NullPointerException in Foo.bar()"
|
|
17
|
+
body = ""
|
|
18
|
+
stack_trace = []
|
|
19
|
+
def full_text(self):
|
|
20
|
+
return f"{self.message}\n at Foo.bar"
|
|
21
|
+
return E()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _mk_repo(name: str) -> RepoConfig:
|
|
25
|
+
return RepoConfig(
|
|
26
|
+
repo_name=name,
|
|
27
|
+
local_path=f"/proj/repos/{name}",
|
|
28
|
+
branch="main",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_prompt_lists_all_repos_in_project():
|
|
33
|
+
repos = [_mk_repo("repo-a"), _mk_repo("repo-b"), _mk_repo("repo-c")]
|
|
34
|
+
prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
|
|
35
|
+
marker="sentinel-deadbeef",
|
|
36
|
+
all_repos=repos, project_root="/proj")
|
|
37
|
+
assert "repo-a" in prompt
|
|
38
|
+
assert "repo-b" in prompt
|
|
39
|
+
assert "repo-c" in prompt
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_prompt_names_primary_repo_as_origin():
|
|
43
|
+
repos = [_mk_repo("primary"), _mk_repo("other")]
|
|
44
|
+
prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
|
|
45
|
+
marker="m", all_repos=repos, project_root="/p")
|
|
46
|
+
# Primary repo must be clearly identified as where the error originated
|
|
47
|
+
assert "primary" in prompt
|
|
48
|
+
# Sanity: project root is referenced, not the per-repo path
|
|
49
|
+
assert "/p" in prompt
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_prompt_explains_repos_prefix_path_format():
|
|
53
|
+
repos = [_mk_repo("r1")]
|
|
54
|
+
prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
|
|
55
|
+
marker="m", all_repos=repos, project_root="/p")
|
|
56
|
+
# Must instruct claude to use the repos/<name>/ path prefix
|
|
57
|
+
assert "repos/" in prompt
|
|
58
|
+
# And explain the multi-repo header
|
|
59
|
+
assert "Affected repos" in prompt
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_prompt_instructs_cairn_checkpoint_call():
|
|
63
|
+
repos = [_mk_repo("r1")]
|
|
64
|
+
prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
|
|
65
|
+
marker="m", all_repos=repos, project_root="/p")
|
|
66
|
+
assert "cairn_checkpoint" in prompt
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_prompt_keeps_marker_instruction():
|
|
70
|
+
"""Sentinel marker injection is still required for verification flow."""
|
|
71
|
+
repos = [_mk_repo("r1")]
|
|
72
|
+
prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
|
|
73
|
+
marker="sentinel-abc12345",
|
|
74
|
+
all_repos=repos, project_root="/p")
|
|
75
|
+
assert "sentinel-abc12345" in prompt
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_prompt_keeps_escalation_keywords():
|
|
79
|
+
"""NEEDS_HUMAN / BOSS_ESCALATE keywords must remain — main.py keys off them."""
|
|
80
|
+
repos = [_mk_repo("r1")]
|
|
81
|
+
prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
|
|
82
|
+
marker="m", all_repos=repos, project_root="/p")
|
|
83
|
+
assert "NEEDS_HUMAN:" in prompt
|
|
84
|
+
assert "BOSS_ESCALATE:" in prompt
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_prompt_works_with_single_repo_project():
|
|
88
|
+
"""Single-repo projects should still get a sensible prompt — no list weirdness."""
|
|
89
|
+
repo = _mk_repo("only-repo")
|
|
90
|
+
prompt = _build_prompt(_mk_event(), repo, log_file=None, marker="m",
|
|
91
|
+
all_repos=[repo], project_root="/p")
|
|
92
|
+
assert "only-repo" in prompt
|
|
93
|
+
assert "repos/" in prompt # prefix instruction still present
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_multi_repo_apply.py — Tests for apply_and_commit_multi().
|
|
3
|
+
|
|
4
|
+
The flow under test:
|
|
5
|
+
Phase 1 (atomic) — every affected repo's sub-patch is dry-run.
|
|
6
|
+
If any fails, ALL are marked "aborted" with no mutations.
|
|
7
|
+
Phase 2 (per-repo)— apply + test + commit, serially. Per-repo failures
|
|
8
|
+
don't abort the others (partial-success allowed at this point).
|
|
9
|
+
|
|
10
|
+
Tests mock _git and _run_tests so they're fast and deterministic.
|
|
11
|
+
"""
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from unittest.mock import patch, MagicMock
|
|
14
|
+
from types import SimpleNamespace
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from sentinel import git_manager
|
|
19
|
+
from sentinel.config_loader import RepoConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _mk_event(fingerprint="deadbeef00000001", source="src"):
|
|
23
|
+
e = SimpleNamespace()
|
|
24
|
+
e.fingerprint = fingerprint
|
|
25
|
+
e.source = source
|
|
26
|
+
e.message = "boom"
|
|
27
|
+
e.stack_trace = []
|
|
28
|
+
e.timestamp = "2026-04-24T10:00:00Z"
|
|
29
|
+
e.log_file = ""
|
|
30
|
+
e.short_summary = lambda: "boom in foo"
|
|
31
|
+
return e
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _mk_repo(tmp_path: Path, name: str) -> RepoConfig:
|
|
35
|
+
p = tmp_path / "repos" / name
|
|
36
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
return RepoConfig(repo_name=name, local_path=str(p), branch="main")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _mk_cfg(tmp_path: Path):
|
|
41
|
+
cfg = SimpleNamespace()
|
|
42
|
+
cfg.workspace_dir = str(tmp_path / "workspace")
|
|
43
|
+
Path(cfg.workspace_dir).mkdir(exist_ok=True)
|
|
44
|
+
cfg.project_name = "test-project"
|
|
45
|
+
cfg.github_token = ""
|
|
46
|
+
cfg.slack_bot_token = ""
|
|
47
|
+
cfg.slack_channel = ""
|
|
48
|
+
return cfg
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ok():
|
|
52
|
+
"""Mock _git result indicating success."""
|
|
53
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fail(err="bad"):
|
|
57
|
+
return SimpleNamespace(returncode=1, stdout="", stderr=err)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _git_dispatch(returns_per_cmd: dict):
|
|
61
|
+
"""Build a fake _git that returns based on the command verb (args[0]).
|
|
62
|
+
|
|
63
|
+
`returns_per_cmd` maps verb → list of CompletedProcess results (one per call).
|
|
64
|
+
Falls back to _ok() if a verb isn't listed.
|
|
65
|
+
"""
|
|
66
|
+
counters = {k: 0 for k in returns_per_cmd}
|
|
67
|
+
def fake_git(args, cwd, env=None, timeout=git_manager.GIT_TIMEOUT):
|
|
68
|
+
verb = args[0]
|
|
69
|
+
if verb in returns_per_cmd:
|
|
70
|
+
results = returns_per_cmd[verb]
|
|
71
|
+
i = counters[verb]
|
|
72
|
+
counters[verb] = min(i + 1, len(results) - 1) if results else 0
|
|
73
|
+
return results[i] if i < len(results) else results[-1]
|
|
74
|
+
if verb == "rev-parse":
|
|
75
|
+
return SimpleNamespace(returncode=0, stdout="abc1234567890def\n", stderr="")
|
|
76
|
+
return _ok()
|
|
77
|
+
return fake_git
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── helpers to build patches ──────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def _patch_one_repo(repo_name="r1") -> str:
|
|
83
|
+
return (
|
|
84
|
+
f"diff --git a/repos/{repo_name}/src/F.java b/repos/{repo_name}/src/F.java\n"
|
|
85
|
+
f"--- a/repos/{repo_name}/src/F.java\n"
|
|
86
|
+
f"+++ b/repos/{repo_name}/src/F.java\n"
|
|
87
|
+
f"@@ -1 +1 @@\n-old\n+new\n"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _patch_two_repos(lib="lib", consumer="consumer") -> str:
|
|
92
|
+
return (
|
|
93
|
+
f"# Affected repos: {lib}, {consumer}\n"
|
|
94
|
+
f"diff --git a/repos/{lib}/src/A.java b/repos/{lib}/src/A.java\n"
|
|
95
|
+
f"--- a/repos/{lib}/src/A.java\n+++ b/repos/{lib}/src/A.java\n"
|
|
96
|
+
f"@@ -1 +1 @@\n-a\n+b\n"
|
|
97
|
+
f"diff --git a/repos/{consumer}/pom.xml b/repos/{consumer}/pom.xml\n"
|
|
98
|
+
f"--- a/repos/{consumer}/pom.xml\n+++ b/repos/{consumer}/pom.xml\n"
|
|
99
|
+
f"@@ -1 +1 @@\n-1.0\n+1.1\n"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── empty / unknown ───────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def test_empty_patch_returns_empty(tmp_path):
|
|
106
|
+
patch_file = tmp_path / "empty.diff"
|
|
107
|
+
patch_file.write_text("")
|
|
108
|
+
cfg = _mk_cfg(tmp_path)
|
|
109
|
+
repos = [_mk_repo(tmp_path, "r1")]
|
|
110
|
+
result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
|
|
111
|
+
assert result == []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_patch_with_unknown_repo_returns_empty(tmp_path):
|
|
115
|
+
patch_file = tmp_path / "x.diff"
|
|
116
|
+
patch_file.write_text(_patch_one_repo("ghost-repo"))
|
|
117
|
+
cfg = _mk_cfg(tmp_path)
|
|
118
|
+
repos = [_mk_repo(tmp_path, "real-repo")]
|
|
119
|
+
result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
|
|
120
|
+
# ghost-repo is unknown → nothing applied
|
|
121
|
+
assert result == []
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── single-repo happy path ────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def test_single_repo_happy_path(tmp_path):
|
|
127
|
+
patch_file = tmp_path / "s.diff"
|
|
128
|
+
patch_file.write_text(_patch_one_repo("r1"))
|
|
129
|
+
cfg = _mk_cfg(tmp_path)
|
|
130
|
+
repos = [_mk_repo(tmp_path, "r1")]
|
|
131
|
+
|
|
132
|
+
with patch.object(git_manager, "_git", side_effect=_git_dispatch({})), \
|
|
133
|
+
patch.object(git_manager, "_run_tests", return_value=True), \
|
|
134
|
+
patch.object(git_manager, "_check_protected_paths", return_value=False), \
|
|
135
|
+
patch.object(git_manager, "_append_changelog"):
|
|
136
|
+
result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
|
|
137
|
+
|
|
138
|
+
assert len(result) == 1
|
|
139
|
+
r = result[0]
|
|
140
|
+
assert r["repo_name"] == "r1"
|
|
141
|
+
assert r["status"] == "committed"
|
|
142
|
+
assert r["commit_hash"].startswith("abc")
|
|
143
|
+
assert r["apply_order"] == 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── two-repo happy path with header order ─────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def test_two_repos_happy_path_preserves_apply_order(tmp_path):
|
|
149
|
+
patch_file = tmp_path / "m.diff"
|
|
150
|
+
patch_file.write_text(_patch_two_repos("lib", "consumer"))
|
|
151
|
+
cfg = _mk_cfg(tmp_path)
|
|
152
|
+
repos = [_mk_repo(tmp_path, "lib"), _mk_repo(tmp_path, "consumer")]
|
|
153
|
+
|
|
154
|
+
with patch.object(git_manager, "_git", side_effect=_git_dispatch({})), \
|
|
155
|
+
patch.object(git_manager, "_run_tests", return_value=True), \
|
|
156
|
+
patch.object(git_manager, "_check_protected_paths", return_value=False), \
|
|
157
|
+
patch.object(git_manager, "_append_changelog"):
|
|
158
|
+
result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
|
|
159
|
+
|
|
160
|
+
assert [r["repo_name"] for r in result] == ["lib", "consumer"]
|
|
161
|
+
assert [r["apply_order"] for r in result] == [0, 1]
|
|
162
|
+
assert all(r["status"] == "committed" for r in result)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── atomic abort — one dry-run failure aborts ALL ─────────────────────────────
|
|
166
|
+
|
|
167
|
+
def test_dry_run_failure_aborts_all_repos(tmp_path):
|
|
168
|
+
patch_file = tmp_path / "m.diff"
|
|
169
|
+
patch_file.write_text(_patch_two_repos("lib", "consumer"))
|
|
170
|
+
cfg = _mk_cfg(tmp_path)
|
|
171
|
+
repos = [_mk_repo(tmp_path, "lib"), _mk_repo(tmp_path, "consumer")]
|
|
172
|
+
|
|
173
|
+
# dry-run is `git apply --check ...`. We make the FIRST `apply --check` fail
|
|
174
|
+
# by making the apply verb fail on the second call (consumer's dry-run).
|
|
175
|
+
apply_results = [_ok(), _fail("conflict in consumer")]
|
|
176
|
+
|
|
177
|
+
with patch.object(git_manager, "_git",
|
|
178
|
+
side_effect=_git_dispatch({"apply": apply_results})), \
|
|
179
|
+
patch.object(git_manager, "_run_tests", return_value=True), \
|
|
180
|
+
patch.object(git_manager, "_check_protected_paths", return_value=False), \
|
|
181
|
+
patch.object(git_manager, "_append_changelog"):
|
|
182
|
+
result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
|
|
183
|
+
|
|
184
|
+
# Both repos must be marked aborted (atomic invariant)
|
|
185
|
+
assert len(result) == 2
|
|
186
|
+
assert all(r["status"] == "aborted" for r in result)
|
|
187
|
+
# No commits anywhere
|
|
188
|
+
assert all(r["commit_hash"] == "" for r in result)
|
|
189
|
+
# Reason should mention which repo's dry-run failed
|
|
190
|
+
assert any("consumer" in r["reason"] for r in result)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_protected_paths_aborts_immediately(tmp_path):
|
|
194
|
+
patch_file = tmp_path / "x.diff"
|
|
195
|
+
patch_file.write_text(_patch_one_repo("r1"))
|
|
196
|
+
cfg = _mk_cfg(tmp_path)
|
|
197
|
+
repos = [_mk_repo(tmp_path, "r1")]
|
|
198
|
+
|
|
199
|
+
with patch.object(git_manager, "_git", side_effect=_git_dispatch({})), \
|
|
200
|
+
patch.object(git_manager, "_run_tests", return_value=True), \
|
|
201
|
+
patch.object(git_manager, "_check_protected_paths", return_value=True):
|
|
202
|
+
result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
|
|
203
|
+
|
|
204
|
+
assert len(result) == 1
|
|
205
|
+
assert result[0]["status"] == "aborted"
|
|
206
|
+
assert "protected" in result[0]["reason"].lower()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ── per-repo failure in phase 2 ──────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
def test_phase2_test_failure_marks_only_that_repo(tmp_path):
|
|
212
|
+
"""If tests fail for one repo in phase 2, the other repos still get committed."""
|
|
213
|
+
patch_file = tmp_path / "m.diff"
|
|
214
|
+
patch_file.write_text(_patch_two_repos("lib", "consumer"))
|
|
215
|
+
cfg = _mk_cfg(tmp_path)
|
|
216
|
+
repos = [_mk_repo(tmp_path, "lib"), _mk_repo(tmp_path, "consumer")]
|
|
217
|
+
|
|
218
|
+
# _run_tests returns True for lib, False for consumer
|
|
219
|
+
test_results = [True, False]
|
|
220
|
+
test_iter = iter(test_results)
|
|
221
|
+
|
|
222
|
+
with patch.object(git_manager, "_git", side_effect=_git_dispatch({})), \
|
|
223
|
+
patch.object(git_manager, "_run_tests", side_effect=lambda *a, **kw: next(test_iter)), \
|
|
224
|
+
patch.object(git_manager, "_check_protected_paths", return_value=False), \
|
|
225
|
+
patch.object(git_manager, "_append_changelog"):
|
|
226
|
+
result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
|
|
227
|
+
|
|
228
|
+
by_name = {r["repo_name"]: r for r in result}
|
|
229
|
+
assert by_name["lib"]["status"] == "committed"
|
|
230
|
+
assert by_name["consumer"]["status"] == "failed"
|
|
231
|
+
assert "test" in by_name["consumer"]["reason"].lower()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── sub-patch files written to disk ──────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
def test_sub_patch_files_are_written(tmp_path):
|
|
237
|
+
"""Each affected repo gets its own per-repo .diff file for traceability."""
|
|
238
|
+
patch_file = tmp_path / "m.diff"
|
|
239
|
+
patch_file.write_text(_patch_two_repos("lib", "consumer"))
|
|
240
|
+
cfg = _mk_cfg(tmp_path)
|
|
241
|
+
repos = [_mk_repo(tmp_path, "lib"), _mk_repo(tmp_path, "consumer")]
|
|
242
|
+
|
|
243
|
+
with patch.object(git_manager, "_git", side_effect=_git_dispatch({})), \
|
|
244
|
+
patch.object(git_manager, "_run_tests", return_value=True), \
|
|
245
|
+
patch.object(git_manager, "_check_protected_paths", return_value=False), \
|
|
246
|
+
patch.object(git_manager, "_append_changelog"):
|
|
247
|
+
result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
|
|
248
|
+
|
|
249
|
+
for r in result:
|
|
250
|
+
sub_path = r["sub_patch_path"]
|
|
251
|
+
assert Path(sub_path).exists(), f"sub-patch missing for {r['repo_name']}"
|
|
252
|
+
contents = Path(sub_path).read_text()
|
|
253
|
+
# Path prefix was stripped — repos/<name>/ should NOT appear
|
|
254
|
+
assert f"repos/{r['repo_name']}" not in contents
|