From 7b5a073f5b41209d60ac5aca76e445d9b23254ef Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Fri, 15 May 2026 13:05:53 -0700 Subject: [PATCH] =?UTF-8?q?feat(drone):=20add=20generic=20drone=20@git=20p?= =?UTF-8?q?r=20command=20=E2=80=94=20works=20from=20any=20branch=20includi?= =?UTF-8?q?ng=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../drone/apps/handlers/git/dev_pr_handler.py | 98 ++++++++++++++++++- src/aipass/drone/apps/modules/git_module.py | 35 +++---- .../drone/apps/plugins/devpulse_ops/auth.py | 5 +- 3 files changed, 116 insertions(+), 22 deletions(-) diff --git a/src/aipass/drone/apps/handlers/git/dev_pr_handler.py b/src/aipass/drone/apps/handlers/git/dev_pr_handler.py index d86ce46a..76419366 100644 --- a/src/aipass/drone/apps/handlers/git/dev_pr_handler.py +++ b/src/aipass/drone/apps/handlers/git/dev_pr_handler.py @@ -6,10 +6,11 @@ # Modified: 2026-05-12 # ============================================= -"""Dev branch PR handler — push dev and create PR to main.""" +"""Branch PR handlers — generic and dev-specific.""" from __future__ import annotations +import re import subprocess from aipass.prax import logger @@ -17,6 +18,101 @@ from aipass.drone.apps.handlers.json import json_handler from aipass.drone.apps.handlers.git.lock_handler import find_repo_root +def _slugify(text: str) -> str: + """Convert text to a branch-safe slug.""" + slug = text.lower().strip().replace(" ", "-") + slug = re.sub(r"[^a-z0-9-]", "", slug) + slug = re.sub(r"-{2,}", "-", slug).strip("-") + return slug[:40] + + +def create_branch_pr(description: str, target_branch: str = "main") -> dict: + """Push current branch and create a PR to target. + + Works from any branch. Detects HEAD, pushes to origin, creates PR via gh CLI. + + Returns: + Dict with success, message, and pr_url keys. + """ + repo_root = find_repo_root() + + try: + head = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.error("Failed to detect current branch: %s", exc) + return {"success": False, "message": f"Failed to detect current branch: {exc}", "pr_url": ""} + + pr_branch = head.stdout.strip() + + if pr_branch == target_branch: + pr_branch = _slugify(description) + if not pr_branch: + return {"success": False, "message": "Description required to generate branch name.", "pr_url": ""} + push_refspec = f"{target_branch}:{pr_branch}" + else: + push_refspec = pr_branch + + try: + push = subprocess.run( + ["git", "push", "-u", "origin", push_refspec], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.error("git push failed: %s", exc) + return {"success": False, "message": f"Push failed: {exc}", "pr_url": ""} + + if push.returncode != 0: + return {"success": False, "message": f"Push failed: {push.stderr.strip()}", "pr_url": ""} + + try: + pr = subprocess.run( + ["gh", "pr", "create", "--head", pr_branch, "--base", target_branch, "--title", description, "--body", ""], + capture_output=True, + text=True, + cwd=str(repo_root), + ) + except FileNotFoundError as exc: + logger.warning("gh CLI not found: %s", exc) + return {"success": False, "message": "gh CLI not found. Install: https://cli.github.com/", "pr_url": ""} + except (OSError, subprocess.SubprocessError) as exc: + logger.error("gh pr create failed: %s", exc) + return {"success": False, "message": f"PR creation failed: {exc}", "pr_url": ""} + + if pr.returncode != 0: + stderr = pr.stderr.strip() + if "already exists" in stderr: + existing_url = "" + for line in stderr.splitlines(): + if "github.com" in line: + existing_url = line.strip() + break + msg = ( + f"Pushed to {pr_branch}. PR already open: {existing_url}" + if existing_url + else f"Pushed to {pr_branch}. PR already open." + ) + json_handler.log_operation( + "branch_pr_push_existing", {"pr_url": existing_url, "branch": pr_branch, "description": description} + ) + return {"success": True, "message": msg, "pr_url": existing_url} + return {"success": False, "message": f"PR creation failed: {stderr}", "pr_url": ""} + + pr_url = pr.stdout.strip() + json_handler.log_operation( + "create_branch_pr", {"pr_url": pr_url, "branch": pr_branch, "target": target_branch, "description": description} + ) + logger.info("PR created from %s: %s", pr_branch, pr_url) + + return {"success": True, "message": f"PR created: {pr_url}", "pr_url": pr_url} + + def create_dev_pr(description: str) -> dict: """Push dev branch and create a PR to main. diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index 40afb84f..4663596e 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -55,7 +55,6 @@ _COMMANDS = ( "unlock", "dev-pr", "delete-branch", - "system-pr", "merge", "smart-sync", "fix", @@ -153,8 +152,6 @@ def handle_command(command: str | None = None, args: list[str] | None = None) -> return _handle_sync(args) if command == "unlock": return _handle_unlock(args) - if command == "system-pr": - return _handle_system_pr(args, caller) if command == "merge": return _handle_merge(args, caller) if command == "smart-sync": @@ -162,7 +159,7 @@ def handle_command(command: str | None = None, args: list[str] | None = None) -> if command == "fix": return _handle_fix(args, caller) if command == "pr": - return {"stdout": "", "stderr": "Agent PRs are deprecated.", "exit_code": 1} + return _handle_pr(args) available = ", ".join(_COMMANDS) return { @@ -215,6 +212,21 @@ def _handle_branches() -> dict: return {"stdout": result["message"], "stderr": "", "exit_code": 0} +def _handle_pr(args: list[str]) -> dict: + """Handle the pr subcommand — push current branch and create PR to main.""" + if not args: + return { + "stdout": "", + "stderr": "Usage: drone @git pr ", + "exit_code": 1, + } + description = " ".join(args) + result = dev_pr_handler.create_branch_pr(description) + if result["success"]: + return {"stdout": result["message"], "stderr": "", "exit_code": 0} + return {"stdout": "", "stderr": result["message"], "exit_code": 1} + + def _handle_dev_pr(args: list[str]) -> dict: """Handle the dev-pr subcommand (owner tier).""" if not args: @@ -244,15 +256,6 @@ def _handle_delete_branch(args: list[str]) -> dict: return {"stdout": "", "stderr": result["message"], "exit_code": 1} -def _handle_system_pr(_args: list[str], _caller: str) -> dict: - """Handle the system-pr subcommand — DEPRECATED.""" - return { - "stdout": "", - "stderr": "system-pr is deprecated. Use: drone @git dev-pr ", - "exit_code": 1, - } - - def _handle_merge(args: list[str], caller: str) -> dict: """Handle the merge subcommand (owner-tier, auth pre-checked).""" if not args: @@ -542,7 +545,7 @@ def get_help(command: str | None = None) -> str: "git workflow [args] — Passthrough to gh workflow CLI [global]\n Examples: list, view , run \n" ) if command == "pr": - return "git pr — DEPRECATED. Agent PRs are no longer supported. Devpulse handles git.\n" + return "git pr — Push current branch and create PR to main [owner]\n" if command == "status": return "git status [--all] — Show git status filtered to your branch (--all for repo-wide) [global]\n" if command == "diff": @@ -585,8 +588,6 @@ def get_help(command: str | None = None) -> str: ) if command == "unlock": return "git unlock --force — Force-release the PR lock [owner]\n" - if command == "system-pr": - return "git system-pr — DEPRECATED. Use: drone @git dev-pr \n" if command == "merge": return ( "git merge — Merge a PR and sync local main [owner]\n" @@ -624,13 +625,13 @@ def get_help(command: str | None = None) -> str: "Owner (devpulse only):\n" " commit [--all | files] Commit changes (selective or --all)\n" " checkout Switch branches\n" + " pr Push current branch and create PR to main\n" " dev-pr Push dev and create PR to main\n" " delete-branch Delete a remote branch\n" " merge Merge a PR\n" " sync [--autostash] Checkout main and pull\n" " smart-sync Fetch + rebase if behind\n" " unlock --force Force-release the PR lock\n" - " system-pr DEPRECATED (use dev-pr)\n" " fix [--dry-run] Fix broken git states\n" ) diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py index d87d5db8..16d61443 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/auth.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/auth.py @@ -35,11 +35,11 @@ GIT_ACCESS_TIERS: dict[str, dict] = { "checkout", "sync", "unlock", - "system-pr", "merge", "smart-sync", "fix", "dev-pr", + "pr", "delete-branch", ], "allowed_callers": ["devpulse"], @@ -121,9 +121,6 @@ def verify_git_access(command: str) -> str: Raises: PermissionError: If the caller is not authorized for this command. """ - if command == "pr": - raise PermissionError("Agent PRs are deprecated. Build code, run tests, report results. Devpulse handles git.") - global_cmds = GIT_ACCESS_TIERS["global"]["commands"] owner_tier = GIT_ACCESS_TIERS["owner"]