Create Relay Bot MVP
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'}"
|
||||
Reference in New Issue
Block a user