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.
Files changed (46) hide show
  1. jleechanorg_pr_automation/STORAGE_STATE_TESTING_PROTOCOL.md +326 -0
  2. jleechanorg_pr_automation/__init__.py +64 -9
  3. jleechanorg_pr_automation/automation_safety_manager.py +306 -95
  4. jleechanorg_pr_automation/automation_safety_wrapper.py +13 -19
  5. jleechanorg_pr_automation/automation_utils.py +87 -65
  6. jleechanorg_pr_automation/check_codex_comment.py +7 -1
  7. jleechanorg_pr_automation/codex_branch_updater.py +21 -9
  8. jleechanorg_pr_automation/codex_config.py +70 -3
  9. jleechanorg_pr_automation/jleechanorg_pr_monitor.py +1954 -234
  10. jleechanorg_pr_automation/logging_utils.py +86 -0
  11. jleechanorg_pr_automation/openai_automation/__init__.py +3 -0
  12. jleechanorg_pr_automation/openai_automation/codex_github_mentions.py +1111 -0
  13. jleechanorg_pr_automation/openai_automation/debug_page_content.py +88 -0
  14. jleechanorg_pr_automation/openai_automation/oracle_cli.py +364 -0
  15. jleechanorg_pr_automation/openai_automation/test_auth_restoration.py +244 -0
  16. jleechanorg_pr_automation/openai_automation/test_codex_comprehensive.py +355 -0
  17. jleechanorg_pr_automation/openai_automation/test_codex_integration.py +254 -0
  18. jleechanorg_pr_automation/orchestrated_pr_runner.py +516 -0
  19. jleechanorg_pr_automation/tests/__init__.py +0 -0
  20. jleechanorg_pr_automation/tests/test_actionable_counting_matrix.py +84 -86
  21. jleechanorg_pr_automation/tests/test_attempt_limit_logic.py +124 -0
  22. jleechanorg_pr_automation/tests/test_automation_marker_functions.py +175 -0
  23. jleechanorg_pr_automation/tests/test_automation_over_running_reproduction.py +9 -11
  24. jleechanorg_pr_automation/tests/test_automation_safety_limits.py +91 -79
  25. jleechanorg_pr_automation/tests/test_automation_safety_manager_comprehensive.py +53 -53
  26. jleechanorg_pr_automation/tests/test_codex_actor_matching.py +1 -1
  27. jleechanorg_pr_automation/tests/test_fixpr_prompt.py +54 -0
  28. jleechanorg_pr_automation/tests/test_fixpr_return_value.py +140 -0
  29. jleechanorg_pr_automation/tests/test_graphql_error_handling.py +26 -26
  30. jleechanorg_pr_automation/tests/test_model_parameter.py +317 -0
  31. jleechanorg_pr_automation/tests/test_orchestrated_pr_runner.py +697 -0
  32. jleechanorg_pr_automation/tests/test_packaging_integration.py +127 -0
  33. jleechanorg_pr_automation/tests/test_pr_filtering_matrix.py +246 -193
  34. jleechanorg_pr_automation/tests/test_pr_monitor_eligibility.py +354 -0
  35. jleechanorg_pr_automation/tests/test_pr_targeting.py +102 -7
  36. jleechanorg_pr_automation/tests/test_version_consistency.py +51 -0
  37. jleechanorg_pr_automation/tests/test_workflow_specific_limits.py +202 -0
  38. jleechanorg_pr_automation/tests/test_workspace_dispatch_missing_dir.py +119 -0
  39. jleechanorg_pr_automation/utils.py +81 -56
  40. jleechanorg_pr_automation-0.2.45.dist-info/METADATA +864 -0
  41. jleechanorg_pr_automation-0.2.45.dist-info/RECORD +45 -0
  42. jleechanorg_pr_automation-0.1.1.dist-info/METADATA +0 -222
  43. jleechanorg_pr_automation-0.1.1.dist-info/RECORD +0 -23
  44. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/WHEEL +0 -0
  45. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/entry_points.txt +0 -0
  46. {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"