535 lines
16 KiB
Python
Executable File
535 lines
16 KiB
Python
Executable File
#!/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()
|