143 lines
4.1 KiB
Python
Executable File
143 lines
4.1 KiB
Python
Executable File
#!/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()
|