From c9b7feaf0ca8006d57502c40dbc609befba85738 Mon Sep 17 00:00:00 2001 From: AIPass Date: Mon, 30 Mar 2026 16:18:07 -0700 Subject: [PATCH] feat(system): feat(drone): add merge, smart-sync, and fix plugins to devpulse_ops (#149) Co-authored-by: @devpulse --- ...ey_privileged_git_handler_fo_2026-03-30.md | 53 +- src/aipass/drone/.seedgo/bypass.json | 30 + src/aipass/drone/apps/modules/git_module.py | 154 ++++- .../apps/plugins/devpulse_ops/fix_plugin.py | 133 ++++ .../apps/plugins/devpulse_ops/merge_plugin.py | 99 +++ .../apps/plugins/devpulse_ops/sync_plugin.py | 114 ++++ .../drone/tests/test_devpulse_plugins.py | 623 ++++++++++++++++++ 7 files changed, 1203 insertions(+), 3 deletions(-) create mode 100644 src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py create mode 100644 src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py create mode 100644 src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py create mode 100644 src/aipass/drone/tests/test_devpulse_plugins.py diff --git a/src/aipass/devpulse/DPLAN-0087_devpulse_master_key_privileged_git_handler_fo_2026-03-30.md b/src/aipass/devpulse/DPLAN-0087_devpulse_master_key_privileged_git_handler_fo_2026-03-30.md index fcebe7e2..6f2b689d 100644 --- a/src/aipass/devpulse/DPLAN-0087_devpulse_master_key_privileged_git_handler_fo_2026-03-30.md +++ b/src/aipass/devpulse/DPLAN-0087_devpulse_master_key_privileged_git_handler_fo_2026-03-30.md @@ -163,9 +163,60 @@ elif command == "system-pr": - **Dry-run mode:** `drone @git system-pr --dry-run "description"` shows what would be staged and the PR description without actually creating anything. Good for reviewing before committing. - **Selective staging:** `drone @git system-pr --include "src/aipass/backup/ src/aipass/drone/" "description"` to stage only specific branch directories. Useful when not all changes should go in one PR. - **PR template:** Auto-generate the PR body from git diff stats, listing which branches were modified and how many files/lines per branch. -- **Merge plugin:** `drone @git merge 147` to merge a PR from the CLI. Would use `gh pr merge 147 --squash`. Same auth gate. - **Patrick mode:** `drone @git system-pr --author patrick "description"` changes the Co-Authored-By to Patrick's name. Requires a separate auth check (env var, config file, or interactive prompt). +## Phase 2 Plugins (dispatched to drone) + +### merge_plugin.py — `drone @git merge ` +- Squash-merges a PR via gh cli, auto-syncs local main after +- Same auth gate as system-pr + +### sync_plugin.py — `drone @git smart-sync` +- Detects diverged local/origin main, rebases cleanly +- Aborts and reports on conflict instead of leaving a mess + +### fix_plugin.py — `drone @git fix` +- Emergency button: detects stuck rebase, detached HEAD, diverged state, dirty index +- Auto-resolves common broken states, reports what it did + +## Phase 3: Git Snapshot Cache (Patrick's design) + +**IMPORTANT: This is NOT a plugin.** Plugins are devpulse-only privileged extensions. The snapshot cache protects ALL git operations for ALL branches — it's a handler. Lives in `drone/apps/handlers/git/snapshot_handler.py` alongside pr_handler, lock_handler, sync_handler. Both `pr_handler.py` and the devpulse plugins call it before any destructive action. + +### snapshot_handler.py — safety net for all git operations + +**Concept:** Before every git action (PR, merge, rebase), the affected files get copied to a local snapshot folder. Rolling history of ~10 snapshots. When the 11th comes in, the oldest rolls off — but not deleted, rolls to the backup system (like plans and deleted branches do). + +**Why:** We lost files in the past due to git operations gone wrong. The drone git workflow was built partly for this reason. But even with safe workflows, things can go sideways — a rebase drops a file, a merge conflict resolution loses changes, a force-push overwrites work. The snapshot cache means files are never truly lost. + +**Design:** +``` +drone/apps/plugins/devpulse_ops/ +├── snapshot_plugin.py # Snapshot before action, restore on demand +``` + +Each branch gets a `.git_snapshots/` directory (or a central one in the repo root): +``` +.git_snapshots/ +├── 001_2026-03-30T14:00_system-pr/ # Snapshot of files before PR #148 +│ ├── manifest.json # What files, what action, timestamp +│ └── files/ # Actual file copies +├── 002_2026-03-30T14:30_merge-148/ # Snapshot before merge +│ ├── manifest.json +│ └── files/ +└── ...up to 010 (rolling) +``` + +**Integration:** Every other plugin calls `snapshot_plugin.take_snapshot(action, files)` before doing anything destructive. The snapshot happens silently — no user interaction needed. + +**Rolloff:** When snapshot 11 arrives, snapshot 1 gets moved to the backup system (same pattern as deleted branches and closed plans — they don't delete, they archive). + +**Restore:** `drone @git restore ` or `drone @git restore latest` to recover files from a snapshot. Shows what would be restored before doing it. + +**Patrick's words:** "Even if there were multiple errors where files got lost and the temp file got overridden, at least we have the previous versions. When you make a PR, the files that you've included get cached to a temporary folder. They don't delete, they just roll off somewhere. So there's always like — what happened to those files? They were there last week. We look at the backup system, we see the trail." + +**Not building now** — this is Phase 3, after merge/sync/fix are proven. But the architecture should accommodate it from the start. + ## Relationships - **Related DPLANs:** DPLAN-0086 (Patrick's git workflow cheat sheet — this solves several of his pain points) - **Related FPLANs:** None yet — drone will create one after reviewing this design diff --git a/src/aipass/drone/.seedgo/bypass.json b/src/aipass/drone/.seedgo/bypass.json index 20f84167..db20ff6e 100644 --- a/src/aipass/drone/.seedgo/bypass.json +++ b/src/aipass/drone/.seedgo/bypass.json @@ -76,6 +76,36 @@ "file": "apps/plugins/devpulse_ops/pr_plugin.py", "standard": "trigger", "reason": "create_system_pr is a privileged operation — trigger integration deferred until trigger branch supports plugin events." + }, + { + "file": "apps/plugins/devpulse_ops/merge_plugin.py", + "standard": "architecture", + "reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure." + }, + { + "file": "apps/plugins/devpulse_ops/merge_plugin.py", + "standard": "encapsulation", + "reason": "Plugin imports lock_handler.find_repo_root() — drone-internal plugin, same branch." + }, + { + "file": "apps/plugins/devpulse_ops/sync_plugin.py", + "standard": "architecture", + "reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure." + }, + { + "file": "apps/plugins/devpulse_ops/sync_plugin.py", + "standard": "encapsulation", + "reason": "Plugin imports lock_handler.find_repo_root() — drone-internal plugin, same branch." + }, + { + "file": "apps/plugins/devpulse_ops/fix_plugin.py", + "standard": "architecture", + "reason": "Plugin lives in apps/plugins/ by design — separate from 3-layer structure." + }, + { + "file": "apps/plugins/devpulse_ops/fix_plugin.py", + "standard": "encapsulation", + "reason": "Plugin imports lock_handler.find_repo_root() — drone-internal plugin, same branch." } ], "notes": { diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index ddd41beb..24dbeba1 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -28,7 +28,7 @@ DRONE_MODULE = { "description": "Git workflow — PR, status, sync, lock management", } -_COMMANDS = ("pr", "status", "sync", "lock", "unlock", "system-pr") +_COMMANDS = ("pr", "status", "sync", "lock", "unlock", "system-pr", "merge", "smart-sync", "fix") def _detect_branch_dir() -> tuple[str, Path] | None: @@ -83,6 +83,12 @@ def handle_command(command: str | None = None, args: list[str] | None = None) -> if command == "system-pr": return _handle_system_pr(args) + if command == "merge": + return _handle_merge(args) + if command == "smart-sync": + return _handle_smart_sync(args) + if command == "fix": + return _handle_fix(args) if command == "pr": return _handle_pr(args) if command == "status": @@ -149,6 +155,129 @@ def _handle_system_pr(args: list[str]) -> dict: } +def _handle_merge(args: list[str]) -> dict: + """Handle the merge subcommand (devpulse-only).""" + if not args: + return { + "stdout": "", + "stderr": "Usage: drone @git merge ", + "exit_code": 1, + } + + pr_number = args[0] + + try: + from aipass.drone.apps.plugins.devpulse_ops.auth import verify_caller + from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr + except ImportError as exc: + logger.error("Failed to import devpulse_ops merge plugin: %s", exc) + return { + "stdout": "", + "stderr": f"devpulse_ops plugin not available: {exc}", + "exit_code": 1, + } + + try: + caller = verify_caller() + except PermissionError as exc: + logger.error("merge authorization failed: %s", exc) + return { + "stdout": "", + "stderr": str(exc), + "exit_code": 1, + } + + result = merge_pr(pr_number, caller) + + if result["success"]: + return { + "stdout": result["message"], + "stderr": "", + "exit_code": 0, + } + return { + "stdout": "", + "stderr": result["message"], + "exit_code": 1, + } + + +def _handle_smart_sync(args: list[str]) -> dict: + """Handle the smart-sync subcommand (devpulse-only).""" + try: + from aipass.drone.apps.plugins.devpulse_ops.auth import verify_caller + from aipass.drone.apps.plugins.devpulse_ops.sync_plugin import smart_sync + except ImportError as exc: + logger.error("Failed to import devpulse_ops sync plugin: %s", exc) + return { + "stdout": "", + "stderr": f"devpulse_ops plugin not available: {exc}", + "exit_code": 1, + } + + try: + caller = verify_caller() + except PermissionError as exc: + logger.error("smart-sync authorization failed: %s", exc) + return { + "stdout": "", + "stderr": str(exc), + "exit_code": 1, + } + + result = smart_sync(caller) + + if result["success"]: + return { + "stdout": result["message"], + "stderr": "", + "exit_code": 0, + } + return { + "stdout": "", + "stderr": result["message"], + "exit_code": 1, + } + + +def _handle_fix(args: list[str]) -> dict: + """Handle the fix subcommand (devpulse-only).""" + try: + from aipass.drone.apps.plugins.devpulse_ops.auth import verify_caller + from aipass.drone.apps.plugins.devpulse_ops.fix_plugin import fix_git_state + except ImportError as exc: + logger.error("Failed to import devpulse_ops fix plugin: %s", exc) + return { + "stdout": "", + "stderr": f"devpulse_ops plugin not available: {exc}", + "exit_code": 1, + } + + try: + caller = verify_caller() + except PermissionError as exc: + logger.error("fix authorization failed: %s", exc) + return { + "stdout": "", + "stderr": str(exc), + "exit_code": 1, + } + + result = fix_git_state(caller) + + if result["success"]: + return { + "stdout": result["message"], + "stderr": "", + "exit_code": 0, + } + return { + "stdout": "", + "stderr": result["message"], + "exit_code": 1, + } + + def _handle_pr(args: list[str]) -> dict: """Handle the PR subcommand.""" if not args: @@ -296,6 +425,21 @@ def get_help(command: str | None = None) -> str: " Stages all tracked changes, creates a disposable feature branch,\n" " and opens a PR. Requires devpulse passport authorization.\n" ) + if command == "merge": + return ( + "git merge — Squash-merge a PR and sync local main (devpulse only)\n" + " Runs gh pr merge --squash --delete-branch, then git pull --rebase.\n" + ) + if command == "smart-sync": + return ( + "git smart-sync — Fetch origin and rebase if behind (devpulse only)\n" + " Detects divergence and rebases safely; aborts on conflict.\n" + ) + if command == "fix": + return ( + "git fix — Detect and fix common broken git states (devpulse only)\n" + " Fixes stuck rebases, detached HEAD, diverged branches, dirty index.\n" + ) return ( "git — Git workflow: PR, status, sync, lock management\n" @@ -303,6 +447,9 @@ def get_help(command: str | None = None) -> str: "Commands:\n" " pr Create a PR with scoped changes\n" " system-pr Create a system-wide PR (devpulse only)\n" + " merge Squash-merge a PR (devpulse only)\n" + " smart-sync Fetch + rebase if behind (devpulse only)\n" + " fix Fix broken git states (devpulse only)\n" " status Show git status for your branch\n" " sync Checkout main and pull\n" " lock Check lock status\n" @@ -325,8 +472,11 @@ def get_introspective() -> str: " plugins/devpulse_ops/\n" " - auth.py (verify_caller — passport-based authorization)\n" " - pr_plugin.py (create_system_pr — system-wide PR workflow)\n" + " - merge_plugin.py (merge_pr — squash-merge PR + sync)\n" + " - sync_plugin.py (smart_sync — fetch + rebase if behind)\n" + " - fix_plugin.py (fix_git_state — detect/fix broken states)\n" "\n" - "Commands: pr, system-pr, status, sync, lock, unlock\n" + "Commands: pr, system-pr, merge, smart-sync, fix, status, sync, lock, unlock\n" ) diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py new file mode 100644 index 00000000..86637a66 --- /dev/null +++ b/src/aipass/drone/apps/plugins/devpulse_ops/fix_plugin.py @@ -0,0 +1,133 @@ +# =================== AIPass ==================== +# Name: fix_plugin.py +# Description: Detect and fix common broken git states +# Version: 1.0.0 +# Created: 2026-03-30 +# Modified: 2026-03-30 +# ============================================= + +"""Detect and fix common broken git states. + +Runs a sequence of checks for stuck rebases, detached HEAD, diverged +branches, and dirty index, applying automatic fixes where safe. +Only authorized callers (verified via :mod:`auth`) may invoke this. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from aipass.prax import logger +from aipass.drone.apps.handlers.json import json_handler +from aipass.drone.apps.handlers.git.lock_handler import find_repo_root + + +def fix_git_state(caller: str) -> dict: + """Detect and fix common broken git states. + + Checks are run in sequence; multiple fixes can happen in one call. + + Args: + caller: The verified caller name (e.g. ``"devpulse"``). + + Returns: + Dict with success, actions_taken (list[str]), and message. + """ + repo_root = find_repo_root() + git_dir = repo_root / ".git" + actions: list[str] = [] + + result: dict = { + "success": False, + "actions_taken": actions, + "message": "", + } + + try: + # Check 1: Stuck in rebase + rebase_merge = git_dir / "rebase-merge" + rebase_apply = git_dir / "rebase-apply" + if rebase_merge.exists() or rebase_apply.exists(): + abort = subprocess.run( + ["git", "rebase", "--abort"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if abort.returncode == 0: + actions.append("Aborted stuck rebase") + logger.info("fix_git_state: aborted stuck rebase") + else: + actions.append(f"Failed to abort rebase: {abort.stderr.strip()}") + logger.error("fix_git_state: rebase abort failed: %s", abort.stderr.strip()) + + # Check 2: Detached HEAD + sym_ref = subprocess.run( + ["git", "symbolic-ref", "-q", "HEAD"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if sym_ref.returncode != 0: + checkout = subprocess.run( + ["git", "checkout", "main"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if checkout.returncode == 0: + actions.append("Checked out main (was detached HEAD)") + logger.info("fix_git_state: checked out main from detached HEAD") + else: + actions.append(f"Failed to checkout main: {checkout.stderr.strip()}") + logger.error("fix_git_state: checkout main failed: %s", checkout.stderr.strip()) + + # Check 3: Diverged from origin + rev_list = subprocess.run( + ["git", "rev-list", "--left-right", "--count", "main...origin/main"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if rev_list.returncode == 0: + parts = rev_list.stdout.strip().split() + ahead = int(parts[0]) if len(parts) >= 1 else 0 + behind = int(parts[1]) if len(parts) >= 2 else 0 + if ahead > 0 and behind > 0: + actions.append( + f"Diverged from origin (ahead={ahead}, behind={behind}). " + "Run: drone @git smart-sync" + ) + logger.warning("fix_git_state: diverged ahead=%d behind=%d", ahead, behind) + + # Check 4: Dirty index with no intent + cached = subprocess.run( + ["git", "diff", "--cached", "--name-only"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if cached.returncode == 0 and cached.stdout.strip(): + staged_files = cached.stdout.strip().splitlines() + reset = subprocess.run( + ["git", "reset", "HEAD"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if reset.returncode == 0: + actions.append(f"Unstaged {len(staged_files)} file(s) from index") + logger.info("fix_git_state: unstaged %d files", len(staged_files)) + else: + actions.append(f"Failed to reset index: {reset.stderr.strip()}") + logger.error("fix_git_state: reset failed: %s", reset.stderr.strip()) + + result["success"] = True + if actions: + result["message"] = f"Fixed {len(actions)} issue(s): {'; '.join(actions)}" + else: + result["message"] = "Git state is clean — nothing to fix" + + json_handler.log_operation( + "fix_git_state", + { + "caller": caller, + "actions_taken": actions, + }, + ) + logger.info(result["message"]) + return result + + except (OSError, subprocess.SubprocessError) as exc: + result["message"] = f"Fix git state error: {exc}" + logger.error(result["message"]) + return result diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py new file mode 100644 index 00000000..3af4e8fe --- /dev/null +++ b/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py @@ -0,0 +1,99 @@ +# =================== AIPass ==================== +# Name: merge_plugin.py +# Description: Squash-merge a PR and sync local main +# Version: 1.0.0 +# Created: 2026-03-30 +# Modified: 2026-03-30 +# ============================================= + +"""Squash-merge a PR and sync local main. + +Squash-merges the given PR number via ``gh``, deletes the remote branch, +pulls to sync local main, and returns the merge commit hash and PR title. +Only authorized callers (verified via :mod:`auth`) may invoke this. +""" + +from __future__ import annotations + +import subprocess + +from aipass.prax import logger +from aipass.drone.apps.handlers.json import json_handler +from aipass.drone.apps.handlers.git.lock_handler import find_repo_root + + +def merge_pr(pr_number: str, caller: str) -> dict: + """Squash-merge a PR and sync local main. + + Args: + pr_number: The PR number to merge (e.g. ``"42"``). + caller: The verified caller name (e.g. ``"devpulse"``). + + Returns: + Dict with success, pr_number, title, merge_commit, and message. + """ + repo_root = find_repo_root() + + result: dict = { + "success": False, + "pr_number": pr_number, + "title": "", + "merge_commit": "", + "message": "", + } + + try: + # Step 1: Squash-merge the PR + merge = subprocess.run( + ["gh", "pr", "merge", pr_number, "--squash", "--delete-branch"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if merge.returncode != 0: + result["message"] = f"Merge failed: {merge.stderr.strip()}" + logger.error(result["message"]) + return result + + # Step 2: Sync local main + pull = subprocess.run( + ["git", "pull", "--rebase"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if pull.returncode != 0: + result["message"] = f"Pull after merge failed: {pull.stderr.strip()}" + logger.error(result["message"]) + return result + + # Step 3: Get the merge commit hash + rev = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, text=True, cwd=str(repo_root), + ) + merge_commit = rev.stdout.strip() if rev.returncode == 0 else "unknown" + + # Step 4: Get the PR title + title_proc = subprocess.run( + ["gh", "pr", "view", pr_number, "--json", "title", "--jq", ".title"], + capture_output=True, text=True, cwd=str(repo_root), + ) + title = title_proc.stdout.strip() if title_proc.returncode == 0 else "unknown" + + result["success"] = True + result["title"] = title + result["merge_commit"] = merge_commit + result["message"] = f"PR #{pr_number} merged: {title} ({merge_commit[:8]})" + json_handler.log_operation( + "merge_pr", + { + "caller": caller, + "pr_number": pr_number, + "title": title, + "merge_commit": merge_commit, + }, + ) + logger.info(result["message"]) + return result + + except (OSError, subprocess.SubprocessError) as exc: + result["message"] = f"Merge workflow error: {exc}" + logger.error(result["message"]) + return result diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py new file mode 100644 index 00000000..7a0c07a3 --- /dev/null +++ b/src/aipass/drone/apps/plugins/devpulse_ops/sync_plugin.py @@ -0,0 +1,114 @@ +# =================== AIPass ==================== +# Name: sync_plugin.py +# Description: Smart sync — fetch, detect divergence, rebase if needed +# Version: 1.0.0 +# Created: 2026-03-30 +# Modified: 2026-03-30 +# ============================================= + +"""Smart sync — fetch, detect divergence, rebase if needed. + +Fetches the latest remote state, checks whether local main has diverged +from ``origin/main``, and rebases if behind. Aborts cleanly on conflict. +Only authorized callers (verified via :mod:`auth`) may invoke this. +""" + +from __future__ import annotations + +import subprocess + +from aipass.prax import logger +from aipass.drone.apps.handlers.json import json_handler +from aipass.drone.apps.handlers.git.lock_handler import find_repo_root + + +def smart_sync(caller: str) -> dict: + """Fetch origin and rebase local main if behind. + + Args: + caller: The verified caller name (e.g. ``"devpulse"``). + + Returns: + Dict with success, ahead, behind, rebased, and message. + """ + repo_root = find_repo_root() + + result: dict = { + "success": False, + "ahead": 0, + "behind": 0, + "rebased": False, + "message": "", + } + + try: + # Step 1: Fetch origin + fetch = subprocess.run( + ["git", "fetch", "origin"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if fetch.returncode != 0: + result["message"] = f"Fetch failed: {fetch.stderr.strip()}" + logger.error(result["message"]) + return result + + # Step 2: Check divergence + rev_list = subprocess.run( + ["git", "rev-list", "--left-right", "--count", "main...origin/main"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if rev_list.returncode != 0: + result["message"] = f"Divergence check failed: {rev_list.stderr.strip()}" + logger.error(result["message"]) + return result + + parts = rev_list.stdout.strip().split() + ahead = int(parts[0]) if len(parts) >= 1 else 0 + behind = int(parts[1]) if len(parts) >= 2 else 0 + result["ahead"] = ahead + result["behind"] = behind + + # Step 3: Rebase if behind + if behind > 0: + rebase = subprocess.run( + ["git", "rebase", "origin/main"], + capture_output=True, text=True, cwd=str(repo_root), + ) + if rebase.returncode != 0: + # Conflict — abort rebase + subprocess.run( + ["git", "rebase", "--abort"], + capture_output=True, text=True, cwd=str(repo_root), + ) + result["message"] = ( + f"Rebase conflict (ahead={ahead}, behind={behind}). " + "Rebase aborted. Manual resolution required." + ) + logger.error(result["message"]) + return result + + result["rebased"] = True + result["success"] = True + result["message"] = ( + f"Rebased onto origin/main (was {behind} behind, {ahead} ahead)" + ) + else: + result["success"] = True + result["message"] = "Already up to date" + + json_handler.log_operation( + "smart_sync", + { + "caller": caller, + "ahead": ahead, + "behind": behind, + "rebased": result["rebased"], + }, + ) + logger.info(result["message"]) + return result + + except (OSError, subprocess.SubprocessError) as exc: + result["message"] = f"Smart sync error: {exc}" + logger.error(result["message"]) + return result diff --git a/src/aipass/drone/tests/test_devpulse_plugins.py b/src/aipass/drone/tests/test_devpulse_plugins.py new file mode 100644 index 00000000..30c3d72b --- /dev/null +++ b/src/aipass/drone/tests/test_devpulse_plugins.py @@ -0,0 +1,623 @@ +# =================== AIPass ==================== +# Name: test_devpulse_plugins.py +# Description: Tests for devpulse_ops plugins — merge, smart-sync, fix +# Version: 1.0.0 +# Created: 2026-03-30 +# Modified: 2026-03-30 +# ============================================= + +"""Tests for devpulse_ops plugins — merge, smart-sync, fix.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr +from aipass.drone.apps.plugins.devpulse_ops.sync_plugin import smart_sync +from aipass.drone.apps.plugins.devpulse_ops.fix_plugin import fix_git_state + + +# =========================================================================== +# Fixtures +# =========================================================================== + + +@pytest.fixture() +def devpulse_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Create a temp directory with a devpulse passport.""" + trinity = tmp_path / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text( + json.dumps({ + "branch_info": {"branch_name": "devpulse"}, + "identity": {"name": "devpulse"}, + }), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture() +def seedgo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Create a temp directory with a seedgo passport (unauthorized).""" + trinity = tmp_path / ".trinity" + trinity.mkdir() + passport = trinity / "passport.json" + passport.write_text( + json.dumps({ + "branch_info": {"branch_name": "seedgo"}, + "identity": {"name": "seedgo"}, + }), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture() +def repo_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Create a temp repo root with AIPASS_REGISTRY.json and .git dir.""" + registry = tmp_path / "AIPASS_REGISTRY.json" + registry.write_text("{}", encoding="utf-8") + git_dir = tmp_path / ".git" + git_dir.mkdir() + monkeypatch.chdir(tmp_path) + return tmp_path + + +# =========================================================================== +# 1. Auth denial for each command +# =========================================================================== + + +class TestAuthDenialMerge: + """merge command should deny unauthorized callers.""" + + def test_merge_unauthorized(self, seedgo_dir: Path) -> None: + from aipass.drone.apps.modules.git_module import handle_command + + result = handle_command("merge", ["42"]) + assert result["exit_code"] == 1 + assert "not authorized" in result["stderr"] + + +class TestAuthDenialSmartSync: + """smart-sync command should deny unauthorized callers.""" + + def test_smart_sync_unauthorized(self, seedgo_dir: Path) -> None: + from aipass.drone.apps.modules.git_module import handle_command + + result = handle_command("smart-sync", []) + assert result["exit_code"] == 1 + assert "not authorized" in result["stderr"] + + +class TestAuthDenialFix: + """fix command should deny unauthorized callers.""" + + def test_fix_unauthorized(self, seedgo_dir: Path) -> None: + from aipass.drone.apps.modules.git_module import handle_command + + result = handle_command("fix", []) + assert result["exit_code"] == 1 + assert "not authorized" in result["stderr"] + + +# =========================================================================== +# 2. merge_pr tests +# =========================================================================== + + +class TestMergePrHappyPath: + """merge_pr should squash-merge, pull, and return commit + title.""" + + @patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run") + def test_merge_pr_success( + self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path + ) -> None: + mock_root.return_value = tmp_path + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + proc = MagicMock() + proc.returncode = 0 + proc.stderr = "" + if cmd[:3] == ["gh", "pr", "merge"]: + proc.stdout = "Merged\n" + elif cmd[:2] == ["git", "pull"]: + proc.stdout = "Already up to date.\n" + elif cmd[:3] == ["git", "rev-parse", "HEAD"]: + proc.stdout = "abc1234def5678\n" + elif cmd[:3] == ["gh", "pr", "view"]: + proc.stdout = "feat: awesome feature\n" + else: + proc.stdout = "" + return proc + + mock_run.side_effect = side_effect + + result = merge_pr("42", "devpulse") + + assert result["success"] is True + assert result["pr_number"] == "42" + assert result["title"] == "feat: awesome feature" + assert result["merge_commit"] == "abc1234def5678" + assert "42" in result["message"] + + +class TestMergePrFailure: + """merge_pr should return error when gh pr merge fails.""" + + @patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run") + def test_merge_pr_merge_fails( + self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path + ) -> None: + mock_root.return_value = tmp_path + + proc = MagicMock() + proc.returncode = 1 + proc.stderr = "PR is not mergeable" + proc.stdout = "" + mock_run.return_value = proc + + result = merge_pr("99", "devpulse") + + assert result["success"] is False + assert "Merge failed" in result["message"] + assert "not mergeable" in result["message"] + + +# =========================================================================== +# 3. smart_sync tests +# =========================================================================== + + +class TestSmartSyncUpToDate: + """smart_sync should report up-to-date when not behind.""" + + @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run") + def test_smart_sync_up_to_date( + self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path + ) -> None: + mock_root.return_value = tmp_path + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + proc = MagicMock() + proc.returncode = 0 + proc.stderr = "" + if cmd[:3] == ["git", "fetch", "origin"]: + proc.stdout = "" + elif cmd[:3] == ["git", "rev-list", "--left-right"]: + proc.stdout = "0\t0\n" + else: + proc.stdout = "" + return proc + + mock_run.side_effect = side_effect + + result = smart_sync("devpulse") + + assert result["success"] is True + assert result["rebased"] is False + assert "up to date" in result["message"] + + +class TestSmartSyncBehind: + """smart_sync should rebase when behind origin.""" + + @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run") + def test_smart_sync_behind_rebase_success( + self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path + ) -> None: + mock_root.return_value = tmp_path + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + proc = MagicMock() + proc.returncode = 0 + proc.stderr = "" + if cmd[:3] == ["git", "fetch", "origin"]: + proc.stdout = "" + elif cmd[:3] == ["git", "rev-list", "--left-right"]: + proc.stdout = "0\t3\n" + elif cmd[:3] == ["git", "rebase", "origin/main"]: + proc.stdout = "Successfully rebased\n" + else: + proc.stdout = "" + return proc + + mock_run.side_effect = side_effect + + result = smart_sync("devpulse") + + assert result["success"] is True + assert result["rebased"] is True + assert result["behind"] == 3 + + +class TestSmartSyncDivergedRebaseSuccess: + """smart_sync should rebase when diverged (behind > 0) and rebase succeeds.""" + + @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run") + def test_smart_sync_diverged_rebase_ok( + self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path + ) -> None: + mock_root.return_value = tmp_path + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + proc = MagicMock() + proc.returncode = 0 + proc.stderr = "" + if cmd[:3] == ["git", "fetch", "origin"]: + proc.stdout = "" + elif cmd[:3] == ["git", "rev-list", "--left-right"]: + proc.stdout = "2\t5\n" + elif cmd[:3] == ["git", "rebase", "origin/main"]: + proc.stdout = "Successfully rebased\n" + else: + proc.stdout = "" + return proc + + mock_run.side_effect = side_effect + + result = smart_sync("devpulse") + + assert result["success"] is True + assert result["rebased"] is True + assert result["ahead"] == 2 + assert result["behind"] == 5 + + +class TestSmartSyncRebaseConflict: + """smart_sync should abort rebase on conflict and return error.""" + + @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run") + def test_smart_sync_rebase_conflict( + self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path + ) -> None: + mock_root.return_value = tmp_path + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + proc = MagicMock() + proc.stderr = "" + if cmd[:3] == ["git", "fetch", "origin"]: + proc.returncode = 0 + proc.stdout = "" + elif cmd[:3] == ["git", "rev-list", "--left-right"]: + proc.returncode = 0 + proc.stdout = "1\t2\n" + elif cmd[:3] == ["git", "rebase", "origin/main"]: + proc.returncode = 1 + proc.stdout = "" + proc.stderr = "CONFLICT" + elif cmd[:3] == ["git", "rebase", "--abort"]: + proc.returncode = 0 + proc.stdout = "" + else: + proc.returncode = 0 + proc.stdout = "" + return proc + + mock_run.side_effect = side_effect + + result = smart_sync("devpulse") + + assert result["success"] is False + assert "conflict" in result["message"].lower() + assert result["rebased"] is False + + +# =========================================================================== +# 4. fix_git_state tests +# =========================================================================== + + +class TestFixStuckRebase: + """fix_git_state should abort a stuck rebase.""" + + @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run") + def test_fix_stuck_rebase( + self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path + ) -> None: + mock_root.return_value = tmp_path + # Create .git/rebase-merge to simulate stuck rebase + git_dir = tmp_path / ".git" + git_dir.mkdir() + (git_dir / "rebase-merge").mkdir() + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + proc = MagicMock() + proc.returncode = 0 + proc.stderr = "" + proc.stdout = "" + if cmd[:3] == ["git", "symbolic-ref", "-q"]: + proc.stdout = "refs/heads/main\n" + elif cmd[:3] == ["git", "rev-list", "--left-right"]: + proc.stdout = "0\t0\n" + elif cmd[:3] == ["git", "diff", "--cached"]: + proc.stdout = "" + return proc + + mock_run.side_effect = side_effect + + result = fix_git_state("devpulse") + + assert result["success"] is True + assert any("rebase" in a.lower() for a in result["actions_taken"]) + + +class TestFixDetachedHead: + """fix_git_state should checkout main on detached HEAD.""" + + @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run") + def test_fix_detached_head( + self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path + ) -> None: + mock_root.return_value = tmp_path + git_dir = tmp_path / ".git" + git_dir.mkdir() + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + proc = MagicMock() + proc.stderr = "" + proc.stdout = "" + if cmd[:3] == ["git", "symbolic-ref", "-q"]: + proc.returncode = 1 # detached HEAD + elif cmd[:3] == ["git", "checkout", "main"]: + proc.returncode = 0 + elif cmd[:3] == ["git", "rev-list", "--left-right"]: + proc.returncode = 0 + proc.stdout = "0\t0\n" + elif cmd[:3] == ["git", "diff", "--cached"]: + proc.returncode = 0 + proc.stdout = "" + else: + proc.returncode = 0 + return proc + + mock_run.side_effect = side_effect + + result = fix_git_state("devpulse") + + assert result["success"] is True + assert any("detached" in a.lower() for a in result["actions_taken"]) + + +class TestFixDiverged: + """fix_git_state should report divergence and suggest smart-sync.""" + + @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run") + def test_fix_diverged_suggests_sync( + self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path + ) -> None: + mock_root.return_value = tmp_path + git_dir = tmp_path / ".git" + git_dir.mkdir() + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + proc = MagicMock() + proc.returncode = 0 + proc.stderr = "" + proc.stdout = "" + if cmd[:3] == ["git", "symbolic-ref", "-q"]: + proc.stdout = "refs/heads/main\n" + elif cmd[:3] == ["git", "rev-list", "--left-right"]: + proc.stdout = "3\t2\n" # diverged + elif cmd[:3] == ["git", "diff", "--cached"]: + proc.stdout = "" + return proc + + mock_run.side_effect = side_effect + + result = fix_git_state("devpulse") + + assert result["success"] is True + assert any("smart-sync" in a for a in result["actions_taken"]) + + +class TestFixCleanState: + """fix_git_state should report nothing to fix when clean.""" + + @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run") + def test_fix_clean_state( + self, mock_run: MagicMock, mock_root: MagicMock, tmp_path: Path + ) -> None: + mock_root.return_value = tmp_path + git_dir = tmp_path / ".git" + git_dir.mkdir() + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + proc = MagicMock() + proc.returncode = 0 + proc.stderr = "" + proc.stdout = "" + if cmd[:3] == ["git", "symbolic-ref", "-q"]: + proc.stdout = "refs/heads/main\n" + elif cmd[:3] == ["git", "rev-list", "--left-right"]: + proc.stdout = "0\t0\n" + elif cmd[:3] == ["git", "diff", "--cached"]: + proc.stdout = "" + return proc + + mock_run.side_effect = side_effect + + result = fix_git_state("devpulse") + + assert result["success"] is True + assert len(result["actions_taken"]) == 0 + assert "nothing to fix" in result["message"].lower() + + +# =========================================================================== +# 5. Routing tests +# =========================================================================== + + +class TestGitModuleRouting: + """Test that git_module routes merge, smart-sync, fix correctly.""" + + def test_merge_in_commands(self) -> None: + from aipass.drone.apps.modules.git_module import _COMMANDS + + assert "merge" in _COMMANDS + + def test_smart_sync_in_commands(self) -> None: + from aipass.drone.apps.modules.git_module import _COMMANDS + + assert "smart-sync" in _COMMANDS + + def test_fix_in_commands(self) -> None: + from aipass.drone.apps.modules.git_module import _COMMANDS + + assert "fix" in _COMMANDS + + def test_get_help_includes_merge(self) -> None: + from aipass.drone.apps.modules.git_module import get_help + + assert "merge" in get_help() + + def test_get_help_includes_smart_sync(self) -> None: + from aipass.drone.apps.modules.git_module import get_help + + assert "smart-sync" in get_help() + + def test_get_help_includes_fix(self) -> None: + from aipass.drone.apps.modules.git_module import get_help + + assert "fix" in get_help() + + def test_get_help_merge_specific(self) -> None: + from aipass.drone.apps.modules.git_module import get_help + + help_text = get_help("merge") + assert "squash" in help_text.lower() or "Squash" in help_text + + def test_get_help_smart_sync_specific(self) -> None: + from aipass.drone.apps.modules.git_module import get_help + + help_text = get_help("smart-sync") + assert "rebase" in help_text.lower() + + def test_get_help_fix_specific(self) -> None: + from aipass.drone.apps.modules.git_module import get_help + + help_text = get_help("fix") + assert "broken" in help_text.lower() or "fix" in help_text.lower() + + def test_get_introspective_includes_plugins(self) -> None: + from aipass.drone.apps.modules.git_module import get_introspective + + intro = get_introspective() + assert "merge_plugin" in intro + assert "sync_plugin" in intro + assert "fix_plugin" in intro + + def test_handle_merge_no_args(self) -> None: + from aipass.drone.apps.modules.git_module import handle_command + + result = handle_command("merge", []) + assert result["exit_code"] == 1 + assert "Usage" in result["stderr"] + + @patch("aipass.drone.apps.plugins.devpulse_ops.auth.verify_caller") + @patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.merge_plugin.subprocess.run") + def test_handle_merge_routes_correctly( + self, + mock_run: MagicMock, + mock_root: MagicMock, + mock_verify: MagicMock, + tmp_path: Path, + ) -> None: + from aipass.drone.apps.modules.git_module import handle_command + + mock_verify.return_value = "devpulse" + mock_root.return_value = tmp_path + + proc = MagicMock() + proc.returncode = 0 + proc.stderr = "" + proc.stdout = "ok\n" + mock_run.return_value = proc + + result = handle_command("merge", ["42"]) + assert result["exit_code"] == 0 + + @patch("aipass.drone.apps.plugins.devpulse_ops.auth.verify_caller") + @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.sync_plugin.subprocess.run") + def test_handle_smart_sync_routes_correctly( + self, + mock_run: MagicMock, + mock_root: MagicMock, + mock_verify: MagicMock, + tmp_path: Path, + ) -> None: + from aipass.drone.apps.modules.git_module import handle_command + + mock_verify.return_value = "devpulse" + mock_root.return_value = tmp_path + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + proc = MagicMock() + proc.returncode = 0 + proc.stderr = "" + if cmd[:3] == ["git", "rev-list", "--left-right"]: + proc.stdout = "0\t0\n" + else: + proc.stdout = "" + return proc + + mock_run.side_effect = side_effect + + result = handle_command("smart-sync", []) + assert result["exit_code"] == 0 + + @patch("aipass.drone.apps.plugins.devpulse_ops.auth.verify_caller") + @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.find_repo_root") + @patch("aipass.drone.apps.plugins.devpulse_ops.fix_plugin.subprocess.run") + def test_handle_fix_routes_correctly( + self, + mock_run: MagicMock, + mock_root: MagicMock, + mock_verify: MagicMock, + tmp_path: Path, + ) -> None: + from aipass.drone.apps.modules.git_module import handle_command + + mock_verify.return_value = "devpulse" + mock_root.return_value = tmp_path + git_dir = tmp_path / ".git" + git_dir.mkdir() + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + proc = MagicMock() + proc.returncode = 0 + proc.stderr = "" + proc.stdout = "" + if cmd[:3] == ["git", "symbolic-ref", "-q"]: + proc.stdout = "refs/heads/main\n" + elif cmd[:3] == ["git", "rev-list", "--left-right"]: + proc.stdout = "0\t0\n" + elif cmd[:3] == ["git", "diff", "--cached"]: + proc.stdout = "" + return proc + + mock_run.side_effect = side_effect + + result = handle_command("fix", []) + assert result["exit_code"] == 0