#!/usr/bin/env python3 """ CI Auto-Revert — Poka-yoke for broken merges. Monitors the main branch post-merge and auto-reverts via local git if CI fails. Usage: python ci_auto_revert.py / python ci_auto_revert.py Timmy_Foundation/hermes-agent Recommended cron: */10 * * * * """ import os import sys import json import subprocess import tempfile from datetime import datetime, timedelta, timezone from urllib import request, error GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com") REVERT_WINDOW_MINUTES = 10 def api_call(method, path): url = f"{GITEA_URL}/api/v1{path}" headers = {"Authorization": f"token {GITEA_TOKEN}"} req = request.Request(url, method=method, headers=headers) try: with request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode()) except error.HTTPError as e: return {"error": e.read().decode(), "status": e.code} def get_recent_commits(owner, repo, since): since_iso = since.strftime("%Y-%m-%dT%H:%M:%SZ") return api_call("GET", f"/repos/{owner}/{repo}/commits?sha=main&since={since_iso}&limit=20") def get_commit_status(owner, repo, sha): return api_call("GET", f"/repos/{owner}/{repo}/commits/{sha}/status") def revert_via_git(clone_url, sha, msg, owner, repo): with tempfile.TemporaryDirectory() as tmpdir: # Clone with token auth_url = clone_url.replace("https://", f"https://bezalel:{GITEA_TOKEN}@") subprocess.run(["git", "clone", "--depth", "10", auth_url, tmpdir], check=True, capture_output=True) # Configure git subprocess.run(["git", "-C", tmpdir, "config", "user.email", "bezalel@timmy.foundation"], check=True, capture_output=True) subprocess.run(["git", "-C", tmpdir, "config", "user.name", "Bezalel"], check=True, capture_output=True) # Revert the commit revert_msg = f"[auto-revert] {msg}\n\nOriginal commit {sha} failed CI." result = subprocess.run( ["git", "-C", tmpdir, "revert", "--no-edit", "-m", revert_msg, sha], capture_output=True, text=True, ) if result.returncode != 0: return {"error": f"git revert failed: {result.stderr}"} # Push push_result = subprocess.run( ["git", "-C", tmpdir, "push", "origin", "main"], capture_output=True, text=True, ) if push_result.returncode != 0: return {"error": f"git push failed: {push_result.stderr}"} return {"ok": True, "reverted_sha": sha} def main(): if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} ") sys.exit(1) repo_full = sys.argv[1] owner, repo = repo_full.split("/", 1) since = datetime.now(timezone.utc) - timedelta(minutes=REVERT_WINDOW_MINUTES + 5) commits = get_recent_commits(owner, repo, since) if not isinstance(commits, list): print(f"ERROR fetching commits: {commits}") sys.exit(1) reverted = 0 for commit in commits: sha = commit.get("sha", "") msg = commit.get("commit", {}).get("message", "").split("\n")[0] commit_time = commit.get("commit", {}).get("committer", {}).get("date", "") if not commit_time: continue commit_dt = datetime.fromisoformat(commit_time.replace("Z", "+00:00")) age_min = (datetime.now(timezone.utc) - commit_dt).total_seconds() / 60 if age_min > REVERT_WINDOW_MINUTES: continue status = get_commit_status(owner, repo, sha) state = status.get("state", "") if state == "failure": print(f"ALERT: Commit {sha[:8]} '{msg}' failed CI ({age_min:.1f}m old). Initiating revert...") repo_info = api_call("GET", f"/repos/{owner}/{repo}") clone_url = repo_info.get("clone_url", "") if not clone_url: print(f" Cannot find clone URL") continue result = revert_via_git(clone_url, sha, msg, owner, repo) if "error" in result: print(f" Revert failed: {result['error']}") else: print(f" Reverted successfully.") reverted += 1 elif state == "success": print(f"OK: Commit {sha[:8]} '{msg}' passed CI.") elif state == "pending": print(f"PENDING: Commit {sha[:8]} '{msg}' still running CI.") else: print(f"UNKNOWN: Commit {sha[:8]} '{msg}' has CI state '{state}'.") if reverted == 0: print("No broken merges found in the last 10 minutes.") if __name__ == "__main__": main()