[功能] publish-moviepilot-plugin 技能改为单提交推送 (#7168)

This commit is contained in:
zkmydgth
2026-10-24 06:58:30 +08:00
committed by GitHub
parent d8cb95ab9e
commit d62e9ba579
4 changed files with 258 additions and 62 deletions

View File

@@ -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,也不会修改

View File

@@ -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

View File

@@ -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",
}

View File

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