219 lines
6.7 KiB
Python
219 lines
6.7 KiB
Python
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_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()
|