from __future__ import annotations from pathlib import Path from unittest.mock import Mock, call, patch from lai_automation.models import ( AgentResult, ChangeSummary, Issue, Label, PullRequest, ) from lai_automation.worker import Worker def queued_issue() -> Issue: return Issue( number=7, title="Wrong calculation", body="The original report", html_url="https://gitea.example.test/owner/lai/issues/7", labels=(Label(1, "ai:queued"), Label(9, "bug")), ) def labels() -> dict[str, Label]: return { "ai:queued": Label(1, "ai:queued"), "ai:running": Label(2, "ai:running"), "ai:ready": Label(3, "ai:ready"), "ai:failed": Label(4, "ai:failed"), } @patch("lai_automation.worker.GitWorkspace") def test_successful_issue_creates_pr_and_ready_label( GitWorkspace, settings, ) -> None: issue = queued_issue() running = Issue( **{ **issue.__dict__, "labels": (Label(2, "ai:running"), Label(9, "bug")), } ) gitea = Mock() gitea.get_issue.return_value = issue gitea.set_automation_state.side_effect = [running, running] gitea.create_pull_request.return_value = PullRequest( 18, "https://gitea.example.test/owner/lai/pulls/18", ) agent = Mock() agent.run.return_value = AgentResult( "Submitted", "Implemented and tested", 4, 0.2, ) workspace = GitWorkspace.return_value workspace.path = settings.workspace_root / "issue-7" / "repo" workspace.branch = "ai/issue-7" workspace.summary.return_value = ChangeSummary( ("app/Services/Fix.php", "tests/Feature/FixTest.php"), 20, 2, ) workspace.has_symlink.return_value = False workspace.has_path.return_value = False workspace.commit.return_value = "abc123" workspace.normalize_changed_whitespace.return_value = ( "tests/Feature/FixTest.php", ) Worker(settings, gitea, agent).process(issue, labels()) workspace.prepare.assert_called_once() workspace.run_trusted.assert_has_calls( [ call( "composer install", extra_environment=settings.test_environment, ), call( "php artisan test", extra_environment=settings.test_environment, ), ] ) workspace.push.assert_called_once() workspace.normalize_changed_whitespace.assert_called_once() assert workspace.summary.call_count == 3 assert workspace.check_diff.call_count == 2 assert gitea.create_pull_request.call_args.args[0] == "ai/issue-7" assert "Fixes #7" in gitea.create_pull_request.call_args.args[2] assert "The original report" in gitea.create_pull_request.call_args.args[2] assert gitea.set_automation_state.call_args_list[-1].args[1] == "ai:ready" workspace.cleanup.assert_called_once() @patch("lai_automation.worker.GitWorkspace") def test_failed_tests_prevent_push_and_mark_issue_failed( GitWorkspace, settings, ) -> None: issue = queued_issue() running = Issue( **{ **issue.__dict__, "labels": (Label(2, "ai:running"),), } ) gitea = Mock() gitea.get_issue.side_effect = [issue, running] gitea.set_automation_state.return_value = running agent = Mock() agent.run.return_value = AgentResult("Submitted", "Done", 2, 0.1) workspace = GitWorkspace.return_value workspace.path = settings.workspace_root / "issue-7" / "repo" workspace.summary.return_value = ChangeSummary(("app/Fix.php",), 2, 0) workspace.has_symlink.return_value = False workspace.has_path.return_value = False workspace.normalize_changed_whitespace.return_value = () workspace.run_trusted.side_effect = [ Mock(), RuntimeError("tests failed with token=hidden"), ] Worker(settings, gitea, agent).process(issue, labels()) workspace.push.assert_not_called() gitea.create_pull_request.assert_not_called() assert gitea.set_automation_state.call_args_list[-1].args[1] == "ai:failed" failure_comment = gitea.comment.call_args_list[-1].args[1] assert "tests failed" in failure_comment assert "hidden" not in failure_comment def test_run_once_only_processes_first_queued_issue(settings) -> None: gitea = Mock() issue = queued_issue() second_issue = Issue(**{**issue.__dict__, "number": 8}) expected_labels = labels() gitea.ensure_automation_labels.return_value = expected_labels gitea.list_queued_issues.return_value = [issue, second_issue] worker = Worker(settings, gitea, Mock()) worker.process = Mock() assert worker.run_once() is True worker.process.assert_called_once_with(issue, expected_labels) def test_run_once_returns_false_without_work(settings) -> None: gitea = Mock() gitea.ensure_automation_labels.return_value = labels() gitea.list_queued_issues.return_value = [] assert Worker(settings, gitea, Mock()).run_once() is False def test_agent_failure_details_identify_step_limit(settings) -> None: worker = Worker(settings, Mock(), Mock()) result = AgentResult("LimitsExceeded", "", settings.agent_step_limit, 0.2) details = worker._agent_failure_details( result, elapsed=180.0, trajectory=Path("/workspaces/trajectories/issue-43.traj.json"), ) assert f"step limit reached ({settings.agent_step_limit})" in details assert "model_calls=40" in details assert "cost=0.2000" in details assert "issue-43.traj.json" in details @patch("lai_automation.worker.GitWorkspace") def test_failed_validation_is_given_to_repair_attempt( GitWorkspace, settings, ) -> None: settings = settings.__class__( **{**settings.__dict__, "max_agent_attempts": 2} ) issue = queued_issue() running = Issue(**{**issue.__dict__, "labels": (Label(2, "ai:running"),)}) gitea = Mock() gitea.get_issue.return_value = issue gitea.set_automation_state.side_effect = [running, running] gitea.create_pull_request.return_value = PullRequest( 18, "https://gitea.example.test/owner/lai/pulls/18", ) agent = Mock() agent.run.side_effect = [ AgentResult("Submitted", "First", 4, 0.2), AgentResult("Submitted", "Repaired", 2, 0.1), ] workspace = GitWorkspace.return_value workspace.path = settings.workspace_root / "issue-7" / "repo" workspace.branch = "ai/issue-7" workspace.summary.return_value = ChangeSummary(("app/Fix.php",), 2, 0) workspace.normalize_changed_whitespace.return_value = () workspace.has_symlink.return_value = False workspace.has_path.return_value = False workspace.run_trusted.side_effect = [ Mock(), RuntimeError("PHP parse error on line 3"), Mock(), ] workspace.commit.return_value = "abc123" Worker(settings, gitea, agent).process(issue, labels()) assert agent.run.call_count == 2 assert "PHP parse error on line 3" in agent.run.call_args_list[1].args[3] workspace.push.assert_called_once() assert gitea.set_automation_state.call_args_list[-1].args[1] == "ai:ready" @patch("lai_automation.worker.GitWorkspace") def test_preflight_runs_setup_and_tests_without_model( GitWorkspace, settings, ) -> None: workspace = GitWorkspace.return_value agent = Mock() Worker(settings, Mock(), agent).preflight() GitWorkspace.assert_called_once_with(settings, 0) workspace.prepare.assert_called_once() workspace.run_trusted.assert_has_calls( [ call( "composer install", extra_environment=settings.test_environment, ), call( "php artisan test", extra_environment=settings.test_environment, ), ] ) agent.run.assert_not_called() workspace.cleanup.assert_called_once() @patch("lai_automation.worker.GitWorkspace") def test_preflight_cleans_up_after_failure(GitWorkspace, settings) -> None: workspace = GitWorkspace.return_value workspace.run_trusted.side_effect = RuntimeError("test failure") try: Worker(settings, Mock(), Mock()).preflight() except RuntimeError: pass else: raise AssertionError("Expected preflight failure") workspace.cleanup.assert_called_once()