mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-21 07:24:29 +08:00
feat(skill): support creating plugin publish repos
This commit is contained in:
@@ -8,6 +8,8 @@ description: >-
|
||||
repositories, package.json/package.v2.json metadata, plugins/plugins.v2
|
||||
layouts, safe file exclusion, diff preview before publishing, incremental
|
||||
GitHub Contents API updates, and syncing local plugin changes back from GitHub.
|
||||
Includes asking whether to use an existing repository or create a new public
|
||||
repository when no target repository is available.
|
||||
Also use for Chinese requests mentioning 插件发布, 插件维护, 推送插件到 GitHub,
|
||||
从 GitHub 拉取插件, 同步本地插件仓库, 增量发布插件, 插件仓库维护.
|
||||
allowed-tools: list_directory read_file write_file edit_file execute_command query_system_settings update_system_settings
|
||||
@@ -25,6 +27,8 @@ through GitHub while protecting local secrets and unrelated plugins.
|
||||
- Merge only that plugin's entry into `package.v2.json` or `package.json`.
|
||||
- Preview local/remote differences before writing.
|
||||
- Pull remote plugin files back to the local plugin source.
|
||||
- Create the target GitHub repository when the user explicitly chooses automatic
|
||||
creation; repositories are public by default unless the user asks for private.
|
||||
- Reuse MoviePilot settings `GITHUB_TOKEN`, `REPO_GITHUB_TOKEN`,
|
||||
and `PLUGIN_LOCAL_REPO_PATHS` when available.
|
||||
|
||||
@@ -49,7 +53,12 @@ through GitHub while protecting local secrets and unrelated plugins.
|
||||
2. Identify the GitHub repository as `owner/repo`.
|
||||
- Use the user's explicit repository first.
|
||||
- If omitted, infer only when the local source has an obvious Git remote.
|
||||
- If neither is available, ask for the target repository.
|
||||
- If neither is available, ask whether to use an existing repository or
|
||||
automatically create a new public repository.
|
||||
- If the user chooses an existing repository, ask for `owner/repo`.
|
||||
- If the user chooses automatic creation, ask for the target `owner/repo`
|
||||
and state that the repository will be public by default.
|
||||
- Do not create a private repository unless the user explicitly asks for it.
|
||||
3. Select the package version layout.
|
||||
- Prefer `v2` when `package.v2.json` or `plugins.v2/<plugin_id_lower>/`
|
||||
exists.
|
||||
@@ -83,13 +92,20 @@ python skills/publish-moviepilot-plugin/scripts/publish_plugin.py pull \
|
||||
--plugin-id MyPlugin \
|
||||
--local-repo /path/to/MoviePilot-Plugins \
|
||||
--package-version v2
|
||||
|
||||
python skills/publish-moviepilot-plugin/scripts/publish_plugin.py create-repo \
|
||||
--repo owner/repo
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `create-repo`: create the target GitHub repository. Default visibility is
|
||||
public; use `--private` only when the user explicitly asked for private.
|
||||
- `preview`: compare local filtered files with remote files and print JSON.
|
||||
- `push`: upload changed files and merge the plugin package entry.
|
||||
- `pull`: write remote plugin files and package entry into local source.
|
||||
- `--create-repo-if-missing`: on push, create the target public repository when
|
||||
GitHub reports that it does not exist.
|
||||
- `--delete-remote`: on push, delete remote plugin files that no longer exist
|
||||
locally after exclusions.
|
||||
- `--force`: on pull, allow overwriting local files that differ from remote.
|
||||
@@ -102,6 +118,10 @@ Options:
|
||||
|
||||
- Always run `preview` before `push` unless the user explicitly asks for a
|
||||
direct push and already reviewed the diff.
|
||||
- When no repository is known, ask the user to choose:
|
||||
`使用已有 GitHub 仓库` or `自动创建 GitHub 仓库(默认 public)`.
|
||||
- Only run `create-repo` or `push --create-repo-if-missing` after the user has
|
||||
explicitly chosen automatic creation.
|
||||
- Never upload these files unless explicitly included:
|
||||
`.env`, `.env.*`, `config/`, `data/`, `cache/`, `logs/`, `tmp/`,
|
||||
`__pycache__/`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`,
|
||||
@@ -122,10 +142,17 @@ Options:
|
||||
User asks: `把本地 MyPlugin 发布到我的 GitHub 插件仓库`
|
||||
|
||||
1. Find `MyPlugin` under configured `PLUGIN_LOCAL_REPO_PATHS`.
|
||||
2. Ask for `owner/repo` if it cannot be inferred.
|
||||
2. Ask whether to use an existing repository or create a new public repository
|
||||
if `owner/repo` cannot be inferred.
|
||||
3. Run `preview` and summarize the diff.
|
||||
4. Run `push` only after the user confirms or requested immediate publish.
|
||||
|
||||
User asks: `发布插件,没有 GitHub 仓库`
|
||||
|
||||
1. Ask for the target `owner/repo` and confirm automatic creation.
|
||||
2. Run `create-repo` or use `push --create-repo-if-missing`.
|
||||
3. Continue with `preview` and `push` after repository creation succeeds.
|
||||
|
||||
User asks: `同步 GitHub 上 MyPlugin 的最新代码到本地`
|
||||
|
||||
1. Run `pull` without `--force`.
|
||||
|
||||
@@ -22,6 +22,7 @@ DEFAULT_BRANCH = "main"
|
||||
DEFAULT_TIMEOUT = 60
|
||||
GITHUB_API_BASE = "https://api.github.com"
|
||||
USER_AGENT = "MoviePilot-Plugin-Publisher"
|
||||
COMMANDS_REQUIRE_LOCAL_PLUGIN = {"preview", "push", "pull"}
|
||||
PACKAGE_BY_VERSION = {
|
||||
"legacy": ("package.json", "plugins"),
|
||||
"v1": ("package.json", "plugins"),
|
||||
@@ -331,6 +332,39 @@ class GitHubClient:
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
def repo_exists(self) -> bool:
|
||||
"""
|
||||
检查目标仓库是否存在或当前 token 是否可访问。
|
||||
|
||||
:return: 仓库存在或可访问时返回 True
|
||||
"""
|
||||
try:
|
||||
self.request("GET", f"/repos/{self.repo}")
|
||||
return True
|
||||
except GitHubError as err:
|
||||
if err.status == 404:
|
||||
return False
|
||||
raise
|
||||
|
||||
def create_repo(self, private: bool = False) -> Any:
|
||||
"""
|
||||
创建目标 GitHub 仓库。
|
||||
|
||||
:param private: 是否创建私有仓库,默认创建公开仓库
|
||||
:return: GitHub API 响应
|
||||
"""
|
||||
owner, repo_name = self.repo.split("/", 1)
|
||||
payload = {
|
||||
"name": repo_name,
|
||||
"private": private,
|
||||
"auto_init": True,
|
||||
}
|
||||
user_data = self.request("GET", "/user")
|
||||
login = user_data.get("login") if isinstance(user_data, dict) else ""
|
||||
if login and str(login).lower() == owner.lower():
|
||||
return self.request("POST", "/user/repos", payload=payload)
|
||||
return self.request("POST", f"/orgs/{quote(owner)}/repos", payload=payload)
|
||||
|
||||
|
||||
def normalize_repo(repo: str) -> str:
|
||||
"""
|
||||
@@ -872,6 +906,33 @@ def compare_package(
|
||||
return remote_package, merged_content, change_type
|
||||
|
||||
|
||||
def load_remote_files_for_push(
|
||||
context: dict[str, Any],
|
||||
args: argparse.Namespace,
|
||||
) -> tuple[dict[str, FileState], Optional[FileState], bool]:
|
||||
"""
|
||||
读取推送所需的远端文件,允许显式自动建仓时把缺失仓库视为空仓库。
|
||||
|
||||
:param context: 运行上下文
|
||||
:param args: 命令行参数
|
||||
:return: 远端插件文件、远端 package 文件、仓库是否已存在
|
||||
"""
|
||||
client: GitHubClient = context["client"]
|
||||
repo_exists = client.repo_exists()
|
||||
if not repo_exists and not args.create_repo_if_missing:
|
||||
raise GitHubError(
|
||||
404,
|
||||
"目标仓库不存在。请先创建仓库,或在用户明确同意后使用 --create-repo-if-missing。",
|
||||
)
|
||||
if not repo_exists:
|
||||
return {}, None, False
|
||||
return (
|
||||
client.list_files(context["remote_prefix"]),
|
||||
client.get_file(context["layout"].package_file),
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def preview(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""
|
||||
预览本地插件与远端仓库的差异。
|
||||
@@ -916,20 +977,28 @@ def push(args: argparse.Namespace) -> dict[str, Any]:
|
||||
context["excludes"],
|
||||
context["includes"],
|
||||
)
|
||||
remote_files = client.list_files(context["remote_prefix"])
|
||||
entry = read_package_entry(context["local_repo"], context["layout"], context["plugin_id"])
|
||||
remote_package, package_content, package_change = compare_package(
|
||||
client,
|
||||
context["layout"],
|
||||
remote_files, remote_package, repo_existed = load_remote_files_for_push(context, args)
|
||||
package_content = merge_package_content(
|
||||
remote_package.content if remote_package else None,
|
||||
context["plugin_id"],
|
||||
entry,
|
||||
)
|
||||
if remote_package is None:
|
||||
package_change = "create"
|
||||
elif hashlib.sha256(package_content).hexdigest() != remote_package.sha256:
|
||||
package_change = "update"
|
||||
else:
|
||||
package_change = "same"
|
||||
changes = build_push_changes(local_files, remote_files, rejected, args.delete_remote)
|
||||
payload = result_payload(context, changes, package_change=package_change)
|
||||
payload["repo_existed"] = repo_existed
|
||||
if args.dry_run:
|
||||
payload["dry_run"] = True
|
||||
return payload
|
||||
|
||||
if not repo_existed:
|
||||
client.create_repo(private=args.private)
|
||||
message = args.message or f"Publish {context['plugin_id']}"
|
||||
applied: dict[str, list[str]] = {"create": [], "update": [], "delete": []}
|
||||
if package_change in {"create", "update"}:
|
||||
@@ -956,6 +1025,43 @@ def push(args: argparse.Namespace) -> dict[str, Any]:
|
||||
return payload
|
||||
|
||||
|
||||
def create_repo(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""
|
||||
创建 GitHub 仓库,默认创建公开仓库。
|
||||
|
||||
:param args: 命令行参数
|
||||
:return: 创建结果
|
||||
"""
|
||||
context = build_context(args, require_token=not args.dry_run)
|
||||
client: GitHubClient = context["client"]
|
||||
if args.dry_run:
|
||||
return {
|
||||
"success": True,
|
||||
"repo": context["repo"],
|
||||
"created": False,
|
||||
"private": args.private,
|
||||
"dry_run": True,
|
||||
"message": "dry-run 未访问 GitHub,未创建仓库。",
|
||||
}
|
||||
if client.repo_exists():
|
||||
return {
|
||||
"success": True,
|
||||
"repo": context["repo"],
|
||||
"created": False,
|
||||
"private": args.private,
|
||||
"message": "目标 GitHub 仓库已存在。",
|
||||
}
|
||||
data = client.create_repo(private=args.private)
|
||||
return {
|
||||
"success": True,
|
||||
"repo": context["repo"],
|
||||
"created": True,
|
||||
"private": bool(data.get("private")) if isinstance(data, dict) else args.private,
|
||||
"url": data.get("html_url") if isinstance(data, dict) else None,
|
||||
"message": "目标 GitHub 仓库已创建。",
|
||||
}
|
||||
|
||||
|
||||
def pull(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""
|
||||
从 GitHub 拉取插件文件到本地仓库。
|
||||
@@ -1080,8 +1186,14 @@ def build_context(args: argparse.Namespace, require_token: bool = False) -> dict
|
||||
:return: 运行上下文
|
||||
"""
|
||||
repo = normalize_repo(args.repo)
|
||||
local_repo = resolve_local_repo(args.local_repo, args.plugin_id, args.package_version)
|
||||
layout = resolve_layout(args.package_version, local_repo, args.plugin_id)
|
||||
plugin_id = getattr(args, "plugin_id", "") or ""
|
||||
package_version = getattr(args, "package_version", "auto") or "auto"
|
||||
if args.command in COMMANDS_REQUIRE_LOCAL_PLUGIN:
|
||||
local_repo = resolve_local_repo(args.local_repo, plugin_id, package_version)
|
||||
layout = resolve_layout(package_version, local_repo, plugin_id)
|
||||
else:
|
||||
local_repo = Path.cwd()
|
||||
layout = Layout(package_file="package.v2.json", plugin_root="plugins.v2")
|
||||
token = resolve_token(repo, args.token)
|
||||
if require_token and not token:
|
||||
raise ValueError("未配置 GitHub token,无法写入仓库")
|
||||
@@ -1101,8 +1213,8 @@ def build_context(args: argparse.Namespace, require_token: bool = False) -> dict
|
||||
"branch": branch,
|
||||
"local_repo": local_repo,
|
||||
"layout": layout,
|
||||
"plugin_id": args.plugin_id,
|
||||
"remote_prefix": remote_plugin_prefix(layout, args.plugin_id),
|
||||
"plugin_id": plugin_id,
|
||||
"remote_prefix": remote_plugin_prefix(layout, plugin_id) if plugin_id else "",
|
||||
"client": client,
|
||||
"excludes": excludes,
|
||||
"includes": includes,
|
||||
@@ -1127,9 +1239,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="发布和同步 MoviePilot 本地插件到 GitHub 仓库",
|
||||
)
|
||||
parser.add_argument("command", choices=("preview", "push", "pull"))
|
||||
parser.add_argument("command", choices=("create-repo", "preview", "push", "pull"))
|
||||
parser.add_argument("--repo", required=True, help="GitHub 仓库,格式 owner/repo")
|
||||
parser.add_argument("--plugin-id", required=True, help="插件类名 ID")
|
||||
parser.add_argument("--plugin-id", default="", help="插件类名 ID")
|
||||
parser.add_argument("--local-repo", default="", help="本地插件仓库目录")
|
||||
parser.add_argument("--package-version", default="auto", help="auto、v2 或 legacy")
|
||||
parser.add_argument("--branch", default=DEFAULT_BRANCH, help="目标分支")
|
||||
@@ -1141,6 +1253,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--include", action="append", default=[], help="强制包含 glob")
|
||||
parser.add_argument("--exclude", action="append", default=[], help="额外排除 glob")
|
||||
parser.add_argument("--delete-remote", action="store_true", help="推送时删除远端多余文件")
|
||||
parser.add_argument(
|
||||
"--create-repo-if-missing",
|
||||
action="store_true",
|
||||
help="推送时在目标仓库不存在的情况下自动创建公开仓库",
|
||||
)
|
||||
parser.add_argument("--private", action="store_true", help="创建仓库时使用私有可见性")
|
||||
parser.add_argument("--force", action="store_true", help="拉取时允许覆盖本地冲突")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只输出计划,不写入")
|
||||
return parser
|
||||
@@ -1154,8 +1272,13 @@ def main() -> int:
|
||||
"""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
if args.command in COMMANDS_REQUIRE_LOCAL_PLUGIN and not args.plugin_id:
|
||||
print_json({"success": False, "message": "preview、push、pull 必须提供 --plugin-id"})
|
||||
return 1
|
||||
try:
|
||||
if args.command == "preview":
|
||||
if args.command == "create-repo":
|
||||
payload = create_repo(args)
|
||||
elif args.command == "preview":
|
||||
payload = preview(args)
|
||||
elif args.command == "push":
|
||||
payload = push(args)
|
||||
@@ -1164,7 +1287,8 @@ def main() -> int:
|
||||
except (GitHubError, OSError, ValueError, json.JSONDecodeError) as err:
|
||||
print_json({"success": False, "message": str(err)})
|
||||
return 1
|
||||
payload["success"] = not payload.get("changes", {}).get("conflicts")
|
||||
if "success" not in payload:
|
||||
payload["success"] = not payload.get("changes", {}).get("conflicts")
|
||||
print_json(payload)
|
||||
return 0 if payload["success"] else 1
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def load_publish_plugin_module() -> Any:
|
||||
@@ -68,3 +69,159 @@ def test_merge_package_content_preserves_other_plugins() -> None:
|
||||
|
||||
assert package_data["OtherPlugin"] == {"name": "其他插件", "version": "1.0.0"}
|
||||
assert package_data["MyPlugin"] == {"name": "新插件", "version": "1.0.0"}
|
||||
|
||||
|
||||
def test_push_creates_public_repo_when_explicitly_requested(tmp_path: Path) -> None:
|
||||
"""显式允许自动建仓时,推送会默认创建公开仓库。"""
|
||||
module = load_publish_plugin_module()
|
||||
plugin_dir = tmp_path / "plugins.v2" / "myplugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text("class MyPlugin:\n pass\n", encoding="utf-8")
|
||||
(tmp_path / "package.v2.json").write_text(
|
||||
json.dumps({"MyPlugin": {"name": "测试插件", "version": "1.0.0"}}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
class FakeClient:
|
||||
"""模拟缺失仓库的 GitHub 客户端。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""初始化调用记录。"""
|
||||
self.created_private = None
|
||||
self.put_paths: list[str] = []
|
||||
|
||||
def repo_exists(self) -> bool:
|
||||
"""返回仓库不存在。"""
|
||||
return False
|
||||
|
||||
def create_repo(self, private: bool = False) -> dict[str, Any]:
|
||||
"""记录建仓可见性。"""
|
||||
self.created_private = private
|
||||
return {"private": private, "html_url": "https://github.com/example/repo"}
|
||||
|
||||
def put_file(
|
||||
self,
|
||||
path: str,
|
||||
content: bytes,
|
||||
message: str,
|
||||
sha: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""记录上传路径。"""
|
||||
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}}
|
||||
|
||||
fake_client = FakeClient()
|
||||
|
||||
def fake_build_context(args: Namespace, require_token: bool = False) -> dict[str, Any]:
|
||||
"""构造无需访问网络的推送上下文。"""
|
||||
layout = module.Layout(package_file="package.v2.json", plugin_root="plugins.v2")
|
||||
return {
|
||||
"repo": args.repo,
|
||||
"branch": args.branch,
|
||||
"local_repo": tmp_path,
|
||||
"layout": layout,
|
||||
"plugin_id": args.plugin_id,
|
||||
"remote_prefix": module.remote_plugin_prefix(layout, args.plugin_id),
|
||||
"client": fake_client,
|
||||
"excludes": list(module.DEFAULT_EXCLUDES),
|
||||
"includes": [],
|
||||
}
|
||||
|
||||
original_build_context = module.build_context
|
||||
module.build_context = fake_build_context
|
||||
try:
|
||||
payload = module.push(
|
||||
Namespace(
|
||||
command="push",
|
||||
repo="example/repo",
|
||||
plugin_id="MyPlugin",
|
||||
local_repo=str(tmp_path),
|
||||
package_version="v2",
|
||||
branch="main",
|
||||
token="token",
|
||||
message="Publish MyPlugin",
|
||||
api_base=module.GITHUB_API_BASE,
|
||||
proxy="",
|
||||
timeout=module.DEFAULT_TIMEOUT,
|
||||
include=[],
|
||||
exclude=[],
|
||||
delete_remote=False,
|
||||
create_repo_if_missing=True,
|
||||
private=False,
|
||||
force=False,
|
||||
dry_run=False,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
module.build_context = original_build_context
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_create_repo_dry_run_does_not_touch_github() -> None:
|
||||
"""建仓 dry-run 不访问 GitHub,只返回计划。"""
|
||||
module = load_publish_plugin_module()
|
||||
|
||||
class FakeClient:
|
||||
"""模拟不应被调用的 GitHub 客户端。"""
|
||||
|
||||
def repo_exists(self) -> bool:
|
||||
"""如果被调用则说明 dry-run 行为错误。"""
|
||||
raise AssertionError("dry-run should not query GitHub")
|
||||
|
||||
def create_repo(self, private: bool = False) -> dict[str, Any]:
|
||||
"""如果被调用则说明 dry-run 行为错误。"""
|
||||
raise AssertionError("dry-run should not create GitHub repo")
|
||||
|
||||
def fake_build_context(args: Namespace, require_token: bool = False) -> dict[str, Any]:
|
||||
"""构造无需访问网络的建仓上下文。"""
|
||||
return {
|
||||
"repo": args.repo,
|
||||
"branch": args.branch,
|
||||
"local_repo": Path.cwd(),
|
||||
"layout": module.Layout(package_file="package.v2.json", plugin_root="plugins.v2"),
|
||||
"plugin_id": "",
|
||||
"remote_prefix": "",
|
||||
"client": FakeClient(),
|
||||
"excludes": list(module.DEFAULT_EXCLUDES),
|
||||
"includes": [],
|
||||
}
|
||||
|
||||
original_build_context = module.build_context
|
||||
module.build_context = fake_build_context
|
||||
try:
|
||||
payload = module.create_repo(
|
||||
Namespace(
|
||||
command="create-repo",
|
||||
repo="example/repo",
|
||||
plugin_id="",
|
||||
local_repo="",
|
||||
package_version="auto",
|
||||
branch="main",
|
||||
token="",
|
||||
message="",
|
||||
api_base=module.GITHUB_API_BASE,
|
||||
proxy="",
|
||||
timeout=module.DEFAULT_TIMEOUT,
|
||||
include=[],
|
||||
exclude=[],
|
||||
delete_remote=False,
|
||||
create_repo_if_missing=False,
|
||||
private=False,
|
||||
force=False,
|
||||
dry_run=True,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
module.build_context = original_build_context
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["dry_run"] is True
|
||||
assert payload["private"] is False
|
||||
|
||||
Reference in New Issue
Block a user