Spaces:
Runtime error
Runtime error
| 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 | |
| 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 | |
| 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} | |
| 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} | |
| 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} | |
| 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} | |
| 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"} | |
| 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 | |
| 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)} | |
| 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]} | |
| 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") | |