Files
meeks dfdd8c0931 fix: improve prompts, error messages, and workspace concurrency
- Externalize coordinator, notification, and planning prompts to separate files
- Add workspace mutex for concurrent file operations
- Improve error messages across file_tools, issue_tools, and pr_tools
- Add logging to tool modules for better debugging
- Update tests to match new error message strings
2026-08-01 20:01:46 +02:00

216 lines
7.8 KiB
Python

import os
import logging
import subprocess
import base64
import shutil
import threading
from pathlib import Path
from urllib.parse import urlparse
from .config import GITEA_REPOS_ROOT, GITEA_URL, GITEA_TOKEN
from gitea.client import GiteaClient
logger: logging.Logger = logging.getLogger("gitea-workspace")
class WorkspaceManager:
"""Manages local workspace for Gitea repositories.
Uses per-repo threading locks to prevent concurrent git operations
on the same repository from causing race conditions.
"""
_repo_locks: dict[str, threading.Lock] = {}
_locks_lock: threading.Lock = threading.Lock()
def __init__(self) -> None:
self.root_dir: Path = Path(GITEA_REPOS_ROOT).expanduser().resolve()
self.root_dir.mkdir(parents=True, exist_ok=True)
@classmethod
def get_repo_lock(cls, repo_full_name: str) -> threading.Lock:
"""Get or create a thread-safe lock for a specific repository."""
with cls._locks_lock:
if repo_full_name not in cls._repo_locks:
cls._repo_locks[repo_full_name] = threading.Lock()
return cls._repo_locks[repo_full_name]
def _configure_repo_user(self, repo_path: Path) -> None:
try:
client = GiteaClient()
user = client.repos.get_authenticated_user()
if not user or not user.login:
raise RuntimeError("No authenticated user found.")
username: str = user.login
auth_str: str = f"{username}:{GITEA_TOKEN}"
auth_bytes: bytes = auth_str.encode("utf-8")
auth_b64: str = base64.b64encode(auth_bytes).decode("utf-8")
# Configure extraHeader locally for the repo
subprocess.run(
[
"git",
"-C",
str(repo_path),
"config",
"http.extraHeader",
f"Authorization: Basic {auth_b64}",
],
check=True,
capture_output=True,
)
name: str = user.full_name or user.login
email: str = user.email or f"{user.login}@noreply.gitea"
subprocess.run(
["git", "-C", str(repo_path), "config", "user.name", name],
check=True,
capture_output=True,
)
subprocess.run(
["git", "-C", str(repo_path), "config", "user.email", email],
check=True,
capture_output=True,
)
except Exception as e:
logger.error(f"Error configuring local git user: {e}")
raise
def get_repo_path(self, repo_full_name: str) -> Path:
parts: list[str] = repo_full_name.split("/")
return self.root_dir / parts[0] / parts[1]
def _get_authenticated_url(self, repo_full_name: str) -> str:
parsed = urlparse(GITEA_URL.rstrip("/"))
path = parsed.path.rstrip("/")
return f"{parsed.scheme}://{parsed.netloc}{path}/{repo_full_name}.git"
def sanitize_repo(self, repo_full_name: str, repo_path: Path) -> None:
lock = self.get_repo_lock(repo_full_name)
with lock:
logger.debug(f"Acquired workspace lock for {repo_full_name} (sanitize)")
self._sanitize_repo_inner(repo_full_name, repo_path)
def _sanitize_repo_inner(self, repo_full_name: str, repo_path: Path) -> None:
try:
auth_url = self._get_authenticated_url(repo_full_name)
subprocess.run(
["git", "-C", str(repo_path), "remote", "set-url", "origin", auth_url],
check=True,
capture_output=True,
)
self._configure_repo_user(repo_path)
# Check for any uncommitted changes or untracked files
status_res = subprocess.run(
["git", "-C", str(repo_path), "status", "--porcelain"],
check=True,
capture_output=True,
text=True,
)
if status_res.stdout.strip():
logger.info(
f"Uncommitted changes detected in {repo_path}. Stashing before sanitization."
)
subprocess.run(
[
"git",
"-C",
str(repo_path),
"stash",
"push",
"-u",
"-m",
"Auto-backup before agent sanitization",
],
check=True,
capture_output=True,
)
subprocess.run(
["git", "-C", str(repo_path), "reset", "--hard", "HEAD"],
check=True,
capture_output=True,
)
subprocess.run(
["git", "-C", str(repo_path), "clean", "-fdx"],
check=True,
capture_output=True,
)
try:
subprocess.run(
["git", "-C", str(repo_path), "checkout", "main"],
check=True,
capture_output=True,
)
except subprocess.CalledProcessError:
subprocess.run(
["git", "-C", str(repo_path), "checkout", "master"],
check=True,
capture_output=True,
)
try:
subprocess.run(
["git", "-C", str(repo_path), "pull", "origin", "main"],
check=True,
capture_output=True,
)
except subprocess.CalledProcessError:
subprocess.run(
["git", "-C", str(repo_path), "pull", "origin", "master"],
check=True,
capture_output=True,
)
except Exception as e:
logger.error(f"Error during sanitization: {e}", exc_info=True)
raise RuntimeError(
f"Failed to sanitize repository {repo_full_name} at {repo_path}: {e}"
) from e
def clone_repo(self, repo_full_name: str, clone_url: str | None = None) -> Path:
lock = self.get_repo_lock(repo_full_name)
with lock:
logger.debug(f"Acquired workspace lock for {repo_full_name} (clone)")
return self._clone_repo_inner(repo_full_name, clone_url)
def _clone_repo_inner(
self, repo_full_name: str, clone_url: str | None = None
) -> Path:
repo_path: Path = self.get_repo_path(repo_full_name)
if repo_path.exists():
if not (repo_path / ".git").exists():
new_path: Path = (
repo_path.parent / f"{repo_full_name.replace('/', '_')}_old"
)
if new_path.exists():
shutil.rmtree(new_path)
repo_path.rename(new_path)
return repo_path
logger.info(f"Cloning repository {repo_full_name} to {repo_path}...")
auth_url = self._get_authenticated_url(repo_full_name)
client = GiteaClient()
user = client.repos.get_authenticated_user()
if not user or not user.login:
raise RuntimeError("No authenticated user found.")
username: str = user.login
auth_str: str = f"{username}:{GITEA_TOKEN}"
auth_bytes: bytes = auth_str.encode("utf-8")
auth_b64: str = base64.b64encode(auth_bytes).decode("utf-8")
subprocess.run(
[
"git",
"clone",
"-c",
f"http.extraHeader=Authorization: Basic {auth_b64}",
auth_url,
str(repo_path),
],
check=True,
capture_output=True,
)
self._configure_repo_user(repo_path)
return repo_path