@misterhuydo/sentinel 1.5.63 → 1.6.1

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 (57) hide show
  1. package/.cairn/.hint-lock +1 -1
  2. package/.cairn/session.json +2 -2
  3. package/package.json +1 -1
  4. package/python/scripts/__pycache__/fix_ask_codebase_context.cpython-311.pyc +0 -0
  5. package/python/scripts/__pycache__/fix_ask_codebase_stdin.cpython-311.pyc +0 -0
  6. package/python/scripts/__pycache__/fix_chain_slack.cpython-311.pyc +0 -0
  7. package/python/scripts/__pycache__/fix_fstring.cpython-311.pyc +0 -0
  8. package/python/scripts/__pycache__/fix_knowledge_cache.cpython-311.pyc +0 -0
  9. package/python/scripts/__pycache__/fix_knowledge_cache_staleness.cpython-311.pyc +0 -0
  10. package/python/scripts/__pycache__/fix_merge_confirm.cpython-311.pyc +0 -0
  11. package/python/scripts/__pycache__/fix_permission_messages.cpython-311.pyc +0 -0
  12. package/python/scripts/__pycache__/fix_pr_check_head_detect.cpython-311.pyc +0 -0
  13. package/python/scripts/__pycache__/fix_pr_msg_newlines.cpython-311.pyc +0 -0
  14. package/python/scripts/__pycache__/fix_pr_tracking_boss.cpython-311.pyc +0 -0
  15. package/python/scripts/__pycache__/fix_pr_tracking_db.cpython-311.pyc +0 -0
  16. package/python/scripts/__pycache__/fix_pr_tracking_main.cpython-311.pyc +0 -0
  17. package/python/scripts/__pycache__/fix_project_isolation.cpython-311.pyc +0 -0
  18. package/python/scripts/__pycache__/fix_system_prompt.cpython-311.pyc +0 -0
  19. package/python/scripts/__pycache__/fix_two_bugs.cpython-311.pyc +0 -0
  20. package/python/scripts/__pycache__/patch_chain_release.cpython-311.pyc +0 -0
  21. package/python/sentinel/__init__.py +1 -1
  22. package/python/sentinel/__pycache__/__init__.cpython-311.pyc +0 -0
  23. package/python/sentinel/__pycache__/cairn_client.cpython-311.pyc +0 -0
  24. package/python/sentinel/__pycache__/cicd_trigger.cpython-311.pyc +0 -0
  25. package/python/sentinel/__pycache__/config_loader.cpython-311.pyc +0 -0
  26. package/python/sentinel/__pycache__/dependency_manager.cpython-311.pyc +0 -0
  27. package/python/sentinel/__pycache__/dev_watcher.cpython-311.pyc +0 -0
  28. package/python/sentinel/__pycache__/fix_engine.cpython-311.pyc +0 -0
  29. package/python/sentinel/__pycache__/git_manager.cpython-311.pyc +0 -0
  30. package/python/sentinel/__pycache__/health_checker.cpython-311.pyc +0 -0
  31. package/python/sentinel/__pycache__/issue_watcher.cpython-311.pyc +0 -0
  32. package/python/sentinel/__pycache__/log_fetcher.cpython-311.pyc +0 -0
  33. package/python/sentinel/__pycache__/log_parser.cpython-311.pyc +0 -0
  34. package/python/sentinel/__pycache__/log_syncer.cpython-311.pyc +0 -0
  35. package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
  36. package/python/sentinel/__pycache__/notify.cpython-311.pyc +0 -0
  37. package/python/sentinel/__pycache__/repo_router.cpython-311.pyc +0 -0
  38. package/python/sentinel/__pycache__/repo_task_engine.cpython-311.pyc +0 -0
  39. package/python/sentinel/__pycache__/reporter.cpython-311.pyc +0 -0
  40. package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
  41. package/python/sentinel/__pycache__/sentinel_dev.cpython-311.pyc +0 -0
  42. package/python/sentinel/__pycache__/slack_bot.cpython-311.pyc +0 -0
  43. package/python/sentinel/__pycache__/state_store.cpython-311.pyc +0 -0
  44. package/python/sentinel/cairn_client.py +30 -11
  45. package/python/sentinel/fix_engine.py +200 -46
  46. package/python/sentinel/git_manager.py +335 -0
  47. package/python/sentinel/main.py +320 -6
  48. package/python/sentinel/state_store.py +121 -0
  49. package/python/tests/test_cairn_client.py +72 -0
  50. package/python/tests/test_fix_engine_cmd.py +53 -0
  51. package/python/tests/test_fix_engine_json.py +95 -0
  52. package/python/tests/test_fix_engine_prompt.py +93 -0
  53. package/python/tests/test_multi_repo_apply.py +254 -0
  54. package/python/tests/test_multi_repo_publish.py +175 -0
  55. package/python/tests/test_patch_parser.py +250 -0
  56. package/python/tests/test_project_lock.py +85 -0
  57. package/python/tests/test_state_store.py +87 -0
@@ -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
@@ -0,0 +1,175 @@
1
+ """
2
+ test_multi_repo_publish.py — Tests for publish_multi().
3
+
4
+ publish_multi() takes the list of per-repo results from apply_and_commit_multi()
5
+ and pushes / opens PRs per repo. PR bodies note the sibling repos in the same
6
+ multi-repo fix, so reviewers can find them by branch name.
7
+
8
+ Single-repo behavior must remain identical to today.
9
+ """
10
+ from pathlib import Path
11
+ from unittest.mock import patch
12
+ from types import SimpleNamespace
13
+
14
+ import pytest
15
+
16
+ from sentinel import git_manager
17
+ from sentinel.config_loader import RepoConfig
18
+
19
+
20
+ def _mk_event(fp="deadbeef00000001"):
21
+ e = SimpleNamespace()
22
+ e.fingerprint = fp
23
+ e.source = "src"
24
+ e.message = "boom"
25
+ e.stack_trace = []
26
+ e.timestamp = "2026-04-24T10:00:00Z"
27
+ e.log_file = ""
28
+ e.short_summary = lambda: "boom in foo"
29
+ return e
30
+
31
+
32
+ def _mk_repo(tmp_path: Path, name: str, auto_commit=False) -> RepoConfig:
33
+ p = tmp_path / "repos" / name
34
+ p.mkdir(parents=True, exist_ok=True)
35
+ return RepoConfig(
36
+ repo_name=name,
37
+ repo_url=f"git@github.com:org/{name}.git",
38
+ local_path=str(p),
39
+ branch="main",
40
+ auto_commit=auto_commit,
41
+ )
42
+
43
+
44
+ def _mk_cfg():
45
+ cfg = SimpleNamespace()
46
+ cfg.project_name = "test-project"
47
+ cfg.github_token = "ghp_test"
48
+ cfg.slack_bot_token = ""
49
+ cfg.slack_channel = ""
50
+ cfg.auto_commit = False
51
+ cfg.auto_release = False
52
+ return cfg
53
+
54
+
55
+ def _ok():
56
+ return SimpleNamespace(returncode=0, stdout="", stderr="")
57
+
58
+
59
+ # ── single-repo PR mode (unchanged behaviour) ────────────────────────────────
60
+
61
+ def test_single_committed_repo_opens_one_pr(tmp_path):
62
+ cfg = _mk_cfg()
63
+ repo = _mk_repo(tmp_path, "r1")
64
+ results = [{
65
+ "repo_name": "r1", "repo": repo, "status": "committed",
66
+ "commit_hash": "abc1234", "branch": "main", "apply_order": 0,
67
+ "reason": "", "sub_patch_path": tmp_path / "x.diff",
68
+ }]
69
+
70
+ pr_url = "https://github.com/org/r1/pull/42"
71
+ with patch.object(git_manager, "_git", return_value=_ok()), \
72
+ patch.object(git_manager, "_open_github_pr", return_value=pr_url) as open_pr, \
73
+ patch.object(git_manager, "remote_fix_exists", return_value=False):
74
+ out = git_manager.publish_multi(_mk_event(), results, cfg)
75
+
76
+ assert open_pr.call_count == 1
77
+ assert out[0]["pr_url"] == pr_url
78
+
79
+
80
+ # ── multi-repo: each PR body lists sibling repos by name ──────────────────────
81
+
82
+ def test_two_repos_each_pr_body_mentions_siblings(tmp_path):
83
+ cfg = _mk_cfg()
84
+ lib = _mk_repo(tmp_path, "lib")
85
+ consumer = _mk_repo(tmp_path, "consumer")
86
+ results = [
87
+ {"repo_name": "lib", "repo": lib, "status": "committed",
88
+ "commit_hash": "aaa1111", "branch": "main", "apply_order": 0,
89
+ "reason": "", "sub_patch_path": tmp_path / "lib.diff"},
90
+ {"repo_name": "consumer", "repo": consumer, "status": "committed",
91
+ "commit_hash": "bbb2222", "branch": "main", "apply_order": 1,
92
+ "reason": "", "sub_patch_path": tmp_path / "consumer.diff"},
93
+ ]
94
+
95
+ captured_extras: list[str] = []
96
+
97
+ def fake_open_pr(event, repo, cfg, branch, commit_hash, extra_body=""):
98
+ captured_extras.append(extra_body)
99
+ return f"https://github.com/org/{repo.repo_name}/pull/1"
100
+
101
+ with patch.object(git_manager, "_git", return_value=_ok()), \
102
+ patch.object(git_manager, "_open_github_pr", side_effect=fake_open_pr), \
103
+ patch.object(git_manager, "remote_fix_exists", return_value=False):
104
+ out = git_manager.publish_multi(_mk_event(), results, cfg)
105
+
106
+ assert len(captured_extras) == 2
107
+ # lib's PR body mentions consumer; consumer's PR body mentions lib
108
+ assert "consumer" in captured_extras[0]
109
+ assert "lib" in captured_extras[1]
110
+ # Both PRs are recorded back into results
111
+ assert all(r["pr_url"] for r in out if r["status"] == "committed")
112
+
113
+
114
+ # ── failed entries are not pushed ─────────────────────────────────────────────
115
+
116
+ def test_failed_entries_are_skipped_for_publish(tmp_path):
117
+ cfg = _mk_cfg()
118
+ repo = _mk_repo(tmp_path, "r1")
119
+ results = [{
120
+ "repo_name": "r1", "repo": repo, "status": "failed",
121
+ "commit_hash": "", "branch": "", "apply_order": 0,
122
+ "reason": "tests failed", "sub_patch_path": tmp_path / "x.diff",
123
+ }]
124
+
125
+ with patch.object(git_manager, "_git", return_value=_ok()) as git_call, \
126
+ patch.object(git_manager, "_open_github_pr") as open_pr, \
127
+ patch.object(git_manager, "remote_fix_exists", return_value=False):
128
+ out = git_manager.publish_multi(_mk_event(), results, cfg)
129
+
130
+ open_pr.assert_not_called()
131
+ git_call.assert_not_called()
132
+ assert out[0]["status"] == "failed"
133
+
134
+
135
+ # ── auto_commit mode pushes directly to main, no PR ──────────────────────────
136
+
137
+ def test_auto_commit_mode_pushes_no_pr(tmp_path):
138
+ cfg = _mk_cfg()
139
+ cfg.auto_commit = True # global default
140
+ repo = _mk_repo(tmp_path, "r1", auto_commit=True)
141
+ results = [{
142
+ "repo_name": "r1", "repo": repo, "status": "committed",
143
+ "commit_hash": "abc1234", "branch": "main", "apply_order": 0,
144
+ "reason": "", "sub_patch_path": tmp_path / "x.diff",
145
+ }]
146
+
147
+ with patch.object(git_manager, "_git", return_value=_ok()), \
148
+ patch.object(git_manager, "_open_github_pr") as open_pr:
149
+ out = git_manager.publish_multi(_mk_event(), results, cfg)
150
+
151
+ open_pr.assert_not_called()
152
+ assert out[0]["pr_url"] == ""
153
+ # branch stays as repo.branch (main)
154
+ assert out[0]["branch"] == "main"
155
+
156
+
157
+ # ── duplicate PR detection ───────────────────────────────────────────────────
158
+
159
+ def test_existing_remote_branch_skips_push(tmp_path):
160
+ cfg = _mk_cfg()
161
+ repo = _mk_repo(tmp_path, "r1")
162
+ results = [{
163
+ "repo_name": "r1", "repo": repo, "status": "committed",
164
+ "commit_hash": "abc1234", "branch": "main", "apply_order": 0,
165
+ "reason": "", "sub_patch_path": tmp_path / "x.diff",
166
+ }]
167
+
168
+ with patch.object(git_manager, "_git", return_value=_ok()), \
169
+ patch.object(git_manager, "_open_github_pr") as open_pr, \
170
+ patch.object(git_manager, "remote_fix_exists", return_value=True):
171
+ out = git_manager.publish_multi(_mk_event(), results, cfg)
172
+
173
+ # remote branch already exists → don't open a duplicate PR
174
+ open_pr.assert_not_called()
175
+ assert out[0].get("pr_url", "") == ""