Files
lai_automatisierung/src/lai_automation/git.py
Christian Meyer bfa5cdcd3e
Some checks failed
CI / test (push) Has been cancelled
Add controlled coding agent automation
2026-08-21 22:49:49 +02:00

144 lines
4.9 KiB
Python

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)