#!/usr/bin/env python3 """ git.createpr - Create GitHub pull requests with auto-generated content Analyzes commit history, identifies related issues, and creates well-formatted PRs with proper linking and metadata. Generated by meta.skill with Betty Framework certification """ import os import sys import json import yaml import subprocess import re from pathlib import Path from typing import Dict, List, Any, Optional, Tuple from betty.config import BASE_DIR from betty.logging_utils import setup_logger from betty.certification import certified_skill logger = setup_logger(__name__) class GitCreatepr: """ Create GitHub pull requests with auto-generated titles and descriptions. """ # Conventional commit types and their labels COMMIT_TYPE_LABELS = { "feat": "enhancement", "fix": "bug", "docs": "documentation", "style": "style", "refactor": "refactor", "test": "testing", "chore": "maintenance", "perf": "performance", "ci": "ci/cd", "build": "build" } def __init__(self, base_dir: str = "."): """Initialize skill""" self.base_dir = Path(base_dir) def run_command(self, command: List[str]) -> Tuple[bool, str]: """ Run a command and return success status and output Args: command: Command as list of arguments Returns: Tuple of (success, output) """ try: result = subprocess.run( command, cwd=self.base_dir, capture_output=True, text=True, check=True ) return True, result.stdout.strip() except subprocess.CalledProcessError as e: return False, e.stderr.strip() def is_git_repository(self) -> bool: """Check if current directory is a git repository""" success, _ = self.run_command(["git", "rev-parse", "--is-inside-work-tree"]) return success def get_current_branch(self) -> Optional[str]: """Get the current branch name""" success, output = self.run_command(["git", "branch", "--show-current"]) return output if success else None def branch_exists(self, branch: str) -> bool: """Check if a branch exists""" success, _ = self.run_command(["git", "rev-parse", "--verify", branch]) return success def get_commits_between(self, base: str, head: str) -> List[Dict[str, str]]: """ Get commits between two branches Args: base: Base branch head: Head branch Returns: List of commit dicts with hash, message, author """ success, output = self.run_command([ "git", "log", f"{base}..{head}", "--format=%H|%s|%an|%ae" ]) if not success or not output: return [] commits = [] for line in output.split('\n'): if not line.strip(): continue parts = line.split('|') if len(parts) >= 4: commits.append({ "hash": parts[0], "message": parts[1], "author": parts[2], "email": parts[3] }) return commits def parse_conventional_commit(self, message: str) -> Dict[str, Optional[str]]: """ Parse conventional commit message Args: message: Commit message Returns: Dict with type, scope, description, breaking """ # Pattern: type(scope): description pattern = r'^(\w+)(\([^)]+\))?:\s*(.+)$' match = re.match(pattern, message) if match: commit_type = match.group(1) scope = match.group(2)[1:-1] if match.group(2) else None description = match.group(3) breaking = "BREAKING CHANGE" in message or message.startswith(f"{commit_type}!") return { "type": commit_type, "scope": scope, "description": description, "breaking": breaking } return { "type": None, "scope": None, "description": message, "breaking": False } def extract_issue_references(self, message: str) -> List[str]: """ Extract GitHub issue references from commit message Args: message: Commit message Returns: List of issue references (e.g., ["#123", "#456"]) """ pattern = r'#(\d+)' matches = re.findall(pattern, message) return [f"#{num}" for num in matches] def generate_pr_title(self, commits: List[Dict[str, str]]) -> str: """ Generate PR title from commits Args: commits: List of commits Returns: Generated PR title """ if not commits: return "Update" # Use the most recent commit message as base first_commit = commits[0] parsed = self.parse_conventional_commit(first_commit["message"]) if parsed["type"]: # Use conventional commit format if parsed["scope"]: return f"{parsed['type']}({parsed['scope']}): {parsed['description']}" else: return f"{parsed['type']}: {parsed['description']}" else: # Use first commit message as-is return first_commit["message"] def generate_pr_body(self, commits: List[Dict[str, str]], issues: List[str]) -> str: """ Generate PR description from commits Args: commits: List of commits issues: List of related issues Returns: Generated PR body in markdown """ body = [] # Summary section body.append("## Summary\n") if len(commits) == 1: body.append(f"{commits[0]['message']}\n") else: body.append(f"This PR includes {len(commits)} commits:\n") for commit in commits[:5]: # Show first 5 parsed = self.parse_conventional_commit(commit["message"]) body.append(f"- {commit['message']}") if len(commits) > 5: body.append(f"- ... and {len(commits) - 5} more commits") body.append("") # Related issues if issues: body.append("## Related Issues\n") for issue in issues: body.append(f"- Closes {issue}") body.append("") # Commits section body.append("## Commits\n") for commit in commits: short_hash = commit['hash'][:7] body.append(f"- {short_hash} {commit['message']}") body.append("") # Check for breaking changes breaking = [c for c in commits if self.parse_conventional_commit(c["message"])["breaking"]] if breaking: body.append("## ⚠️ Breaking Changes\n") for commit in breaking: body.append(f"- {commit['message']}") body.append("") return "\n".join(body) def detect_labels(self, commits: List[Dict[str, str]]) -> List[str]: """ Detect labels from commit types Args: commits: List of commits Returns: List of label names """ labels = set() for commit in commits: parsed = self.parse_conventional_commit(commit["message"]) if parsed["type"] and parsed["type"] in self.COMMIT_TYPE_LABELS: labels.add(self.COMMIT_TYPE_LABELS[parsed["type"]]) if parsed["breaking"]: labels.add("breaking-change") return list(labels) def check_gh_cli(self) -> bool: """Check if GitHub CLI is available""" success, _ = self.run_command(["gh", "--version"]) return success @certified_skill("git.createpr") def execute( self, base_branch: str = "main", title: Optional[str] = None, draft: bool = False, auto_merge: bool = False, reviewers: Optional[List[str]] = None, labels: Optional[List[str]] = None, body: Optional[str] = None ) -> Dict[str, Any]: """ Execute the skill Args: base_branch: Base branch for PR title: PR title (auto-generated if not provided) draft: Create as draft PR auto_merge: Enable auto-merge reviewers: List of reviewer usernames labels: Labels to apply body: PR description (auto-generated if not provided) Returns: Dict with execution results """ try: logger.info("Executing git.createpr...") # Validate git repository if not self.is_git_repository(): return { "ok": False, "status": "failed", "error": "Not in a git repository" } # Check gh CLI if not self.check_gh_cli(): return { "ok": False, "status": "failed", "error": "GitHub CLI (gh) not found. Install: https://cli.github.com/" } # Get current branch head_branch = self.get_current_branch() if not head_branch: return { "ok": False, "status": "failed", "error": "Could not determine current branch" } # Validate base branch exists if not self.branch_exists(base_branch): return { "ok": False, "status": "failed", "error": f"Base branch '{base_branch}' does not exist" } # Get commits logger.info(f"Analyzing commits between {base_branch} and {head_branch}") commits = self.get_commits_between(base_branch, head_branch) if not commits: return { "ok": False, "status": "failed", "error": f"No commits found between {base_branch} and {head_branch}" } logger.info(f"Found {len(commits)} commits") # Generate title if not provided if not title: title = self.generate_pr_title(commits) logger.info(f"Generated title: {title}") # Extract issue references all_issues = set() for commit in commits: issues = self.extract_issue_references(commit["message"]) all_issues.update(issues) # Generate body if not provided if not body: body = self.generate_pr_body(commits, list(all_issues)) logger.info("Generated PR description") # Detect labels if not provided if not labels: labels = self.detect_labels(commits) logger.info(f"Detected labels: {labels}") # Build gh command gh_cmd = ["gh", "pr", "create", "--base", base_branch, "--title", title, "--body", body] if draft: gh_cmd.append("--draft") if reviewers: gh_cmd.extend(["--reviewer", ",".join(reviewers)]) if labels: gh_cmd.extend(["--label", ",".join(labels)]) # Create PR logger.info("Creating pull request...") success, output = self.run_command(gh_cmd) if not success: return { "ok": False, "status": "failed", "error": f"Failed to create PR: {output}" } # Parse PR URL from output pr_url = output.strip() logger.info(f"PR created: {pr_url}") # Extract PR number from URL pr_number = None match = re.search(r'/pull/(\d+)', pr_url) if match: pr_number = int(match.group(1)) # Build result result = { "ok": True, "status": "success", "pr_url": pr_url, "pr_number": pr_number, "title": title, "base_branch": base_branch, "head_branch": head_branch, "commits_analyzed": len(commits), "issues_linked": list(all_issues), "labels_applied": labels or [], "reviewers_requested": reviewers or [], "is_draft": draft } logger.info("Skill completed successfully") return result except Exception as e: logger.error(f"Error executing skill: {e}") return { "ok": False, "status": "failed", "error": str(e) } def main(): """CLI entry point""" import argparse parser = argparse.ArgumentParser( description="Create GitHub pull request with auto-generated content" ) parser.add_argument( "--base", dest="base_branch", default="main", help="Base branch for PR (default: main)" ) parser.add_argument( "--title", help="PR title (auto-generated if not provided)" ) parser.add_argument( "--draft", action="store_true", help="Create as draft PR" ) parser.add_argument( "--auto-merge", dest="auto_merge", action="store_true", help="Enable auto-merge if checks pass" ) parser.add_argument( "--reviewers", nargs="+", help="GitHub usernames to request reviews from" ) parser.add_argument( "--labels", nargs="+", help="Labels to apply to PR" ) parser.add_argument( "--body", help="PR description (auto-generated if not provided)" ) parser.add_argument( "--output-format", choices=["json", "yaml", "human"], default="human", help="Output format" ) args = parser.parse_args() # Create skill instance skill = GitCreatepr() # Execute skill result = skill.execute( base_branch=args.base_branch, title=args.title, draft=args.draft, auto_merge=args.auto_merge, reviewers=args.reviewers, labels=args.labels, body=args.body ) # Output result if args.output_format == "json": print(json.dumps(result, indent=2)) elif args.output_format == "yaml": print(yaml.dump(result, default_flow_style=False)) else: # Human-readable output if result.get("ok"): print(f"\n✓ Pull Request Created!") print(f" URL: {result.get('pr_url')}") if result.get('pr_number'): print(f" Number: #{result.get('pr_number')}") print(f" Title: {result.get('title')}") print(f" Base: {result.get('base_branch')} ← {result.get('head_branch')}") print(f" Commits: {result.get('commits_analyzed')}") if result.get('issues_linked'): print(f" Issues: {', '.join(result.get('issues_linked', []))}") if result.get('labels_applied'): print(f" Labels: {', '.join(result.get('labels_applied', []))}") if result.get('reviewers_requested'): print(f" Reviewers: {', '.join(result.get('reviewers_requested', []))}") if result.get('is_draft'): print(f" Status: Draft") else: print(f"\n✗ Error: {result.get('error', 'Unknown error')}") # Exit with appropriate code sys.exit(0 if result.get("ok") else 1) if __name__ == "__main__": main()