Some checks failed
Architecture Lint / Linter Tests (pull_request) Successful in 10s
PR Checklist / pr-checklist (pull_request) Failing after 1m12s
Smoke Test / smoke (pull_request) Failing after 8s
Validate Config / YAML Lint (pull_request) Failing after 9s
Validate Config / JSON Validate (pull_request) Successful in 9s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 10s
Validate Config / Python Test Suite (pull_request) Has been skipped
Validate Config / Shell Script Lint (pull_request) Failing after 14s
Validate Config / Cron Syntax Check (pull_request) Successful in 5s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 6s
Validate Config / Playbook Schema Validation (pull_request) Successful in 9s
Architecture Lint / Lint Repository (pull_request) Failing after 7s
121 lines
3.7 KiB
Python
121 lines
3.7 KiB
Python
#!/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())
|