#!/usr/bin/env python3 """ Auto-Merger — merges approved PRs via squash merge. Checks: 1. PR has at least 1 approval review 2. PR is mergeable 3. No pending change requests 4. From mimo swarm (safety: only auto-merge mimo PRs) Squash merges, closes issue, cleans up branch. """ import json import os import urllib.request import urllib.error from datetime import datetime, timezone GITEA_URL = "https://forge.alexanderwhitestone.com" TOKEN_FILE = os.path.expanduser("~/.config/gitea/token") LOG_DIR = os.path.expanduser("~/.hermes/mimo-swarm/logs") REPO = "Timmy_Foundation/the-nexus" def load_token(): with open(TOKEN_FILE) as f: return f.read().strip() def api_get(path, token): url = f"{GITEA_URL}/api/v1{path}" req = urllib.request.Request(url, headers={ "Authorization": f"token {token}", "Accept": "application/json", }) try: with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read()) except: return None def api_post(path, token, data=None): url = f"{GITEA_URL}/api/v1{path}" body = json.dumps(data or {}).encode() req = urllib.request.Request(url, data=body, headers={ "Authorization": f"token {token}", "Content-Type": "application/json", }, method="POST") try: with urllib.request.urlopen(req, timeout=30) as resp: return resp.status, resp.read().decode() except urllib.error.HTTPError as e: return e.code, e.read().decode() if e.fp else "" def api_delete(path, token): url = f"{GITEA_URL}/api/v1{path}" req = urllib.request.Request(url, headers={ "Authorization": f"token {token}", }, method="DELETE") try: with urllib.request.urlopen(req, timeout=30) as resp: return resp.status except: return 500 def log(msg): ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") print(f"[{ts}] {msg}") log_file = os.path.join(LOG_DIR, f"merger-{datetime.now().strftime('%Y%m%d')}.log") with open(log_file, "a") as f: f.write(f"[{ts}] {msg}\n") def main(): token = load_token() log("=" * 50) log("AUTO-MERGER — checking approved PRs") prs = api_get(f"/repos/{REPO}/pulls?state=open&limit=20", token) if not prs: log("No open PRs") return merged = 0 skipped = 0 for pr in prs: pr_num = pr["number"] head_ref = pr.get("head", {}).get("ref", "") body = pr.get("body", "") or "" mergeable = pr.get("mergeable", False) # Only auto-merge mimo PRs is_mimo = "mimo" in head_ref.lower() or "Automated by mimo" in body if not is_mimo: continue # Check reviews reviews = api_get(f"/repos/{REPO}/pulls/{pr_num}/reviews", token) or [] approvals = [r for r in reviews if r.get("state") == "APPROVED"] changes_requested = [r for r in reviews if r.get("state") == "CHANGES_REQUESTED"] if changes_requested: log(f" SKIP #{pr_num}: has change requests") skipped += 1 continue if not approvals: log(f" SKIP #{pr_num}: no approvals yet") skipped += 1 continue # Attempt squash merge merge_title = pr["title"] merge_msg = f"Squash merge #{pr_num}: {merge_title}\n\n{body}" status, response = api_post(f"/repos/{REPO}/pulls/{pr_num}/merge", token, { "Do": "squash", "MergeTitleField": merge_title, "MergeMessageField": f"Closes #{pr_num}\n\nAutomated merge by mimo swarm.", }) if status == 200: merged += 1 log(f" MERGED #{pr_num}: {merge_title[:50]}") # Delete the branch if head_ref and head_ref != "main": api_delete(f"/repos/{REPO}/git/refs/heads/{head_ref}", token) log(f" Deleted branch: {head_ref}") else: log(f" MERGE FAILED #{pr_num}: status={status}, {response[:200]}") log(f"Merge complete: {merged} merged, {skipped} skipped") if __name__ == "__main__": main()