mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 22:04:57 +08:00
[功能] publish-moviepilot-plugin 技能改为单提交推送 (#7168)
This commit is contained in:
@@ -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,也不会修改
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user