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

284 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""校验 Git 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())