diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a3293e..d8b03cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,10 @@ jobs: - name: Compile backend run: python -m compileall -q backend + - name: Run backend unit tests + working-directory: backend + run: python -m unittest discover -s tests -v + frontend: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 5c05bc8..c7db01a 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Developer Answer - ๐Ÿ”’ **Local-first AI** - ๐Ÿšซ **No paid AI API** - ๐Ÿ›ก๏ธ **Does not execute cloned repository code** +- ๐Ÿงน **Repository inventory, deletion, stale cleanup, and local capacity guard** - ๐ŸŒ™ **Dark mode** - ๐ŸŽจ **Cartoonish / meme-ish student UI** @@ -440,6 +441,9 @@ POST /api/analyze POST /api/search POST /api/ask POST /api/architecture +GET /api/repositories +DELETE /api/repositories/{repo_id} +POST /api/repositories/cleanup ``` ### Health @@ -482,6 +486,18 @@ POST /api/architecture Generates an architecture explanation using repository context. +### Repository lifecycle + +RepoPilot now exposes local repository inventory and cleanup endpoints. The server refuses new clones/uploads once `REPOPILOT_MAX_REPOSITORIES` is reached (default: 100) until old repositories are removed. + +```text +GET /api/repositories +DELETE /api/repositories/{repo_id} +POST /api/repositories/cleanup +``` + +Stale cleanup accepts `{"max_age_hours": 24}` with a bounded range of 1 hour through 30 days. + --- # ๐Ÿง  Why this is a good college project diff --git a/backend/app.py b/backend/app.py index 91e37d5..1d530fd 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,7 +1,7 @@ from fastapi import FastAPI,HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel,Field -from services.github import clone_repo,safe_repo_path +from services.github import clone_repo,safe_repo_path,list_repositories,delete_repository,cleanup_repositories from services.analyzer import analyze,search_code,build_context from services.ollama import available,chat from api_upload import router as upload_router @@ -12,6 +12,7 @@ class Analyze(BaseModel): url:str=Field(min_length=10,max_length=500) class RepoReq(BaseModel): repo_id:str class Ask(RepoReq): question:str=Field(min_length=3,max_length=1200) class Search(RepoReq): query:str=Field(min_length=1,max_length=100) +class Cleanup(BaseModel): max_age_hours:int=Field(default=24,ge=1,le=24*30) @app.get("/api/health") def health(): return {"ok":True,"ollama":available()} @app.post("/api/analyze") @@ -38,3 +39,21 @@ def do_ask(x:Ask): def arch(x:Ask): try:return {"answer":ai(x.repo_id,"Explain the architecture, components, entry points and request/data flow.","architecture")} except Exception as e:raise HTTPException(500,str(e)) + +@app.get("/api/repositories") +def repositories(): + rows=list_repositories() + return {"repositories":rows,"count":len(rows)} + +@app.delete("/api/repositories/{repo_id}") +def remove_repository(repo_id:str): + try: + delete_repository(repo_id) + return {"repo_id":repo_id,"deleted":True} + except (ValueError,FileNotFoundError) as e: + raise HTTPException(404,str(e)) + +@app.post("/api/repositories/cleanup") +def cleanup(x:Cleanup): + deleted=cleanup_repositories(x.max_age_hours) + return {"deleted":deleted,"count":len(deleted),"max_age_hours":x.max_age_hours} diff --git a/backend/services/github.py b/backend/services/github.py index d345c48..dbd8793 100644 --- a/backend/services/github.py +++ b/backend/services/github.py @@ -1,6 +1,7 @@ from pathlib import Path from urllib.parse import urlparse -import re, shutil, subprocess, uuid, zipfile +import os, re, shutil, subprocess, uuid, zipfile +from datetime import datetime, timezone BASE_DIR = Path(__file__).resolve().parents[1] / "repos" BASE_DIR.mkdir(exist_ok=True) @@ -10,6 +11,83 @@ MAX_ZIP_BYTES = 50 * 1024 * 1024 MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024 MAX_ZIP_FILES = 5000 +MAX_REPOSITORIES = int(os.getenv("REPOPILOT_MAX_REPOSITORIES", "100")) + +def repository_ids(): + if not BASE_DIR.exists(): + return [] + return sorted( + p.name + for p in BASE_DIR.iterdir() + if p.is_dir() and re.fullmatch(r"[a-f0-9]{12}", p.name) + ) + + +def ensure_repository_capacity(): + count = len(repository_ids()) + if count >= MAX_REPOSITORIES: + raise RuntimeError( + f"Repository capacity reached ({count}/{MAX_REPOSITORIES}). " + "Delete old repositories or run cleanup before analyzing another one." + ) + + +def repository_summary(repo_id: str): + path = safe_repo_path(repo_id) + total_bytes = 0 + file_count = 0 + for p in path.rglob("*"): + if not p.is_file(): + continue + file_count += 1 + try: + total_bytes += p.stat().st_size + except OSError: + pass + return { + "repo_id": repo_id, + "file_count": file_count, + "total_bytes": total_bytes, + "updated_at": datetime.fromtimestamp( + path.stat().st_mtime, + tz=timezone.utc, + ).isoformat(), + } + + +def list_repositories(): + rows = [] + for repo_id in repository_ids(): + try: + rows.append(repository_summary(repo_id)) + except FileNotFoundError: + continue + rows.sort(key=lambda item: item["updated_at"], reverse=True) + return rows + + +def delete_repository(repo_id: str): + path = safe_repo_path(repo_id) + shutil.rmtree(path) + + +def cleanup_repositories(max_age_hours: int, now: datetime | None = None): + current = now or datetime.now(timezone.utc) + cutoff = current.timestamp() - max_age_hours * 60 * 60 + deleted = [] + for repo_id in repository_ids(): + try: + path = safe_repo_path(repo_id) + modified = path.stat().st_mtime + except (FileNotFoundError, OSError): + continue + if modified >= cutoff: + continue + shutil.rmtree(path, ignore_errors=True) + if not path.exists(): + deleted.append(repo_id) + return sorted(deleted) + def validate_github_url(url: str) -> str: p = urlparse(url.strip()) @@ -25,6 +103,7 @@ def validate_github_url(url: str) -> str: return f"https://github.com/{owner}/{repo}.git" def clone_repo(url: str): + ensure_repository_capacity() safe = validate_github_url(url) repo_id = uuid.uuid4().hex[:12] dest = BASE_DIR / repo_id @@ -40,6 +119,7 @@ def clone_repo(url: str): return repo_id, dest def extract_zip(upload_path: Path): + ensure_repository_capacity() if upload_path.stat().st_size > MAX_ZIP_BYTES: raise ValueError("ZIP is too large. Maximum upload size is 50 MB.") repo_id = uuid.uuid4().hex[:12] diff --git a/backend/tests/test_repository_lifecycle.py b/backend/tests/test_repository_lifecycle.py new file mode 100644 index 0000000..629eed3 --- /dev/null +++ b/backend/tests/test_repository_lifecycle.py @@ -0,0 +1,86 @@ +import os +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +from services import github + + +class RepositoryLifecycleTests(unittest.TestCase): + def make_repo(self, root: Path, repo_id: str, files=None) -> Path: + repo = root / repo_id + repo.mkdir(parents=True) + for name, content in (files or {}).items(): + target = repo / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return repo + + def test_repository_summary_reports_size_and_file_count(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + repo_id = "a" * 12 + self.make_repo( + root, + repo_id, + {"a.txt": "abc", "src/b.py": "print('ok')\n"}, + ) + + with patch("services.github.BASE_DIR", root): + summary = github.repository_summary(repo_id) + + self.assertEqual(summary["repo_id"], repo_id) + self.assertEqual(summary["file_count"], 2) + self.assertEqual(summary["total_bytes"], 15) + self.assertIn("+00:00", summary["updated_at"]) + + def test_capacity_guard_blocks_new_repository_when_full(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + self.make_repo(root, "a" * 12) + self.make_repo(root, "b" * 12) + + with ( + patch("services.github.BASE_DIR", root), + patch("services.github.MAX_REPOSITORIES", 2), + ): + with self.assertRaisesRegex(RuntimeError, "capacity reached"): + github.ensure_repository_capacity() + + def test_cleanup_repositories_deletes_only_expired_repositories(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + old = self.make_repo(root, "a" * 12) + fresh = self.make_repo(root, "b" * 12) + now = datetime(2026, 9, 23, 12, 0, tzinfo=timezone.utc) + old_ts = now.timestamp() - 48 * 60 * 60 + fresh_ts = now.timestamp() - 2 * 60 * 60 + os.utime(old, (old_ts, old_ts)) + os.utime(fresh, (fresh_ts, fresh_ts)) + + with patch("services.github.BASE_DIR", root): + deleted = github.cleanup_repositories(24, now=now) + + self.assertEqual(deleted, ["a" * 12]) + self.assertFalse(old.exists()) + self.assertTrue(fresh.exists()) + + def test_delete_repository_is_scoped_to_valid_repository_id(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + first = self.make_repo(root, "a" * 12) + second = self.make_repo(root, "b" * 12) + + with patch("services.github.BASE_DIR", root): + github.delete_repository("a" * 12) + with self.assertRaises(ValueError): + github.delete_repository("../outside") + + self.assertFalse(first.exists()) + self.assertTrue(second.exists()) + + +if __name__ == "__main__": + unittest.main()