feat(agent): add GitHub pull request workflow

This commit is contained in:
moviepilot-codex[bot]
2026-09-21 12:53:07 +08:00
parent 29583b3742
commit fe7c183d06
8 changed files with 2144 additions and 0 deletions

View File

@@ -0,0 +1,269 @@
"""在用户确认后通过本地 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())