#!/usr/bin/env python3 """ Merge Conflict Detector — catches sibling PRs that will conflict. When multiple PRs branch from the same base commit and touch the same files, merging one invalidates the others. This script detects that pattern before it creates a rebase cascade. Usage: python3 conflict_detector.py # Check all repos python3 conflict_detector.py --repo OWNER/REPO # Check one repo Environment: GITEA_URL — Gitea instance URL GITEA_TOKEN — API token """ import os import sys import json import urllib.request from collections import defaultdict GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com") GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") REPOS = [ "Timmy_Foundation/the-nexus", "Timmy_Foundation/timmy-config", "Timmy_Foundation/timmy-home", "Timmy_Foundation/fleet-ops", "Timmy_Foundation/hermes-agent", "Timmy_Foundation/the-beacon", ] def api(path): url = f"{GITEA_URL}/api/v1{path}" req = urllib.request.Request(url) if GITEA_TOKEN: req.add_header("Authorization", f"token {GITEA_TOKEN}") try: with urllib.request.urlopen(req, timeout=15) as resp: return json.loads(resp.read()) except Exception: return [] def check_repo(repo): """Find sibling PRs that touch the same files.""" prs = api(f"/repos/{repo}/pulls?state=open&limit=50") if not prs: return [] # Group PRs by base commit by_base = defaultdict(list) for pr in prs: base_sha = pr.get("merge_base", pr.get("base", {}).get("sha", "unknown")) by_base[base_sha].append(pr) conflicts = [] for base_sha, siblings in by_base.items(): if len(siblings) < 2: continue # Get files for each sibling file_map = {} for pr in siblings: files = api(f"/repos/{repo}/pulls/{pr['number']}/files") if files: file_map[pr['number']] = set(f['filename'] for f in files) # Find overlapping file sets pr_nums = list(file_map.keys()) for i in range(len(pr_nums)): for j in range(i+1, len(pr_nums)): a, b = pr_nums[i], pr_nums[j] overlap = file_map[a] & file_map[b] if overlap: conflicts.append({ "repo": repo, "pr_a": a, "pr_b": b, "base": base_sha[:8], "files": sorted(overlap), "title_a": next(p["title"] for p in siblings if p["number"] == a), "title_b": next(p["title"] for p in siblings if p["number"] == b), }) return conflicts def main(): repos = REPOS if "--repo" in sys.argv: idx = sys.argv.index("--repo") + 1 if idx < len(sys.argv): repos = [sys.argv[idx]] all_conflicts = [] for repo in repos: conflicts = check_repo(repo) all_conflicts.extend(conflicts) if not all_conflicts: print("No sibling PR conflicts detected. Queue is clean.") return 0 print(f"Found {len(all_conflicts)} potential merge conflicts:") print() for c in all_conflicts: print(f" {c['repo']}:") print(f" PR #{c['pr_a']} vs #{c['pr_b']} (base: {c['base']})") print(f" #{c['pr_a']}: {c['title_a'][:60]}") print(f" #{c['pr_b']}: {c['title_b'][:60]}") print(f" Overlapping files: {', '.join(c['files'])}") print(f" → Merge one first, then rebase the other.") print() return 1 if __name__ == "__main__": sys.exit(main())