feat(skill): support creating plugin publish repos

This commit is contained in:
jxxghp
2026-06-19 09:26:16 +08:00
parent bd53598704
commit e8f6e8647b
3 changed files with 323 additions and 15 deletions
+29 -2
View File
@@ -8,6 +8,8 @@ description: >-
repositories, package.json/package.v2.json metadata, plugins/plugins.v2 repositories, package.json/package.v2.json metadata, plugins/plugins.v2
layouts, safe file exclusion, diff preview before publishing, incremental layouts, safe file exclusion, diff preview before publishing, incremental
GitHub Contents API updates, and syncing local plugin changes back from GitHub. 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, Also use for Chinese requests mentioning 插件发布, 插件维护, 推送插件到 GitHub,
从 GitHub 拉取插件, 同步本地插件仓库, 增量发布插件, 插件仓库维护. 从 GitHub 拉取插件, 同步本地插件仓库, 增量发布插件, 插件仓库维护.
allowed-tools: list_directory read_file write_file edit_file execute_command query_system_settings update_system_settings 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`. - Merge only that plugin's entry into `package.v2.json` or `package.json`.
- Preview local/remote differences before writing. - Preview local/remote differences before writing.
- Pull remote plugin files back to the local plugin source. - 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`, - Reuse MoviePilot settings `GITHUB_TOKEN`, `REPO_GITHUB_TOKEN`,
and `PLUGIN_LOCAL_REPO_PATHS` when available. 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`. 2. Identify the GitHub repository as `owner/repo`.
- Use the user's explicit repository first. - Use the user's explicit repository first.
- If omitted, infer only when the local source has an obvious Git remote. - 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. 3. Select the package version layout.
- Prefer `v2` when `package.v2.json` or `plugins.v2/<plugin_id_lower>/` - Prefer `v2` when `package.v2.json` or `plugins.v2/<plugin_id_lower>/`
exists. exists.
@@ -83,13 +92,20 @@ python skills/publish-moviepilot-plugin/scripts/publish_plugin.py pull \
--plugin-id MyPlugin \ --plugin-id MyPlugin \
--local-repo /path/to/MoviePilot-Plugins \ --local-repo /path/to/MoviePilot-Plugins \
--package-version v2 --package-version v2
python skills/publish-moviepilot-plugin/scripts/publish_plugin.py create-repo \
--repo owner/repo
``` ```
Options: 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. - `preview`: compare local filtered files with remote files and print JSON.
- `push`: upload changed files and merge the plugin package entry. - `push`: upload changed files and merge the plugin package entry.
- `pull`: write remote plugin files and package entry into local source. - `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 - `--delete-remote`: on push, delete remote plugin files that no longer exist
locally after exclusions. locally after exclusions.
- `--force`: on pull, allow overwriting local files that differ from remote. - `--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 - Always run `preview` before `push` unless the user explicitly asks for a
direct push and already reviewed the diff. 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: - Never upload these files unless explicitly included:
`.env`, `.env.*`, `config/`, `data/`, `cache/`, `logs/`, `tmp/`, `.env`, `.env.*`, `config/`, `data/`, `cache/`, `logs/`, `tmp/`,
`__pycache__/`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`, `__pycache__/`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`,
@@ -122,10 +142,17 @@ Options:
User asks: `把本地 MyPlugin 发布到我的 GitHub 插件仓库` User asks: `把本地 MyPlugin 发布到我的 GitHub 插件仓库`
1. Find `MyPlugin` under configured `PLUGIN_LOCAL_REPO_PATHS`. 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. 3. Run `preview` and summarize the diff.
4. Run `push` only after the user confirms or requested immediate publish. 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 的最新代码到本地` User asks: `同步 GitHub 上 MyPlugin 的最新代码到本地`
1. Run `pull` without `--force`. 1. Run `pull` without `--force`.
@@ -22,6 +22,7 @@ DEFAULT_BRANCH = "main"
DEFAULT_TIMEOUT = 60 DEFAULT_TIMEOUT = 60
GITHUB_API_BASE = "https://api.github.com" GITHUB_API_BASE = "https://api.github.com"
USER_AGENT = "MoviePilot-Plugin-Publisher" USER_AGENT = "MoviePilot-Plugin-Publisher"
COMMANDS_REQUIRE_LOCAL_PLUGIN = {"preview", "push", "pull"}
PACKAGE_BY_VERSION = { PACKAGE_BY_VERSION = {
"legacy": ("package.json", "plugins"), "legacy": ("package.json", "plugins"),
"v1": ("package.json", "plugins"), "v1": ("package.json", "plugins"),
@@ -331,6 +332,39 @@ class GitHubClient:
payload=payload, 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: def normalize_repo(repo: str) -> str:
""" """
@@ -872,6 +906,33 @@ def compare_package(
return remote_package, merged_content, change_type 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]: def preview(args: argparse.Namespace) -> dict[str, Any]:
""" """
预览本地插件与远端仓库的差异。 预览本地插件与远端仓库的差异。
@@ -916,20 +977,28 @@ def push(args: argparse.Namespace) -> dict[str, Any]:
context["excludes"], context["excludes"],
context["includes"], context["includes"],
) )
remote_files = client.list_files(context["remote_prefix"])
entry = read_package_entry(context["local_repo"], context["layout"], context["plugin_id"]) entry = read_package_entry(context["local_repo"], context["layout"], context["plugin_id"])
remote_package, package_content, package_change = compare_package( remote_files, remote_package, repo_existed = load_remote_files_for_push(context, args)
client, package_content = merge_package_content(
context["layout"], remote_package.content if remote_package else None,
context["plugin_id"], context["plugin_id"],
entry, 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) changes = build_push_changes(local_files, remote_files, rejected, args.delete_remote)
payload = result_payload(context, changes, package_change=package_change) payload = result_payload(context, changes, package_change=package_change)
payload["repo_existed"] = repo_existed
if args.dry_run: if args.dry_run:
payload["dry_run"] = True payload["dry_run"] = True
return payload return payload
if not repo_existed:
client.create_repo(private=args.private)
message = args.message or f"Publish {context['plugin_id']}" message = args.message or f"Publish {context['plugin_id']}"
applied: dict[str, list[str]] = {"create": [], "update": [], "delete": []} applied: dict[str, list[str]] = {"create": [], "update": [], "delete": []}
if package_change in {"create", "update"}: if package_change in {"create", "update"}:
@@ -956,6 +1025,43 @@ def push(args: argparse.Namespace) -> dict[str, Any]:
return payload 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]: def pull(args: argparse.Namespace) -> dict[str, Any]:
""" """
从 GitHub 拉取插件文件到本地仓库。 从 GitHub 拉取插件文件到本地仓库。
@@ -1080,8 +1186,14 @@ def build_context(args: argparse.Namespace, require_token: bool = False) -> dict
:return: 运行上下文 :return: 运行上下文
""" """
repo = normalize_repo(args.repo) repo = normalize_repo(args.repo)
local_repo = resolve_local_repo(args.local_repo, args.plugin_id, args.package_version) plugin_id = getattr(args, "plugin_id", "") or ""
layout = resolve_layout(args.package_version, local_repo, args.plugin_id) 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) token = resolve_token(repo, args.token)
if require_token and not token: if require_token and not token:
raise ValueError("未配置 GitHub token,无法写入仓库") raise ValueError("未配置 GitHub token,无法写入仓库")
@@ -1101,8 +1213,8 @@ def build_context(args: argparse.Namespace, require_token: bool = False) -> dict
"branch": branch, "branch": branch,
"local_repo": local_repo, "local_repo": local_repo,
"layout": layout, "layout": layout,
"plugin_id": args.plugin_id, "plugin_id": plugin_id,
"remote_prefix": remote_plugin_prefix(layout, args.plugin_id), "remote_prefix": remote_plugin_prefix(layout, plugin_id) if plugin_id else "",
"client": client, "client": client,
"excludes": excludes, "excludes": excludes,
"includes": includes, "includes": includes,
@@ -1127,9 +1239,9 @@ def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="发布和同步 MoviePilot 本地插件到 GitHub 仓库", 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("--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("--local-repo", default="", help="本地插件仓库目录")
parser.add_argument("--package-version", default="auto", help="auto、v2 或 legacy") parser.add_argument("--package-version", default="auto", help="auto、v2 或 legacy")
parser.add_argument("--branch", default=DEFAULT_BRANCH, help="目标分支") 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("--include", action="append", default=[], help="强制包含 glob")
parser.add_argument("--exclude", 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("--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("--force", action="store_true", help="拉取时允许覆盖本地冲突")
parser.add_argument("--dry-run", action="store_true", help="只输出计划,不写入") parser.add_argument("--dry-run", action="store_true", help="只输出计划,不写入")
return parser return parser
@@ -1154,8 +1272,13 @@ def main() -> int:
""" """
parser = build_parser() parser = build_parser()
args = parser.parse_args() 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: try:
if args.command == "preview": if args.command == "create-repo":
payload = create_repo(args)
elif args.command == "preview":
payload = preview(args) payload = preview(args)
elif args.command == "push": elif args.command == "push":
payload = push(args) payload = push(args)
@@ -1164,7 +1287,8 @@ def main() -> int:
except (GitHubError, OSError, ValueError, json.JSONDecodeError) as err: except (GitHubError, OSError, ValueError, json.JSONDecodeError) as err:
print_json({"success": False, "message": str(err)}) print_json({"success": False, "message": str(err)})
return 1 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) print_json(payload)
return 0 if payload["success"] else 1 return 0 if payload["success"] else 1
+158 -1
View File
@@ -1,8 +1,9 @@
import importlib.util import importlib.util
import json import json
import sys import sys
from argparse import Namespace
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Optional
def load_publish_plugin_module() -> Any: 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["OtherPlugin"] == {"name": "其他插件", "version": "1.0.0"}
assert package_data["MyPlugin"] == {"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