168 lines
5.7 KiB
Python
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)
|