import asyncio import sqlite3 import logging import re import time from backend.services.usb_vault import get_secret from backend.services.usb_monitor import get_db_path from backend.ws.agent_ws import ws_manager # Ensure DB table exists def init_github_db(): try: with sqlite3.connect(get_db_path()) as conn: conn.execute('PRAGMA journal_mode=WAL') conn.execute(""" CREATE TABLE IF NOT EXISTS commit_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, commit_hash TEXT, message TEXT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() except Exception as e: logging.error(f"Failed to init github db: {e}") init_github_db() # Mutex to prevent concurrent commits commit_lock = asyncio.Lock() async def run_git(cmd: str, cwd: str = None) -> tuple[int, str, str]: """Runs a git command and returns (returncode, stdout, stderr).""" proc = await asyncio.create_subprocess_shell( cmd, cwd=cwd or '.', stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await proc.communicate() return proc.returncode, stdout.decode().strip(), stderr.decode().strip() async def execute_commit(message: str) -> dict: """Executes a git commit and push with safety checks, queueing, and rate-limit backoff.""" async with commit_lock: logging.info("GITHUB_ENGINE: Acquired commit lock.") # 1. Fetch token from Vault token = get_secret("GITHUB_TOKEN") if not token: return {"status": "error", "message": "GitHub Token not found in vault. Please inject it securely via EXE."} # 2. Incremental Diff Compute # Check if there are unstaged changes or untracked files await run_git("git add -A") code, stdout, stderr = await run_git("git diff --cached --name-only") if not stdout: return {"status": "skipped", "message": "No incremental changes detected. Aborting commit."} logging.info(f"GITHUB_ENGINE: Staged files for commit:\n{stdout}") # 3. Commit code, c_out, c_err = await run_git(f'git commit -m "{message}"') if code != 0: return {"status": "error", "message": f"Commit failed: {c_err or c_out}"} # Extract commit hash hash_code, h_out, h_err = await run_git("git rev-parse HEAD") commit_hash = h_out if hash_code == 0 else "UNKNOWN_HASH" # 4. Push with Rate Limit Backoff # Using a dummy remote URL structure that includes the PAT. # Ensure your git remote is configured correctly if relying on system git. # For simplicity, we just run git push. max_retries = 3 attempt = 0 push_success = False while attempt < max_retries: attempt += 1 logging.info(f"GITHUB_ENGINE: Pushing to remote (Attempt {attempt})...") p_code, p_out, p_err = await run_git("git push") if p_code == 0: push_success = True break # 5. Rate-Limit 403/429 Backoff Parsing # GitHub usually responds with HTTP 403 or 429 and a "retry-after" header embedded in the stderr proxy response. combined_err = (p_err + " " + p_out).lower() if "403" in combined_err or "429" in combined_err or "rate limit" in combined_err: retry_match = re.search(r"retry-after[:\s]+(\d+)", combined_err) sleep_time = int(retry_match.group(1)) if retry_match else 60 logging.warning(f"GITHUB_ENGINE: Rate limit hit. Backing off for {sleep_time} seconds.") await ws_manager.broadcast("github:rate_limit", {"wait_seconds": sleep_time}) await asyncio.sleep(sleep_time) else: return {"status": "error", "message": f"Push failed: {p_err}"} if not push_success: return {"status": "error", "message": "Push failed after max retries."} # 6. SQLite History Logging try: with sqlite3.connect(get_db_path()) as conn: conn.execute('PRAGMA journal_mode=WAL') conn.execute("INSERT INTO commit_history (commit_hash, message) VALUES (?, ?)", (commit_hash, message)) conn.commit() except Exception as e: logging.error(f"Failed to log commit history: {e}") # 7. WebSocket Event Emitted await ws_manager.broadcast("github:commit_success", { "hash": commit_hash, "message": message, "timestamp": time.time() }) logging.info(f"GITHUB_ENGINE: Commit and push successful! Hash: {commit_hash}") return {"status": "success", "hash": commit_hash, "message": "Successfully committed and pushed to remote."}