mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 13:28:32 +08:00
feat(agent): add GitHub pull request workflow
This commit is contained in:
201
skills/submit-pull-request/scripts/clone_repository.py
Normal file
201
skills/submit-pull-request/scripts/clone_repository.py
Normal 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())
|
||||
102
skills/submit-pull-request/scripts/collect_pull_request.py
Normal file
102
skills/submit-pull-request/scripts/collect_pull_request.py
Normal 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())
|
||||
755
skills/submit-pull-request/scripts/pr_common.py
Normal file
755
skills/submit-pull-request/scripts/pr_common.py
Normal 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",
|
||||
]
|
||||
283
skills/submit-pull-request/scripts/prepare_pull_request.py
Normal file
283
skills/submit-pull-request/scripts/prepare_pull_request.py
Normal 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())
|
||||
269
skills/submit-pull-request/scripts/submit_pull_request.py
Normal file
269
skills/submit-pull-request/scripts/submit_pull_request.py
Normal 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())
|
||||
Reference in New Issue
Block a user