| """ |
| Rhodawk AI — Conviction Engine (Auto-Merge Gate) |
| ================================================ |
| Evaluates whether a successfully verified fix meets the conviction threshold |
| for autonomous merge without human review. |
| |
| Conviction criteria (all must be met): |
| 1. adversarial_confidence >= CONVICTION_CONFIDENCE_MIN (default 0.92) |
| 2. adversarial_verdict == "APPROVE" (no conditional) |
| 3. consensus_fraction >= CONVICTION_CONSENSUS_MIN (default 0.85 — 3/3 models agree) |
| 4. Memory engine found a semantically identical past fix that was human-merged |
| (similarity >= CONVICTION_MEMORY_MIN, default 0.85) |
| 5. test_attempts == 1 (fixed on first try — indicates clean, well-understood fix) |
| 6. SAST findings == 0 (zero informational findings on the diff) |
| 7. No new packages introduced in the diff |
| |
| When all criteria pass, auto_merge() is called which uses the GitHub API to |
| merge the PR directly (no human required). |
| |
| Enable with: RHODAWK_AUTO_MERGE=true |
| """ |
|
|
| import os |
| import time |
| import requests |
|
|
| CONVICTION_CONFIDENCE_MIN = float(os.getenv("RHODAWK_CONVICTION_CONFIDENCE", "0.92")) |
| CONVICTION_CONSENSUS_MIN = float(os.getenv("RHODAWK_CONVICTION_CONSENSUS", "0.85")) |
| CONVICTION_MEMORY_MIN = float(os.getenv("RHODAWK_CONVICTION_MEMORY_SIM", "0.85")) |
| AUTO_MERGE_ENABLED = os.getenv("RHODAWK_AUTO_MERGE", "false").lower() == "true" |
|
|
|
|
| def evaluate_conviction( |
| adversarial_review: dict, |
| similar_fixes: list[dict], |
| test_attempts: int, |
| sast_findings_count: int, |
| new_packages: list[str], |
| ) -> tuple[bool, str]: |
| """ |
| Returns (should_auto_merge, reason_string). |
| All criteria must pass for auto-merge to be approved. |
| """ |
| if not AUTO_MERGE_ENABLED: |
| return False, "auto-merge disabled (RHODAWK_AUTO_MERGE != true)" |
|
|
| verdict = adversarial_review.get("verdict", "CONDITIONAL") |
| confidence = float(adversarial_review.get("confidence", 0.0)) |
| consensus_fraction = float(adversarial_review.get("consensus_fraction", 0.0)) |
|
|
| checks: list[tuple[bool, str]] = [ |
| (verdict == "APPROVE", |
| f"adversarial_verdict must be APPROVE, got {verdict}"), |
| (confidence >= CONVICTION_CONFIDENCE_MIN, |
| f"adversarial_confidence {confidence:.3f} < {CONVICTION_CONFIDENCE_MIN}"), |
| (consensus_fraction >= CONVICTION_CONSENSUS_MIN, |
| f"consensus_fraction {consensus_fraction:.3f} < {CONVICTION_CONSENSUS_MIN}"), |
| (test_attempts == 1, |
| f"fixed in {test_attempts} attempt(s), require 1"), |
| (sast_findings_count == 0, |
| f"SAST has {sast_findings_count} finding(s), require 0"), |
| (not new_packages, |
| f"diff introduces new packages: {new_packages}"), |
| ] |
|
|
| memory_check_passed = False |
| best_memory_sim = 0.0 |
| for fix in similar_fixes: |
| sim = float(fix.get("similarity", 0.0)) |
| if sim >= CONVICTION_MEMORY_MIN: |
| memory_check_passed = True |
| best_memory_sim = sim |
| break |
|
|
| checks.append(( |
| memory_check_passed, |
| f"no human-merged memory match with similarity >= {CONVICTION_MEMORY_MIN} " |
| f"(best found: {best_memory_sim:.3f})" |
| )) |
|
|
| failed = [(passed, reason) for passed, reason in checks if not passed] |
| if failed: |
| reasons = "; ".join(r for _, r in failed) |
| return False, f"conviction not met: {reasons}" |
|
|
| return True, ( |
| f"all {len(checks)} conviction criteria passed " |
| f"(confidence={confidence:.3f}, consensus={consensus_fraction:.3f}, " |
| f"memory_sim={best_memory_sim:.3f})" |
| ) |
|
|
|
|
| def auto_merge_pr( |
| repo: str, |
| pr_url: str, |
| token: str, |
| merge_method: str = "squash", |
| ) -> tuple[bool, str]: |
| """ |
| Merge a PR via GitHub API. |
| merge_method: "merge" | "squash" | "rebase" |
| Returns (success, message). |
| """ |
| if not pr_url or "github.com" not in pr_url: |
| return False, f"invalid PR URL: {pr_url}" |
|
|
| try: |
| parts = pr_url.rstrip("/").split("/") |
| pr_number = int(parts[-1]) |
| owner = parts[-4] |
| repo_name = parts[-3] |
| except (IndexError, ValueError) as e: |
| return False, f"could not parse PR URL {pr_url}: {e}" |
|
|
| headers = { |
| "Authorization": f"Bearer {token}", |
| "Accept": "application/vnd.github+json", |
| "X-GitHub-API-Version": "2022-11-28", |
| } |
|
|
| resp = requests.put( |
| f"https://api.github.com/repos/{owner}/{repo_name}/pulls/{pr_number}/merge", |
| headers=headers, |
| json={ |
| "merge_method": merge_method, |
| "commit_title": f"[Rhodawk] Autonomous merge — conviction threshold met", |
| "commit_message": ( |
| "This PR was autonomously merged by Rhodawk AI after meeting all " |
| "conviction criteria:\n" |
| "- Adversarial consensus review: APPROVED (3/3 models)\n" |
| "- Test verification: GREEN (1 attempt)\n" |
| "- SAST gate: CLEAN\n" |
| "- Memory match: CONFIRMED (prior human-merged fix)\n" |
| ), |
| }, |
| timeout=30, |
| ) |
|
|
| if resp.status_code in (200, 201): |
| return True, f"auto-merged PR #{pr_number} via {merge_method}" |
|
|
| return False, f"GitHub merge API returned {resp.status_code}: {resp.text[:200]}" |
|
|