Create Relay Bot MVP

This commit is contained in:
Emil
2026-07-24 22:36:04 +03:00
commit d6dc624301
51 changed files with 2226 additions and 0 deletions
View File
+56
View File
@@ -0,0 +1,56 @@
from pathlib import Path, PurePosixPath
from urllib.parse import urlsplit
SECRET_NAMES = {".env", ".env.local", "id_rsa", "credentials.json", "secrets.yml", "secrets.yaml"}
SKIPPED_PARTS = {".git", "node_modules", ".venv", "venv", "build", "dist", "__pycache__"}
SECRET_SUFFIXES = {".pem", ".key", ".p12", ".pfx"}
def safe_repository_path(root: Path, requested: str) -> Path:
if not requested or "\x00" in requested:
raise ValueError("Invalid path")
candidate = (root / requested).resolve()
if root.resolve() not in candidate.parents and candidate != root.resolve():
raise ValueError("Path is outside the repository")
if any(part in SKIPPED_PARTS for part in candidate.relative_to(root.resolve()).parts):
raise ValueError("Excluded path")
if candidate.name.lower() in SECRET_NAMES or candidate.suffix.lower() in SECRET_SUFFIXES:
raise ValueError("Sensitive file")
return candidate
def validate_repository_url(value: str) -> str:
parsed = urlsplit(value.strip())
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
raise ValueError("Нужна HTTPS-ссылка на Git-репозиторий без токена в URL")
if parsed.query or parsed.fragment:
raise ValueError("Ссылка на репозиторий не должна содержать параметры или fragment")
path = parsed.path.rstrip("/")
if path.endswith(".git"):
path = path[:-4]
if not path or path == "/":
raise ValueError("Неполная ссылка на репозиторий")
return f"https://{parsed.netloc}{path}.git"
def safe_repository_member(requested: str) -> PurePosixPath:
path = PurePosixPath(requested)
if not requested or path.is_absolute() or ".." in path.parts or "\x00" in requested:
raise ValueError("Invalid repository path")
if any(part in SKIPPED_PARTS for part in path.parts):
raise ValueError("Excluded path")
if path.name.lower() in SECRET_NAMES or path.suffix.lower() in SECRET_SUFFIXES:
raise ValueError("Sensitive file")
return path
def allowed_repository_file(path: Path, max_bytes: int) -> bool:
try:
safe_repository_path(path.parent if path.is_absolute() else Path("."), path.name)
return (
path.is_file()
and path.stat().st_size <= max_bytes
and path.suffix.lower() not in SECRET_SUFFIXES
)
except (OSError, ValueError):
return False
+104
View File
@@ -0,0 +1,104 @@
import hashlib
import logging
import subprocess
from pathlib import Path
from urllib.parse import quote, urlsplit, urlunsplit
from app.repository.security import validate_repository_url
from app.repository.tools import RepositoryTools
logger = logging.getLogger(__name__)
class RepositoryService:
def __init__(self, url: str, branch: str, path: Path, token: str | None, max_file_bytes: int):
self.url, self.branch, self.path, self.token, self.max_file_bytes = (
url,
branch,
path,
token,
max_file_bytes,
)
def _authenticated_url(self) -> str:
if not self.token:
return self.url
parsed = urlsplit(self.url)
if parsed.scheme != "https":
raise ValueError("Private repository URL must use HTTPS")
return urlunsplit(
(
parsed.scheme,
f"oauth2:{quote(self.token, safe='')}@{parsed.netloc}",
parsed.path,
parsed.query,
"",
)
)
def sync(self) -> str:
if not self.url:
raise RuntimeError("REPOSITORY_URL is not configured")
self.path.parent.mkdir(parents=True, exist_ok=True)
if not (self.path / ".git").exists():
clone_args = [
"git",
"clone",
"--filter=blob:none",
"--no-checkout",
"--depth",
"50",
]
if self.branch != "HEAD":
clone_args.extend(["--branch", self.branch])
clone_args.extend([self._authenticated_url(), str(self.path)])
subprocess.run(
clone_args,
check=True,
capture_output=True,
text=True,
)
else:
fetch_args = ["git", "fetch", "origin", "--depth", "50"]
if self.branch != "HEAD":
fetch_args.append(self.branch)
subprocess.run(
fetch_args,
cwd=self.path,
check=True,
capture_output=True,
text=True,
)
target = "origin/HEAD" if self.branch == "HEAD" else f"origin/{self.branch}"
subprocess.run(
["git", "reset", "--soft", target],
cwd=self.path,
check=True,
capture_output=True,
text=True,
)
commit = RepositoryTools(self.path, self.max_file_bytes).current_commit()
logger.info("repository_synced branch=%s commit=%s", self.branch, commit)
return commit
def tools(self) -> RepositoryTools:
if not (self.path / ".git").exists():
raise RuntimeError("Repository is not synced")
return RepositoryTools(self.path, self.max_file_bytes)
class RepositoryManager:
def __init__(self, cache_root: Path, token: str | None, max_file_bytes: int):
self.cache_root = cache_root.resolve()
self.token, self.max_file_bytes = token, max_file_bytes
def service_for(
self, url: str, branch: str = "HEAD", cache_path: str | None = None
) -> RepositoryService:
normalized = validate_repository_url(url)
cache = (
Path(cache_path)
if cache_path
else self.cache_root / hashlib.sha256(normalized.encode()).hexdigest()
)
return RepositoryService(normalized, branch, cache, self.token, self.max_file_bytes)
+71
View File
@@ -0,0 +1,71 @@
import subprocess
from pathlib import Path
from app.repository.security import SKIPPED_PARTS, safe_repository_member
class RepositoryTools:
"""Git-backed read tools. A partial clone fetches a blob only when read_file needs it."""
def __init__(self, root: Path, max_file_bytes: int):
self.root, self.max_file_bytes = root.resolve(), max_file_bytes
def _run(self, args: list[str], timeout: int = 15) -> str:
result = subprocess.run(
args, cwd=self.root, text=True, capture_output=True, timeout=timeout, check=False
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "Repository command failed")
return result.stdout
def tree(self, limit: int = 300) -> str:
output = self._run(["git", "ls-tree", "-r", "--name-only", "HEAD"])
paths: list[str] = []
for path in output.splitlines():
if any(part in SKIPPED_PARTS for part in Path(path).parts):
continue
try:
safe_repository_member(path)
except ValueError:
continue
paths.append(path)
return "\n".join(paths[:limit])
def find_files(self, query: str) -> str:
if not query or len(query) > 100:
raise ValueError("Invalid search query")
return "\n".join(
line for line in self.tree(2_000).splitlines() if query.lower() in line.lower()
)[:12_000]
def search_text(self, query: str) -> str:
if not query or len(query) > 300:
raise ValueError("Invalid search query")
return "Text search would fetch too many blobs; use find_files then read_file."
def read_file(self, path: str, start_line: int = 1, end_line: int = 200) -> str:
member = safe_repository_member(path)
if start_line < 1 or end_line < start_line or end_line - start_line > 500:
raise ValueError("Invalid line range")
size = int(self._run(["git", "cat-file", "-s", f"HEAD:{member.as_posix()}"]).strip())
if size > self.max_file_bytes:
raise ValueError("File is unavailable or too large")
content = self._run(["git", "show", f"HEAD:{member.as_posix()}"], timeout=30)
lines = content.splitlines()
return "\n".join(
f"{i}: {line}" for i, line in enumerate(lines[start_line - 1 : end_line], start_line)
)
def current_commit(self) -> str:
return self._run(["git", "rev-parse", "HEAD"]).strip()
def recent_commits(self) -> str:
return self._run(["git", "log", "--oneline", "-10"])
def recent_diff(self) -> str:
return self._run(["git", "diff", "HEAD~1", "HEAD", "--stat"])[:12_000]
def file_info(self, path: str) -> str:
member = safe_repository_member(path)
size = self._run(["git", "cat-file", "-s", f"HEAD:{member.as_posix()}"]).strip()
return f"{path}: {size} bytes, extension={member.suffix or 'none'}"