Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:26:08 +08:00
commit 8f22ddf339
295 changed files with 59710 additions and 0 deletions

View 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()