Files
MoviePilot/skills/submit-pull-request/scripts/submit_pull_request.py
2026-09-21 12:53:07 +08:00

270 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""在用户确认后通过本地 Git 推送分支并创建 Pull Request。"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any, Optional
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from pr_common import ( # noqa: E402
GitHubApiError,
GitHubClient,
PullRequestError,
changes_from_payload,
ensure_fork,
git_auth_environment,
git_current_branch,
git_output,
git_remote_url,
has_github_auth,
load_github_headers,
normalize_branch,
normalize_repo,
pull_head,
read_json_file,
remote_repo_from_url,
result_payload,
run_git,
write_json_file,
)
def _git_failure(result: Any, command: str, reason: str = "git_command_failed") -> PullRequestError:
"""将 Git 命令结果转换为不含凭据的 PR 错误。"""
detail = result.stderr.decode("utf-8", errors="replace")[:500]
return PullRequestError(reason, f"Git {command} 失败: {detail}")
def _status_clean(source_root: Path) -> bool:
"""判断本地 clone 是否没有未提交的工作树变更。"""
result = run_git(source_root, "status", "--porcelain=v1", "--untracked-files=all")
if result.returncode != 0:
raise _git_failure(result, "status")
return not result.stdout
def _commit_changes(
source_root: Path,
changes: list[Any],
commit_message: str,
) -> str:
"""只暂存预览中的文件并创建一个本地 Git Commit。"""
paths = [change.path for change in changes]
add_result = run_git(source_root, "add", "-A", "--", *paths)
if add_result.returncode != 0:
raise _git_failure(add_result, "add")
commit_result = run_git(source_root, "commit", "--message", commit_message)
if commit_result.returncode != 0:
raise _git_failure(commit_result, "commit")
return git_output(source_root, "rev-parse", "HEAD").decode().strip()
def _reuse_or_commit(
payload_file: Path,
payload: dict[str, Any],
source_root: Path,
) -> str:
"""支持推送中断后的安全重试,不重复创建 Commit。"""
existing_commit = str(payload.get("local_commit_sha") or "").strip()
current_head = git_output(source_root, "rev-parse", "HEAD").decode().strip()
if existing_commit:
if current_head != existing_commit:
raise PullRequestError("local_commit_changed", "本地 Commit 已偏离上次提交记录,请重新生成预览。")
if not _status_clean(source_root):
raise PullRequestError("changes_modified", "重试提交前发现工作树又有新的未提交变更。")
return existing_commit
initial_head = str(payload.get("initial_head_sha") or "").strip()
if initial_head and current_head != initial_head:
raise PullRequestError("local_commit_changed", "预览后的本地基线已改变,请重新生成预览。")
changes = changes_from_payload(payload)
commit_sha = _commit_changes(source_root, changes, str(payload["commit_message"]))
payload["local_commit_sha"] = commit_sha
write_json_file(payload_file, payload)
return commit_sha
def _success_existing_pull(
pull: dict[str, Any],
*,
target_repo: str,
fork_repo: str,
branch: str,
base: str,
commit_sha: str = "",
) -> dict[str, Any]:
"""统一构造已有 PR 的幂等成功结果。"""
return {
"success": True,
"execution_outcome": "already_exists",
"target_repo": target_repo,
"fork_repo": fork_repo,
"branch": branch,
"base": base,
"commit_sha": commit_sha or str((pull.get("head") or {}).get("sha") or ""),
"pr_number": pull.get("number"),
"pr_url": pull.get("html_url") or pull.get("url"),
"draft": pull.get("draft"),
"message": "该工作分支已有开放 PR,已复用现有 PR,未重复提交。",
}
def submit_pull_request(payload_file: str, confirmation: str) -> dict[str, Any]:
"""重新校验预览后提交 Git Commit、推送分支并创建 PR。"""
if confirmation != "CONFIRM":
raise PullRequestError("confirmation_required", "提交 PR 必须传入精确确认标记 CONFIRM。")
payload_path = Path(payload_file).expanduser().resolve()
payload = read_json_file(payload_path, reason="invalid_payload")
try:
target_repo = normalize_repo(str(payload.get("target_repo") or ""))
fork_repo = normalize_repo(str(payload.get("fork_repo") or ""))
base = normalize_branch(str(payload.get("base") or ""))
branch = normalize_branch(str(payload.get("branch") or ""))
except ValueError as error:
raise PullRequestError("invalid_payload", str(error)) from error
source_root = Path(str(payload.get("source_root") or "")).expanduser().resolve()
if branch == base:
raise PullRequestError("base_branch_forbidden", "不能直接在 PR 基线分支上提交。")
current_branch = git_current_branch(source_root)
if current_branch != branch:
raise PullRequestError("branch_mismatch", "当前 clone 分支与预览不一致。")
origin_repo = remote_repo_from_url(git_remote_url(source_root, "origin"))
if origin_repo.lower() != fork_repo.lower():
raise PullRequestError("remote_mismatch", f"origin 未指向预览中的 Fork {fork_repo}。")
headers, proxies = load_github_headers(target_repo)
if not has_github_auth(headers):
raise PullRequestError("no_token", "未配置可推送代码和创建 PR 的 GitHub Token。")
client = GitHubClient(headers=headers, proxies=proxies)
user = client.get_user()
login = str(user.get("login") or "").strip()
if not login:
raise PullRequestError("invalid_github_user", "GitHub Token 未返回当前用户。")
expected_login = str(payload.get("github_login") or "").strip()
if expected_login and expected_login.lower() != login.lower():
raise PullRequestError("github_identity_changed", "当前 GitHub Token 用户与预览不一致。")
actual_fork, _ = ensure_fork(client, target_repo, login)
if actual_fork.lower() != fork_repo.lower():
raise PullRequestError("fork_mismatch", "当前用户的 Fork 与预览不一致。")
base_ref = client.get_ref(target_repo, base)
base_sha = str((base_ref.get("object") or {}).get("sha") or "")
expected_base_sha = str(payload.get("expected_base_sha") or "")
if not base_sha or (expected_base_sha and base_sha != expected_base_sha):
raise PullRequestError("base_changed", "上游基线已变化,请重新 clone、修改并生成预览。")
head = pull_head(target_repo, fork_repo, login, branch)
existing_pulls = client.list_open_pulls(target_repo, head=head, base=base)
if existing_pulls:
return _success_existing_pull(
existing_pulls[0],
target_repo=target_repo,
fork_repo=fork_repo,
branch=branch,
base=base,
)
remote_sha: Optional[str] = None
try:
remote_ref = client.get_ref(fork_repo, branch)
remote_sha = str((remote_ref.get("object") or {}).get("sha") or "")
except GitHubApiError as error:
if error.status != 404:
raise
if remote_sha and not payload.get("local_commit_sha"):
raise PullRequestError("branch_exists", f"Fork 中已存在未由本次流程创建的分支: {fork_repo}:{branch}")
commit_sha = _reuse_or_commit(payload_path, payload, source_root)
if remote_sha and remote_sha != commit_sha:
raise PullRequestError("remote_branch_changed", "Fork 远端分支已指向其他 Commit,拒绝覆盖。")
if not remote_sha:
with git_auth_environment(headers) as git_environment:
push_result = run_git(
source_root,
"push",
"--set-upstream",
"origin",
branch,
env=git_environment,
)
if push_result.returncode != 0:
raise _git_failure(push_result, "push", reason="git_push_failed")
verified_ref = client.get_ref(fork_repo, branch)
verified_sha = str((verified_ref.get("object") or {}).get("sha") or "")
if verified_sha != commit_sha:
raise PullRequestError("push_verification_failed", "推送后的远端分支 SHA 与本地 Commit 不一致。")
existing_pulls = client.list_open_pulls(target_repo, head=head, base=base)
if existing_pulls:
return _success_existing_pull(
existing_pulls[0],
target_repo=target_repo,
fork_repo=fork_repo,
branch=branch,
base=base,
commit_sha=commit_sha,
)
title = str(payload.get("title") or "").strip()
if not title:
raise PullRequestError("invalid_payload", "PR 标题不能为空。")
pull = client.create_pull(
target_repo,
{
"title": title,
"head": head,
"base": base,
"body": str(payload.get("body") or ""),
"draft": bool(payload.get("draft", True)),
},
)
actual_base = str((pull.get("base") or {}).get("ref") or base)
actual_head = str((pull.get("head") or {}).get("ref") or branch)
if actual_base != base or actual_head != branch:
raise PullRequestError("pull_verification_failed", "GitHub 返回的 PR head/base 与预期不一致。")
return {
"success": True,
"execution_outcome": "succeeded",
"target_repo": target_repo,
"fork_repo": fork_repo,
"branch": branch,
"base": base,
"base_sha": base_sha,
"commit_sha": commit_sha,
"pr_number": pull.get("number"),
"pr_url": pull.get("html_url") or pull.get("url"),
"draft": bool(payload.get("draft", True)),
"message": "已完成本地 Git Commit、推送 Fork 分支并创建 PR。",
}
def _parse_args() -> argparse.Namespace:
"""解析 PR 提交脚本命令行参数。"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--payload-file", required=True, help="prepare_pull_request.py 输出的 payload JSON")
parser.add_argument("--confirm", required=True, help="必须是精确字符串 CONFIRM")
return parser.parse_args()
def main() -> int:
"""执行 PR 提交入口并输出机器可解析 JSON。"""
try:
arguments = _parse_args()
result = submit_pull_request(arguments.payload_file, arguments.confirm)
except (GitHubApiError, PullRequestError, ValueError) as error:
print(result_payload(success=False, reason=getattr(error, "reason", "invalid_request"), message=str(error)))
return 2
print(result_payload(**result))
return 0
if __name__ == "__main__":
raise SystemExit(main())