Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion backend/app.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand All @@ -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}
82 changes: 81 additions & 1 deletion backend/services/github.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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())
Expand All @@ -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
Expand All @@ -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]
Expand Down
86 changes: 86 additions & 0 deletions backend/tests/test_repository_lifecycle.py
Original file line number Diff line number Diff line change
@@ -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()
Loading