This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
.docker-tmp
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
workspaces
|
||||||
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FAILURE_DB_HOST=host.docker.internal
|
||||||
|
FAILURE_DB_PORT=33060
|
||||||
|
FAILURE_DB_NAME=laravel_ai_chat
|
||||||
|
FAILURE_DB_USER=laravel
|
||||||
|
FAILURE_DB_PASSWORD=
|
||||||
|
|
||||||
|
SOURCE_REPOSITORY=https://gitea.tisinfra.de/christian.meyer/lai.git
|
||||||
|
SOURCE_BRANCH=main
|
||||||
|
GITEA_URL=https://gitea.tisinfra.de
|
||||||
|
GITEA_OWNER=christian.meyer
|
||||||
|
GITEA_REPOSITORY=lai
|
||||||
|
GITEA_USERNAME=automation-bot
|
||||||
|
GITEA_TOKEN=
|
||||||
|
|
||||||
|
# dry-run, pr, direct-main
|
||||||
|
AUTOMATION_MODE=dry-run
|
||||||
|
ALLOW_DIRECT_MAIN=false
|
||||||
|
|
||||||
|
AGENT_COMMAND=aider
|
||||||
|
AGENT_MODEL=mistral/mistral-medium-latest
|
||||||
|
MISTRAL_API_KEY=
|
||||||
|
|
||||||
|
POLL_SECONDS=60
|
||||||
|
STALE_CLAIM_MINUTES=90
|
||||||
|
MAX_CHANGED_FILES=12
|
||||||
|
MAX_CHANGED_LINES=800
|
||||||
|
TEST_COMMANDS=composer install --no-interaction --prefer-dist;;php artisan test
|
||||||
18
.gitea/workflows/ci.yml
Normal file
18
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- run: python -m pip install ".[dev]"
|
||||||
|
- run: ruff check src tests
|
||||||
|
- run: pytest -q
|
||||||
|
- run: docker build -t lai-automation:test .
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.docker-tmp/
|
||||||
|
.docker-buildx/
|
||||||
|
*.pyc
|
||||||
|
workspaces/
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
composer git openssh-client php-cli php-curl php-mbstring php-mysql php-xml php-zip unzip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN pip install --no-cache-dir ".[agent]"
|
||||||
|
|
||||||
|
RUN useradd --create-home --uid 10001 coder \
|
||||||
|
&& mkdir -p /workspaces \
|
||||||
|
&& chmod 700 /home/coder
|
||||||
|
|
||||||
|
ENTRYPOINT ["lai-agent"]
|
||||||
|
CMD ["worker"]
|
||||||
112
README.md
112
README.md
@@ -1,2 +1,112 @@
|
|||||||
# lai_automatisierung
|
# LAI Automatisierung
|
||||||
|
|
||||||
|
Kontrollierter Coding-Agent fuer Failure Reports aus
|
||||||
|
[`lai`](https://gitea.tisinfra.de/christian.meyer/lai). Der Orchestrator verwendet
|
||||||
|
[Aider](https://aider.chat/) headless als austauschbaren Coding-Kern. Git-, Gitea-,
|
||||||
|
Policy- und Freigabelogik bleiben in diesem Projekt.
|
||||||
|
|
||||||
|
## Warum Aider?
|
||||||
|
|
||||||
|
- scriptbarer Einmal-Auftrag mit `--message-file`
|
||||||
|
- gute Git- und Repository-Map-Unterstuetzung
|
||||||
|
- Mistral ueber LiteLLM nutzbar
|
||||||
|
- automatische Commits koennen deaktiviert werden
|
||||||
|
- deutlich kleinerer Betriebsumfang als OpenHands
|
||||||
|
|
||||||
|
OpenHands bleibt eine moegliche spaetere Alternative fuer komplexere, mehrstufige
|
||||||
|
Agentenlaeufe. Sein Headless-Modus arbeitet jedoch immer mit `always-approve`; deshalb
|
||||||
|
muessen auch dort unsere externen Policy-Gates erhalten bleiben.
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
```text
|
||||||
|
failure_reports(open)
|
||||||
|
-> transaktionales Claiming (SKIP LOCKED)
|
||||||
|
-> anonymisierter Agentenauftrag
|
||||||
|
-> frischer Clone von lai.git
|
||||||
|
-> Aider-Aenderung ohne Commit
|
||||||
|
-> Pfad-, Groessen- und Sicherheitsgates
|
||||||
|
-> Tests
|
||||||
|
-> Commit
|
||||||
|
-> PR oder kontrollierter Push auf main
|
||||||
|
-> failure_reports(resolved/agent_failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Coding-Agent erhaelt keinen Zugang zur ERP- oder Kundendatenbank. Rohdaten und
|
||||||
|
`.env`-Dateien sind ausgeschlossen. Repository-Pushes verwenden `GIT_ASKPASS`; Tokens
|
||||||
|
werden nicht in Remote-URLs oder Prozessargumenten gespeichert. Der Orchestrator laeuft
|
||||||
|
als privilegierter Containerprozess, startet Aider aber als separaten Benutzer `coder`.
|
||||||
|
Dieser Unterprozess erhaelt nur Modell-Credentials, nicht die Failure-DB- oder
|
||||||
|
Gitea-Zugangsdaten.
|
||||||
|
|
||||||
|
Laravel-Tests laufen mit PHP und Composer direkt im isolierten Worker. Der Docker-Socket
|
||||||
|
des Hosts wird nicht eingebunden. Abgebrochene Claims werden nach standardmaessig 90
|
||||||
|
Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an.
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# FAILURE_DB_PASSWORD und bei Bedarf MISTRAL_API_KEY eintragen
|
||||||
|
docker compose build
|
||||||
|
docker compose run --rm automation check
|
||||||
|
docker compose run --rm automation once
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Default `AUTOMATION_MODE=dry-run` claimt einen offenen Report, erzeugt den
|
||||||
|
anonymisierten Auftrag und setzt ihn danach wieder auf `open`. Es wird weder ein Agent
|
||||||
|
gestartet noch gepusht.
|
||||||
|
|
||||||
|
## PR-Modus
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
AUTOMATION_MODE=pr
|
||||||
|
GITEA_USERNAME=automation-bot
|
||||||
|
GITEA_TOKEN=...
|
||||||
|
MISTRAL_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Bot benoetigt Schreibzugriff auf `christian.meyer/lai` und die Berechtigung,
|
||||||
|
Branches sowie Pull Requests anzulegen. Jeder Lauf erstellt
|
||||||
|
`ai/failure-{report_id}` und einen PR gegen `main`.
|
||||||
|
|
||||||
|
## Direkter main-Modus
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
AUTOMATION_MODE=direct-main
|
||||||
|
ALLOW_DIRECT_MAIN=true
|
||||||
|
GITEA_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Der zweite Schalter ist ein Kill-Switch. In diesem Modus sind Migrationen, Routes,
|
||||||
|
Auth, Middleware, PermissionService und SQLValidatorService zusaetzlich blockiert.
|
||||||
|
Branch Protection muss den Bot fuer direkte Pushes explizit zulassen. PR-Modus bleibt
|
||||||
|
die empfohlene Betriebsart.
|
||||||
|
|
||||||
|
## Modelle
|
||||||
|
|
||||||
|
Default:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
AGENT_MODEL=mistral/mistral-medium-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Chat-Modell und Coding-Modell sind bewusst getrennt. Fuer Coding-Agenten sollte ein
|
||||||
|
agentisch/coding-optimiertes Modell verwendet werden. Ein Wechsel zu OpenHands oder
|
||||||
|
einem anderen Backend erfordert nur einen neuen Adapter neben `AiderAgent`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
pip install ".[dev]"
|
||||||
|
ruff check src tests
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repositorys
|
||||||
|
|
||||||
|
- Chat: `https://gitea.tisinfra.de/christian.meyer/lai.git`
|
||||||
|
- Automatisierung: `https://gitea.tisinfra.de/christian.meyer/lai_automatisierung.git`
|
||||||
|
|
||||||
|
|||||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
automation:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
FAILURE_DB_HOST: ${FAILURE_DB_HOST:-host.docker.internal}
|
||||||
|
FAILURE_DB_PORT: ${FAILURE_DB_PORT:-33060}
|
||||||
|
FAILURE_DB_NAME: ${FAILURE_DB_NAME:-laravel_ai_chat}
|
||||||
|
FAILURE_DB_USER: ${FAILURE_DB_USER:-laravel}
|
||||||
|
FAILURE_DB_PASSWORD: ${FAILURE_DB_PASSWORD:?FAILURE_DB_PASSWORD must be set}
|
||||||
|
SOURCE_REPOSITORY: ${SOURCE_REPOSITORY:-https://gitea.tisinfra.de/christian.meyer/lai.git}
|
||||||
|
SOURCE_BRANCH: ${SOURCE_BRANCH:-main}
|
||||||
|
AUTOMATION_MODE: ${AUTOMATION_MODE:-dry-run}
|
||||||
|
ALLOW_DIRECT_MAIN: ${ALLOW_DIRECT_MAIN:-false}
|
||||||
|
GITEA_URL: ${GITEA_URL:-https://gitea.tisinfra.de}
|
||||||
|
GITEA_OWNER: ${GITEA_OWNER:-christian.meyer}
|
||||||
|
GITEA_REPOSITORY: ${GITEA_REPOSITORY:-lai}
|
||||||
|
GITEA_USERNAME: ${GITEA_USERNAME:-automation-bot}
|
||||||
|
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||||
|
AGENT_MODEL: ${AGENT_MODEL:-mistral/mistral-medium-latest}
|
||||||
|
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
|
||||||
|
POLL_SECONDS: ${POLL_SECONDS:-60}
|
||||||
|
STALE_CLAIM_MINUTES: ${STALE_CLAIM_MINUTES:-90}
|
||||||
|
TEST_COMMANDS: ${TEST_COMMANDS:-composer install --no-interaction --prefer-dist;;php artisan test}
|
||||||
|
extra_hosts:
|
||||||
|
- host.docker.internal:host-gateway
|
||||||
|
volumes:
|
||||||
|
- automation-workspaces:/workspaces
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
automation-workspaces:
|
||||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "lai-automation"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Controlled coding-agent automation for Laravel AI failure reports"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"PyMySQL>=1.1,<2",
|
||||||
|
"requests>=2.32,<3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=8.3,<9", "ruff>=0.9,<1"]
|
||||||
|
agent = ["aider-chat>=0.86,<1"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
lai-agent = "lai_automation.cli:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/lai_automation"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
1
src/lai_automation/__init__.py
Normal file
1
src/lai_automation/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
62
src/lai_automation/agent.py
Normal file
62
src/lai_automation/agent.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class AiderAgent:
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
def run(self, workspace: Path, task: str) -> str:
|
||||||
|
task_file = workspace / ".git" / "failure-task.txt"
|
||||||
|
task_file.write_text(task)
|
||||||
|
task_file.chmod(0o600)
|
||||||
|
command = [
|
||||||
|
self.settings.agent_command,
|
||||||
|
"--model",
|
||||||
|
self.settings.agent_model,
|
||||||
|
"--message-file",
|
||||||
|
str(task_file),
|
||||||
|
"--yes",
|
||||||
|
"--no-auto-commits",
|
||||||
|
"--no-dirty-commits",
|
||||||
|
"--no-stream",
|
||||||
|
]
|
||||||
|
coder = pwd.getpwnam("coder")
|
||||||
|
os.chown(task_file, coder.pw_uid, coder.pw_gid)
|
||||||
|
for path in workspace.rglob("*"):
|
||||||
|
if ".git" not in path.parts:
|
||||||
|
os.chown(path, coder.pw_uid, coder.pw_gid, follow_symlinks=False)
|
||||||
|
os.chown(workspace, coder.pw_uid, coder.pw_gid, follow_symlinks=False)
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
"HOME": coder.pw_dir,
|
||||||
|
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
|
||||||
|
}
|
||||||
|
for name in ("MISTRAL_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
|
||||||
|
if os.getenv(name):
|
||||||
|
environment[name] = os.environ[name]
|
||||||
|
|
||||||
|
def drop_privileges() -> None:
|
||||||
|
os.setgroups([])
|
||||||
|
os.setgid(coder.pw_gid)
|
||||||
|
os.setuid(coder.pw_uid)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=workspace,
|
||||||
|
env=environment,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
preexec_fn=drop_privileges,
|
||||||
|
timeout=3600,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Aider failed ({result.returncode}): {result.stderr[-4000:]}")
|
||||||
|
return result.stdout[-12_000:]
|
||||||
36
src/lai_automation/cli.py
Normal file
36
src/lai_automation/cli.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
from .worker import AutomationWorker
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="LAI controlled coding-agent automation")
|
||||||
|
parser.add_argument("command", choices=["once", "worker", "check"])
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
)
|
||||||
|
settings = Settings.from_env()
|
||||||
|
|
||||||
|
if args.command == "check":
|
||||||
|
print(f"mode={settings.mode}")
|
||||||
|
print(f"source={settings.source_repository}")
|
||||||
|
print(f"model={settings.agent_model}")
|
||||||
|
print(f"direct_main_enabled={settings.allow_direct_main}")
|
||||||
|
return
|
||||||
|
|
||||||
|
worker = AutomationWorker(settings)
|
||||||
|
if args.command == "once":
|
||||||
|
worker.run_once()
|
||||||
|
else:
|
||||||
|
worker.run_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
89
src/lai_automation/config.py
Normal file
89
src/lai_automation/config.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(name: str, default: bool = False) -> bool:
|
||||||
|
return os.getenv(name, str(default)).lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _commands(value: str) -> tuple[str, ...]:
|
||||||
|
return tuple(command.strip() for command in value.split(";;") if command.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
database_host: str
|
||||||
|
database_port: int
|
||||||
|
database_name: str
|
||||||
|
database_user: str
|
||||||
|
database_password: str
|
||||||
|
source_repository: str
|
||||||
|
source_branch: str
|
||||||
|
workspace_root: Path
|
||||||
|
mode: str
|
||||||
|
allow_direct_main: bool
|
||||||
|
gitea_url: str
|
||||||
|
gitea_owner: str
|
||||||
|
gitea_repository: str
|
||||||
|
gitea_token: str
|
||||||
|
gitea_username: str
|
||||||
|
agent_command: str
|
||||||
|
agent_model: str
|
||||||
|
test_commands: tuple[str, ...]
|
||||||
|
poll_seconds: int
|
||||||
|
max_changed_files: int
|
||||||
|
max_changed_lines: int
|
||||||
|
stale_claim_minutes: int
|
||||||
|
dry_run: bool
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> Settings:
|
||||||
|
mode = os.getenv("AUTOMATION_MODE", "dry-run").lower()
|
||||||
|
if mode not in {"dry-run", "pr", "direct-main"}:
|
||||||
|
raise ValueError("AUTOMATION_MODE must be dry-run, pr, or direct-main")
|
||||||
|
|
||||||
|
settings = cls(
|
||||||
|
database_host=os.getenv("FAILURE_DB_HOST", "host.docker.internal"),
|
||||||
|
database_port=int(os.getenv("FAILURE_DB_PORT", "33060")),
|
||||||
|
database_name=os.getenv("FAILURE_DB_NAME", "laravel_ai_chat"),
|
||||||
|
database_user=os.getenv("FAILURE_DB_USER", "laravel"),
|
||||||
|
database_password=os.getenv("FAILURE_DB_PASSWORD", ""),
|
||||||
|
source_repository=os.getenv(
|
||||||
|
"SOURCE_REPOSITORY", "https://gitea.tisinfra.de/christian.meyer/lai.git"
|
||||||
|
),
|
||||||
|
source_branch=os.getenv("SOURCE_BRANCH", "main"),
|
||||||
|
workspace_root=Path(os.getenv("WORKSPACE_ROOT", "/workspaces")),
|
||||||
|
mode=mode,
|
||||||
|
allow_direct_main=_bool("ALLOW_DIRECT_MAIN"),
|
||||||
|
gitea_url=os.getenv("GITEA_URL", "https://gitea.tisinfra.de"),
|
||||||
|
gitea_owner=os.getenv("GITEA_OWNER", "christian.meyer"),
|
||||||
|
gitea_repository=os.getenv("GITEA_REPOSITORY", "lai"),
|
||||||
|
gitea_token=os.getenv("GITEA_TOKEN", ""),
|
||||||
|
gitea_username=os.getenv("GITEA_USERNAME", "automation-bot"),
|
||||||
|
agent_command=os.getenv("AGENT_COMMAND", "aider"),
|
||||||
|
agent_model=os.getenv("AGENT_MODEL", "mistral/mistral-medium-latest"),
|
||||||
|
test_commands=_commands(
|
||||||
|
os.getenv(
|
||||||
|
"TEST_COMMANDS",
|
||||||
|
"composer install --no-interaction --prefer-dist;;php artisan test",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
poll_seconds=int(os.getenv("POLL_SECONDS", "60")),
|
||||||
|
max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")),
|
||||||
|
max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")),
|
||||||
|
stale_claim_minutes=int(os.getenv("STALE_CLAIM_MINUTES", "90")),
|
||||||
|
dry_run=mode == "dry-run",
|
||||||
|
)
|
||||||
|
settings.validate()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
if self.mode == "direct-main" and not self.allow_direct_main:
|
||||||
|
raise ValueError("direct-main requires ALLOW_DIRECT_MAIN=true")
|
||||||
|
if self.mode in {"pr", "direct-main"} and not self.gitea_token:
|
||||||
|
raise ValueError(f"{self.mode} requires GITEA_TOKEN")
|
||||||
|
if not self.database_password:
|
||||||
|
raise ValueError("FAILURE_DB_PASSWORD is required")
|
||||||
107
src/lai_automation/database.py
Normal file
107
src/lai_automation/database.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
from pymysql.cursors import DictCursor
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
from .models import FailureReport
|
||||||
|
|
||||||
|
|
||||||
|
class FailureRepository:
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connection(self) -> Iterator[pymysql.Connection]:
|
||||||
|
connection = pymysql.connect(
|
||||||
|
host=self.settings.database_host,
|
||||||
|
port=self.settings.database_port,
|
||||||
|
user=self.settings.database_user,
|
||||||
|
password=self.settings.database_password,
|
||||||
|
database=self.settings.database_name,
|
||||||
|
cursorclass=DictCursor,
|
||||||
|
autocommit=False,
|
||||||
|
connect_timeout=10,
|
||||||
|
read_timeout=30,
|
||||||
|
write_timeout=30,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def claim_next(self) -> FailureReport | None:
|
||||||
|
with self.connection() as connection, connection.cursor() as cursor:
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE failure_reports
|
||||||
|
SET status = 'open', updated_at = NOW()
|
||||||
|
WHERE status = 'agent_running'
|
||||||
|
AND updated_at < DATE_SUB(NOW(), INTERVAL %s MINUTE)
|
||||||
|
""",
|
||||||
|
(self.settings.stale_claim_minutes,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
cursor.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
|
||||||
|
cursor.execute("START TRANSACTION")
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM failure_reports
|
||||||
|
WHERE status = 'open'
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
connection.commit()
|
||||||
|
return None
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE failure_reports SET status = 'agent_running', updated_at = NOW() WHERE id = %s",
|
||||||
|
(row["id"],),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return FailureReport.from_row(row)
|
||||||
|
|
||||||
|
def release(self, report_id: int, reason: str) -> None:
|
||||||
|
self._finish(report_id, "open", {"last_dry_run": reason})
|
||||||
|
|
||||||
|
def complete(self, report_id: int, metadata: dict) -> None:
|
||||||
|
self._finish(report_id, "resolved", metadata)
|
||||||
|
|
||||||
|
def fail(self, report_id: int, error: str, metadata: dict | None = None) -> None:
|
||||||
|
payload = {"agent_error": error, **(metadata or {})}
|
||||||
|
self._finish(report_id, "agent_failed", payload)
|
||||||
|
|
||||||
|
def _finish(self, report_id: int, status: str, additions: dict) -> None:
|
||||||
|
with self.connection() as connection, connection.cursor() as cursor:
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT context FROM failure_reports WHERE id = %s", (report_id,))
|
||||||
|
row = cursor.fetchone() or {}
|
||||||
|
context = row.get("context") or {}
|
||||||
|
if isinstance(context, str):
|
||||||
|
context = json.loads(context)
|
||||||
|
context.update(additions)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE failure_reports
|
||||||
|
SET status = %s, context = %s, updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(status, json.dumps(context, ensure_ascii=True), report_id),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
except Exception as exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Could not finalize failure report {report_id} as {status}"
|
||||||
|
) from exception
|
||||||
143
src/lai_automation/git.py
Normal file
143
src/lai_automation/git.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChangeSummary:
|
||||||
|
files: tuple[str, ...]
|
||||||
|
added_lines: int
|
||||||
|
deleted_lines: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def changed_lines(self) -> int:
|
||||||
|
return self.added_lines + self.deleted_lines
|
||||||
|
|
||||||
|
|
||||||
|
class GitWorkspace:
|
||||||
|
def __init__(self, settings: Settings, report_id: int) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.path = settings.workspace_root / f"failure-{report_id}"
|
||||||
|
self.branch = f"ai/failure-{report_id}"
|
||||||
|
|
||||||
|
def prepare(self) -> None:
|
||||||
|
repository = urlparse(self.settings.source_repository)
|
||||||
|
gitea = urlparse(self.settings.gitea_url)
|
||||||
|
if repository.scheme != "https" or repository.hostname != gitea.hostname:
|
||||||
|
raise ValueError("SOURCE_REPOSITORY must use HTTPS on the configured Gitea host")
|
||||||
|
shutil.rmtree(self.path, ignore_errors=True)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._git("clone", "--branch", self.settings.source_branch, "--single-branch", self.settings.source_repository, str(self.path), cwd=self.path.parent)
|
||||||
|
self._git("checkout", "-b", self.branch)
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
check: bool = True,
|
||||||
|
username: str | None = None,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
environment = None
|
||||||
|
drop_privileges = None
|
||||||
|
if username:
|
||||||
|
account = pwd.getpwnam(username)
|
||||||
|
environment = {
|
||||||
|
"HOME": account.pw_dir,
|
||||||
|
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def drop_privileges() -> None:
|
||||||
|
os.setgroups([])
|
||||||
|
os.setgid(account.pw_gid)
|
||||||
|
os.setuid(account.pw_uid)
|
||||||
|
|
||||||
|
return subprocess.run(
|
||||||
|
shlex.split(command),
|
||||||
|
cwd=self.path,
|
||||||
|
env=environment,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=check,
|
||||||
|
timeout=1800,
|
||||||
|
preexec_fn=drop_privileges,
|
||||||
|
)
|
||||||
|
|
||||||
|
def summary(self) -> ChangeSummary:
|
||||||
|
self._git("add", "-N", "--all")
|
||||||
|
output = self._git("diff", "--numstat").stdout
|
||||||
|
files: list[str] = []
|
||||||
|
added = deleted = 0
|
||||||
|
for line in output.splitlines():
|
||||||
|
add, remove, filename = line.split("\t", 2)
|
||||||
|
files.append(filename)
|
||||||
|
added += int(add) if add.isdigit() else 0
|
||||||
|
deleted += int(remove) if remove.isdigit() else 0
|
||||||
|
return ChangeSummary(tuple(files), added, deleted)
|
||||||
|
|
||||||
|
def commit(self, report_id: int) -> str:
|
||||||
|
self._git("add", "--all")
|
||||||
|
self._git("commit", "-m", f"Fix failure report #{report_id}")
|
||||||
|
return self._git("rev-parse", "HEAD").stdout.strip()
|
||||||
|
|
||||||
|
def push_branch(self) -> None:
|
||||||
|
self._git_authenticated("push", "origin", f"HEAD:{self.branch}")
|
||||||
|
|
||||||
|
def push_main(self) -> None:
|
||||||
|
self._git_authenticated("push", "origin", f"HEAD:{self.settings.source_branch}")
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
if self.path.exists():
|
||||||
|
shutil.rmtree(self.path)
|
||||||
|
|
||||||
|
def _git(self, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=cwd or self.path,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _git_authenticated(self, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
askpass = self.path / ".git" / "lai-askpass.sh"
|
||||||
|
username_file = self.path / ".git" / "lai-bot-user"
|
||||||
|
token_file = self.path / ".git" / "lai-bot-token"
|
||||||
|
try:
|
||||||
|
username_file.write_text(self.settings.gitea_username)
|
||||||
|
token_file.write_text(self.settings.gitea_token)
|
||||||
|
username_file.chmod(0o600)
|
||||||
|
token_file.chmod(0o600)
|
||||||
|
askpass.write_text(
|
||||||
|
"#!/bin/sh\ncase \"$1\" in *Username*) cat \"$GIT_BOT_USER_FILE\";; *) cat \"$GIT_BOT_TOKEN_FILE\";; esac\n"
|
||||||
|
)
|
||||||
|
askpass.chmod(0o700)
|
||||||
|
env = {
|
||||||
|
"HOME": os.environ.get("HOME", "/root"),
|
||||||
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||||
|
"GIT_ASKPASS": str(askpass),
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"GIT_BOT_USER_FILE": str(username_file),
|
||||||
|
"GIT_BOT_TOKEN_FILE": str(token_file),
|
||||||
|
}
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=self.path,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
askpass.unlink(missing_ok=True)
|
||||||
|
username_file.unlink(missing_ok=True)
|
||||||
|
token_file.unlink(missing_ok=True)
|
||||||
46
src/lai_automation/gitea.py
Normal file
46
src/lai_automation/gitea.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClient:
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
"Authorization": f"token {settings.gitea_token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
})
|
||||||
|
retry = Retry(
|
||||||
|
total=3,
|
||||||
|
backoff_factor=1,
|
||||||
|
status_forcelist=(429, 500, 502, 503, 504),
|
||||||
|
allowed_methods=("POST",),
|
||||||
|
)
|
||||||
|
self.session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||||
|
|
||||||
|
def create_pull_request(self, branch: str, title: str, body: str) -> str:
|
||||||
|
url = (
|
||||||
|
f"{self.settings.gitea_url.rstrip('/')}/api/v1/repos/"
|
||||||
|
f"{self.settings.gitea_owner}/{self.settings.gitea_repository}/pulls"
|
||||||
|
)
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"base": self.settings.source_branch,
|
||||||
|
"head": branch,
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
pull_request_url = payload.get("html_url") or payload.get("url")
|
||||||
|
if not pull_request_url:
|
||||||
|
raise ValueError("Gitea response did not contain a pull request URL")
|
||||||
|
return pull_request_url
|
||||||
37
src/lai_automation/models.py
Normal file
37
src/lai_automation/models.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FailureReport:
|
||||||
|
id: int
|
||||||
|
chat_message_id: int | None
|
||||||
|
source: str
|
||||||
|
category: str
|
||||||
|
user_feedback: str | None
|
||||||
|
prompt: str | None
|
||||||
|
response: str | None
|
||||||
|
sql_query: str | None
|
||||||
|
error_message: str | None
|
||||||
|
context: dict[str, Any]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: dict[str, Any]) -> FailureReport:
|
||||||
|
context = row.get("context") or {}
|
||||||
|
if isinstance(context, str):
|
||||||
|
context = json.loads(context)
|
||||||
|
return cls(
|
||||||
|
id=row["id"],
|
||||||
|
chat_message_id=row.get("chat_message_id"),
|
||||||
|
source=row["source"],
|
||||||
|
category=row["category"],
|
||||||
|
user_feedback=row.get("user_feedback"),
|
||||||
|
prompt=row.get("prompt"),
|
||||||
|
response=row.get("response"),
|
||||||
|
sql_query=row.get("sql_query"),
|
||||||
|
error_message=row.get("error_message"),
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
65
src/lai_automation/policy.py
Normal file
65
src/lai_automation/policy.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
from .git import ChangeSummary
|
||||||
|
|
||||||
|
_ALWAYS_DENIED = (
|
||||||
|
".env",
|
||||||
|
"vendor/",
|
||||||
|
"storage/",
|
||||||
|
"bootstrap/cache/",
|
||||||
|
"docker/",
|
||||||
|
"docker-compose",
|
||||||
|
".gitea/",
|
||||||
|
".github/",
|
||||||
|
)
|
||||||
|
|
||||||
|
_DIRECT_MAIN_DENIED = (
|
||||||
|
"database/migrations/",
|
||||||
|
"app/Http/Middleware/",
|
||||||
|
"app/Models/User.php",
|
||||||
|
"app/Services/PermissionService.php",
|
||||||
|
"app/Services/SQLValidatorService.php",
|
||||||
|
"config/auth.php",
|
||||||
|
"config/database.php",
|
||||||
|
"routes/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PolicyResult:
|
||||||
|
allowed: bool
|
||||||
|
errors: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_changes(
|
||||||
|
summary: ChangeSummary,
|
||||||
|
settings: Settings,
|
||||||
|
workspace: Path | None = None,
|
||||||
|
) -> PolicyResult:
|
||||||
|
errors: list[str] = []
|
||||||
|
if not summary.files:
|
||||||
|
errors.append("Agent produced no file changes")
|
||||||
|
if len(summary.files) > settings.max_changed_files:
|
||||||
|
errors.append(f"Changed {len(summary.files)} files; limit is {settings.max_changed_files}")
|
||||||
|
if summary.changed_lines > settings.max_changed_lines:
|
||||||
|
errors.append(f"Changed {summary.changed_lines} lines; limit is {settings.max_changed_lines}")
|
||||||
|
|
||||||
|
for filename in summary.files:
|
||||||
|
if PurePosixPath(filename).is_absolute() or ".." in PurePosixPath(filename).parts:
|
||||||
|
errors.append(f"Invalid changed path: {filename}")
|
||||||
|
continue
|
||||||
|
if workspace and (workspace / filename).is_symlink():
|
||||||
|
errors.append(f"Symbolic links are not allowed: {filename}")
|
||||||
|
continue
|
||||||
|
if any(filename == prefix or filename.startswith(prefix) for prefix in _ALWAYS_DENIED):
|
||||||
|
errors.append(f"Denied path changed: {filename}")
|
||||||
|
if settings.mode == "direct-main" and any(
|
||||||
|
filename == prefix or filename.startswith(prefix) for prefix in _DIRECT_MAIN_DENIED
|
||||||
|
):
|
||||||
|
errors.append(f"Direct-main denied path changed: {filename}")
|
||||||
|
|
||||||
|
return PolicyResult(not errors, tuple(errors))
|
||||||
63
src/lai_automation/prompt.py
Normal file
63
src/lai_automation/prompt.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .models import FailureReport
|
||||||
|
|
||||||
|
_SECRET_PATTERNS = (
|
||||||
|
(
|
||||||
|
re.compile(r"(?i)\b(?:mysql|mariadb|postgres(?:ql)?|sqlserver)://[^\s]+"),
|
||||||
|
"[DATABASE_URL]",
|
||||||
|
),
|
||||||
|
(re.compile(r"(?i)(password|passwd|secret|token|api[_ -]?key)\s*[:=]\s*\S+"), r"\1=[REDACTED]"),
|
||||||
|
(re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), "[EMAIL]"),
|
||||||
|
(re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "[IP]"),
|
||||||
|
(re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]+"), "Bearer [REDACTED]"),
|
||||||
|
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[AWS_KEY]"),
|
||||||
|
(re.compile(r"-----BEGIN [A-Z ]+KEY-----[\s\S]+?-----END [A-Z ]+KEY-----"), "[PRIVATE_KEY]"),
|
||||||
|
(re.compile(r"\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"), "[JWT]"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redact(value: str | None) -> str:
|
||||||
|
result = value or ""
|
||||||
|
for pattern, replacement in _SECRET_PATTERNS:
|
||||||
|
result = pattern.sub(replacement, result)
|
||||||
|
return result[:12_000]
|
||||||
|
|
||||||
|
|
||||||
|
def build_task(report: FailureReport) -> str:
|
||||||
|
prompt = redact(report.prompt)
|
||||||
|
feedback = redact(report.user_feedback)
|
||||||
|
error = redact(report.error_message)
|
||||||
|
sql = redact(report.sql_query)
|
||||||
|
|
||||||
|
return f"""You are fixing failure report #{report.id} in the Laravel AI Chat repository.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- First add a regression test that reproduces the reported behavior.
|
||||||
|
- Make the smallest production change that fixes the root cause.
|
||||||
|
- Preserve read-only SQL validation and tenant isolation.
|
||||||
|
- Never read or modify .env files, credentials, deployment secrets, or production data.
|
||||||
|
- Do not weaken tests, authentication, authorization, SQL validation, or safety checks.
|
||||||
|
- Do not commit or push; the orchestrator owns Git publication.
|
||||||
|
|
||||||
|
Failure category: {report.category}
|
||||||
|
Source: {report.source}
|
||||||
|
Model: {redact(str(report.context.get('model', 'unknown')))}
|
||||||
|
Database type: {redact(str(report.context.get('database_type', 'unknown')))}
|
||||||
|
|
||||||
|
User request:
|
||||||
|
{prompt or '[not available]'}
|
||||||
|
|
||||||
|
User feedback:
|
||||||
|
{feedback or '[not available]'}
|
||||||
|
|
||||||
|
Observed error:
|
||||||
|
{error or '[not available]'}
|
||||||
|
|
||||||
|
Generated SQL, if relevant:
|
||||||
|
{sql or '[not available]'}
|
||||||
|
|
||||||
|
Run focused tests while working. Leave the repository with the fix and regression test applied.
|
||||||
|
"""
|
||||||
133
src/lai_automation/worker.py
Normal file
133
src/lai_automation/worker.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from .agent import AiderAgent
|
||||||
|
from .config import Settings
|
||||||
|
from .database import FailureRepository
|
||||||
|
from .git import GitWorkspace
|
||||||
|
from .gitea import GiteaClient
|
||||||
|
from .models import FailureReport
|
||||||
|
from .policy import evaluate_changes
|
||||||
|
from .prompt import build_task
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationWorker:
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.reports = FailureRepository(settings)
|
||||||
|
self.agent = AiderAgent(settings)
|
||||||
|
self.gitea = GiteaClient(settings)
|
||||||
|
self.stop_event = threading.Event()
|
||||||
|
|
||||||
|
def run_once(self) -> bool:
|
||||||
|
report = self.reports.claim_next()
|
||||||
|
if not report:
|
||||||
|
LOG.info("No open failure reports")
|
||||||
|
return False
|
||||||
|
|
||||||
|
LOG.info("Claimed failure report %s", report.id)
|
||||||
|
if self.settings.dry_run:
|
||||||
|
task = build_task(report)
|
||||||
|
LOG.info("Dry-run task for report %s:\n%s", report.id, task)
|
||||||
|
self.reports.release(report.id, "Task generated successfully; no agent executed")
|
||||||
|
return True
|
||||||
|
|
||||||
|
workspace = GitWorkspace(self.settings, report.id)
|
||||||
|
metadata: dict = {"mode": self.settings.mode, "model": self.settings.agent_model}
|
||||||
|
try:
|
||||||
|
workspace.prepare()
|
||||||
|
task = build_task(report)
|
||||||
|
metadata["agent_output"] = self.agent.run(workspace.path, task)
|
||||||
|
|
||||||
|
summary = workspace.summary()
|
||||||
|
metadata["changes"] = asdict(summary)
|
||||||
|
policy = evaluate_changes(summary, self.settings, workspace.path)
|
||||||
|
if not policy.allowed:
|
||||||
|
raise RuntimeError("Policy rejected changes: " + "; ".join(policy.errors))
|
||||||
|
|
||||||
|
metadata["tests"] = self._run_tests(workspace)
|
||||||
|
summary = workspace.summary()
|
||||||
|
metadata["changes"] = asdict(summary)
|
||||||
|
policy = evaluate_changes(summary, self.settings, workspace.path)
|
||||||
|
if not policy.allowed:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Policy rejected final changes: " + "; ".join(policy.errors)
|
||||||
|
)
|
||||||
|
commit = workspace.commit(report.id)
|
||||||
|
metadata["commit"] = commit
|
||||||
|
|
||||||
|
if self.settings.mode == "pr":
|
||||||
|
workspace.push_branch()
|
||||||
|
metadata["pull_request"] = self.gitea.create_pull_request(
|
||||||
|
workspace.branch,
|
||||||
|
f"Fix failure report #{report.id}",
|
||||||
|
self._pull_request_body(report, summary, metadata["tests"]),
|
||||||
|
)
|
||||||
|
elif self.settings.mode == "direct-main":
|
||||||
|
workspace.push_main()
|
||||||
|
metadata["direct_main"] = True
|
||||||
|
|
||||||
|
self.reports.complete(report.id, metadata)
|
||||||
|
LOG.info("Completed failure report %s", report.id)
|
||||||
|
return True
|
||||||
|
except Exception as exception:
|
||||||
|
LOG.exception("Failure report %s failed", report.id)
|
||||||
|
self.reports.fail(report.id, str(exception), metadata)
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
workspace.cleanup()
|
||||||
|
except OSError:
|
||||||
|
LOG.exception("Could not clean workspace for report %s", report.id)
|
||||||
|
|
||||||
|
def run_forever(self) -> None:
|
||||||
|
signal.signal(signal.SIGTERM, self._stop)
|
||||||
|
signal.signal(signal.SIGINT, self._stop)
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
self.run_once()
|
||||||
|
self.stop_event.wait(self.settings.poll_seconds)
|
||||||
|
|
||||||
|
def _stop(self, signum: int, _frame) -> None:
|
||||||
|
LOG.info("Received signal %s; stopping after the current report", signum)
|
||||||
|
self.stop_event.set()
|
||||||
|
|
||||||
|
def _run_tests(self, workspace: GitWorkspace) -> list[dict]:
|
||||||
|
results = []
|
||||||
|
for command in self.settings.test_commands:
|
||||||
|
result = workspace.run(command, check=False, username="coder")
|
||||||
|
results.append({
|
||||||
|
"command": command,
|
||||||
|
"returncode": result.returncode,
|
||||||
|
"stdout": result.stdout[-6000:],
|
||||||
|
"stderr": result.stderr[-6000:],
|
||||||
|
})
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Test command failed: {command}\n{result.stderr[-2000:]}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pull_request_body(report: FailureReport, summary, tests: list[dict]) -> str:
|
||||||
|
checks = "\n".join(
|
||||||
|
f"- {'PASS' if item['returncode'] == 0 else 'FAIL'} `{item['command']}`"
|
||||||
|
for item in tests
|
||||||
|
)
|
||||||
|
files = "\n".join(f"- `{filename}`" for filename in summary.files)
|
||||||
|
return f"""Automated fix for internal failure report #{report.id}.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
{files}
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
{checks}
|
||||||
|
|
||||||
|
## Risk controls
|
||||||
|
- No production database access was provided to the coding agent.
|
||||||
|
- Secret and deployment paths are blocked by policy.
|
||||||
|
- Human review is required before merge.
|
||||||
|
"""
|
||||||
47
tests/test_config.py
Normal file
47
tests/test_config.py
Normal 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
28
tests/test_git.py
Normal 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
27
tests/test_gitea.py
Normal 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
41
tests/test_policy.py
Normal 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
47
tests/test_prompt.py
Normal 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
37
tests/test_worker.py
Normal 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()
|
||||||
Reference in New Issue
Block a user