"""
AtomGit Pull Request Creation Tool
自动创建 PR,从当前分支生成 PR 标题和描述
用法:
python3 pr_creation.py --branch <branch> --fork-owner <owner> [--title "标题"] [--dry-run]
"""
import os
import sys
import json
import argparse
import subprocess
from pathlib import Path
from typing import List, Dict, Optional
from atomgit_sdk import AtomGitClient, resolve_atomgit_context
def run_git(args: List[str], cwd: str = None) -> str:
"""运行 git 命令并返回输出"""
result = subprocess.run(
["git"] + args, cwd=cwd or os.getcwd(), capture_output=True, text=True
)
if result.returncode != 0:
raise Exception(f"Git 命令失败: {' '.join(args)}\n{result.stderr}")
return result.stdout.strip()
def get_current_branch() -> str:
"""获取当前分支名"""
return run_git(["rev-parse", "--abbrev-ref", "HEAD"])
def is_valid_branch_name(name: str) -> bool:
"""验证分支名是否有效"""
if not name or name in ("master", "main", "HEAD"):
return False
try:
run_git(["rev-parse", "--verify", name])
return True
except:
return False
def get_best_base_ref(base_branch: str) -> str:
"""智能解析最佳基准引用,优先使用远程分支"""
remotes = run_git(["remote"]).split()
if "upstream" in remotes:
return f"upstream/{base_branch}"
if "origin" in remotes:
return f"origin/{base_branch}"
return base_branch
def get_commit_messages(branch: str, base_branch: str = "master") -> List[Dict]:
"""获取分支相对于基线的提交信息"""
base_ref = get_best_base_ref(base_branch)
log_format = "%H%n%s%n%b%n---COMMIT_END---"
try:
output = run_git(["log", f"{base_ref}..{branch}", f"--format={log_format}"])
except:
output = run_git(["log", f"{base_branch}..{branch}", f"--format={log_format}"])
commits = []
for block in output.split("---COMMIT_END---"):
if not block.strip():
continue
lines = block.strip().split("\n", 2)
if len(lines) >= 2:
commits.append(
{
"hash": lines[0],
"subject": lines[1],
"body": lines[2] if len(lines) > 2 else "",
}
)
return commits
def get_changed_files(branch: str, base_branch: str = "master") -> List[str]:
"""获取变更文件列表"""
base_ref = get_best_base_ref(base_branch)
try:
output = run_git(["diff", "--name-only", f"{base_ref}...{branch}"])
except:
output = run_git(["diff", "--name-only", f"{base_branch}...{branch}"])
return [f for f in output.split("\n") if f.strip()]
def get_diff_stats(branch: str, base_branch: str = "master") -> Dict:
"""获取 diff 统计信息"""
base_ref = get_best_base_ref(base_branch)
try:
output = run_git(["diff", "--stat", f"{base_ref}...{branch}"])
except:
output = run_git(["diff", "--stat", f"{base_branch}...{branch}"])
stats = {"files_changed": 0, "insertions": 0, "deletions": 0}
for line in output.split("\n"):
if "file" in line and "changed" in line:
parts = line.split(",")
for part in parts:
part = part.strip()
if "file" in part:
stats["files_changed"] = int(part.split()[0])
elif "insertion" in part:
stats["insertions"] = int(part.split()[0])
elif "deletion" in part:
stats["deletions"] = int(part.split()[0])
return stats
def add_ai_signature(description: str, ai_model: str) -> str:
"""添加 AI 签名(单独一行, 与正文空一行)"""
signature = f"\n\n---\n\n🤖 generated by ai@{ai_model}"
if "generated by ai@" not in description:
return description + signature
return description
def load_config(config_path: str) -> dict:
"""加载配置文件"""
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
if not config.get("atomgit") or not config["atomgit"].get("token"):
raise Exception("配置文件中缺少 atomgit.token")
return config
def main():
parser = argparse.ArgumentParser(description="AtomGit PR 创建工具")
parser.add_argument("--branch", type=str, help="源分支名")
parser.add_argument("--base", type=str, default="master", help="目标分支名")
parser.add_argument("--title", type=str, help="PR 标题")
parser.add_argument("--body", type=str, help="PR 描述文本")
parser.add_argument("--description-file", type=str, help="从文件读取 PR 描述 (Markdown 格式)")
parser.add_argument("--config", type=str, default="config.json", help="配置文件路径")
parser.add_argument("--owner", type=str, help="目标仓库 owner,覆盖 config.json")
parser.add_argument("--repo", type=str, help="目标仓库 repo,覆盖 config.json")
parser.add_argument(
"--url",
type=str,
help="AtomGit/GitCode 仓库或 PR 链接,用于自动解析 owner/repo",
)
parser.add_argument(
"--fork-owner",
type=str,
required=True,
help="Fork 仓库的 owner(必需,通过 git remote -v 获取)",
)
parser.add_argument("--draft", action="store_true", help="创建为草稿 PR")
parser.add_argument("--dry-run", action="store_true", help="仅显示计划,不实际创建")
parser.add_argument("-y", "--yes", action="store_true", help="自动确认创建 PR")
parser.add_argument(
"--ai-model",
type=str,
default="ai",
help="AI模型名称,用于签名 (默认: ai)",
)
args = parser.parse_args()
try:
sdk_config, parsed_url = resolve_atomgit_context(
args.config, owner=args.owner, repo=args.repo, url=args.url
)
except Exception as e:
print(f"❌ 加载配置失败: {e}")
sys.exit(1)
branch = args.branch or get_current_branch()
if not is_valid_branch_name(branch):
print(f"❌ 无效的分支名: {branch}")
sys.exit(1)
if branch in ("master", "main"):
print("❌ 不能从 master/main 分支创建 PR")
sys.exit(1)
print("=" * 60)
print("🚀 AtomGit PR Creator")
print("=" * 60)
print(f"源分支: {branch}")
print(f"目标分支: {args.base}")
print(f"目标仓库: {sdk_config.owner}/{sdk_config.repo}")
print(f"Fork owner: {args.fork_owner}")
if args.url:
print(f"解析链接: {args.url}")
if parsed_url.get("pr_number") is not None:
print("ℹ️ 已忽略链接中的 PR 编号,仅使用其中的仓库信息创建 PR")
if parsed_url.get("issue_number") is not None:
print("ℹ️ 已忽略链接中的 Issue 编号,仅使用其中的仓库信息创建 PR")
print()
print(">>> 获取提交信息...")
commits = get_commit_messages(branch, args.base)
print(f"✓ 找到 {len(commits)} 个提交")
if not commits:
print(f"❌ 在 {args.base} 和 {branch} 之间未找到提交")
print(" 请确保你的分支与 upstream/master 同步")
sys.exit(1)
print(">>> 获取变更文件...")
files = get_changed_files(branch, args.base)
print(f"✓ 找到 {len(files)} 个变更文件")
print(">>> 计算变更统计...")
stats = get_diff_stats(branch, args.base)
print(
f"✓ +{stats['insertions']}/-{stats['deletions']} 行, {stats['files_changed']} 文件"
)
print(">>> 生成 PR 描述...")
if args.description_file:
try:
with open(args.description_file, "r", encoding="utf-8") as f:
description = f.read()
print(f"✓ 从文件读取描述: {args.description_file}")
except Exception as e:
print(f"❌ 读取描述文件失败: {e}")
sys.exit(1)
elif args.body:
description = args.body
else:
print("❌ 错误: 必须提供 PR 描述。")
print(" 建议先让 AI 分析变更并生成专业描述文件,然后使用 --description-file 参数。")
print(" 或者使用 --body 参数直接传入描述内容。")
sys.exit(1)
description = add_ai_signature(description, args.ai_model)
title = args.title or commits[0]["subject"]
print(f"✓ 标题: {title}")
print()
print("=" * 60)
print("生成的 PR 描述:")
print("=" * 60)
print(description)
print("=" * 60)
print()
if args.dry_run:
print("⚠ Dry run 模式,未创建 PR")
sys.exit(0)
if args.yes:
answer = "y"
else:
try:
answer = input("\n是否创建 PR?(y/n): ").strip().lower()
except:
answer = "n"
if answer in ("y", "yes"):
print(">>> 创建 PR...")
api = AtomGitClient(sdk_config)
pr_head = f"{args.fork_owner}:{branch}"
try:
pr = api.create_pull_request(
title=title,
body=description,
head=pr_head,
base=args.base,
draft=args.draft,
)
pr_number = pr.get("number")
pr_url = api.get_pr_url(pr_number)
print()
print("✅ PR 创建成功!")
print(f"PR 编号: #{pr_number}")
print(f"PR 链接: {pr_url}")
except Exception as e:
print(f"❌ 创建 PR 失败: {e}")
sys.exit(1)
else:
print(">>> 已取消创建 PR")
sys.exit(0)
if __name__ == "__main__":
main()