jleechanorg-pr-automation 0.1.1__py3-none-any.whl → 0.2.45__py3-none-any.whl
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.
- jleechanorg_pr_automation/STORAGE_STATE_TESTING_PROTOCOL.md +326 -0
- jleechanorg_pr_automation/__init__.py +64 -9
- jleechanorg_pr_automation/automation_safety_manager.py +306 -95
- jleechanorg_pr_automation/automation_safety_wrapper.py +13 -19
- jleechanorg_pr_automation/automation_utils.py +87 -65
- jleechanorg_pr_automation/check_codex_comment.py +7 -1
- jleechanorg_pr_automation/codex_branch_updater.py +21 -9
- jleechanorg_pr_automation/codex_config.py +70 -3
- jleechanorg_pr_automation/jleechanorg_pr_monitor.py +1954 -234
- jleechanorg_pr_automation/logging_utils.py +86 -0
- jleechanorg_pr_automation/openai_automation/__init__.py +3 -0
- jleechanorg_pr_automation/openai_automation/codex_github_mentions.py +1111 -0
- jleechanorg_pr_automation/openai_automation/debug_page_content.py +88 -0
- jleechanorg_pr_automation/openai_automation/oracle_cli.py +364 -0
- jleechanorg_pr_automation/openai_automation/test_auth_restoration.py +244 -0
- jleechanorg_pr_automation/openai_automation/test_codex_comprehensive.py +355 -0
- jleechanorg_pr_automation/openai_automation/test_codex_integration.py +254 -0
- jleechanorg_pr_automation/orchestrated_pr_runner.py +516 -0
- jleechanorg_pr_automation/tests/__init__.py +0 -0
- jleechanorg_pr_automation/tests/test_actionable_counting_matrix.py +84 -86
- jleechanorg_pr_automation/tests/test_attempt_limit_logic.py +124 -0
- jleechanorg_pr_automation/tests/test_automation_marker_functions.py +175 -0
- jleechanorg_pr_automation/tests/test_automation_over_running_reproduction.py +9 -11
- jleechanorg_pr_automation/tests/test_automation_safety_limits.py +91 -79
- jleechanorg_pr_automation/tests/test_automation_safety_manager_comprehensive.py +53 -53
- jleechanorg_pr_automation/tests/test_codex_actor_matching.py +1 -1
- jleechanorg_pr_automation/tests/test_fixpr_prompt.py +54 -0
- jleechanorg_pr_automation/tests/test_fixpr_return_value.py +140 -0
- jleechanorg_pr_automation/tests/test_graphql_error_handling.py +26 -26
- jleechanorg_pr_automation/tests/test_model_parameter.py +317 -0
- jleechanorg_pr_automation/tests/test_orchestrated_pr_runner.py +697 -0
- jleechanorg_pr_automation/tests/test_packaging_integration.py +127 -0
- jleechanorg_pr_automation/tests/test_pr_filtering_matrix.py +246 -193
- jleechanorg_pr_automation/tests/test_pr_monitor_eligibility.py +354 -0
- jleechanorg_pr_automation/tests/test_pr_targeting.py +102 -7
- jleechanorg_pr_automation/tests/test_version_consistency.py +51 -0
- jleechanorg_pr_automation/tests/test_workflow_specific_limits.py +202 -0
- jleechanorg_pr_automation/tests/test_workspace_dispatch_missing_dir.py +119 -0
- jleechanorg_pr_automation/utils.py +81 -56
- jleechanorg_pr_automation-0.2.45.dist-info/METADATA +864 -0
- jleechanorg_pr_automation-0.2.45.dist-info/RECORD +45 -0
- jleechanorg_pr_automation-0.1.1.dist-info/METADATA +0 -222
- jleechanorg_pr_automation-0.1.1.dist-info/RECORD +0 -23
- {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/WHEEL +0 -0
- {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/entry_points.txt +0 -0
- {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
# Ensure repository root is importable
|
|
8
|
+
ROOT = Path(__file__).resolve().parents[3]
|
|
9
|
+
if str(ROOT) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(ROOT))
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
import automation.jleechanorg_pr_automation.orchestrated_pr_runner as runner
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_sanitize_workspace_name_includes_pr_number():
|
|
18
|
+
assert runner.sanitize_workspace_name("feature/my-branch", 42) == "pr-42-feature-my-branch"
|
|
19
|
+
assert runner.sanitize_workspace_name("!!!", 7) == "pr-7"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_query_recent_prs_invalid_json(monkeypatch):
|
|
23
|
+
monkeypatch.setattr(
|
|
24
|
+
runner,
|
|
25
|
+
"run_cmd",
|
|
26
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout="not json", stderr=""),
|
|
27
|
+
)
|
|
28
|
+
with pytest.raises(RuntimeError):
|
|
29
|
+
runner.query_recent_prs(24)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_query_recent_prs_skips_incomplete_data(monkeypatch):
|
|
33
|
+
response = {
|
|
34
|
+
"data": {
|
|
35
|
+
"search": {
|
|
36
|
+
"nodes": [
|
|
37
|
+
{
|
|
38
|
+
"__typename": "PullRequest",
|
|
39
|
+
"number": None,
|
|
40
|
+
"repository": {"name": "repo", "nameWithOwner": "org/repo"},
|
|
41
|
+
"headRefName": "branch",
|
|
42
|
+
"headRefOid": "abc",
|
|
43
|
+
"updatedAt": "2024-01-01T00:00:00Z",
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"pageInfo": {"hasNextPage": False, "endCursor": None},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
monkeypatch.setattr(
|
|
51
|
+
runner,
|
|
52
|
+
"run_cmd",
|
|
53
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(response), stderr=""),
|
|
54
|
+
)
|
|
55
|
+
assert runner.query_recent_prs(24) == []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.parametrize(
|
|
59
|
+
"exc_factory, expected_fragment",
|
|
60
|
+
[
|
|
61
|
+
(
|
|
62
|
+
lambda cmd, timeout: subprocess.CalledProcessError(
|
|
63
|
+
1, cmd, stderr="fetch failed"
|
|
64
|
+
),
|
|
65
|
+
"fetch failed",
|
|
66
|
+
),
|
|
67
|
+
(
|
|
68
|
+
subprocess.TimeoutExpired,
|
|
69
|
+
"timed out",
|
|
70
|
+
),
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
def test_ensure_base_clone_recovers_from_fetch_failure(monkeypatch, tmp_path, capsys, exc_factory, expected_fragment):
|
|
74
|
+
repo_full = "org/repo"
|
|
75
|
+
runner.BASE_CLONE_ROOT = tmp_path
|
|
76
|
+
base_dir = tmp_path / "repo"
|
|
77
|
+
base_dir.mkdir()
|
|
78
|
+
(base_dir / "stale.txt").write_text("stale")
|
|
79
|
+
|
|
80
|
+
def fake_run_cmd(cmd, cwd=None, check=True, timeout=None):
|
|
81
|
+
if cmd[:2] == ["git", "fetch"]:
|
|
82
|
+
raise exc_factory(cmd, timeout)
|
|
83
|
+
if cmd[:2] == ["git", "clone"]:
|
|
84
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
(base_dir / "fresh.txt").write_text("fresh")
|
|
86
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
87
|
+
|
|
88
|
+
monkeypatch.setattr(runner, "run_cmd", fake_run_cmd)
|
|
89
|
+
|
|
90
|
+
result = runner.ensure_base_clone(repo_full)
|
|
91
|
+
|
|
92
|
+
assert result == base_dir
|
|
93
|
+
assert not (base_dir / "stale.txt").exists()
|
|
94
|
+
assert (base_dir / "fresh.txt").exists()
|
|
95
|
+
|
|
96
|
+
output = capsys.readouterr().out
|
|
97
|
+
assert "Fetch failed for org/repo" in output
|
|
98
|
+
assert expected_fragment in output
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_prepare_workspace_dir_cleans_worktree(monkeypatch, tmp_path):
|
|
102
|
+
runner.WORKSPACE_ROOT_BASE = tmp_path
|
|
103
|
+
target = tmp_path / "repo" / "ws"
|
|
104
|
+
target.mkdir(parents=True)
|
|
105
|
+
git_dir = tmp_path / "base" / ".git" / "worktrees" / "ws"
|
|
106
|
+
git_dir.mkdir(parents=True)
|
|
107
|
+
git_file = target / ".git"
|
|
108
|
+
git_file.write_text(f"gitdir: {git_dir}")
|
|
109
|
+
|
|
110
|
+
calls = []
|
|
111
|
+
|
|
112
|
+
def fake_run_cmd(cmd, cwd=None, check=True, timeout=None):
|
|
113
|
+
calls.append({"cmd": cmd, "cwd": cwd, "check": check, "timeout": timeout})
|
|
114
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
115
|
+
|
|
116
|
+
monkeypatch.setattr(runner, "run_cmd", fake_run_cmd)
|
|
117
|
+
|
|
118
|
+
result = runner.prepare_workspace_dir("repo", "ws")
|
|
119
|
+
|
|
120
|
+
assert result == target
|
|
121
|
+
assert any("worktree" in " ".join(call["cmd"]) for call in calls)
|
|
122
|
+
assert not target.exists()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_dispatch_agent_for_pr_validates_fields(tmp_path, monkeypatch):
|
|
126
|
+
runner.WORKSPACE_ROOT_BASE = tmp_path
|
|
127
|
+
|
|
128
|
+
class FakeDispatcher:
|
|
129
|
+
def analyze_task_and_create_agents(self, _description, forced_cli=None):
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
def create_dynamic_agent(self, _spec):
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
assert runner.dispatch_agent_for_pr(FakeDispatcher(), {"repo_full": None}) is False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_dispatch_agent_for_pr_injects_workspace(monkeypatch, tmp_path):
|
|
139
|
+
runner.WORKSPACE_ROOT_BASE = tmp_path
|
|
140
|
+
|
|
141
|
+
created_specs = []
|
|
142
|
+
captured_desc = []
|
|
143
|
+
|
|
144
|
+
class FakeDispatcher:
|
|
145
|
+
def analyze_task_and_create_agents(self, _description, forced_cli=None):
|
|
146
|
+
captured_desc.append(_description)
|
|
147
|
+
return [{"id": "agent"}]
|
|
148
|
+
|
|
149
|
+
def create_dynamic_agent(self, spec):
|
|
150
|
+
created_specs.append(spec)
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
pr = {"repo_full": "org/repo", "repo": "repo", "number": 5, "branch": "feature/x"}
|
|
154
|
+
assert runner.dispatch_agent_for_pr(FakeDispatcher(), pr)
|
|
155
|
+
assert created_specs
|
|
156
|
+
workspace_config = created_specs[0].get("workspace_config")
|
|
157
|
+
assert workspace_config
|
|
158
|
+
assert "pr-5" in workspace_config["workspace_name"]
|
|
159
|
+
# commit prefix guidance should be present in task description with agent CLI
|
|
160
|
+
assert captured_desc and "-automation-commit]" in captured_desc[0]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_has_failing_checks_uses_state_only(monkeypatch):
|
|
164
|
+
fake_checks = [{"name": "ci", "state": "FAILED", "workflow": "ci.yml"}]
|
|
165
|
+
monkeypatch.setattr(
|
|
166
|
+
runner,
|
|
167
|
+
"run_cmd",
|
|
168
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(fake_checks), stderr=""),
|
|
169
|
+
)
|
|
170
|
+
assert runner.has_failing_checks("org/repo", 1) is True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_kill_tmux_session_matches_variants(monkeypatch):
|
|
174
|
+
calls = []
|
|
175
|
+
|
|
176
|
+
def fake_run_cmd(cmd, check=True, timeout=None, cwd=None):
|
|
177
|
+
calls.append(cmd)
|
|
178
|
+
if cmd[:2] == ["tmux", "has-session"]:
|
|
179
|
+
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
|
180
|
+
if cmd[:2] == ["tmux", "ls"]:
|
|
181
|
+
return SimpleNamespace(returncode=0, stdout="pr-14-foo_: 1 windows", stderr="")
|
|
182
|
+
if cmd[:2] == ["tmux", "kill-session"]:
|
|
183
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
184
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
185
|
+
|
|
186
|
+
monkeypatch.setattr(runner, "run_cmd", fake_run_cmd)
|
|
187
|
+
|
|
188
|
+
runner.kill_tmux_session_if_exists("pr-14-foo.")
|
|
189
|
+
|
|
190
|
+
assert any(cmd[:2] == ["tmux", "kill-session"] for cmd in calls)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ============================================================================
|
|
194
|
+
# MATRIX TESTS - Phase 1: RED (Failing Tests)
|
|
195
|
+
# ============================================================================
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# Matrix 1: has_failing_checks() - Additional State Tests
|
|
199
|
+
# ========================================================
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_has_failing_checks_state_failure(monkeypatch):
|
|
203
|
+
"""Test FAILURE state (variant of FAILED) triggers True."""
|
|
204
|
+
fake_checks = [{"name": "ci", "state": "FAILURE", "workflow": "ci.yml"}]
|
|
205
|
+
monkeypatch.setattr(
|
|
206
|
+
runner,
|
|
207
|
+
"run_cmd",
|
|
208
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(fake_checks), stderr=""),
|
|
209
|
+
)
|
|
210
|
+
assert runner.has_failing_checks("org/repo", 1) is True
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_has_failing_checks_state_cancelled(monkeypatch):
|
|
214
|
+
"""Test CANCELLED state triggers True."""
|
|
215
|
+
fake_checks = [{"name": "ci", "state": "CANCELLED", "workflow": "ci.yml"}]
|
|
216
|
+
monkeypatch.setattr(
|
|
217
|
+
runner,
|
|
218
|
+
"run_cmd",
|
|
219
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(fake_checks), stderr=""),
|
|
220
|
+
)
|
|
221
|
+
assert runner.has_failing_checks("org/repo", 1) is True
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_has_failing_checks_state_timed_out(monkeypatch):
|
|
225
|
+
"""Test TIMED_OUT state triggers True."""
|
|
226
|
+
fake_checks = [{"name": "ci", "state": "TIMED_OUT", "workflow": "ci.yml"}]
|
|
227
|
+
monkeypatch.setattr(
|
|
228
|
+
runner,
|
|
229
|
+
"run_cmd",
|
|
230
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(fake_checks), stderr=""),
|
|
231
|
+
)
|
|
232
|
+
assert runner.has_failing_checks("org/repo", 1) is True
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_has_failing_checks_state_action_required(monkeypatch):
|
|
236
|
+
"""Test ACTION_REQUIRED state triggers True."""
|
|
237
|
+
fake_checks = [{"name": "ci", "state": "ACTION_REQUIRED", "workflow": "ci.yml"}]
|
|
238
|
+
monkeypatch.setattr(
|
|
239
|
+
runner,
|
|
240
|
+
"run_cmd",
|
|
241
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(fake_checks), stderr=""),
|
|
242
|
+
)
|
|
243
|
+
assert runner.has_failing_checks("org/repo", 1) is True
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_has_failing_checks_state_success(monkeypatch):
|
|
247
|
+
"""Test SUCCESS state returns False."""
|
|
248
|
+
fake_checks = [{"name": "ci", "state": "SUCCESS", "workflow": "ci.yml"}]
|
|
249
|
+
monkeypatch.setattr(
|
|
250
|
+
runner,
|
|
251
|
+
"run_cmd",
|
|
252
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(fake_checks), stderr=""),
|
|
253
|
+
)
|
|
254
|
+
assert runner.has_failing_checks("org/repo", 1) is False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_has_failing_checks_state_pending(monkeypatch):
|
|
258
|
+
"""Test PENDING state returns False."""
|
|
259
|
+
fake_checks = [{"name": "ci", "state": "PENDING", "workflow": "ci.yml"}]
|
|
260
|
+
monkeypatch.setattr(
|
|
261
|
+
runner,
|
|
262
|
+
"run_cmd",
|
|
263
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(fake_checks), stderr=""),
|
|
264
|
+
)
|
|
265
|
+
assert runner.has_failing_checks("org/repo", 1) is False
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_has_failing_checks_empty_state(monkeypatch):
|
|
269
|
+
"""Test empty/None state returns False."""
|
|
270
|
+
fake_checks = [{"name": "ci", "state": "", "workflow": "ci.yml"}]
|
|
271
|
+
monkeypatch.setattr(
|
|
272
|
+
runner,
|
|
273
|
+
"run_cmd",
|
|
274
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(fake_checks), stderr=""),
|
|
275
|
+
)
|
|
276
|
+
assert runner.has_failing_checks("org/repo", 1) is False
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_has_failing_checks_multiple_all_pass(monkeypatch):
|
|
280
|
+
"""Test multiple checks all passing returns False."""
|
|
281
|
+
fake_checks = [
|
|
282
|
+
{"name": "ci", "state": "SUCCESS", "workflow": "ci.yml"},
|
|
283
|
+
{"name": "lint", "state": "SUCCESS", "workflow": "lint.yml"},
|
|
284
|
+
{"name": "test", "state": "SUCCESS", "workflow": "test.yml"},
|
|
285
|
+
]
|
|
286
|
+
monkeypatch.setattr(
|
|
287
|
+
runner,
|
|
288
|
+
"run_cmd",
|
|
289
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(fake_checks), stderr=""),
|
|
290
|
+
)
|
|
291
|
+
assert runner.has_failing_checks("org/repo", 1) is False
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_has_failing_checks_multiple_mixed(monkeypatch):
|
|
295
|
+
"""Test multiple checks with one failing returns True."""
|
|
296
|
+
fake_checks = [
|
|
297
|
+
{"name": "ci", "state": "SUCCESS", "workflow": "ci.yml"},
|
|
298
|
+
{"name": "lint", "state": "FAILED", "workflow": "lint.yml"},
|
|
299
|
+
{"name": "test", "state": "SUCCESS", "workflow": "test.yml"},
|
|
300
|
+
]
|
|
301
|
+
monkeypatch.setattr(
|
|
302
|
+
runner,
|
|
303
|
+
"run_cmd",
|
|
304
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(fake_checks), stderr=""),
|
|
305
|
+
)
|
|
306
|
+
assert runner.has_failing_checks("org/repo", 1) is True
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_has_failing_checks_empty_array(monkeypatch):
|
|
310
|
+
"""Test empty checks array returns False."""
|
|
311
|
+
fake_checks = []
|
|
312
|
+
monkeypatch.setattr(
|
|
313
|
+
runner,
|
|
314
|
+
"run_cmd",
|
|
315
|
+
lambda *_, **__: SimpleNamespace(returncode=0, stdout=json.dumps(fake_checks), stderr=""),
|
|
316
|
+
)
|
|
317
|
+
assert runner.has_failing_checks("org/repo", 1) is False
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_has_failing_checks_api_error(monkeypatch):
|
|
321
|
+
"""Test API error (non-zero returncode) returns False."""
|
|
322
|
+
monkeypatch.setattr(
|
|
323
|
+
runner,
|
|
324
|
+
"run_cmd",
|
|
325
|
+
lambda *_, **__: SimpleNamespace(returncode=1, stdout="", stderr="API error"),
|
|
326
|
+
)
|
|
327
|
+
assert runner.has_failing_checks("org/repo", 1) is False
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# Matrix 2: kill_tmux_session_if_exists() - Additional Variant Tests
|
|
331
|
+
# ===================================================================
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_kill_tmux_session_direct_match(monkeypatch):
|
|
335
|
+
"""Test direct session name match kills the session."""
|
|
336
|
+
calls = []
|
|
337
|
+
|
|
338
|
+
def fake_run_cmd(cmd, check=True, timeout=None, cwd=None):
|
|
339
|
+
calls.append(cmd)
|
|
340
|
+
if cmd[:2] == ["tmux", "has-session"]:
|
|
341
|
+
# First call matches "pr-14-bar" directly
|
|
342
|
+
if cmd[3] == "pr-14-bar":
|
|
343
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
344
|
+
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
|
345
|
+
if cmd[:2] == ["tmux", "kill-session"]:
|
|
346
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
347
|
+
if cmd[:2] == ["tmux", "ls"]:
|
|
348
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
349
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
350
|
+
|
|
351
|
+
monkeypatch.setattr(runner, "run_cmd", fake_run_cmd)
|
|
352
|
+
runner.kill_tmux_session_if_exists("pr-14-bar")
|
|
353
|
+
|
|
354
|
+
kill_calls = [cmd for cmd in calls if cmd[:2] == ["tmux", "kill-session"]]
|
|
355
|
+
assert any("pr-14-bar" in cmd for cmd in kill_calls)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_kill_tmux_session_underscore_variant(monkeypatch):
|
|
359
|
+
"""Test session name with trailing underscore matches."""
|
|
360
|
+
calls = []
|
|
361
|
+
|
|
362
|
+
def fake_run_cmd(cmd, check=True, timeout=None, cwd=None):
|
|
363
|
+
calls.append(cmd)
|
|
364
|
+
if cmd[:2] == ["tmux", "has-session"]:
|
|
365
|
+
# Match "pr-14-baz_" directly
|
|
366
|
+
if cmd[3] == "pr-14-baz_":
|
|
367
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
368
|
+
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
|
369
|
+
if cmd[:2] == ["tmux", "kill-session"]:
|
|
370
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
371
|
+
if cmd[:2] == ["tmux", "ls"]:
|
|
372
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
373
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
374
|
+
|
|
375
|
+
monkeypatch.setattr(runner, "run_cmd", fake_run_cmd)
|
|
376
|
+
runner.kill_tmux_session_if_exists("pr-14-baz_")
|
|
377
|
+
|
|
378
|
+
kill_calls = [cmd for cmd in calls if cmd[:2] == ["tmux", "kill-session"]]
|
|
379
|
+
assert any("pr-14-baz_" in cmd for cmd in kill_calls)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_kill_tmux_session_generic_name(monkeypatch):
|
|
383
|
+
"""Test generic session name without pr-prefix."""
|
|
384
|
+
calls = []
|
|
385
|
+
|
|
386
|
+
def fake_run_cmd(cmd, check=True, timeout=None, cwd=None):
|
|
387
|
+
calls.append(cmd)
|
|
388
|
+
if cmd[:2] == ["tmux", "has-session"]:
|
|
389
|
+
# Match "session_" variant
|
|
390
|
+
if cmd[3] == "session_":
|
|
391
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
392
|
+
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
|
393
|
+
if cmd[:2] == ["tmux", "kill-session"]:
|
|
394
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
395
|
+
if cmd[:2] == ["tmux", "ls"]:
|
|
396
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
397
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
398
|
+
|
|
399
|
+
monkeypatch.setattr(runner, "run_cmd", fake_run_cmd)
|
|
400
|
+
runner.kill_tmux_session_if_exists("session")
|
|
401
|
+
|
|
402
|
+
kill_calls = [cmd for cmd in calls if cmd[:2] == ["tmux", "kill-session"]]
|
|
403
|
+
assert any("session_" in cmd for cmd in kill_calls)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def test_kill_tmux_session_multiple_pr_matches(monkeypatch):
|
|
407
|
+
"""Test multiple sessions with same PR number all get killed."""
|
|
408
|
+
calls = []
|
|
409
|
+
|
|
410
|
+
def fake_run_cmd(cmd, check=True, timeout=None, cwd=None):
|
|
411
|
+
calls.append(cmd)
|
|
412
|
+
if cmd[:2] == ["tmux", "has-session"]:
|
|
413
|
+
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
|
414
|
+
if cmd[:2] == ["tmux", "ls"]:
|
|
415
|
+
return SimpleNamespace(
|
|
416
|
+
returncode=0,
|
|
417
|
+
stdout="pr-5-test-alpha: 1 windows\npr-5-test-beta: 1 windows\npr-5-extra: 1 windows",
|
|
418
|
+
stderr="",
|
|
419
|
+
)
|
|
420
|
+
if cmd[:2] == ["tmux", "kill-session"]:
|
|
421
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
422
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
423
|
+
|
|
424
|
+
monkeypatch.setattr(runner, "run_cmd", fake_run_cmd)
|
|
425
|
+
runner.kill_tmux_session_if_exists("pr-5-test")
|
|
426
|
+
|
|
427
|
+
kill_calls = [cmd for cmd in calls if cmd[:2] == ["tmux", "kill-session"]]
|
|
428
|
+
# Should kill all three pr-5-* sessions
|
|
429
|
+
assert len(kill_calls) >= 3
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def test_kill_tmux_session_no_sessions_exist(monkeypatch):
|
|
433
|
+
"""Test graceful handling when no sessions exist."""
|
|
434
|
+
calls = []
|
|
435
|
+
|
|
436
|
+
def fake_run_cmd(cmd, check=True, timeout=None, cwd=None):
|
|
437
|
+
calls.append(cmd)
|
|
438
|
+
if cmd[:2] == ["tmux", "has-session"]:
|
|
439
|
+
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
|
440
|
+
if cmd[:2] == ["tmux", "ls"]:
|
|
441
|
+
return SimpleNamespace(returncode=1, stdout="", stderr="no server running")
|
|
442
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
443
|
+
|
|
444
|
+
monkeypatch.setattr(runner, "run_cmd", fake_run_cmd)
|
|
445
|
+
|
|
446
|
+
# Should not raise exception
|
|
447
|
+
runner.kill_tmux_session_if_exists("nonexistent")
|
|
448
|
+
|
|
449
|
+
# No kill commands should be issued
|
|
450
|
+
kill_calls = [cmd for cmd in calls if cmd[:2] == ["tmux", "kill-session"]]
|
|
451
|
+
assert len(kill_calls) == 0
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_kill_tmux_session_tmux_ls_failure(monkeypatch):
|
|
455
|
+
"""Test graceful handling when tmux ls fails."""
|
|
456
|
+
calls = []
|
|
457
|
+
|
|
458
|
+
def fake_run_cmd(cmd, check=True, timeout=None, cwd=None):
|
|
459
|
+
calls.append(cmd)
|
|
460
|
+
if cmd[:2] == ["tmux", "has-session"]:
|
|
461
|
+
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
|
462
|
+
if cmd[:2] == ["tmux", "ls"]:
|
|
463
|
+
raise Exception("tmux command failed")
|
|
464
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
465
|
+
|
|
466
|
+
monkeypatch.setattr(runner, "run_cmd", fake_run_cmd)
|
|
467
|
+
|
|
468
|
+
# Should not raise exception (graceful error handling)
|
|
469
|
+
runner.kill_tmux_session_if_exists("test-session")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def test_kill_tmux_session_no_false_positive_pr_numbers(monkeypatch):
|
|
473
|
+
"""Test pr-1 does NOT kill pr-10, pr-11, pr-100 (substring matching bug fix)."""
|
|
474
|
+
calls = []
|
|
475
|
+
|
|
476
|
+
def fake_run_cmd(cmd, check=True, timeout=None, cwd=None):
|
|
477
|
+
calls.append(cmd)
|
|
478
|
+
if cmd[:2] == ["tmux", "has-session"]:
|
|
479
|
+
return SimpleNamespace(returncode=1, stdout="", stderr="")
|
|
480
|
+
if cmd[:2] == ["tmux", "ls"]:
|
|
481
|
+
# Session list contains pr-1-feature and pr-10-other, pr-100-test
|
|
482
|
+
return SimpleNamespace(
|
|
483
|
+
returncode=0,
|
|
484
|
+
stdout="pr-1-feature: 1 windows\npr-10-other: 1 windows\npr-100-test: 1 windows",
|
|
485
|
+
stderr="",
|
|
486
|
+
)
|
|
487
|
+
if cmd[:2] == ["tmux", "kill-session"]:
|
|
488
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
489
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
490
|
+
|
|
491
|
+
monkeypatch.setattr(runner, "run_cmd", fake_run_cmd)
|
|
492
|
+
runner.kill_tmux_session_if_exists("pr-1-feature")
|
|
493
|
+
|
|
494
|
+
kill_calls = [cmd for cmd in calls if cmd[:2] == ["tmux", "kill-session"]]
|
|
495
|
+
killed_sessions = [cmd[3] for cmd in kill_calls if len(cmd) > 3]
|
|
496
|
+
|
|
497
|
+
# Should ONLY kill pr-1-feature, NOT pr-10-other or pr-100-test
|
|
498
|
+
assert "pr-1-feature" in killed_sessions, "Should kill pr-1-feature"
|
|
499
|
+
assert "pr-10-other" not in killed_sessions, "Should NOT kill pr-10-other (false positive)"
|
|
500
|
+
assert "pr-100-test" not in killed_sessions, "Should NOT kill pr-100-test (false positive)"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# Matrix 3: dispatch_agent_for_pr() - Additional Validation Tests
|
|
504
|
+
# ================================================================
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def test_dispatch_agent_for_pr_missing_repo(tmp_path, monkeypatch):
|
|
508
|
+
"""Test validation fails when repo is None."""
|
|
509
|
+
runner.WORKSPACE_ROOT_BASE = tmp_path
|
|
510
|
+
|
|
511
|
+
class FakeDispatcher:
|
|
512
|
+
def analyze_task_and_create_agents(self, _description, forced_cli=None):
|
|
513
|
+
return []
|
|
514
|
+
|
|
515
|
+
def create_dynamic_agent(self, _spec):
|
|
516
|
+
return True
|
|
517
|
+
|
|
518
|
+
pr = {"repo_full": "org/repo", "repo": None, "number": 5, "branch": "feature"}
|
|
519
|
+
assert runner.dispatch_agent_for_pr(FakeDispatcher(), pr) is False
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def test_dispatch_agent_for_pr_missing_number(tmp_path, monkeypatch):
|
|
523
|
+
"""Test validation fails when number is None."""
|
|
524
|
+
runner.WORKSPACE_ROOT_BASE = tmp_path
|
|
525
|
+
|
|
526
|
+
class FakeDispatcher:
|
|
527
|
+
def analyze_task_and_create_agents(self, _description, forced_cli=None):
|
|
528
|
+
return []
|
|
529
|
+
|
|
530
|
+
def create_dynamic_agent(self, _spec):
|
|
531
|
+
return True
|
|
532
|
+
|
|
533
|
+
pr = {"repo_full": "org/repo", "repo": "repo", "number": None, "branch": "feature"}
|
|
534
|
+
assert runner.dispatch_agent_for_pr(FakeDispatcher(), pr) is False
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# ============================================================================
|
|
538
|
+
# RED PHASE: Test for workspace path collision bug (PR #318 root cause)
|
|
539
|
+
# ============================================================================
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def test_multiple_repos_same_pr_number_no_collision(tmp_path, monkeypatch):
|
|
543
|
+
"""
|
|
544
|
+
Test that PRs with the same number from different repos don't collide on workspace paths.
|
|
545
|
+
|
|
546
|
+
BUG REPRODUCTION: dispatch_agent_for_pr() must create distinct workspace_config
|
|
547
|
+
for PRs with same number from different repos.
|
|
548
|
+
|
|
549
|
+
Real incident: PR #318 logs showed agent running in wrong repo directory.
|
|
550
|
+
ROOT CAUSE: Need to verify full agent dispatch flow, not just workspace prep.
|
|
551
|
+
|
|
552
|
+
This tests the COMPLETE dispatch flow to verify workspace_config uniqueness.
|
|
553
|
+
"""
|
|
554
|
+
runner.WORKSPACE_ROOT_BASE = tmp_path
|
|
555
|
+
|
|
556
|
+
# Track workspace configs that get passed to agents
|
|
557
|
+
captured_configs = []
|
|
558
|
+
|
|
559
|
+
class FakeDispatcher:
|
|
560
|
+
def analyze_task_and_create_agents(self, _description, forced_cli=None):
|
|
561
|
+
return [{"id": "agent-spec"}]
|
|
562
|
+
|
|
563
|
+
def create_dynamic_agent(self, spec):
|
|
564
|
+
captured_configs.append(spec.get("workspace_config"))
|
|
565
|
+
return True
|
|
566
|
+
|
|
567
|
+
# Mock kill_tmux_session_if_exists to avoid tmux calls in test
|
|
568
|
+
monkeypatch.setattr(runner, "kill_tmux_session_if_exists", lambda name: None)
|
|
569
|
+
|
|
570
|
+
# Simulate two PRs with same number from different repos
|
|
571
|
+
pr_worldarchitect = {
|
|
572
|
+
"repo_full": "jleechanorg/worldarchitect.ai",
|
|
573
|
+
"repo": "worldarchitect.ai",
|
|
574
|
+
"number": 318,
|
|
575
|
+
"branch": "fix-doc-size",
|
|
576
|
+
"title": "Fix doc size check"
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
pr_ai_universe = {
|
|
580
|
+
"repo_full": "jleechanorg/ai_universe_frontend",
|
|
581
|
+
"repo": "ai_universe_frontend",
|
|
582
|
+
"number": 318,
|
|
583
|
+
"branch": "fix-playwright-tests",
|
|
584
|
+
"title": "Fix playwright tests"
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
# Dispatch agents for both PRs
|
|
588
|
+
success1 = runner.dispatch_agent_for_pr(FakeDispatcher(), pr_worldarchitect)
|
|
589
|
+
success2 = runner.dispatch_agent_for_pr(FakeDispatcher(), pr_ai_universe)
|
|
590
|
+
|
|
591
|
+
assert success1, "Failed to dispatch agent for worldarchitect.ai PR #318"
|
|
592
|
+
assert success2, "Failed to dispatch agent for ai_universe_frontend PR #318"
|
|
593
|
+
assert len(captured_configs) == 2, f"Expected 2 workspace configs, got {len(captured_configs)}"
|
|
594
|
+
|
|
595
|
+
config1 = captured_configs[0]
|
|
596
|
+
config2 = captured_configs[1]
|
|
597
|
+
|
|
598
|
+
# CRITICAL: workspace_root must be different for different repos
|
|
599
|
+
assert config1["workspace_root"] != config2["workspace_root"], \
|
|
600
|
+
f"BUG: workspace_root collision! Both: {config1['workspace_root']}"
|
|
601
|
+
|
|
602
|
+
# Verify correct repo association
|
|
603
|
+
assert "worldarchitect.ai" in config1["workspace_root"], \
|
|
604
|
+
f"Config1 should contain 'worldarchitect.ai': {config1}"
|
|
605
|
+
assert "ai_universe_frontend" in config2["workspace_root"], \
|
|
606
|
+
f"Config2 should contain 'ai_universe_frontend': {config2}"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def test_dispatch_agent_for_pr_accepts_model_for_all_clis(monkeypatch, tmp_path):
|
|
610
|
+
"""Test that model parameter is accepted for all CLIs, not just claude."""
|
|
611
|
+
runner.WORKSPACE_ROOT_BASE = tmp_path
|
|
612
|
+
|
|
613
|
+
captured_model = None
|
|
614
|
+
|
|
615
|
+
class FakeDispatcher:
|
|
616
|
+
def analyze_task_and_create_agents(self, task_description, forced_cli=None):
|
|
617
|
+
return [{"id": "agent-spec"}]
|
|
618
|
+
|
|
619
|
+
def create_dynamic_agent(self, spec):
|
|
620
|
+
nonlocal captured_model
|
|
621
|
+
captured_model = spec.get("model")
|
|
622
|
+
return True
|
|
623
|
+
|
|
624
|
+
monkeypatch.setattr(runner, "kill_tmux_session_if_exists", lambda name: None)
|
|
625
|
+
monkeypatch.setattr(runner, "prepare_workspace_dir", lambda repo, name: None)
|
|
626
|
+
|
|
627
|
+
pr = {
|
|
628
|
+
"repo_full": "jleechanorg/test-repo",
|
|
629
|
+
"repo": "test-repo",
|
|
630
|
+
"number": 123,
|
|
631
|
+
"branch": "test-branch",
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
# Test with gemini CLI
|
|
635
|
+
success = runner.dispatch_agent_for_pr(
|
|
636
|
+
FakeDispatcher(),
|
|
637
|
+
pr,
|
|
638
|
+
agent_cli="gemini",
|
|
639
|
+
model="gemini-3-auto"
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
assert success, "Should succeed with gemini CLI and model parameter"
|
|
643
|
+
assert captured_model == "gemini-3-auto", f"Expected 'gemini-3-auto', got '{captured_model}'"
|
|
644
|
+
|
|
645
|
+
# Test with codex CLI
|
|
646
|
+
captured_model = None
|
|
647
|
+
success = runner.dispatch_agent_for_pr(
|
|
648
|
+
FakeDispatcher(),
|
|
649
|
+
pr,
|
|
650
|
+
agent_cli="codex",
|
|
651
|
+
model="composer-1"
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
assert success, "Should succeed with codex CLI and model parameter"
|
|
655
|
+
assert captured_model == "composer-1", f"Expected 'composer-1', got '{captured_model}'"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def test_dispatch_agent_for_pr_rejects_invalid_model(monkeypatch, tmp_path):
|
|
659
|
+
"""Test that invalid model names are rejected."""
|
|
660
|
+
runner.WORKSPACE_ROOT_BASE = tmp_path
|
|
661
|
+
|
|
662
|
+
class FakeDispatcher:
|
|
663
|
+
def analyze_task_and_create_agents(self, task_description, forced_cli=None):
|
|
664
|
+
return [{"id": "agent-spec"}]
|
|
665
|
+
|
|
666
|
+
def create_dynamic_agent(self, spec):
|
|
667
|
+
return True
|
|
668
|
+
|
|
669
|
+
monkeypatch.setattr(runner, "kill_tmux_session_if_exists", lambda name: None)
|
|
670
|
+
monkeypatch.setattr(runner, "prepare_workspace_dir", lambda repo, name: None)
|
|
671
|
+
|
|
672
|
+
pr = {
|
|
673
|
+
"repo_full": "jleechanorg/test-repo",
|
|
674
|
+
"repo": "test-repo",
|
|
675
|
+
"number": 123,
|
|
676
|
+
"branch": "test-branch",
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
# Test with invalid model name (contains space)
|
|
680
|
+
success = runner.dispatch_agent_for_pr(
|
|
681
|
+
FakeDispatcher(),
|
|
682
|
+
pr,
|
|
683
|
+
agent_cli="gemini",
|
|
684
|
+
model="invalid model name"
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
assert not success, "Should reject invalid model name with spaces"
|
|
688
|
+
|
|
689
|
+
# Test with invalid model name (contains special characters)
|
|
690
|
+
success = runner.dispatch_agent_for_pr(
|
|
691
|
+
FakeDispatcher(),
|
|
692
|
+
pr,
|
|
693
|
+
agent_cli="gemini",
|
|
694
|
+
model="model@invalid"
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
assert not success, "Should reject invalid model name with special characters"
|