Add controlled coding agent automation
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-08-21 22:49:49 +02:00
parent 9bac41f071
commit bfa5cdcd3e
25 changed files with 1263 additions and 1 deletions

47
tests/test_config.py Normal file
View File

@@ -0,0 +1,47 @@
from pathlib import Path
import pytest
from lai_automation.config import Settings
def settings(**overrides) -> Settings:
values = {
"database_host": "db",
"database_port": 3306,
"database_name": "lai",
"database_user": "worker",
"database_password": "secret",
"source_repository": "https://example.test/lai.git",
"source_branch": "main",
"workspace_root": Path("/tmp/workspaces"),
"mode": "dry-run",
"allow_direct_main": False,
"gitea_url": "https://example.test",
"gitea_owner": "owner",
"gitea_repository": "lai",
"gitea_token": "",
"gitea_username": "bot",
"agent_command": "aider",
"agent_model": "mistral/test",
"test_commands": ("php artisan test",),
"poll_seconds": 60,
"max_changed_files": 12,
"max_changed_lines": 800,
"stale_claim_minutes": 90,
"dry_run": True,
}
values.update(overrides)
return Settings(**values)
def test_direct_main_requires_explicit_kill_switch() -> None:
config = settings(mode="direct-main", dry_run=False, gitea_token="token")
with pytest.raises(ValueError, match="ALLOW_DIRECT_MAIN"):
config.validate()
def test_pr_requires_gitea_token() -> None:
config = settings(mode="pr", dry_run=False)
with pytest.raises(ValueError, match="GITEA_TOKEN"):
config.validate()

28
tests/test_git.py Normal file
View File

@@ -0,0 +1,28 @@
import subprocess
from test_config import settings
from lai_automation.git import GitWorkspace
def test_test_command_does_not_interpret_shell_metacharacters(tmp_path) -> None:
workspace = GitWorkspace(settings(workspace_root=tmp_path), 1)
workspace.path.mkdir()
result = workspace.run("printf safe ; touch injected", check=False)
assert result.stdout == "safe"
assert result.returncode != 0
assert not (workspace.path / "injected").exists()
def test_summary_includes_untracked_files(tmp_path) -> None:
workspace = GitWorkspace(settings(workspace_root=tmp_path), 2)
workspace.path.mkdir()
subprocess.run(["git", "init"], cwd=workspace.path, check=True, capture_output=True)
(workspace.path / "new.php").write_text("<?php\n")
summary = workspace.summary()
assert summary.files == ("new.php",)
assert summary.added_lines == 1

27
tests/test_gitea.py Normal file
View File

@@ -0,0 +1,27 @@
from unittest.mock import Mock
import pytest
from test_config import settings
from lai_automation.gitea import GiteaClient
def test_create_pull_request_requires_url() -> None:
client = GiteaClient(settings(gitea_token="token"))
response = Mock()
response.json.return_value = {"number": 12}
client.session.post = Mock(return_value=response)
with pytest.raises(ValueError, match="pull request URL"):
client.create_pull_request("ai/failure-1", "Fix", "Body")
def test_create_pull_request_returns_html_url() -> None:
client = GiteaClient(settings(gitea_token="token"))
response = Mock()
response.json.return_value = {"html_url": "https://example.test/owner/lai/pulls/12"}
client.session.post = Mock(return_value=response)
result = client.create_pull_request("ai/failure-1", "Fix", "Body")
assert result.endswith("/pulls/12")

41
tests/test_policy.py Normal file
View File

@@ -0,0 +1,41 @@
from dataclasses import replace
from test_config import settings
from lai_automation.git import ChangeSummary
from lai_automation.policy import evaluate_changes
def test_policy_accepts_small_application_change() -> None:
result = evaluate_changes(
ChangeSummary(("app/Services/Example.php", "tests/Feature/ExampleTest.php"), 40, 5),
settings(),
)
assert result.allowed
def test_policy_rejects_secrets_and_vendor_paths() -> None:
result = evaluate_changes(ChangeSummary((".env", "vendor/foo.php"), 2, 0), settings())
assert not result.allowed
assert len(result.errors) == 2
def test_direct_main_blocks_validator_and_migrations() -> None:
config = replace(
settings(),
mode="direct-main",
dry_run=False,
allow_direct_main=True,
gitea_token="token",
)
result = evaluate_changes(
ChangeSummary(("app/Services/SQLValidatorService.php", "database/migrations/new.php"), 10, 2),
config,
)
assert not result.allowed
def test_policy_rejects_symlinks(tmp_path) -> None:
(tmp_path / "link.php").symlink_to("/etc/passwd")
result = evaluate_changes(ChangeSummary(("link.php",), 1, 0), settings(), tmp_path)
assert not result.allowed

47
tests/test_prompt.py Normal file
View File

@@ -0,0 +1,47 @@
from lai_automation.models import FailureReport
from lai_automation.prompt import build_task, redact
def test_redact_removes_common_secrets_and_personal_markers() -> None:
value = "password=hunter2 token: abc123 admin@example.com 10.2.3.4 Bearer secret.jwt"
result = redact(value)
assert "hunter2" not in result
assert "abc123" not in result
assert "admin@example.com" not in result
assert "10.2.3.4" not in result
assert "secret.jwt" not in result
def test_task_contains_constraints_and_redacted_report() -> None:
report = FailureReport(
id=7,
chat_message_id=91,
source="user_feedback",
category="incorrect_answer",
user_feedback="password=secret-value",
prompt="List locations for admin@example.com",
response=None,
sql_query="SELECT * FROM company",
error_message=None,
context={"model": "mistral", "database_type": "customer"},
)
task = build_task(report)
assert "failure report #7" in task
assert "secret-value" not in task
assert "admin@example.com" not in task
assert "Do not commit or push" in task
def test_redact_removes_extended_credentials() -> None:
private_key = "-----BEGIN PRIVATE KEY-----\nprivate-material\n-----END PRIVATE KEY-----"
value = (
"AKIAIOSFODNN7EXAMPLE "
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.signature "
"mysql://admin:database-password@db.internal/lai "
f"{private_key}"
)
result = redact(value)
assert "AKIAIOSFODNN7EXAMPLE" not in result
assert "eyJhbGci" not in result
assert "database-password" not in result
assert "private-material" not in result

37
tests/test_worker.py Normal file
View File

@@ -0,0 +1,37 @@
from unittest.mock import Mock
from test_config import settings
from lai_automation.models import FailureReport
from lai_automation.worker import AutomationWorker
def test_dry_run_generates_task_without_agent_or_git() -> None:
report = FailureReport(
id=11,
chat_message_id=1,
source="automatic",
category="sql_execution",
user_feedback=None,
prompt="List locations",
response=None,
sql_query=None,
error_message="Unknown column",
context={},
)
worker = AutomationWorker(settings())
worker.reports = Mock()
worker.reports.claim_next.return_value = report
assert worker.run_once()
worker.reports.release.assert_called_once()
worker.agent = Mock()
worker.agent.run.assert_not_called()
def test_stop_signal_interrupts_worker_wait() -> None:
worker = AutomationWorker(settings())
worker._stop(15, None)
assert worker.stop_event.is_set()