Files
lai_automatisierung/src/lai_automation/git.py
Christian Meyer 441c598890
Some checks failed
CI / test (push) Has been cancelled
Recover from malformed agent edits
2026-08-28 12:58:09 +02:00

168 lines
5.7 KiB
Python

from __future__ import annotations
import os
import pwd
import shlex
import shutil
import subprocess
from collections.abc import Mapping, Sequence
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("config", "user.name", "LAI Automation")
self._git("config", "user.email", "automation@localhost")
self._git("checkout", "-b", self.branch)
def run(
self,
command: str,
check: bool = True,
username: str | None = None,
extra_environment: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
return self.run_args(
shlex.split(command),
check=check,
username=username,
extra_environment=extra_environment,
)
def run_args(
self,
command: Sequence[str],
check: bool = True,
username: str | None = None,
extra_environment: Mapping[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
environment = dict(os.environ)
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)
environment.update(extra_environment or {})
return subprocess.run(
command,
cwd=self.path,
env=environment,
text=True,
capture_output=True,
check=check,
timeout=1800,
preexec_fn=drop_privileges,
)
def discard_changes(self) -> None:
self._git("reset", "--hard", "HEAD")
self._git("clean", "-fd")
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)