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,201 @@
"""为 PR 工作流确保 Fork,并从 Fork 创建一个隔离的 Git clone。"""
from __future__ import annotations
import argparse
import hashlib
import sys
import tempfile
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,
build_branch_name,
ensure_fork,
git_auth_environment,
git_output,
github_clone_url,
has_github_auth,
load_github_headers,
normalize_branch,
normalize_repo,
redact_text,
result_payload,
run_git,
runtime_dir,
write_json_file,
)
def _default_state_file(target_repo: str, destination: Path) -> Path:
"""为本次 clone 生成运行状态文件路径。"""
digest = hashlib.sha256(f"{target_repo}:{destination}".encode("utf-8")).hexdigest()[:16]
return runtime_dir() / "states" / f"{digest}.json"
def _resolve_destination(target_repo: str, destination: Optional[str]) -> Path:
"""解析并检查 clone 目标,拒绝覆盖已有非空目录。"""
if destination:
path = Path(destination).expanduser().resolve()
if path.exists() and (not path.is_dir() or any(path.iterdir())):
raise PullRequestError("destination_exists", f"clone 目标不是空目录: {path}")
path.mkdir(parents=True, exist_ok=True)
return path
owner, name = normalize_repo(target_repo).split("/", 1)
clone_dir = runtime_dir() / "clones"
clone_dir.mkdir(parents=True, exist_ok=True)
return Path(tempfile.mkdtemp(prefix=f"{owner}-{name}-", dir=clone_dir))
def _run_checked_git(root: Path, *arguments: str, env: dict[str, str]) -> None:
"""运行 clone 流程中的 Git 命令并隐藏可能的凭据。"""
result = run_git(root, *arguments, env=env)
if result.returncode != 0:
detail = redact_text(result.stderr.decode("utf-8", errors="replace"))[:500]
raise PullRequestError("git_command_failed", f"Git 命令失败: {arguments[0]}: {detail}")
def clone_repository(
target_repo: str,
*,
destination: Optional[str] = None,
base: Optional[str] = None,
branch: Optional[str] = None,
state_file: Optional[str] = None,
) -> dict[str, Any]:
"""确保目标 Fork 存在,按上游基线创建隔离工作分支和状态文件。"""
target = normalize_repo(target_repo)
headers, proxies = load_github_headers(target)
if not has_github_auth(headers):
raise PullRequestError("no_token", "未配置可创建 Fork 和推送代码的 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 未返回当前用户。")
upstream = client.get_repo(target)
selected_base = normalize_branch(base or str(upstream.get("default_branch") or ""))
base_ref = client.get_ref(target, selected_base)
base_sha = str((base_ref.get("object") or {}).get("sha") or "")
if not base_sha:
raise PullRequestError("invalid_github_ref", f"无法读取上游基线: {target}:{selected_base}")
fork_repo, fork_created = ensure_fork(client, target, login)
selected_branch = normalize_branch(branch) if branch else build_branch_name(target, selected_base)
if selected_branch == selected_base:
raise PullRequestError("base_branch_forbidden", "工作分支不能与 PR 基线分支相同。")
try:
client.get_ref(fork_repo, selected_branch)
except GitHubApiError as error:
if error.status != 404:
raise
else:
raise PullRequestError("branch_exists", f"Fork 中已存在工作分支: {fork_repo}:{selected_branch}")
clone_path = _resolve_destination(target, destination)
with git_auth_environment(headers) as git_environment:
_run_checked_git(
clone_path.parent,
"clone",
"--no-tags",
github_clone_url(fork_repo),
str(clone_path),
env=git_environment,
)
_run_checked_git(
clone_path,
"remote",
"add",
"upstream",
github_clone_url(target),
env=git_environment,
)
_run_checked_git(
clone_path,
"fetch",
"--no-tags",
"upstream",
selected_base,
env=git_environment,
)
fetched_sha = git_output(clone_path, "rev-parse", "FETCH_HEAD", env=git_environment).decode().strip()
if fetched_sha != base_sha:
raise PullRequestError("base_changed", "上游基线在 clone 过程中发生变化,请重新开始。")
_run_checked_git(
clone_path,
"switch",
"--create",
selected_branch,
"FETCH_HEAD",
env=git_environment,
)
state_path = Path(state_file).expanduser().resolve() if state_file else _default_state_file(target, clone_path)
if state_path == clone_path or clone_path in state_path.parents:
raise PullRequestError("invalid_state", "state_file 必须位于 clone 目录之外。")
state = {
"version": 1,
"target_repo": target,
"fork_repo": fork_repo,
"source_root": str(clone_path),
"base": selected_base,
"expected_base_sha": base_sha,
"branch": selected_branch,
"origin_remote": "origin",
"upstream_remote": "upstream",
"fork_created": fork_created,
}
write_json_file(state_path, state)
return {
"success": True,
"target_repo": target,
"fork_repo": fork_repo,
"fork_created": fork_created,
"source_root": str(clone_path),
"state_file": str(state_path),
"base": selected_base,
"base_sha": base_sha,
"branch": selected_branch,
"message": "已完成 Fork 复用/创建,并从上游基线创建隔离 Git 工作分支。",
}
def _parse_args() -> argparse.Namespace:
"""解析 clone 脚本命令行参数。"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target-repo", required=True, help="上游 GitHub owner/repo")
parser.add_argument("--destination", help="clone 目标目录;必须不存在或为空")
parser.add_argument("--base", help="PR 基线分支;默认读取上游 default_branch")
parser.add_argument("--branch", help="工作分支;默认生成隔离分支")
parser.add_argument("--state-file", help="将 clone 状态写入指定 JSON 文件")
return parser.parse_args()
def main() -> int:
"""执行 clone 入口并输出机器可解析 JSON。"""
try:
arguments = _parse_args()
result = clone_repository(
arguments.target_repo,
destination=arguments.destination,
base=arguments.base,
branch=arguments.branch,
state_file=arguments.state_file,
)
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())

View File

@@ -0,0 +1,102 @@
"""从隔离 Git clone 收集 PR 预览所需的变更摘要。"""
from __future__ import annotations
import argparse
import hashlib
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
PullRequestError,
change_fingerprint,
collect_changes,
git_current_branch,
read_json_file,
result_payload,
runtime_dir,
write_json_file,
)
def collect_pull_request(source_root: str, *, state_file: Optional[str] = None) -> dict[str, Any]:
"""收集当前 clone 的 Git 变更,并保存不含源码正文的摘要文件。"""
root = Path(source_root).expanduser().resolve()
mode, changes = collect_changes(root)
branch = git_current_branch(root)
state: dict[str, Any] = {}
if state_file:
state = read_json_file(Path(state_file).expanduser().resolve())
if Path(str(state.get("source_root") or "")).expanduser().resolve() != root:
raise PullRequestError("state_mismatch", "clone 状态文件与 source_root 不匹配。")
if state.get("branch") and state["branch"] != branch:
raise PullRequestError("state_mismatch", "clone 状态文件与当前工作分支不匹配。")
fingerprint = change_fingerprint(changes)
digest = hashlib.sha256(f"{root}:{fingerprint}".encode("utf-8")).hexdigest()[:20]
changes_file = runtime_dir() / "changes" / f"{digest}.json"
summary = [
{
key: item[key]
for key in (
"path",
"operation",
"before_sha256",
"after_sha256",
"size_bytes",
"mode",
)
}
for item in changes
]
write_json_file(
changes_file,
{
"version": 1,
"source_root": str(root),
"branch": branch,
"mode": mode,
"fingerprint": fingerprint,
"changes": summary,
},
)
return {
"success": True,
"mode": mode,
"source_root": str(root),
"branch": branch,
"state_file": state_file,
"changes_file": str(changes_file),
"change_count": len(summary),
"changes": summary,
"message": "已从 Git 工作树生成 PR 变更摘要;摘要不包含源码正文。",
}
def _parse_args() -> argparse.Namespace:
"""解析变更采集脚本命令行参数。"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-root", required=True, help="先前 git clone 的仓库根目录")
parser.add_argument("--state-file", help="clone_repository.py 输出的状态文件")
return parser.parse_args()
def main() -> int:
"""执行变更采集入口并输出机器可解析 JSON。"""
try:
arguments = _parse_args()
result = collect_pull_request(arguments.source_root, state_file=arguments.state_file)
except (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())

View File

@@ -0,0 +1,755 @@
"""submit-pull-request Skill 的 Git、GitHub 和路径安全共享逻辑。"""
from __future__ import annotations
import hashlib
import json
import os
import re
import secrets
import subprocess
import sys
import tempfile
import time
import urllib.parse
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any, Iterator, Optional
def _find_repo_root() -> Path:
"""从当前工作目录或 Skill 路径定位 MoviePilot 源码根目录。"""
script_path = Path(__file__).resolve()
candidates = [Path.cwd().resolve(), *Path.cwd().resolve().parents]
candidates.extend([script_path.parent, *script_path.parents])
for candidate in candidates:
if (candidate / "app" / "runtime" / "config.py").is_file():
return candidate
return script_path.parents[3]
SCRIPT_ROOT = _find_repo_root()
if str(SCRIPT_ROOT) not in sys.path:
sys.path.insert(0, str(SCRIPT_ROOT))
from app.adapters.network.http import RequestUtils # noqa: E402
from app.runtime.config import settings # noqa: E402
from app.runtime.settings import get_runtime_setting # noqa: E402
GITHUB_API_BASE = "https://api.github.com"
GITHUB_API_VERSION = "2022-11-28"
DEFAULT_TIMEOUT_SECONDS = 30
MAX_FILE_BYTES = 10 * 1024 * 1024
MAX_CHANGED_FILES = 200
_REPO_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_BRANCH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$")
_TOKEN_PATTERN = re.compile(r"(?i)(bearer|token)\s+[^\s,;]+")
_SSH_REPO_PATTERN = re.compile(r"^(?:[^@]+@)?([^:]+):([^/]+/[^/]+?)(?:\.git)?$")
_REJECTED_PATH_PARTS = {
".git",
".env",
"config",
"data",
"cache",
"logs",
"tmp",
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
"node_modules",
}
_REJECTED_SUFFIXES = {
".db",
".sqlite",
".sqlite3",
".log",
".key",
".pem",
".p12",
".pfx",
".secret",
}
class PullRequestError(RuntimeError):
"""PR 发布流程遇到的可向 Agent 返回的稳定错误。"""
def __init__(self, reason: str, message: str, *, status: int = 0) -> None:
"""保存机器可判断的原因、用户可读消息和可选 HTTP 状态。"""
super().__init__(message)
self.reason = reason
self.message = message
self.status = status
class GitHubApiError(PullRequestError):
"""GitHub API 返回非成功状态或无法解析响应。"""
@dataclass(frozen=True)
class Change:
"""表示一个待提交文件的最终内容和 Git 工作树摘要。"""
path: str
source_path: Path
operation: str
before_sha256: Optional[str]
after_sha256: Optional[str]
size_bytes: int
mode: str
def result_payload(**payload: Any) -> str:
"""将脚本结果序列化为稳定 JSON,供 Agent 解析。"""
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
def redact_text(value: Any) -> str:
"""去除错误信息中可能出现的 Authorization 凭据。"""
text = str(value or "")
return _TOKEN_PATTERN.sub(r"\1 <REDACTED>", text)
def normalize_repo(repo: str) -> str:
"""规范化并校验 GitHub owner/repo 标识。"""
value = (repo or "").strip().removesuffix(".git")
if value.startswith(("http://", "https://")):
parsed = urllib.parse.urlparse(value)
if parsed.netloc.lower() not in {"github.com", "www.github.com"}:
raise ValueError("目标仓库必须位于 GitHub")
parts = [part for part in parsed.path.strip("/").split("/") if part]
if len(parts) >= 2:
value = f"{parts[0]}/{parts[1]}"
if not _REPO_PATTERN.fullmatch(value):
raise ValueError("目标仓库必须是 owner/repo 或 GitHub URL")
return value
def normalize_branch(branch: str) -> str:
"""校验分支名,拒绝 ref 前缀、空段和路径穿越。"""
value = (branch or "").strip()
if value.startswith("refs/") or not _BRANCH_PATTERN.fullmatch(value):
raise ValueError("分支名包含不支持的字符")
if value.startswith("/") or value.endswith("/") or ".." in value or "//" in value:
raise ValueError("分支名不能包含空段或连续点")
return value
def normalize_relative_path(path: str) -> str:
"""将文件路径规范化为仓库内 POSIX 相对路径。"""
raw_value = str(path or "").replace("\\", "/")
if not raw_value or raw_value.startswith("/") or re.match(r"^[A-Za-z]:/", raw_value):
raise ValueError(f"文件路径不在源目录内: {path}")
value = raw_value.strip("/")
pure_path = PurePosixPath(value)
if not value or value == "." or pure_path.is_absolute() or ".." in pure_path.parts:
raise ValueError(f"文件路径不在源目录内: {path}")
return pure_path.as_posix()
def is_rejected_path(path: str) -> bool:
"""判断路径是否指向运行时状态、缓存或明显敏感文件。"""
normalized = normalize_relative_path(path)
parts = set(PurePosixPath(normalized).parts)
if parts & _REJECTED_PATH_PARTS:
return True
return PurePosixPath(normalized).suffix.lower() in _REJECTED_SUFFIXES
def sha256_bytes(content: bytes) -> str:
"""返回原始字节的 SHA-256 摘要。"""
return hashlib.sha256(content).hexdigest()
def sha256_file(path: Path) -> str:
"""按块计算文件摘要,避免把大文件一次性载入内存。"""
digest = hashlib.sha256()
with path.open("rb") as file_handle:
for chunk in iter(lambda: file_handle.read(64 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def runtime_dir() -> Path:
"""返回普通 Agent 用户可读写的 Skill 工作目录,不把草稿写入源码仓库。"""
directory = Path(get_runtime_setting("CONFIG_PATH")) / "agent" / "submit-pull-request"
directory.mkdir(parents=True, exist_ok=True)
return directory
def write_json_file(path: Path, payload: dict[str, Any]) -> None:
"""以原子替换方式写入 Skill 运行状态 JSON。"""
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{secrets.token_hex(4)}.tmp")
try:
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, path)
finally:
if temporary.exists():
temporary.unlink()
def read_json_file(path: Path, *, reason: str = "invalid_state") -> dict[str, Any]:
"""读取并校验 Skill 运行状态 JSON。"""
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise PullRequestError(reason, f"无法读取运行状态文件: {path}") from error
if not isinstance(payload, dict):
raise PullRequestError(reason, f"运行状态文件不是 JSON 对象: {path}")
return payload
def _run_git(
root: Path,
*arguments: str,
env: Optional[dict[str, str]] = None,
) -> subprocess.CompletedProcess[bytes]:
"""运行不经过 shell 的 Git 命令,避免用户输入触发命令注入。"""
try:
return subprocess.run(
["git", *arguments],
cwd=root,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
except OSError as error:
raise PullRequestError("git_unavailable", "当前环境没有可用的 Git 命令。") from error
def _git_output(root: Path, *arguments: str, env: Optional[dict[str, str]] = None) -> bytes:
"""运行 Git 并在失败时返回不含凭据的稳定错误。"""
result = _run_git(root, *arguments, env=env)
if result.returncode != 0:
detail = redact_text(result.stderr.decode("utf-8", errors="replace"))[:500]
raise PullRequestError(
"git_command_failed",
f"Git 命令失败: {arguments[0]}{(': ' + detail) if detail else ''}",
)
return result.stdout
def run_git(
root: Path,
*arguments: str,
env: Optional[dict[str, str]] = None,
) -> subprocess.CompletedProcess[bytes]:
"""运行 Git 并返回完整结果,供 clone、提交和推送脚本复用。"""
return _run_git(root, *arguments, env=env)
def git_output(root: Path, *arguments: str, env: Optional[dict[str, str]] = None) -> bytes:
"""运行 Git 并要求命令成功,供需要稳定失败原因的脚本复用。"""
return _git_output(root, *arguments, env=env)
def find_git_root(source_root: Path) -> Optional[Path]:
"""返回源目录对应的 Git 根目录;没有仓库时返回 None。"""
result = _run_git(source_root, "rev-parse", "--show-toplevel")
if result.returncode != 0:
return None
try:
root = Path(result.stdout.decode("utf-8").strip()).resolve()
except OSError:
return None
return root if root.is_dir() else None
def require_git_root(source_root: Path) -> Path:
"""要求源目录本身是 clone 的 Git 根目录。"""
source_root = source_root.expanduser().resolve()
if not source_root.is_dir():
raise PullRequestError("invalid_source_root", f"源目录不存在或不是目录: {source_root}")
git_root = find_git_root(source_root)
if git_root is None:
raise PullRequestError("git_required", "PR 提交必须基于先前 git clone 的仓库目录。")
if git_root != source_root:
raise PullRequestError(
"source_root_must_be_git_root",
f"请把 clone 的仓库根目录作为 source_root: {git_root}",
)
return git_root
def _git_sha256(root: Path, path: str) -> Optional[str]:
"""读取 HEAD 中的文件内容并计算摘要,用于记录变更前版本。"""
result = _run_git(root, "show", f"HEAD:{path}")
if result.returncode != 0:
return None
return sha256_bytes(result.stdout)
def _file_mode(path: Path) -> str:
"""返回普通文件或可执行文件对应的 Git tree mode。"""
return "100755" if path.is_file() and path.stat().st_mode & 0o111 else "100644"
def _git_changes(root: Path) -> list[dict[str, Any]]:
"""收集 HEAD 到当前工作树的变更,包括未跟踪文件。"""
result = _run_git(root, "diff", "--name-status", "--no-renames", "-z", "HEAD", "--")
if result.returncode != 0:
raise PullRequestError("git_status_failed", "无法读取当前 Git 工作树变更。")
changes: dict[str, str] = {}
tokens = result.stdout.decode("utf-8", errors="surrogateescape").split("\0")
index = 0
while index + 1 < len(tokens):
status, path = tokens[index], tokens[index + 1]
index += 2
if status and path:
changes[normalize_relative_path(path)] = status[0]
untracked = _run_git(root, "ls-files", "--others", "--exclude-standard", "-z")
if untracked.returncode != 0:
raise PullRequestError("git_status_failed", "无法读取未跟踪文件列表。")
for path in untracked.stdout.decode("utf-8", errors="surrogateescape").split("\0"):
if path:
changes[normalize_relative_path(path)] = "A"
result_changes = []
for path, status in sorted(changes.items()):
if is_rejected_path(path):
raise PullRequestError("sensitive_path", f"拒绝提交运行时或敏感路径: {path}")
local_path = root / path
if local_path.is_symlink():
raise PullRequestError("unsupported_change", f"拒绝提交符号链接文件: {path}")
source_path = local_path.resolve(strict=False)
if not _is_within(source_path, root):
raise PullRequestError("path_outside_root", f"文件路径超出源目录: {path}")
exists = source_path.is_file()
if status == "D" or not exists:
if status != "D":
raise PullRequestError("unsupported_change", f"变更不是普通文件: {path}")
operation = "delete"
else:
operation = "create" if status == "A" else "update"
result_changes.append(
{
"path": path,
"source_path": str(source_path),
"operation": operation,
"before_sha256": None if operation == "create" else _git_sha256(root, path),
"after_sha256": sha256_file(source_path) if exists else None,
"size_bytes": source_path.stat().st_size if exists else 0,
"mode": _file_mode(source_path),
}
)
return result_changes
def _is_within(path: Path, root: Path) -> bool:
"""判断解析后的路径是否仍位于指定根目录。"""
try:
path.relative_to(root)
return True
except ValueError:
return False
def git_current_branch(root: Path) -> str:
"""读取当前 clone 的分支名,拒绝 detached HEAD。"""
branch = _git_output(root, "symbolic-ref", "--quiet", "--short", "HEAD").decode().strip()
if not branch:
raise PullRequestError("detached_head", "当前 clone 处于 detached HEAD,不能安全提交 PR。")
try:
return normalize_branch(branch)
except ValueError as error:
raise PullRequestError("invalid_branch", str(error)) from error
def git_remote_url(root: Path, remote: str = "origin") -> str:
"""读取指定 Git remote URL。"""
return _git_output(root, "remote", "get-url", remote).decode().strip()
def remote_repo_from_url(remote_url: str) -> str:
"""从 HTTPS、SSH 或 scp 风格 remote URL 提取 GitHub 仓库名。"""
value = (remote_url or "").strip()
if value.startswith(("http://", "https://", "ssh://")):
parsed = urllib.parse.urlparse(value)
if parsed.hostname and parsed.hostname.lower() not in {"github.com", "www.github.com"}:
raise PullRequestError("unsupported_remote", "Git remote 必须指向 GitHub。")
return normalize_repo(parsed.path.strip("/"))
match = _SSH_REPO_PATTERN.fullmatch(value)
if not match or match.group(1).lower() not in {"github.com", "www.github.com"}:
raise PullRequestError("unsupported_remote", "无法确认 Git remote 的 GitHub 仓库。")
return normalize_repo(match.group(2))
def collect_changes(source_root: Path) -> tuple[str, list[dict[str, Any]]]:
"""只从先前 clone 的 Git 工作树收集代码变更。"""
git_root = require_git_root(source_root)
changes = _git_changes(git_root)
if not changes:
raise PullRequestError("no_changes", "没有检测到可提交的 Git 工作树变更。")
if len(changes) > MAX_CHANGED_FILES:
raise PullRequestError("too_many_files", f"单次 PR 最多允许 {MAX_CHANGED_FILES} 个文件。")
return "git", changes
def change_fingerprint(changes: list[dict[str, Any]]) -> str:
"""根据路径、操作、最终摘要和文件模式生成幂等变更指纹。"""
stable = [
{
"path": item["path"],
"operation": item["operation"],
"after_sha256": item.get("after_sha256"),
"mode": item.get("mode", "100644"),
}
for item in sorted(changes, key=lambda value: value["path"])
]
return hashlib.sha256(
json.dumps(stable, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
def build_branch_name(target_repo: str, base: str) -> str:
"""生成不会覆盖远端已有分支的 Agent 工作分支名。"""
seed = f"{target_repo}:{base}:{time.time_ns()}:{secrets.token_hex(8)}".encode()
return normalize_branch(f"agent/moviepilot/{hashlib.sha256(seed).hexdigest()[:16]}")
def load_github_headers(repo: str) -> tuple[dict[str, str], Any]:
"""读取仓库专属或全局 GitHub 请求头,不返回明文 Token。"""
headers: dict[str, str] = {}
proxy: Any = None
try:
headers = dict(settings.REPO_GITHUB_HEADERS(repo=repo))
proxy = settings.PROXY
except Exception:
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
proxy = {"http": proxy_url, "https": proxy_url} if proxy_url else None
has_authorization = any(key.lower() == "authorization" and value for key, value in headers.items())
if not has_authorization:
token = (
os.environ.get("MOVIEPILOT_GITHUB_TOKEN")
or os.environ.get("GITHUB_TOKEN")
or os.environ.get("GH_TOKEN")
or ""
).strip()
if token:
headers["Authorization"] = f"Bearer {token}"
if proxy is None:
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
proxy = {"http": proxy_url, "https": proxy_url} if proxy_url else None
headers.setdefault("Accept", "application/vnd.github+json")
headers.setdefault("User-Agent", "MoviePilot-Agent-PR")
headers.setdefault("X-GitHub-Api-Version", GITHUB_API_VERSION)
return headers, proxy
def has_github_auth(headers: dict[str, str]) -> bool:
"""判断请求头是否包含认证信息,不解析或输出 Token。"""
return any(key.lower() == "authorization" and value for key, value in headers.items())
def github_token_from_headers(headers: dict[str, str]) -> str:
"""从内部 GitHub Authorization 请求头提取 Git HTTPS 所需的令牌。"""
authorization = next(
(value for key, value in headers.items() if key.lower() == "authorization"),
"",
)
scheme, _, token = authorization.partition(" ")
if scheme.lower() not in {"bearer", "token"} or not token.strip():
raise PullRequestError("unsupported_token", "当前 GitHub Token 格式不能用于 Git HTTPS 推送。")
return token.strip()
@contextmanager
def git_auth_environment(headers: dict[str, str]) -> Iterator[dict[str, str]]:
"""通过临时 askpass 和进程环境向 Git 提供令牌,避免令牌出现在命令行。"""
token = github_token_from_headers(headers)
with tempfile.TemporaryDirectory(prefix="git-askpass-", dir=runtime_dir()) as directory:
askpass = Path(directory) / "askpass.sh"
askpass.write_text(
"#!/bin/sh\n"
'case "${1:-}" in\n'
" *[Uu]sername*) printf '%s\\n' 'x-access-token' ;;\n"
" *) printf '%s\\n' \"${MOVIEPILOT_GIT_TOKEN}\" ;;\n"
"esac\n",
encoding="utf-8",
)
askpass.chmod(0o700)
environment = os.environ.copy()
environment.update(
{
"GIT_ASKPASS": str(askpass),
"GIT_TERMINAL_PROMPT": "0",
"MOVIEPILOT_GIT_TOKEN": token,
}
)
yield environment
def github_clone_url(repo: str) -> str:
"""返回不嵌入令牌的 GitHub HTTPS clone URL。"""
return f"https://github.com/{normalize_repo(repo)}.git"
def _quote_path(value: str) -> str:
"""编码 GitHub API 路径中的单个或多个层级。"""
return "/".join(urllib.parse.quote(part, safe="") for part in value.split("/"))
class GitHubClient:
"""通过 MoviePilot RequestUtils 调用 GitHub REST API。"""
def __init__(
self,
*,
headers: dict[str, str],
proxies: Any = None,
timeout: int = DEFAULT_TIMEOUT_SECONDS,
) -> None:
"""保存 HTTP 配置;Token 仅保留在请求头内。"""
self.headers = headers
self.proxies = proxies
self.timeout = timeout
def request(self, method: str, path: str, payload: Optional[dict[str, Any]] = None) -> Any:
"""发起一次 JSON 请求并将 GitHub 错误转换为稳定异常。"""
body = None
headers = dict(self.headers)
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
response = None
try:
response = RequestUtils(
proxies=self.proxies,
headers=headers,
timeout=self.timeout,
).request(
method=method.lower(),
url=f"{GITHUB_API_BASE}{path}",
data=body,
raise_exception=False,
)
if response is None:
raise GitHubApiError("github_unavailable", "GitHub API 未返回响应。")
raw = response.content or b""
if response.status_code >= 400:
message = raw.decode("utf-8", errors="replace")[:500]
raise GitHubApiError(
"github_api_error",
f"GitHub API 返回 HTTP {response.status_code}: {redact_text(message)}",
status=response.status_code,
)
if not raw:
return None
try:
return json.loads(raw.decode("utf-8"))
except json.JSONDecodeError as error:
raise GitHubApiError("invalid_github_response", "GitHub API 响应不是合法 JSON。") from error
except GitHubApiError:
raise
except Exception as error:
raise GitHubApiError("network_error", f"访问 GitHub API 失败: {redact_text(error)}") from error
finally:
if response is not None:
response.close()
def get_user(self) -> dict[str, Any]:
"""读取当前 Token 对应的 GitHub 用户。"""
data = self.request("GET", "/user")
if not isinstance(data, dict) or not data.get("login"):
raise GitHubApiError("invalid_github_user", "无法确定 GitHub Token 对应的用户。")
return data
def get_repo(self, repo: str) -> dict[str, Any]:
"""读取仓库详情。"""
data = self.request("GET", f"/repos/{normalize_repo(repo)}")
if not isinstance(data, dict):
raise GitHubApiError("invalid_github_repo", f"GitHub 仓库响应无效: {repo}")
return data
def get_ref(self, repo: str, branch: str) -> dict[str, Any]:
"""读取分支引用。"""
branch = normalize_branch(branch)
data = self.request(
"GET",
f"/repos/{normalize_repo(repo)}/git/ref/heads/{_quote_path(branch)}",
)
if not isinstance(data, dict) or not isinstance(data.get("object"), dict):
raise GitHubApiError("invalid_github_ref", f"GitHub 分支响应无效: {repo}:{branch}")
return data
def create_fork(self, repo: str) -> dict[str, Any]:
"""请求为当前认证用户创建 Fork。"""
data = self.request("POST", f"/repos/{normalize_repo(repo)}/forks", {"default_branch_only": True})
if not isinstance(data, dict):
raise GitHubApiError("invalid_fork_response", "GitHub Fork 响应无效。")
return data
def list_open_pulls(self, repo: str, *, head: str, base: str) -> list[dict[str, Any]]:
"""列出指定 head/base 的开放 PR,用于幂等重试。"""
query = urllib.parse.urlencode({"state": "open", "head": head, "base": base, "per_page": "20"})
data = self.request("GET", f"/repos/{normalize_repo(repo)}/pulls?{query}")
return data if isinstance(data, list) else []
def create_pull(self, repo: str, payload: dict[str, Any]) -> dict[str, Any]:
"""创建上游 Pull Request。"""
data = self.request("POST", f"/repos/{normalize_repo(repo)}/pulls", payload)
if not isinstance(data, dict) or not data.get("html_url"):
raise GitHubApiError("invalid_pull_response", "GitHub PR 响应缺少 URL。")
return data
def ensure_fork(client: GitHubClient, upstream_repo: str, login: str) -> tuple[str, bool]:
"""复用当前用户对目标仓库的已有 Fork,否则创建并等待可读。"""
upstream = normalize_repo(upstream_repo)
owner, name = upstream.split("/", 1)
if owner.lower() == login.lower():
repository = client.get_repo(upstream)
permissions = repository.get("permissions") if isinstance(repository, dict) else None
if isinstance(permissions, dict) and permissions.get("push") is False:
raise PullRequestError("fork_permission", f"当前 Token 没有向目标仓库推送的权限: {upstream}")
return upstream, False
fork_repo = normalize_repo(f"{login}/{name}")
try:
fork_data = client.get_repo(fork_repo)
except GitHubApiError as error:
if error.status != 404:
raise
client.create_fork(upstream)
deadline = time.monotonic() + 60
fork_data = None
while time.monotonic() < deadline:
try:
fork_data = client.get_repo(fork_repo)
break
except GitHubApiError as retry_error:
if retry_error.status != 404:
raise
time.sleep(1)
if fork_data is None:
raise PullRequestError("fork_timeout", f"Fork {fork_repo} 创建后未在规定时间内可用。")
created = True
else:
created = False
parent = fork_data.get("parent") if isinstance(fork_data, dict) else None
if not isinstance(fork_data, dict) or fork_data.get("fork") is not True or not isinstance(parent, dict):
raise PullRequestError("invalid_fork", f"{fork_repo} 存在,但不是目标仓库的 Fork。")
try:
parent_full_name = normalize_repo(str(parent.get("full_name") or ""))
except ValueError as error:
raise PullRequestError("invalid_fork", f"无法确认 {fork_repo} 的上游仓库。") from error
if parent_full_name.lower() != upstream.lower():
raise PullRequestError("wrong_fork_parent", f"{fork_repo} 的上游不是 {upstream}。")
return fork_repo, created
def pull_head(target_repo: str, fork_repo: str, login: str, branch: str) -> str:
"""返回 GitHub 创建 PR 所需的 head 表达式。"""
if normalize_repo(target_repo).lower() == normalize_repo(fork_repo).lower():
return normalize_branch(branch)
return f"{login}:{normalize_branch(branch)}"
def changes_from_payload(payload: dict[str, Any]) -> list[Change]:
"""读取并验证预览中的变更,确认 Git 工作树仍与预览一致。"""
source_root = Path(str(payload.get("source_root") or "")).expanduser().resolve()
changes_file = Path(str(payload.get("changes_file") or "")).expanduser().resolve()
require_git_root(source_root)
if not changes_file.is_file():
raise PullRequestError("missing_changes", "PR 变更清单不存在,请重新准备预览。")
try:
data = json.loads(changes_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise PullRequestError("invalid_changes", "PR 变更清单无法读取。") from error
raw_changes = data.get("changes") if isinstance(data, dict) else None
if not isinstance(raw_changes, list) or not raw_changes:
raise PullRequestError("no_changes", "PR 变更清单为空。")
result = []
for raw in raw_changes:
if not isinstance(raw, dict):
raise PullRequestError("invalid_changes", "PR 变更清单包含无效条目。")
path = normalize_relative_path(str(raw.get("path") or ""))
local_path = source_root / path
if local_path.is_symlink():
raise PullRequestError("unsupported_change", f"拒绝提交符号链接文件: {path}")
source_path = local_path.resolve(strict=False)
if not _is_within(source_path, source_root):
raise PullRequestError("path_outside_root", f"文件路径超出源目录: {path}")
if is_rejected_path(path):
raise PullRequestError("sensitive_path", f"拒绝提交运行时或敏感路径: {path}")
operation = str(raw.get("operation") or "")
if operation not in {"create", "update", "delete"}:
raise PullRequestError("invalid_changes", f"文件操作无效: {path}")
exists = source_path.is_file()
after_sha256 = sha256_file(source_path) if exists else None
if after_sha256 != raw.get("after_sha256"):
raise PullRequestError("changes_modified", f"预览后文件发生变化: {path}")
if operation != "delete" and not exists:
raise PullRequestError("missing_file", f"待提交文件不存在: {path}")
if operation == "delete" and exists:
raise PullRequestError("delete_changed", f"待删除文件重新出现: {path}")
size_bytes = source_path.stat().st_size if exists else 0
if size_bytes > MAX_FILE_BYTES:
raise PullRequestError("file_too_large", f"文件超过 {MAX_FILE_BYTES // 1024 // 1024} MiB: {path}")
mode = _file_mode(source_path)
if mode != raw.get("mode", "100644"):
raise PullRequestError("changes_modified", f"预览后文件权限发生变化: {path}")
result.append(
Change(
path=path,
source_path=source_path,
operation=operation,
before_sha256=raw.get("before_sha256"),
after_sha256=after_sha256,
size_bytes=size_bytes,
mode=mode,
)
)
_, current_changes = collect_changes(source_root)
expected_fingerprint = data.get("fingerprint") if isinstance(data, dict) else None
current_fingerprint = change_fingerprint(current_changes)
if expected_fingerprint != current_fingerprint:
raise PullRequestError("changes_modified", "Git 工作树与已确认的 PR 预览不一致。")
return result
__all__ = [
"Change",
"GitHubApiError",
"GitHubClient",
"MAX_CHANGED_FILES",
"PullRequestError",
"build_branch_name",
"change_fingerprint",
"changes_from_payload",
"collect_changes",
"ensure_fork",
"find_git_root",
"git_output",
"git_auth_environment",
"git_current_branch",
"git_remote_url",
"github_clone_url",
"github_token_from_headers",
"has_github_auth",
"is_rejected_path",
"load_github_headers",
"normalize_branch",
"normalize_repo",
"pull_head",
"read_json_file",
"remote_repo_from_url",
"require_git_root",
"result_payload",
"run_git",
"runtime_dir",
"write_json_file",
]

View File

@@ -0,0 +1,283 @@
"""校验 Git clone 的变更并生成需要用户确认的 PR 预览。"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
from typing import Any
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_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,
runtime_dir,
write_json_file,
)
def _text_field(draft: dict[str, Any], name: str, *, required: bool = True, limit: int = 100_000) -> str:
"""读取并限制草稿文本字段,避免把超大内容送入 GitHub API。"""
value = draft.get(name, "")
if not isinstance(value, str):
raise PullRequestError("invalid_draft", f"草稿字段 {name} 必须是字符串。")
value = value.strip()
if required and not value:
raise PullRequestError("invalid_draft", f"草稿缺少 {name}。")
if len(value) > limit:
raise PullRequestError("invalid_draft", f"草稿字段 {name} 过长。")
return value
def _read_changes_metadata(changes_file: Path) -> dict[str, Any]:
"""读取采集脚本生成的变更摘要元数据。"""
data = read_json_file(changes_file, reason="invalid_changes")
if not isinstance(data.get("fingerprint"), str) or not data.get("fingerprint"):
raise PullRequestError("invalid_changes", "变更摘要缺少指纹。")
return data
def _preview_text(
*,
target_repo: str,
fork_repo: str,
base: str,
branch: str,
changes: list[Any],
title: str,
commit_message: str,
body: str,
draft: bool,
existing_pull: list[dict[str, Any]],
) -> str:
"""生成可直接展示给用户的完整 PR 预览。"""
lines = [
"# MoviePilot Agent PR 预览",
"",
f"- 上游仓库:`{target_repo}`",
f"- Fork:`{fork_repo}`(复用已有 Fork;如不存在,clone 阶段已创建)",
f"- 基线分支:`{base}`",
f"- 工作分支:`{branch}`",
f"- PR 类型:{'Draft' if draft else 'Ready for review'}",
"- 提交方式:本地 Git `add` → `commit` → `push`,成功后通过 GitHub API 创建 PR",
"",
"## 文件变更",
"",
]
for change in changes:
lines.append(f"- `{change.operation}` `{change.path}` ({change.size_bytes} bytes, mode {change.mode})")
lines.extend(
[
"",
"## Commit message",
"",
commit_message,
"",
"## PR 标题",
"",
title,
"",
"## PR 正文",
"",
body or "(空)",
"",
"## 幂等提示",
"",
"提交脚本会重新校验工作树摘要、上游基线、Fork remote 和推送后的分支 SHA;不执行强制推送或合并。",
]
)
if existing_pull:
lines.extend(["", "## 已存在的开放 PR", ""])
for pull in existing_pull:
lines.append(f"- {pull.get('html_url') or pull.get('url') or '(无 URL)'}")
return "\n".join(lines) + "\n"
def prepare_pull_request(draft_file: str) -> dict[str, Any]:
"""校验草稿和 Git 工作树,生成 payload 与完整预览文件。"""
draft_path = Path(draft_file).expanduser().resolve()
draft = read_json_file(draft_path, reason="invalid_draft")
try:
target_repo = normalize_repo(str(draft.get("target_repo") or ""))
except ValueError as error:
raise PullRequestError("invalid_draft", str(error)) from error
source_root = Path(str(draft.get("source_root") or "")).expanduser().resolve()
changes_file = Path(str(draft.get("changes_file") or "")).expanduser().resolve()
draft_for_changes = {"source_root": str(source_root), "changes_file": str(changes_file)}
changes = changes_from_payload(
{
**draft_for_changes,
"fingerprint": _read_changes_metadata(changes_file).get("fingerprint"),
}
)
branch = git_current_branch(source_root)
requested_branch = draft.get("branch")
if requested_branch:
try:
requested_branch = normalize_branch(str(requested_branch))
except ValueError as error:
raise PullRequestError("invalid_draft", str(error)) from error
if requested_branch != branch:
raise PullRequestError("branch_mismatch", "草稿分支与当前 clone 工作分支不一致。")
state: dict[str, Any] = {}
if draft.get("state_file"):
state = read_json_file(Path(str(draft["state_file"])).expanduser().resolve())
if Path(str(state.get("source_root") or "")).expanduser().resolve() != source_root:
raise PullRequestError("state_mismatch", "clone 状态文件与 source_root 不匹配。")
if state.get("target_repo", "").lower() != target_repo.lower():
raise PullRequestError("state_mismatch", "clone 状态文件与目标仓库不匹配。")
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 未返回当前用户。")
upstream = client.get_repo(target_repo)
base = str(draft.get("base") or state.get("base") or upstream.get("default_branch") or "").strip()
try:
base = normalize_branch(base)
except ValueError as error:
raise PullRequestError("invalid_draft", str(error)) from error
base_ref = client.get_ref(target_repo, base)
base_sha = str((base_ref.get("object") or {}).get("sha") or "")
if not base_sha:
raise PullRequestError("invalid_github_ref", f"无法读取上游基线: {target_repo}:{base}")
expected_state_sha = str(state.get("expected_base_sha") or draft.get("expected_base_sha") or "")
if expected_state_sha and expected_state_sha != base_sha:
raise PullRequestError("base_changed", "上游基线已变化,请重新 clone 后再修改代码。")
initial_head_sha = git_output(source_root, "rev-parse", "HEAD").decode().strip()
if initial_head_sha != base_sha:
raise PullRequestError(
"local_base_mismatch",
"本地 clone 的工作分支不是当前上游基线,请使用 clone_repository.py 重新准备。",
)
if branch == base:
raise PullRequestError("base_branch_forbidden", "不能直接在 PR 基线分支上提交。")
fork_repo, fork_created = ensure_fork(client, target_repo, login)
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},实际为 {origin_repo}。",
)
try:
client.get_ref(fork_repo, branch)
except GitHubApiError as error:
if error.status != 404:
raise
else:
raise PullRequestError("branch_exists", f"Fork 中已存在工作分支: {fork_repo}:{branch}")
title = _text_field(draft, "title", limit=256)
commit_message = _text_field(draft, "commit_message", limit=256)
body = _text_field(draft, "body", required=False)
draft_pr = draft.get("draft", True)
if not isinstance(draft_pr, bool):
raise PullRequestError("invalid_draft", "草稿字段 draft 必须是布尔值。")
head = pull_head(target_repo, fork_repo, login, branch)
existing_pull = client.list_open_pulls(target_repo, head=head, base=base)
changes_metadata = _read_changes_metadata(changes_file)
payload = {
"version": 1,
"target_repo": target_repo,
"fork_repo": fork_repo,
"source_root": str(source_root),
"state_file": str(Path(str(draft["state_file"])).expanduser().resolve()) if draft.get("state_file") else "",
"changes_file": str(changes_file),
"change_fingerprint": changes_metadata["fingerprint"],
"base": base,
"expected_base_sha": base_sha,
"initial_head_sha": initial_head_sha,
"branch": branch,
"github_login": login,
"head": head,
"title": title,
"body": body,
"commit_message": commit_message,
"draft": draft_pr,
"fork_created": fork_created,
}
payload_digest = hashlib.sha256(
json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
).hexdigest()[:20]
payload_file = runtime_dir() / "payloads" / f"{payload_digest}.json"
preview_file = runtime_dir() / "previews" / f"{payload_digest}.md"
write_json_file(payload_file, payload)
preview_file.parent.mkdir(parents=True, exist_ok=True)
preview_file.write_text(
_preview_text(
target_repo=target_repo,
fork_repo=fork_repo,
base=base,
branch=branch,
changes=changes,
title=title,
commit_message=commit_message,
body=body,
draft=draft_pr,
existing_pull=existing_pull,
),
encoding="utf-8",
)
return {
"success": True,
"payload_file": str(payload_file),
"preview_file": str(preview_file),
"target_repo": target_repo,
"fork_repo": fork_repo,
"base": base,
"base_sha": base_sha,
"branch": branch,
"change_count": len(changes),
"existing_pull_requests": existing_pull,
"message": "预览已生成;请向用户展示 preview_file 全文并等待明确确认。",
}
def _parse_args() -> argparse.Namespace:
"""解析 PR 预览脚本命令行参数。"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--draft-file", required=True, help="Agent 写入 runtime_dir 的 PR 草稿 JSON")
return parser.parse_args()
def main() -> int:
"""执行 PR 预览入口并输出机器可解析 JSON。"""
try:
arguments = _parse_args()
result = prepare_pull_request(arguments.draft_file)
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())

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