From d62e9ba57923bd95e92014f34743dabadf3087ee Mon Sep 17 00:00:00 2001 From: zkmydgth <35453590+zkmydgth@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:58:30 +0800 Subject: [PATCH] =?UTF-8?q?[=E5=8A=9F=E8=83=BD]=20publish-moviepilot-plugi?= =?UTF-8?q?n=20=E6=8A=80=E8=83=BD=E6=94=B9=E4=B8=BA=E5=8D=95=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=8E=A8=E9=80=81=20(#7168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/publish-moviepilot-plugin/SKILL.md | 6 +- .../scripts/publish_plugin.py | 191 ++++++++++++++---- tests/test_builtin_skill_boundaries.py | 2 +- tests/test_publish_moviepilot_plugin_skill.py | 121 +++++++++-- 4 files changed, 258 insertions(+), 62 deletions(-) diff --git a/skills/publish-moviepilot-plugin/SKILL.md b/skills/publish-moviepilot-plugin/SKILL.md index 804f4ca5d..084710370 100644 --- a/skills/publish-moviepilot-plugin/SKILL.md +++ b/skills/publish-moviepilot-plugin/SKILL.md @@ -1,6 +1,6 @@ --- name: publish-moviepilot-plugin -version: 5 +version: 6 description: >- Use this skill when the user asks to publish, upload, sync, pull, push, diff, or maintain a MoviePilot local plugin in a GitHub repository. Covers using the @@ -134,6 +134,10 @@ Options: - `--dry-run`: print planned changes without writing. - `--proxy URL`: use an explicit HTTP/HTTPS proxy for GitHub API requests. +`push` 通过 Git Data API(blob → tree → commit → 移动分支引用)提交:无论一次改动 +多少文件都只产生**一个 commit**,CI 只会触发一次、diff 完整,`package` 版本与源码 +版本不会出现中间态;返回 JSON 中带 `commit_sha`。空仓库会自动创建根提交并建立分支。 + ## GitHub Release 与 README `push` 只同步插件源码与 package 条目,**不会创建 GitHub Release,也不会修改 diff --git a/skills/publish-moviepilot-plugin/scripts/publish_plugin.py b/skills/publish-moviepilot-plugin/scripts/publish_plugin.py index 1e07c4269..4662f9944 100644 --- a/skills/publish-moviepilot-plugin/scripts/publish_plugin.py +++ b/skills/publish-moviepilot-plugin/scripts/publish_plugin.py @@ -191,7 +191,7 @@ class GitHubError(RuntimeError): class GitHubClient: """ - 基于 GitHub Contents API 的轻量客户端。 + 基于 GitHub Contents / Git Data API 的轻量客户端。 """ def __init__( @@ -349,49 +349,158 @@ class GitHubClient: return [] return tree - def put_file(self, path: str, content: bytes, message: str, sha: Optional[str]) -> Any: + def create_blob(self, content: bytes) -> str: """ - 创建或更新远端文件。 + 上传一个 Git blob。 - :param path: 仓库内 POSIX 路径 :param content: 文件内容 - :param message: 提交消息 - :param sha: 远端现有文件 SHA,创建时为 None - :return: GitHub API 响应 + :return: blob SHA """ - payload: dict[str, Any] = { - "message": message, - "content": base64.b64encode(content).decode("ascii"), - "branch": self.branch, - } - if sha: - payload["sha"] = sha - return self.request( - "PUT", - f"/repos/{self.repo}/contents/{quote_path(path)}", + data = self.request( + "POST", + f"/repos/{self.repo}/git/blobs", + payload={ + "content": base64.b64encode(content).decode("ascii"), + "encoding": "base64", + }, + ) + sha = data.get("sha") if isinstance(data, dict) else None + if not sha: + raise GitHubError(0, "创建 Git blob 失败:响应缺少 sha") + return str(sha) + + def create_tree(self, entries: list[dict[str, Any]], base_tree: Optional[str] = None) -> str: + """ + 基于已有树创建一棵新树。 + + :param entries: 树条目;删除文件时 sha 传 None + :param base_tree: 基线树 SHA;空仓库传 None + :return: 新树 SHA + """ + payload: dict[str, Any] = {"tree": entries} + if base_tree: + payload["base_tree"] = base_tree + data = self.request( + "POST", + f"/repos/{self.repo}/git/trees", payload=payload, ) + sha = data.get("sha") if isinstance(data, dict) else None + if not sha: + raise GitHubError(0, "创建 Git tree 失败:响应缺少 sha") + return str(sha) - def delete_file(self, path: str, message: str, sha: str) -> Any: + def create_commit(self, message: str, tree_sha: str, parents: list[str]) -> str: """ - 删除远端文件。 + 创建一个提交对象。 - :param path: 仓库内 POSIX 路径 - :param message: 提交消息 - :param sha: 远端文件 SHA + :param message: 提交说明 + :param tree_sha: 提交指向的树 + :param parents: 父提交;空仓库传空列表 + :return: 提交 SHA + """ + data = self.request( + "POST", + f"/repos/{self.repo}/git/commits", + payload={"message": message, "tree": tree_sha, "parents": parents}, + ) + sha = data.get("sha") if isinstance(data, dict) else None + if not sha: + raise GitHubError(0, "创建 Git commit 失败:响应缺少 sha") + return str(sha) + + def get_branch_commit(self) -> Optional[dict[str, Any]]: + """ + 读取目标分支当前提交。 + + :return: 分支接口返回的 commit 对象;空仓库或分支不存在时返回 None + """ + try: + data = self.request("GET", f"/repos/{self.repo}/branches/{quote(self.branch)}") + except GitHubError as error: + if error.status in (404, 409): + return None + raise + commit = data.get("commit") if isinstance(data, dict) else None + if not isinstance(commit, dict) or not commit.get("sha"): + return None + return commit + + def update_branch(self, commit_sha: str) -> Any: + """ + 把分支引用指向新提交,且禁止强推。 + + :param commit_sha: 新提交 SHA :return: GitHub API 响应 """ - payload = { - "message": message, - "sha": sha, - "branch": self.branch, - } return self.request( - "DELETE", - f"/repos/{self.repo}/contents/{quote_path(path)}", - payload=payload, + "PATCH", + f"/repos/{self.repo}/git/refs/heads/{urllib.parse.quote(self.branch, safe='/')}", + payload={"sha": commit_sha, "force": False}, ) + def create_branch_ref(self, commit_sha: str) -> Any: + """ + 在空仓库中创建目标分支引用。 + + :param commit_sha: 新提交 SHA + :return: GitHub API 响应 + """ + return self.request( + "POST", + f"/repos/{self.repo}/git/refs", + payload={"ref": f"refs/heads/{self.branch}", "sha": commit_sha}, + ) + + def commit_files( + self, + files: dict[str, bytes], + deletes: list[str], + message: str, + ) -> Optional[str]: + """ + 用 Git Data API 把多个文件的变更合并成一次提交。 + + Contents API 每个文件一次 PUT 就是一次提交,会让 CI 只看到最后一次 + 推送的文件差异;这里改为 blob → tree → commit → 移动分支引用, + 无论改多少文件都只产生一个提交。 + + :param files: 仓库路径到文件内容的映射 + :param deletes: 需要删除的仓库路径 + :param message: 提交说明 + :return: 新提交 SHA;没有任何变更时返回 None + """ + if not files and not deletes: + return None + head = self.get_branch_commit() + base_tree = None + parents: list[str] = [] + if head: + tree = head.get("commit", {}).get("tree") if isinstance(head.get("commit"), dict) else None + base_tree = tree.get("sha") if isinstance(tree, dict) else None + parents = [str(head["sha"])] + entries: list[dict[str, Any]] = [] + for path, content in files.items(): + entries.append( + { + "path": path, + "mode": "100644", + "type": "blob", + "sha": self.create_blob(content), + } + ) + for path in deletes: + entries.append( + {"path": path, "mode": "100644", "type": "blob", "sha": None} + ) + tree_sha = self.create_tree(entries, base_tree) + commit_sha = self.create_commit(message, tree_sha, parents) + if head: + self.update_branch(commit_sha) + else: + self.create_branch_ref(commit_sha) + return commit_sha + def repo_exists(self) -> bool: """ 检查目标仓库是否存在或当前令牌是否可访问。 @@ -1087,27 +1196,25 @@ def push(args: argparse.Namespace) -> dict[str, Any]: client.create_repo(private=args.private) message = args.message or f"Publish {context['plugin_id']}" applied: dict[str, list[str]] = {"create": [], "update": [], "delete": []} + files_to_write: dict[str, bytes] = {} if package_change in {"create", "update"}: - client.put_file( - context["layout"].package_file, - package_content, - message, - remote_package.sha if remote_package else None, - ) + files_to_write[context["layout"].package_file] = package_content applied[package_change].append(context["layout"].package_file) for path in changes.create: - client.put_file(path, local_files[path].content, message, None) + files_to_write[path] = local_files[path].content applied["create"].append(path) for path in changes.update: - client.put_file(path, local_files[path].content, message, remote_files[path].sha) + files_to_write[path] = local_files[path].content applied["update"].append(path) + delete_paths: list[str] = [] for path in changes.delete: - remote_sha = remote_files[path].sha - if remote_sha: - client.delete_file(path, message, remote_sha) + if remote_files[path].sha: + delete_paths.append(path) applied["delete"].append(path) + commit_sha = client.commit_files(files_to_write, delete_paths, message) payload["applied"] = applied - payload["message"] = "插件已推送到 GitHub 仓库。" + payload["commit_sha"] = commit_sha + payload["message"] = "插件已以单个提交推送到 GitHub 仓库。" return payload diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index db056140e..16ef13c4c 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -137,7 +137,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "generate-identifiers": "4", "create-moviepilot-plugin": "7", "create-moviepilot-skill": "3", - "publish-moviepilot-plugin": "5", + "publish-moviepilot-plugin": "6", "downloader-operation": "3", "mediaserver-operation": "3", } diff --git a/tests/test_publish_moviepilot_plugin_skill.py b/tests/test_publish_moviepilot_plugin_skill.py index 0eda39c94..cde1b242c 100644 --- a/tests/test_publish_moviepilot_plugin_skill.py +++ b/tests/test_publish_moviepilot_plugin_skill.py @@ -7,7 +7,7 @@ import json import sys from argparse import Namespace from pathlib import Path -from typing import Any, Optional +from typing import Any def load_publish_plugin_module() -> Any: @@ -104,7 +104,9 @@ def test_push_creates_public_repo_when_explicitly_requested(tmp_path: Path) -> N 初始化调用记录。 """ self.created_private = None - self.put_paths: list[str] = [] + self.commit_count = 0 + self.committed_files: list[str] = [] + self.committed_deletes: list[str] = [] def repo_exists(self) -> bool: """ @@ -119,24 +121,19 @@ def test_push_creates_public_repo_when_explicitly_requested(tmp_path: Path) -> N self.created_private = private return {"private": private, "html_url": "https://github.com/example/repo"} - def put_file( + def commit_files( self, - path: str, - content: bytes, + files: dict[str, bytes], + deletes: list[str], message: str, - sha: Optional[str] = None, - ) -> dict[str, Any]: + ) -> str: """ - 记录上传路径。 + 记录一次批量提交的文件集合。 """ - self.put_paths.append(path) - return {"content": {"path": path}} - - def delete_file(self, path: str, message: str, sha: str) -> dict[str, Any]: - """ - 记录删除路径。 - """ - return {"content": {"path": path}} + self.commit_count += 1 + self.committed_files = sorted(files) + self.committed_deletes = list(deletes) + return "commit-sha" fake_client = FakeClient() @@ -187,8 +184,10 @@ def test_push_creates_public_repo_when_explicitly_requested(tmp_path: Path) -> N assert payload["repo_existed"] is False assert fake_client.created_private is False - assert "package.v2.json" in fake_client.put_paths - assert "plugins.v2/myplugin/__init__.py" in fake_client.put_paths + assert fake_client.commit_count == 1 + assert "package.v2.json" in fake_client.committed_files + assert "plugins.v2/myplugin/__init__.py" in fake_client.committed_files + assert payload["commit_sha"] == "commit-sha" def test_create_repo_dry_run_does_not_touch_github() -> None: @@ -291,3 +290,89 @@ def test_resolve_layout_prefers_plugin_directory_over_manifest(tmp_path: Path) - assert layout.package_file == "package.v2.json" assert layout.plugin_root == "plugins.v2" + + +def test_commit_files_creates_single_commit_for_all_changes() -> None: + """ + 多文件变更应通过 Git Data API 合并为一个提交。 + """ + module = load_publish_plugin_module() + client = module.GitHubClient(repo="example/repo") + calls: list[tuple[str, str, Any]] = [] + blob_index = {"value": 0} + + def fake_request(method: str, path: str, payload: Any = None, accept: str = "") -> Any: + """ + 模拟 GitHub API 响应并记录调用。 + """ + del accept + calls.append((method, path, payload)) + if method == "GET" and "/branches/" in path: + return {"commit": {"sha": "parent-1", "commit": {"tree": {"sha": "base-tree"}}}} + if path.endswith("/git/blobs"): + blob_index["value"] += 1 + return {"sha": f"blob-{blob_index['value']}"} + if path.endswith("/git/trees"): + return {"sha": "new-tree"} + if path.endswith("/git/commits"): + return {"sha": "new-commit"} + if "/git/refs/heads/" in path: + return {"ok": True} + raise AssertionError(f"未预期的请求: {method} {path}") + + client.request = fake_request # type: ignore[method-assign] + commit_sha = client.commit_files( + {"package.v3.json": b"{}", "plugins.v3/demo/__init__.py": b""}, + ["plugins.v3/demo/old.py"], + "Publish Demo", + ) + + assert commit_sha == "new-commit" + tree_calls = [payload for method, path, payload in calls if path.endswith("/git/trees")] + assert len(tree_calls) == 1 + entries = {item["path"]: item for item in tree_calls[0]["tree"]} + assert entries["plugins.v3/demo/old.py"]["sha"] is None + assert tree_calls[0]["base_tree"] == "base-tree" + commit_calls = [payload for method, path, payload in calls if path.endswith("/git/commits")] + assert len(commit_calls) == 1 + assert commit_calls[0]["parents"] == ["parent-1"] + ref_calls = [(method, path) for method, path, _ in calls if "/git/refs/heads/" in path] + assert ref_calls == [("PATCH", "/repos/example/repo/git/refs/heads/main")] + assert blob_index["value"] == 2 + + +def test_commit_files_creates_branch_in_empty_repo() -> None: + """ + 空仓库没有分支时,应创建根提交并创建分支引用。 + """ + module = load_publish_plugin_module() + client = module.GitHubClient(repo="example/repo") + calls: list[tuple[str, str, Any]] = [] + + def fake_request(method: str, path: str, payload: Any = None, accept: str = "") -> Any: + """ + 模拟空仓库响应。 + """ + del accept + calls.append((method, path, payload)) + if method == "GET" and "/branches/" in path: + raise module.GitHubError(404, "Branch not found") + if path.endswith("/git/blobs"): + return {"sha": "blob-1"} + if path.endswith("/git/trees"): + return {"sha": "new-tree"} + if path.endswith("/git/commits"): + return {"sha": "root-commit"} + if path.endswith("/git/refs"): + return {"ref": "refs/heads/main"} + raise AssertionError(f"未预期的请求: {method} {path}") + + client.request = fake_request # type: ignore[method-assign] + commit_sha = client.commit_files({"package.v3.json": b"{}"}, [], "Init") + + assert commit_sha == "root-commit" + commit_calls = [payload for method, path, payload in calls if path.endswith("/git/commits")] + assert commit_calls[0]["parents"] == [] + tree_calls = [payload for method, path, payload in calls if path.endswith("/git/trees")] + assert "base_tree" not in tree_calls[0] + assert any(method == "POST" and path.endswith("/git/refs") for method, path, _ in calls)