purvansh01's picture
Deploy to HuggingFace (with Recent Activity Telemetry)
ae84c0a
Raw
History Blame Contribute Delete
16.3 kB
from fastapi import (
FastAPI,
WebSocket,
WebSocketDisconnect,
BackgroundTasks,
HTTPException,
)
from pydantic import BaseModel
from github import Github
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import Optional
from agents import run_agent_loop
import asyncio
import json
import uuid
import httpx
import os
import sys
import platform
import subprocess
import re
import logging
from contextlib import asynccontextmanager
logger = logging.getLogger(__name__)
from pathlib import Path
def get_safe_repo_dir(repo_name: str) -> Path:
base_tmp = Path("/tmp").resolve()
clean_name = repo_name.replace("/", "_").replace("\\", "_")
if ".." in clean_name:
raise HTTPException(status_code=400, detail="Invalid repository name")
repo_dir = (base_tmp / clean_name).resolve()
if not repo_dir.is_relative_to(base_tmp) or repo_dir == base_tmp:
raise HTTPException(status_code=400, detail="Invalid repository name")
return repo_dir
def get_safe_target_path(repo_dir: Path, file_path: str) -> Path:
clean_path = file_path.lstrip("/").lstrip("\\")
if ".." in clean_path.replace("\\", "/").split("/"):
raise HTTPException(status_code=400, detail="Path traversal not allowed")
target_path = (repo_dir / clean_path).resolve()
if not target_path.is_relative_to(repo_dir):
raise HTTPException(status_code=403, detail="Invalid file path")
return target_path
gitnexus_process = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global gitnexus_process
# Start the GitNexus MCP server in the background
try:
gitnexus_process = subprocess.Popen(
["gitnexus", "serve"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
print("GitNexus server started on port 4747")
except Exception as e:
print(f"Failed to start GitNexus: {e}")
yield
if gitnexus_process:
gitnexus_process.terminate()
print("GitNexus server stopped")
app = FastAPI(title="AutoMaintainer Backend", lifespan=lifespan)
# Allow the Next.js frontend to connect to this API
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class StartRequest(BaseModel):
repo_name: str
target_issue: Optional[int] = None
class FileUpdateRequest(BaseModel):
file_path: str
content: str
commit_message: Optional[str] = None
class FileCreateRequest(BaseModel):
file_path: str
content: str = ""
is_dir: bool = False
commit_message: Optional[str] = None
active_task: Optional[asyncio.Task] = None
@app.post("/start")
async def start_agents(req: StartRequest):
global active_task
if active_task and not active_task.done():
return {"status": "already_running"}
run_id = str(uuid.uuid4())
active_task = asyncio.create_task(
run_agent_loop(req.repo_name, req.target_issue, run_id)
)
return {"status": "started", "run_id": run_id}
@app.post("/repo/{repo_name:path}/file")
async def update_repo_file(repo_name: str, payload: FileUpdateRequest):
token = os.getenv("GITHUB_TOKEN")
if not token:
raise HTTPException(status_code=401, detail="GitHub token not configured")
gh = Github(token)
try:
repo = gh.get_repo(repo_name)
except Exception as e:
raise HTTPException(status_code=404, detail=f"Repository not found: {e}")
try:
file = repo.get_contents(payload.file_path)
except Exception as e:
raise HTTPException(status_code=404, detail=f"File not found in repo: {e}")
message = (
payload.commit_message or f"Update {payload.file_path} via AutoMaintainer IDE"
)
try:
repo.update_file(file.path, message, payload.content, file.sha)
# Write to local clone so the IDE doesn't show stale reads
repo_dir = get_safe_repo_dir(repo_name)
if repo_dir.exists():
target_path = get_safe_target_path(repo_dir, payload.file_path)
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(payload.content, encoding="utf-8")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update file: {e}")
return {"status": "updated", "message": message}
@app.post("/repo/{repo_name:path}/file/create")
async def create_repo_file(repo_name: str, payload: FileCreateRequest):
token = os.getenv("GITHUB_TOKEN")
if not token:
raise HTTPException(status_code=401, detail="GitHub token not configured")
gh = Github(token)
try:
repo = gh.get_repo(repo_name)
except Exception as e:
raise HTTPException(status_code=404, detail=f"Repository not found: {e}")
message = (
payload.commit_message or f"Create {payload.file_path} via AutoMaintainer IDE"
)
actual_path = payload.file_path
actual_content = payload.content
if payload.is_dir:
actual_path = f"{payload.file_path.rstrip('/')}/.gitkeep"
actual_content = ""
try:
repo.create_file(actual_path, message, actual_content)
# Write to local clone
repo_dir = get_safe_repo_dir(repo_name)
if repo_dir.exists():
target_path = get_safe_target_path(repo_dir, actual_path)
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(actual_content, encoding="utf-8")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create file: {e}")
return {"status": "created", "message": message}
@app.delete("/repo/{repo_name:path}/file")
async def delete_repo_file(repo_name: str, file_path: str):
token = os.getenv("GITHUB_TOKEN")
if not token:
raise HTTPException(status_code=401, detail="GitHub token not configured")
gh = Github(token)
try:
repo = gh.get_repo(repo_name)
except Exception as e:
raise HTTPException(status_code=404, detail=f"Repository not found: {e}")
try:
file = repo.get_contents(file_path)
except Exception as e:
raise HTTPException(status_code=404, detail=f"File not found in repo: {e}")
message = f"Delete {file_path} via AutoMaintainer IDE"
try:
repo.delete_file(file.path, message, file.sha)
# Local delete
import shutil
repo_dir = get_safe_repo_dir(repo_name)
if repo_dir.exists():
target_path = get_safe_target_path(repo_dir, file_path)
if target_path.exists():
if target_path.is_file():
target_path.unlink()
elif target_path.is_dir():
shutil.rmtree(target_path)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to delete file: {e}")
return {"status": "deleted", "message": message}
@app.post("/stop")
async def stop_agents():
global active_task
if active_task and not active_task.done():
active_task.cancel()
active_task = None
return {"status": "stopped"}
return {"status": "not_running"}
@app.websocket("/api/terminal/ws")
async def terminal_ws(websocket: WebSocket, repo_url: str = ""):
origin = websocket.headers.get("origin")
if origin and not (
origin.startswith("http://localhost")
or "huggingface.co" in origin
or origin.startswith("http://127.0.0.1")
):
await websocket.close(code=1008)
return
await websocket.accept()
cwd = None
if repo_url:
from urllib.parse import urlparse
parsed = urlparse(repo_url)
repo_name = parsed.path.strip("/")
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
parts = [p for p in repo_name.split("/") if p]
if len(parts) >= 2:
repo_name = f"{parts[-2]}/{parts[-1]}"
clean_name = repo_name.replace("/", "_").replace("\\", "_")
base_tmp = os.path.abspath("/tmp")
repo_dir = os.path.abspath(os.path.join(base_tmp, clean_name))
if repo_dir.startswith(base_tmp + os.sep) and os.path.exists(repo_dir):
cwd = repo_dir
if sys.platform == "win32":
import pywinpty
cols, rows = 80, 24
pty = pywinpty.PTY(cols, rows)
pty.spawn(pywinpty.winpty.get_default_cmd(), cwd=cwd)
async def read_from_pty():
while True:
try:
data = await asyncio.to_thread(pty.read)
if data:
await websocket.send_text(data)
else:
await asyncio.sleep(0.01)
except Exception:
break
read_task = asyncio.create_task(read_from_pty())
try:
while True:
message = await websocket.receive_text()
if message.startswith('{"type":"resize"'):
msg_data = json.loads(message)
pty.set_size(msg_data["cols"], msg_data["rows"])
else:
await asyncio.to_thread(pty.write, message)
except Exception:
pass
finally:
read_task.cancel()
try:
del pty
except Exception:
pass
else:
import pty
import fcntl
import termios
import struct
import signal
pid, fd = pty.fork()
if pid == 0:
if cwd:
os.chdir(cwd)
os.environ["TERM"] = "xterm-256color"
os.execv("/bin/bash", ["/bin/bash"])
else:
async def read_from_pty():
while True:
try:
data = await asyncio.to_thread(os.read, fd, 1024)
if data:
await websocket.send_text(
data.decode("utf-8", errors="replace")
)
else:
break
except Exception:
break
read_task = asyncio.create_task(read_from_pty())
try:
while True:
message = await websocket.receive_text()
if message.startswith('{"type":"resize"'):
msg_data = json.loads(message)
winsize = struct.pack(
"HHHH", msg_data["rows"], msg_data["cols"], 0, 0
)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
else:
await asyncio.to_thread(os.write, fd, message.encode("utf-8"))
except Exception:
pass
finally:
read_task.cancel()
try:
os.close(fd)
except Exception:
pass
try:
os.kill(pid, signal.SIGKILL)
os.waitpid(pid, 0)
except Exception:
pass
@app.get("/repo/{repo_name:path}/tree")
def get_repo_tree(repo_name: str):
repo_dir = get_safe_repo_dir(repo_name)
if not os.path.exists(repo_dir):
raise HTTPException(
status_code=404,
detail="Repository not cloned yet. Start an agent loop first.",
)
ignored_dirs = {
".git",
"node_modules",
"__pycache__",
"venv",
"env",
"build",
"dist",
}
def build_tree(path):
tree = []
try:
for item in os.listdir(path):
if item in ignored_dirs:
continue
item_path = os.path.join(path, item)
# Prevent symlink loops
if os.path.islink(item_path):
continue
is_dir = os.path.isdir(item_path)
node = {
"name": item,
"path": Path(item_path).relative_to(repo_dir).as_posix(),
"type": "directory" if is_dir else "file",
}
if is_dir:
node["children"] = build_tree(item_path)
tree.append(node)
except (OSError, PermissionError) as e:
logger.warning(f"Error accessing path {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error accessing file system")
# Sort directories first, then files
tree.sort(key=lambda x: (x["type"] != "directory", x["name"].lower()))
return tree
return {"name": repo_name, "type": "directory", "children": build_tree(repo_dir)}
@app.get("/repo/{repo_name:path}/search")
def search_repo(repo_name: str, q: str):
repo_dir = get_safe_repo_dir(repo_name)
if not repo_dir.exists():
raise HTTPException(status_code=404, detail="Repo not found locally")
ignored_dirs = {
".git",
"node_modules",
"__pycache__",
"venv",
"env",
"build",
"dist",
".next",
}
results = []
try:
for root, dirs, files in os.walk(repo_dir):
dirs[:] = [
d
for d in dirs
if d not in ignored_dirs and not os.path.islink(os.path.join(root, d))
]
for file in files:
file_path = os.path.join(root, file)
if os.path.islink(file_path):
continue
try:
with open(file_path, "r", encoding="utf-8") as f:
for i, line in enumerate(f):
if q.lower() in line.lower():
rel_path = (
Path(file_path).relative_to(repo_dir).as_posix()
)
results.append(
{
"file": rel_path,
"line_number": i + 1,
"snippet": line.strip()[:200],
}
)
except UnicodeDecodeError:
pass
except Exception:
pass
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return {"query": q, "results": results[:100]}
@app.get("/repo/{repo_name:path}/file")
def get_repo_file(repo_name: str, file_path: str):
repo_dir = get_safe_repo_dir(repo_name)
target_path = get_safe_target_path(repo_dir, file_path)
import os, stat
try:
fd = os.open(target_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
except OSError:
raise HTTPException(status_code=404, detail="File not found")
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
raise HTTPException(status_code=404, detail="File not found")
with os.fdopen(fd, "r", encoding="utf-8") as f:
content = f.read()
return {"content": content}
except UnicodeDecodeError:
raise HTTPException(status_code=415, detail="Cannot read binary file")
except HTTPException:
raise
except Exception as e:
logger.exception(f"Failed to read file {target_path}: {e}")
raise HTTPException(
status_code=500, detail="An internal error occurred while reading the file"
)
finally:
try:
os.close(fd)
except OSError:
pass
# Serve the static Next.js frontend if the out directory exists
if os.path.exists("../dashboard/out"):
app.mount(
"/", StaticFiles(directory="../dashboard/out", html=True), name="dashboard"
)
elif os.path.exists("dashboard/out"): # In docker container
app.mount("/", StaticFiles(directory="dashboard/out", html=True), name="dashboard")