From e8f6e8647b64956b4396c00d44f00403095eda3e Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 19 Jun 2026 09:26:16 +0800 Subject: [PATCH] feat(skill): support creating plugin publish repos --- skills/publish-moviepilot-plugin/SKILL.md | 31 +++- .../scripts/publish_plugin.py | 148 ++++++++++++++-- tests/test_publish_moviepilot_plugin_skill.py | 159 +++++++++++++++++- 3 files changed, 323 insertions(+), 15 deletions(-) diff --git a/skills/publish-moviepilot-plugin/SKILL.md b/skills/publish-moviepilot-plugin/SKILL.md index ba69578e..cda5f4db 100644 --- a/skills/publish-moviepilot-plugin/SKILL.md +++ b/skills/publish-moviepilot-plugin/SKILL.md @@ -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//` 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`. diff --git a/skills/publish-moviepilot-plugin/scripts/publish_plugin.py b/skills/publish-moviepilot-plugin/scripts/publish_plugin.py index 12e40b03..e0cc2716 100644 --- a/skills/publish-moviepilot-plugin/scripts/publish_plugin.py +++ b/skills/publish-moviepilot-plugin/scripts/publish_plugin.py @@ -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 diff --git a/tests/test_publish_moviepilot_plugin_skill.py b/tests/test_publish_moviepilot_plugin_skill.py index 069ad7f5..67700545 100644 --- a/tests/test_publish_moviepilot_plugin_skill.py +++ b/tests/test_publish_moviepilot_plugin_skill.py @@ -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