Spaces:
Running
Running
| import concurrent.futures | |
| import os | |
| import re | |
| import shutil | |
| import subprocess # nosec B404 — subprocess is required for git diff; all inputs are validated before use | |
| import uuid | |
| from pathlib import Path | |
| from threading import Lock | |
| from git import Repo | |
| from app.core.config import get_settings | |
| from app.core.logger import get_logger | |
| CLONE_TIMEOUT_SECONDS = 60 | |
| settings = get_settings() | |
| logger = get_logger(__name__) | |
| _clone_lock = Lock() | |
| def _get_dir_size_mb(path: Path) -> float: | |
| total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) | |
| return total / (1024 * 1024) | |
| def get_changed_files(local_path: str, base_sha: str, head_sha: str) -> list[str]: | |
| """Return list of .py files changed between two commits.""" | |
| result = subprocess.run( # nosec B603 B607 — hardcoded executable, no shell, shas are validated by git itself | |
| ["git", "diff", "--name-only", base_sha, head_sha], | |
| cwd=local_path, | |
| capture_output=True, | |
| text=True, | |
| timeout=30, | |
| ) | |
| if result.returncode != 0: | |
| return [] | |
| return [ | |
| f | |
| for f in result.stdout.splitlines() | |
| if f.endswith(".py") and os.path.exists(os.path.join(local_path, f)) | |
| ] | |
| GITHUB_URL_PATTERN = re.compile( | |
| r"^https://github\.com/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(\.git)?$" | |
| ) | |
| def clone_repository(github_url: str) -> str: | |
| if not GITHUB_URL_PATTERN.match(github_url.strip()): | |
| raise ValueError( | |
| f"Invalid GitHub URL: '{github_url}'. " | |
| "Must be in the format https://github.com/owner/repo" | |
| ) | |
| repo_name = github_url.rstrip("/").split("/")[-1] | |
| if repo_name.endswith(".git"): | |
| repo_name = repo_name[:-4] | |
| clone_dir = Path(settings.repo_clone_dir) | |
| clone_dir.mkdir(parents=True, exist_ok=True) | |
| with _clone_lock: | |
| local_path = clone_dir / f"{repo_name}_{uuid.uuid4().hex[:8]}" | |
| try: | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: | |
| future = ex.submit( | |
| lambda: Repo.clone_from(github_url, str(local_path), depth=1) | |
| ) | |
| try: | |
| future.result(timeout=CLONE_TIMEOUT_SECONDS) | |
| except concurrent.futures.TimeoutError: | |
| shutil.rmtree(local_path, ignore_errors=True) | |
| raise TimeoutError(f"Clone timed out after {CLONE_TIMEOUT_SECONDS}s") | |
| except TimeoutError: | |
| raise | |
| except Exception: | |
| shutil.rmtree(local_path, ignore_errors=True) | |
| raise | |
| size_mb = _get_dir_size_mb(local_path) | |
| if size_mb > settings.max_repo_size_mb: | |
| shutil.rmtree(local_path, ignore_errors=True) | |
| raise ValueError( | |
| f"Repository size {size_mb:.1f}MB exceeds limit of {settings.max_repo_size_mb}MB" | |
| ) | |
| return str(local_path) | |
| def delete_repository(local_path: str) -> None: | |
| path = Path(local_path) | |
| if path.exists(): | |
| shutil.rmtree(path, ignore_errors=True) | |
| logger.info("Deleted cloned repository", extra={"path": str(local_path)}) | |
| def resolve_remote_head_sha(github_url: str) -> str | None: | |
| """ | |
| Resolve the current HEAD commit SHA of a remote repo without cloning, | |
| using `git ls-remote`. Used as the cache key for analysis results. | |
| Returns None if the SHA can't be determined (e.g. network error, | |
| private repo without credentials) — callers should treat this as | |
| "cache not applicable" and fall back to the uncached pipeline. | |
| """ | |
| try: | |
| result = subprocess.run( # nosec B603 B607 — hardcoded executable, no shell, URL is validated by caller | |
| ["git", "ls-remote", github_url, "HEAD"], | |
| capture_output=True, | |
| text=True, | |
| timeout=15, | |
| ) | |
| except Exception: | |
| logger.warning("Failed to resolve remote HEAD sha", extra={"url": github_url}) | |
| return None | |
| if result.returncode != 0 or not result.stdout.strip(): | |
| return None | |
| return result.stdout.split()[0] | |