Initial commit
This commit is contained in:
534
skills/git.createpr/git_createpr.py
Executable file
534
skills/git.createpr/git_createpr.py
Executable file
@@ -0,0 +1,534 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user