Files
MoviePilot/skills/submit-pull-request/scripts/clone_repository.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

217 lines
7.4 KiB
Python
Raw Permalink 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.
"""
为 PR 工作流确保 Fork,并从 Fork 创建一个隔离的 Git 克隆。
"""
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:
"""
为本次克隆生成运行状态文件路径。
"""
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:
"""
解析并检查克隆目标,拒绝覆盖已有非空目录。
"""
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:
"""
运行克隆流程中的 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:
"""
解析克隆脚本命令行参数。
"""
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:
"""
执行克隆入口并输出机器可解析 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())