136 lines
4.6 KiB
Python
136 lines
4.6 KiB
Python
#!/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 <repo_owner>/<repo_name>
|
|
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]} <owner/repo>")
|
|
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()
|