├── tests ├── __init__.py ├── constant.py ├── store │ ├── plugin_configs.json │ ├── registry_bots.json │ ├── registry_adapters.json │ ├── store_bots.json5 │ ├── registry_drivers.json │ ├── store_plugins.json5 │ ├── store_adapters.json5 │ ├── store_drivers.json5 │ ├── registry_plugins.json │ └── registry_results.json ├── plugins │ └── github │ │ ├── config │ │ ├── utils.py │ │ ├── conftest.py │ │ ├── utils │ │ │ └── test_config_update_file.py │ │ └── process │ │ │ └── test_config_auto_merge.py │ │ ├── publish │ │ ├── utils │ │ │ ├── test_ansi.py │ │ │ ├── test_extract_name_from_title.py │ │ │ ├── test_get_pull_requests_by_label.py │ │ │ ├── test_get_type.py │ │ │ ├── test_ensure_issue_content.py │ │ │ ├── test_comment_issue.py │ │ │ └── test_history_workflow.py │ │ └── process │ │ │ └── test_auto_merge.py │ │ ├── resolve │ │ └── utils.py │ │ ├── conftest.py │ │ ├── event.py │ │ ├── remove │ │ ├── render │ │ │ └── test_remove_render_data.py │ │ ├── utils │ │ │ └── test_update_file.py │ │ └── process │ │ │ └── test_remove_auto_merge.py │ │ ├── utils │ │ └── test_github_utils.py │ │ └── handlers │ │ └── test_git_handler.py ├── providers │ ├── store_test │ │ ├── output_failed.json │ │ ├── test_utils.py │ │ ├── output.json │ │ ├── conftest.py │ │ └── test_step_summary.py │ ├── docker_test │ │ ├── test_parse_requirements.py │ │ ├── test_render_plugin_test.py │ │ └── test_extract_version.py │ ├── test_json5.py │ └── validation │ │ ├── fields │ │ ├── test_name.py │ │ └── test_homepage.py │ │ ├── test_driver.py │ │ ├── utils.py │ │ └── test_adapter.py └── utils.py ├── .python-version ├── assets └── logo.png ├── src ├── providers │ ├── store_test │ │ ├── __init__.py │ │ ├── utils.py │ │ ├── constants.py │ │ ├── __main__.py │ │ └── validation.py │ ├── logger.py │ ├── docker_test │ │ ├── __main__.py │ │ ├── render.py │ │ ├── templates │ │ │ ├── runner.py.jinja │ │ │ └── fake.py.jinja │ │ └── __init__.py │ ├── validation │ │ ├── constants.py │ │ ├── utils.py │ │ └── __init__.py │ ├── constants.py │ └── utils.py └── plugins │ └── github │ ├── plugins │ ├── config │ │ ├── constants.py │ │ └── utils.py │ ├── publish │ │ ├── templates │ │ │ ├── summary.md.jinja │ │ │ ├── render_data.md.jinja │ │ │ ├── comment.md.jinja │ │ │ └── render_error.md.jinja │ │ ├── depends.py │ │ ├── constants.py │ │ └── render.py │ ├── remove │ │ ├── templates │ │ │ └── comment.md.jinja │ │ ├── depends.py │ │ ├── constants.py │ │ ├── render.py │ │ ├── utils.py │ │ ├── validation.py │ │ └── __init__.py │ └── resolve │ │ └── __init__.py │ ├── handlers │ ├── __init__.py │ ├── git.py │ └── issue.py │ ├── __init__.py │ ├── constants.py │ ├── depends │ └── utils.py │ ├── config.py │ ├── utils.py │ └── typing.py ├── .env ├── .editorconfig ├── action.yml ├── .github ├── workflows │ ├── release-draft.yml │ └── main.yml ├── release-drafter.yml └── renovate.json5 ├── .pre-commit-config.yaml ├── docker ├── noneflow.dockerfile └── nonetest.dockerfile ├── LICENSE ├── examples ├── noneflow-results.yml ├── noneflow-registry.yml ├── noneflow.yml └── store-test.yml ├── README.md ├── bot.py ├── pyproject.toml └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.14.2 2 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonebot/noneflow/HEAD/assets/logo.png -------------------------------------------------------------------------------- /tests/constant.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | PROJECT_SOURCE_PATH = Path("./src") 4 | -------------------------------------------------------------------------------- /src/providers/store_test/__init__.py: -------------------------------------------------------------------------------- 1 | """测试插件商店中的插件 2 | 3 | 直接通过 `python -m src.providers.store_test` 运行 4 | """ 5 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/config/constants.py: -------------------------------------------------------------------------------- 1 | RESULTS_BRANCH = "results" 2 | 3 | COMMIT_MESSAGE_PREFIX = "chore: edit config" 4 | BRANCH_NAME_PREFIX = "config/issue" 5 | -------------------------------------------------------------------------------- /tests/store/plugin_configs.json: -------------------------------------------------------------------------------- 1 | { 2 | "nonebot-plugin-treehelp:nonebot_plugin_treehelp": "TEST_CONFIG=true", 3 | "nonebot-plugin-datastore:nonebot_plugin_datastore": "" 4 | } 5 | -------------------------------------------------------------------------------- /src/plugins/github/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .git import GitHandler as GitHandler 2 | from .github import GithubHandler as GithubHandler 3 | from .issue import IssueHandler as IssueHandler 4 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # GitHub Actions 变量 2 | GITHUB_REPOSITORY 3 | GITHUB_RUN_ID 4 | GITHUB_EVENT_NAME 5 | GITHUB_EVENT_PATH 6 | GITHUB_STEP_SUMMARY 7 | 8 | # 配置 9 | GITHUB_APPS 10 | INPUT_CONFIG 11 | -------------------------------------------------------------------------------- /src/providers/store_test/utils.py: -------------------------------------------------------------------------------- 1 | from src.providers.utils import load_json_from_web 2 | 3 | 4 | def get_user_id(name: str) -> int: 5 | """获取用户信息""" 6 | data = load_json_from_web(f"https://api.github.com/users/{name}") 7 | return data["id"] 8 | -------------------------------------------------------------------------------- /tests/store/registry_bots.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "CoolQBot", 4 | "desc": "基于 NoneBot2 的聊天机器人", 5 | "author": "he0119", 6 | "homepage": "https://github.com/he0119/CoolQBot", 7 | "tags": [], 8 | "is_official": false 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /src/plugins/github/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import nonebot 4 | 5 | from .config import Config 6 | 7 | plugin_config = nonebot.get_plugin_config(Config) 8 | 9 | # 加载子插件 10 | sub_plugins = nonebot.load_plugins(str((Path(__file__).parent / "plugins").resolve())) 11 | -------------------------------------------------------------------------------- /tests/plugins/github/config/utils.py: -------------------------------------------------------------------------------- 1 | def generate_issue_body( 2 | module_name: str = "module_name", 3 | project_link: str = "project_link", 4 | config: str = "log_level=DEBUG", 5 | ): 6 | return f"""### PyPI 项目名\n\n{project_link}\n\n### 插件模块名\n\n{module_name}\n\n### 插件配置项\n\n```dotenv\n{config}\n```""" 7 | -------------------------------------------------------------------------------- /src/providers/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | logger = logging.getLogger("Noneflow") 5 | default_handler = logging.StreamHandler(sys.stdout) 6 | default_handler.setFormatter( 7 | logging.Formatter("[%(asctime)s %(name)s] %(levelname)s: %(message)s") 8 | ) 9 | logger.addHandler(default_handler) 10 | -------------------------------------------------------------------------------- /tests/providers/store_test/output_failed.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": null, 3 | "output": "\u521b\u5efa\u6d4b\u8bd5\u76ee\u5f55 plugin_test\n For further information visit https://errors.pydantic.dev/2.9/v/model_type\u001b[0m", 4 | "load": false, 5 | "run": true, 6 | "version": "0.3.9", 7 | "config": null, 8 | "test_env": "python==3.12.7" 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | 11 | [*.py] 12 | indent_size = 4 13 | 14 | [*.md] 15 | max_line_length = off 16 | insert_final_newline = false 17 | 18 | [*.yml] 19 | trim_trailing_whitespace = true 20 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "NoneFlow" 2 | description: "Manage publish related issues in nonebot2 project" 3 | author: "hemengyang " 4 | inputs: 5 | config: 6 | description: "JSON with settings as described in the README" 7 | required: true 8 | runs: 9 | using: "docker" 10 | image: "docker/noneflow.dockerfile" 11 | branding: 12 | icon: "box" 13 | color: "orange" 14 | -------------------------------------------------------------------------------- /.github/workflows/release-draft.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, reopened, synchronize] 9 | 10 | jobs: 11 | update_release_draft: 12 | name: Update Release Draft 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: release-drafter/release-drafter@v6 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /tests/store/registry_adapters.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "module_name": "nonebot.adapters.onebot.v11", 4 | "project_link": "nonebot-adapter-onebot", 5 | "name": "OneBot V11", 6 | "desc": "OneBot V11 协议", 7 | "author": "yanyongyu", 8 | "homepage": "https://onebot.adapters.nonebot.dev/", 9 | "tags": [], 10 | "is_official": true, 11 | "time": "2024-10-24T07:34:56.115315Z", 12 | "version": "2.4.6" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/publish/templates/summary.md.jinja: -------------------------------------------------------------------------------- 1 | {# 发布工作流总结 #} 2 | # 📃 插件 {{ project_link }} ({{ version }}) 3 | 4 | > **{{ "✅ 插件已尝试运行" if run else "⚠️ 插件未开始运行"}}** 5 | > **{{ "✅ 插件加载成功" if load else "⚠️ 插件加载失败"}}** 6 | 7 | ## 插件元数据 8 | 9 |
{{ metadata }}
10 | 11 | ## 插件输出 12 | 13 |
{{ output }}
14 | 15 | --- 16 | 17 | 💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow) -------------------------------------------------------------------------------- /src/plugins/github/plugins/remove/templates/comment.md.jinja: -------------------------------------------------------------------------------- 1 | # 📃 商店下架检查 2 | 3 | > {{ title }} 4 | 5 | **{{ "✅ 所有检查通过,一切准备就绪!" if valid else "⚠️ 在下架检查过程中,我们发现以下问题:"}}** 6 | 7 | {% if not valid %} 8 | > ⚠️ {{ error }} 9 | {% else %} 10 | > 成功发起插件下架流程,对应的拉取请求 {{ pr_url}} 已经创建。 11 | {% endif %} 12 | 13 | --- 14 | 15 | 💡 如需修改信息,请直接修改 issue,机器人会自动更新检查结果。 16 | 17 | 💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow) 18 | 19 | -------------------------------------------------------------------------------- /src/plugins/github/constants.py: -------------------------------------------------------------------------------- 1 | NONEFLOW_MARKER = "" 2 | 3 | BRANCH_NAME_PREFIX = "publish/issue" 4 | 5 | TITLE_MAX_LENGTH = 50 6 | """标题最大长度""" 7 | 8 | # 匹配信息的正则表达式 9 | # 格式:### {标题}\n\n{内容} 10 | ISSUE_PATTERN = r"### {}\s+([^\s#].*?)(?=(?:\s+###|$))" 11 | ISSUE_FIELD_TEMPLATE = "### {}" 12 | ISSUE_FIELD_PATTERN = r"### {}\s+" 13 | 14 | SKIP_COMMENT = "/skip" 15 | 16 | PUBLISH_LABEL = "Publish" 17 | REMOVE_LABEL = "Remove" 18 | CONFIG_LABEL = "Config" 19 | -------------------------------------------------------------------------------- /tests/plugins/github/publish/utils/test_ansi.py: -------------------------------------------------------------------------------- 1 | from nonebug import App 2 | 3 | 4 | async def test_strip_ansi(app: App): 5 | from src.plugins.github.plugins.publish.validation import strip_ansi 6 | 7 | assert strip_ansi("test") == "test" 8 | 9 | assert ( 10 | strip_ansi( 11 | "插件 nonebot-plugin-status 的信息如下: name : nonebot-plugin-status" 12 | ) 13 | == "插件 nonebot-plugin-status 的信息如下: name : nonebot-plugin-status" 14 | ) 15 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/remove/depends.py: -------------------------------------------------------------------------------- 1 | from nonebot.params import Depends 2 | 3 | from src.plugins.github.depends import get_labels_name 4 | 5 | 6 | def check_labels(labels: list[str] | str): 7 | """检查标签是否存在""" 8 | if isinstance(labels, str): 9 | labels = [labels] 10 | 11 | async def _check_labels( 12 | has_labels: list[str] = Depends(get_labels_name), 13 | ) -> bool: 14 | return any(label in has_labels for label in labels) 15 | 16 | return Depends(_check_labels) 17 | -------------------------------------------------------------------------------- /src/providers/store_test/constants.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | TEST_DIR = Path("plugin_test") 4 | """ 测试文件夹 """ 5 | 6 | RESULTS_PATH = TEST_DIR / "results.json" 7 | """ 测试结果保存路径 """ 8 | 9 | ADAPTERS_PATH = TEST_DIR / "adapters.json" 10 | """ 生成的适配器列表保存路径 """ 11 | 12 | BOTS_PATH = TEST_DIR / "bots.json" 13 | """ 生成的机器人列表保存路径 """ 14 | 15 | DRIVERS_PATH = TEST_DIR / "drivers.json" 16 | """ 生成的驱动器列表保存路径 """ 17 | 18 | PLUGINS_PATH = TEST_DIR / "plugins.json" 19 | """ 生成的插件列表保存路径 """ 20 | 21 | PLUGIN_CONFIG_PATH = TEST_DIR / "plugin_configs.json" 22 | """ 生成的插件配置保存路径 """ 23 | -------------------------------------------------------------------------------- /tests/store/store_bots.json5: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "CoolQBot", 4 | "desc": "基于 NoneBot2 的聊天机器人", 5 | "author_id": 1, 6 | "homepage": "https://github.com/he0119/CoolQBot", 7 | "tags": [ 8 | { 9 | "label": "sync", 10 | "color": "#ffffff" 11 | } 12 | ], 13 | "is_official": false 14 | }, 15 | { 16 | "name": "Github Bot", 17 | "desc": "在QQ获取/处理Github repo/pr/issue", 18 | "author_id": 2, 19 | "homepage": "https://github.com/cscs181/QQ-GitHub-Bot", 20 | "tags": [], 21 | "is_official": false 22 | }, 23 | ] 24 | -------------------------------------------------------------------------------- /tests/plugins/github/publish/utils/test_extract_name_from_title.py: -------------------------------------------------------------------------------- 1 | from nonebug import App 2 | 3 | 4 | async def test_extract_name_from_title(app: App): 5 | from src.plugins.github.plugins.publish.utils import extract_name_from_title 6 | from src.providers.validation import PublishType 7 | 8 | assert extract_name_from_title("Adapter: test", PublishType.ADAPTER) == "test" 9 | assert extract_name_from_title("Bot: test", PublishType.BOT) == "test" 10 | assert extract_name_from_title("Plugin: test", PublishType.PLUGIN) == "test" 11 | assert extract_name_from_title("Plugin: test", PublishType.BOT) is None 12 | -------------------------------------------------------------------------------- /tests/plugins/github/resolve/utils.py: -------------------------------------------------------------------------------- 1 | def get_pr_labels(labels: list[str]): 2 | from githubkit.rest import PullRequestPropLabelsItems as Label 3 | 4 | return [ 5 | Label.model_construct( 6 | **{ 7 | "color": "2A2219", 8 | "default": False, 9 | "description": "", 10 | "id": 2798075966, 11 | "name": label, 12 | "node_id": "MDU6TGFiZWwyNzk4MDc1OTY2", 13 | "url": "https://api.github.com/repos/he0119/action-test/labels/Remove", 14 | } 15 | ) 16 | for label in labels 17 | ] 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_commit_msg: "style: auto fix by pre-commit hooks" 3 | autofix_prs: true 4 | autoupdate_branch: main 5 | autoupdate_schedule: quarterly 6 | autoupdate_commit_msg: "chore: auto update by pre-commit hooks" 7 | repos: 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.14.8 10 | hooks: 11 | - id: ruff-check 12 | args: [--fix] 13 | - id: ruff-format 14 | 15 | - repo: https://github.com/pre-commit/mirrors-prettier 16 | rev: v4.0.0-alpha.8 17 | hooks: 18 | - id: prettier 19 | types_or: [javascript, jsx, ts, tsx, markdown, yaml, json] 20 | -------------------------------------------------------------------------------- /tests/plugins/github/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest_mock import MockerFixture 3 | 4 | 5 | @pytest.fixture 6 | def mock_installation(mocker: MockerFixture): 7 | mock_installation = mocker.MagicMock() 8 | mock_installation.id = 123 9 | mock_installation_resp = mocker.MagicMock() 10 | mock_installation_resp.parsed_data = mock_installation 11 | return mock_installation_resp 12 | 13 | 14 | @pytest.fixture 15 | def mock_installation_token(mocker: MockerFixture): 16 | mock_token = mocker.MagicMock() 17 | mock_token.token = "test-token" 18 | mock_token_resp = mocker.MagicMock() 19 | mock_token_resp.parsed_data = mock_token 20 | return mock_token_resp 21 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def find_datetime_loc(path: Path) -> list[str]: 5 | """ 6 | 找到所有包含 `from datetime import datetime` 的相对导入路径 7 | """ 8 | result: list[str] = [] 9 | 10 | def to_module_path(file_path: Path) -> str: 11 | relative_path = file_path.relative_to(path.parent) 12 | parts = list(relative_path.with_suffix("").parts) 13 | if parts[-1] == "__init__": 14 | parts = parts[:-1] 15 | return ".".join(parts) 16 | 17 | for p in path.rglob("*.py"): 18 | content = p.read_text("utf-8") 19 | if "from datetime import datetime" in content: 20 | result.append(to_module_path(p)) 21 | return result 22 | -------------------------------------------------------------------------------- /tests/store/registry_drivers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "module_name": "~none", 4 | "project_link": "", 5 | "name": "None", 6 | "desc": "None 驱动器", 7 | "author": "yanyongyu", 8 | "homepage": "/docs/advanced/driver", 9 | "tags": [], 10 | "is_official": true, 11 | "time": "2024-10-31T13:47:14.152851Z", 12 | "version": "2.4.0" 13 | }, 14 | { 15 | "module_name": "~fastapi", 16 | "project_link": "nonebot2[fastapi]", 17 | "name": "FastAPI", 18 | "desc": "FastAPI 驱动器", 19 | "author": "yanyongyu", 20 | "homepage": "/docs/advanced/driver", 21 | "tags": [], 22 | "is_official": true, 23 | "time": "2024-10-31T13:47:14.152851Z", 24 | "version": "2.4.0" 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /src/plugins/github/depends/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from src.plugins.github.typing import PullRequestLabels 4 | from src.providers.validation.models import PublishType 5 | 6 | 7 | def extract_issue_number_from_ref(ref: str) -> int | None: 8 | """从 Ref 中提取议题号""" 9 | match = re.search(r"(\w{4,10})\/issue(\d+)", ref) 10 | if match: 11 | return int(match.group(2)) 12 | 13 | 14 | def get_type_by_labels(labels: PullRequestLabels) -> PublishType | None: 15 | """通过拉取请求的标签获取发布类型""" 16 | for label in labels: 17 | if isinstance(label, str): 18 | continue 19 | for type in PublishType: 20 | if label.name == type.value: 21 | return type 22 | return None 23 | -------------------------------------------------------------------------------- /src/providers/docker_test/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | from .plugin_test import PluginTest 5 | 6 | 7 | def main(): 8 | """根据传入的环境变量进行测试 9 | 10 | PYTHON_VERSION 为运行测试的 Python 版本 11 | PROJECT_LINK 为插件的项目名 12 | MODULE_NAME 为插件的模块名 13 | PLUGIN_CONFIG 为该插件的配置 14 | """ 15 | python_version = os.environ.get("PYTHON_VERSION", "") 16 | 17 | project_link = os.environ.get("PROJECT_LINK", "") 18 | module_name = os.environ.get("MODULE_NAME", "") 19 | plugin_config = os.environ.get("PLUGIN_CONFIG", None) 20 | 21 | plugin = PluginTest(python_version, project_link, module_name, plugin_config) 22 | 23 | asyncio.run(plugin.run()) 24 | 25 | 26 | if __name__ == "__main__": 27 | main() 28 | -------------------------------------------------------------------------------- /tests/store/store_plugins.json5: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "module_name": "nonebot_plugin_datastore", 4 | "project_link": "nonebot-plugin-datastore", 5 | "author": "he0119", 6 | "author_id": 1, 7 | "tags": [ 8 | { 9 | "label": "sync", 10 | "color": "#ffffff" 11 | } 12 | ], 13 | "is_official": false 14 | }, 15 | { 16 | "module_name": "nonebot_plugin_treehelp", 17 | "project_link": "nonebot-plugin-treehelp", 18 | "author_id": 1, 19 | "tags": [], 20 | "is_official": false 21 | }, 22 | { 23 | "module_name": "nonebot_plugin_wordcloud", 24 | "project_link": "nonebot-plugin-wordcloud", 25 | "author_id": 1, 26 | "tags": [], 27 | "is_official": false 28 | }, 29 | ] 30 | -------------------------------------------------------------------------------- /tests/providers/store_test/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from respx import MockRouter 3 | 4 | from src.providers.constants import STORE_ADAPTERS_URL 5 | 6 | 7 | async def test_load_json_failed(mocked_api: MockRouter): 8 | """测试加载 json 失败""" 9 | from src.providers.utils import load_json_from_web 10 | 11 | mocked_api.get(STORE_ADAPTERS_URL).respond(404) 12 | 13 | with pytest.raises(ValueError, match="下载文件失败:"): 14 | load_json_from_web(STORE_ADAPTERS_URL) 15 | 16 | 17 | async def test_get_pypi_data_failed(mocked_api: MockRouter): 18 | """获取 PyPI 数据失败""" 19 | from src.providers.utils import get_pypi_data 20 | 21 | with pytest.raises(ValueError, match="获取 PyPI 数据失败:"): 22 | get_pypi_data("project_link_failed") 23 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/remove/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from src.plugins.github.constants import ISSUE_PATTERN 4 | 5 | # Bot 6 | REMOVE_BOT_HOMEPAGE_PATTERN = re.compile( 7 | ISSUE_PATTERN.format("机器人项目仓库/主页链接") 8 | ) 9 | REMOVE_BOT_NAME_PATTERN = re.compile(ISSUE_PATTERN.format("机器人名称")) 10 | # Plugin 11 | REMOVE_PLUGIN_PROJECT_LINK_PATTERN = re.compile(ISSUE_PATTERN.format("PyPI 项目名")) 12 | REMOVE_PLUGIN_IMPORT_NAME_PATTERN = re.compile(ISSUE_PATTERN.format("import 包名")) 13 | REMOVE_PLUGIN_MODULE_NAME_PATTERN = re.compile(ISSUE_PATTERN.format("插件模块名")) 14 | 15 | # Driver / Adapter 16 | REMOVE_HOMEPAGE_PATTERN = re.compile(ISSUE_PATTERN.format("项目主页")) 17 | 18 | BRANCH_NAME_PREFIX = "remove/issue" 19 | 20 | COMMIT_MESSAGE_PREFIX = ":pencil2: remove" 21 | -------------------------------------------------------------------------------- /tests/store/store_adapters.json5: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "module_name": "nonebot.adapters.onebot.v11", 4 | "project_link": "nonebot-adapter-onebot", 5 | "name": "OneBot V11", 6 | "desc": "OneBot V11 协议", 7 | "author_id": 1, 8 | "homepage": "https://onebot.adapters.nonebot.dev/", 9 | "tags": [ 10 | { 11 | "label": "sync", 12 | "color": "#ffffff" 13 | } 14 | ], 15 | "is_official": true 16 | }, 17 | { 18 | "module_name": "nonebot.adapters.onebot.v12", 19 | "project_link": "nonebot-adapter-onebot", 20 | "name": "OneBot V12", 21 | "desc": "OneBot V12 协议", 22 | "author_id": 1, 23 | "homepage": "https://onebot.adapters.nonebot.dev/", 24 | "tags": [], 25 | "is_official": true 26 | }, 27 | ] 28 | -------------------------------------------------------------------------------- /src/providers/docker_test/render.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import jinja2 4 | 5 | env = jinja2.Environment( 6 | loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"), 7 | enable_async=True, 8 | lstrip_blocks=True, 9 | trim_blocks=True, 10 | keep_trailing_newline=True, 11 | ) 12 | 13 | 14 | async def render_runner(module_name: str, deps: list[str]) -> str: 15 | """生成 runner.py 文件内容""" 16 | template = env.get_template("runner.py.jinja") 17 | 18 | return await template.render_async( 19 | module_name=module_name, 20 | deps=deps, 21 | ) 22 | 23 | 24 | async def render_fake(): 25 | """生成 fake.py 文件内容""" 26 | template = env.get_template("fake.py.jinja") 27 | 28 | return await template.render_async() 29 | -------------------------------------------------------------------------------- /tests/providers/store_test/output.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "name": "TREEHELP", 4 | "desc": "\u8ba2\u9605\u725b\u5ba2/CF/AT\u5e73\u53f0\u7684\u6bd4\u8d5b\u4fe1\u606f", 5 | "usage": "/contest.list \u83b7\u53d6\u6240\u6709/CF/\u725b\u5ba2/AT\u5e73\u53f0\u7684\u6bd4\u8d5b\u4fe1\u606f\n/contest.subscribe \u8ba2\u9605CF/\u725b\u5ba2/AT\u5e73\u53f0\u7684\u6bd4\u8d5b\u4fe1\u606f\n/contest.update \u624b\u52a8\u66f4\u65b0\u6bd4\u8d5b\u4fe1\u606f\n", 6 | "type": "application", 7 | "homepage": "https://nonebot.dev/", 8 | "supported_adapters": null 9 | }, 10 | "output": "\u521b\u5efa\u6d4b\u8bd5\u76ee\u5f55 plugin_test\n require(\"nonebot_plugin_alconna\")", 11 | "load": true, 12 | "run": true, 13 | "version": "0.2.0", 14 | "config": null, 15 | "test_env": "python==3.12.7" 16 | } 17 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/publish/templates/render_data.md.jinja: -------------------------------------------------------------------------------- 1 | {% macro render_data(key, value, skip_test) %} 2 | {% if key == "homepage" %} 3 | 项目 主页 返回状态码 200。 4 | {%- elif key == "tags" and value %} 5 | 标签: {{ value|tags_to_str }}。 6 | {%- elif key == "project_link" %} 7 | 项目 {{ value }} 已发布至 PyPI。 8 | {%- elif key == "type" %} 9 | 插件类型: {{ value }}。 10 | {%- elif key == "supported_adapters" %} 11 | 插件支持的适配器: {{ value|supported_adapters_to_str }}。 12 | {%- elif key == "action_url" %} 13 | {% if skip_test %} 14 | 插件 加载测试 已跳过。 15 | {%- else %} 16 | 插件 加载测试 通过。 17 | {%- endif %} 18 | {%- elif key == "time" %} 19 | 发布时间:{{ value|format_time }}。 20 | {%- else %} 21 | {{ key|key_to_name }}: {{ value }}。 22 | {%- endif %} 23 | {% endmacro %} 24 | -------------------------------------------------------------------------------- /tests/providers/docker_test/test_parse_requirements.py: -------------------------------------------------------------------------------- 1 | def test_parse_requirements(): 2 | """解析 poetry export --without-hashes 的输出""" 3 | from src.providers.docker_test.plugin_test import parse_requirements 4 | 5 | output = """ 6 | anyio==4.6.2.post1 ; python_version >= "3.9" and python_version < "4.0" 7 | nonebot2[httpx]==2.4.0 ; python_version >= "3.9" and python_version < "4.0" 8 | nonebug==0.4.2 ; python_version >= "3.9" and python_version < "4.0" 9 | pydantic-core==2.27.0 ; python_version >= "3.9" and python_version < "4.0" 10 | pydantic==2.10.0 ; python_version >= "3.9" and python_version < "4.0" 11 | """ 12 | 13 | requirements = parse_requirements(output) 14 | 15 | assert requirements == { 16 | "anyio": "4.6.2.post1", 17 | "nonebot2": "2.4.0", 18 | "nonebug": "0.4.2", 19 | "pydantic-core": "2.27.0", 20 | "pydantic": "2.10.0", 21 | } 22 | -------------------------------------------------------------------------------- /tests/store/store_drivers.json5: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "module_name": "~none", 4 | "project_link": "", 5 | "name": "None", 6 | "desc": "None 驱动器", 7 | "author_id": 1, 8 | "homepage": "/docs/advanced/driver", 9 | "tags": [ 10 | { 11 | "label": "sync", 12 | "color": "#ffffff" 13 | } 14 | ], 15 | "is_official": true 16 | }, 17 | { 18 | "module_name": "~fastapi", 19 | "project_link": "nonebot2[fastapi]", 20 | "name": "FastAPI", 21 | "desc": "FastAPI 驱动器", 22 | "author_id": 1, 23 | "homepage": "/docs/advanced/driver", 24 | "tags": [], 25 | "is_official": true 26 | }, 27 | { 28 | "module_name": "~quart", 29 | "project_link": "nonebot2[quart]", 30 | "name": "Quart", 31 | "desc": "Quart 驱动器", 32 | "author_id": 1, 33 | "homepage": "/docs/advanced/driver", 34 | "tags": [], 35 | "is_official": true 36 | }, 37 | ] 38 | -------------------------------------------------------------------------------- /docker/noneflow.dockerfile: -------------------------------------------------------------------------------- 1 | # 这样能分别控制 uv 和 Python 版本 2 | FROM python:3.14.2-slim 3 | COPY --from=ghcr.io/astral-sh/uv:0.9.17 /uv /bin/uv 4 | 5 | # 设置时区 6 | ENV TZ=Asia/Shanghai 7 | 8 | # 启用字节码编译,加速 NoneFlow 启动 9 | ENV UV_COMPILE_BYTECODE=1 10 | 11 | # 在不更新 uv.lock 文件的情况下运行 12 | ENV UV_FROZEN=1 13 | 14 | # 从缓存中复制而不是链接,因为缓存是挂载的 15 | ENV UV_LINK_MODE=copy 16 | 17 | # 安装依赖 18 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 19 | --mount=type=cache,target=/var/lib/apt,sharing=locked \ 20 | apt update && apt-get --no-install-recommends install -y git 21 | 22 | # Python 依赖 23 | COPY pyproject.toml uv.lock /app/ 24 | RUN --mount=type=cache,target=/root/.cache/uv \ 25 | uv sync --project /app/ --no-dev 26 | 27 | # 将可执行文件放在环境的路径前面 28 | ENV PATH="/app/.venv/bin:$PATH" 29 | 30 | # NoneFlow 本体 31 | COPY bot.py .env /app/ 32 | COPY src /app/src/ 33 | 34 | CMD ["uv", "run", "--project", "/app/", "--no-dev", "/app/bot.py"] 35 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/remove/render.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import jinja2 4 | from pydantic_core import PydanticCustomError 5 | 6 | from .validation import RemoveInfo 7 | 8 | env = jinja2.Environment( 9 | loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"), 10 | enable_async=True, 11 | lstrip_blocks=True, 12 | trim_blocks=True, 13 | autoescape=True, 14 | keep_trailing_newline=True, 15 | ) 16 | 17 | 18 | async def render_comment(result: RemoveInfo, pr_url: str) -> str: 19 | """将验证结果转换为评论内容""" 20 | title = f"{result.publish_type}: remove {result.name}" 21 | 22 | template = env.get_template("comment.md.jinja") 23 | return await template.render_async(title=title, pr_url=pr_url, valid=True, error=[]) 24 | 25 | 26 | async def render_error(exception: PydanticCustomError): 27 | """将错误转换成评论内容""" 28 | template = env.get_template("comment.md.jinja") 29 | return await template.render_async( 30 | title="Error", valid=False, error=exception.message() 31 | ) 32 | -------------------------------------------------------------------------------- /tests/store/registry_plugins.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "module_name": "nonebot_plugin_datastore", 4 | "project_link": "nonebot-plugin-datastore", 5 | "name": "数据存储", 6 | "desc": "NoneBot 数据存储插件", 7 | "author": "he0119", 8 | "homepage": "https://github.com/he0119/nonebot-plugin-datastore", 9 | "tags": [], 10 | "is_official": false, 11 | "type": "library", 12 | "supported_adapters": null, 13 | "valid": true, 14 | "time": "2024-06-20T07:53:23.524486Z", 15 | "version": "1.3.0", 16 | "skip_test": false 17 | }, 18 | { 19 | "module_name": "nonebot_plugin_treehelp", 20 | "project_link": "nonebot-plugin-treehelp", 21 | "name": "帮助", 22 | "desc": "获取插件帮助信息", 23 | "author": "he0119", 24 | "homepage": "https://github.com/he0119/nonebot-plugin-treehelp", 25 | "tags": [], 26 | "is_official": false, 27 | "type": "application", 28 | "supported_adapters": null, 29 | "valid": true, 30 | "time": "2024-07-13T04:41:40.905441Z", 31 | "version": "0.5.0", 32 | "skip_test": false 33 | } 34 | ] 35 | -------------------------------------------------------------------------------- /docker/nonetest.dockerfile: -------------------------------------------------------------------------------- 1 | # 这样能分别控制 uv 和 Python 版本 2 | FROM python:3.14.2-slim 3 | COPY --from=ghcr.io/astral-sh/uv:0.9.17 /uv /bin/uv 4 | 5 | WORKDIR /app 6 | 7 | # 设置时区 8 | ENV TZ=Asia/Shanghai 9 | 10 | # 启用字节码编译,加速 NoneFlow 启动 11 | ENV UV_COMPILE_BYTECODE=1 12 | 13 | # 在不更新 uv.lock 文件的情况下运行 14 | ENV UV_FROZEN=1 15 | 16 | # 从缓存中复制而不是链接,因为缓存是挂载的 17 | ENV UV_LINK_MODE=copy 18 | 19 | # OpenCV 所需的依赖 20 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 21 | --mount=type=cache,target=/var/lib/apt,sharing=locked \ 22 | apt update && apt-get install -y ffmpeg libsm6 libxext6 23 | 24 | # 插件测试需要 Poetry 25 | ENV PATH="${PATH}:/root/.local/bin" 26 | RUN --mount=type=cache,target=/root/.cache/uv \ 27 | uv tool install "poetry<2.0.0" 28 | 29 | # Python 依赖 30 | COPY pyproject.toml uv.lock /app/ 31 | RUN --mount=type=cache,target=/root/.cache/uv \ 32 | uv sync --project /app/ --no-dev 33 | 34 | # 将可执行文件放在环境的路径前面 35 | ENV PATH="/app/.venv/bin:$PATH" 36 | 37 | # NoneFlow 本体 38 | COPY src /app/src/ 39 | 40 | CMD ["uv", "run", "--project", "/app/", "--no-dev", "-m", "src.providers.docker_test"] 41 | -------------------------------------------------------------------------------- /src/providers/validation/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import LiteralString 3 | 4 | # PyPI 格式 5 | PYPI_PACKAGE_NAME_PATTERN = re.compile( 6 | r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE 7 | ) 8 | # import 包名格式 9 | PYTHON_MODULE_NAME_REGEX = re.compile( 10 | r"^([A-Z]|[A-Z][A-Z0-9._-]*[A-Z0-9])$", re.IGNORECASE 11 | ) 12 | 13 | NAME_MAX_LENGTH = 50 14 | """名称最大长度""" 15 | 16 | PLUGIN_VALID_TYPE: list[LiteralString] = ["application", "library"] 17 | """插件类型当前只支持 application 和 library""" 18 | 19 | # Pydantic 错误信息翻译 20 | MESSAGE_TRANSLATIONS = { 21 | "string_type": "值不是合法的字符串", 22 | "model_type": "值不是合法的字典", 23 | "list_type": "值不是合法的列表", 24 | "set_type": "值不是合法的集合", 25 | "json_type": "JSON 格式不合法", 26 | "missing": "字段不存在", 27 | "color_error": "颜色格式不正确", 28 | "string_too_long": "字符串长度不能超过 {max_length} 个字符", 29 | "too_long": "列表长度不能超过 {max_length} 个元素", 30 | "string_pattern_mismatch": "字符串应满足格式 '{pattern}'", 31 | "plugin.test": "插件无法正常加载", 32 | "plugin.metadata": "无法获取到插件元数据", 33 | "plugin.type": "插件类型只能是 application 或 library", 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 hemengyang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/publish/depends.py: -------------------------------------------------------------------------------- 1 | from githubkit.rest import PullRequestSimple 2 | from nonebot.adapters.github import GitHubBot 3 | from nonebot.params import Depends 4 | 5 | from src.plugins.github.depends import ( 6 | get_issue_title, 7 | get_repo_info, 8 | get_type_by_labels_name, 9 | ) 10 | from src.plugins.github.plugins.publish import utils 11 | from src.providers.models import RepoInfo 12 | from src.providers.validation.models import PublishType 13 | 14 | 15 | def get_type_by_title(title: str = Depends(get_issue_title)) -> PublishType | None: 16 | """通过标题获取类型""" 17 | return utils.get_type_by_title(title) 18 | 19 | 20 | async def get_pull_requests_by_label( 21 | bot: GitHubBot, 22 | repo_info: RepoInfo = Depends(get_repo_info), 23 | publish_type: PublishType = Depends(get_type_by_labels_name), 24 | ) -> list[PullRequestSimple]: 25 | pulls = ( 26 | await bot.rest.pulls.async_list(**repo_info.model_dump(), state="open") 27 | ).parsed_data 28 | return [ 29 | pull 30 | for pull in pulls 31 | if publish_type.value in [label.name for label in pull.labels] 32 | ] 33 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: $CHANGES 2 | name-template: "v$RESOLVED_VERSION" 3 | tag-template: "v$RESOLVED_VERSION" 4 | exclude-labels: 5 | - "dependencies" 6 | - "skip-changelog" 7 | autolabeler: 8 | - label: "bug" 9 | branch: 10 | - '/fix\/.+/' 11 | - label: "change" 12 | branch: 13 | - '/change\/.+/' 14 | - label: "enhancement" 15 | branch: 16 | - '/feature\/.+/' 17 | - '/feat\/.+/' 18 | - '/improve\/.+/' 19 | - label: "ci" 20 | files: 21 | - .github/**/* 22 | - label: "breaking-change" 23 | title: 24 | - "/.+!:.+/" 25 | categories: 26 | - title: 💥 破坏性变更 27 | labels: 28 | - breaking-change 29 | - title: 🚀 新功能 30 | labels: 31 | - enhancement 32 | - title: 🐛 Bug 修复 33 | labels: 34 | - bug 35 | - title: 💫 杂项 36 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 37 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 38 | version-resolver: 39 | major: 40 | labels: 41 | - "major" 42 | minor: 43 | labels: 44 | - "minor" 45 | patch: 46 | labels: 47 | - "patch" 48 | default: patch 49 | -------------------------------------------------------------------------------- /src/plugins/github/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pydantic import BaseModel, ConfigDict, Field, field_validator 4 | 5 | from src.providers.models import RepoInfo 6 | 7 | 8 | class PublishConfig(BaseModel): 9 | base: str 10 | plugin_path: Path 11 | bot_path: Path 12 | adapter_path: Path 13 | registry_repository: RepoInfo = Field( 14 | default=RepoInfo(owner="nonebot", repo="registry") 15 | ) 16 | store_repository: RepoInfo = Field( 17 | default=RepoInfo(owner="nonebot", repo="nonebot2") 18 | ) 19 | artifact_path: Path = Field( 20 | default=Path("artifact"), 21 | description="Artifact 存储路径,默认是 `artifact` 目录", 22 | ) 23 | 24 | @field_validator("registry_repository", "store_repository", mode="before") 25 | @classmethod 26 | def check_repositorys(cls, v: str) -> RepoInfo | None: 27 | if not v: 28 | return None 29 | owner, repo = v.split("/") 30 | return RepoInfo(owner=owner, repo=repo) 31 | 32 | 33 | class Config(BaseModel, extra="ignore"): 34 | model_config = ConfigDict(coerce_numbers_to_str=True) 35 | 36 | input_config: PublishConfig 37 | github_repository: str 38 | github_run_id: str 39 | github_step_summary: Path 40 | -------------------------------------------------------------------------------- /tests/plugins/github/event.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import cast 3 | 4 | from nonebot.adapters.github import ( 5 | Adapter, 6 | Event, 7 | IssueCommentCreated, 8 | IssuesOpened, 9 | PullRequestClosed, 10 | PullRequestReviewSubmitted, 11 | ) 12 | 13 | # 事件类型对应的事件名称和事件文件名 14 | EVENT_INFO = { 15 | IssuesOpened: ("issues", "issue-open"), 16 | IssueCommentCreated: ("issue_comment", "issue-comment"), 17 | PullRequestClosed: ("pull_request", "pr-close"), 18 | PullRequestReviewSubmitted: ( 19 | "pull_request_review", 20 | "pull_request_review_submitted", 21 | ), 22 | } 23 | 24 | 25 | def get_mock_event[T: Event]( 26 | event_type: type[T], filename: str = "", id: str = "1" 27 | ) -> T: 28 | """通过事件类型获取事件对象""" 29 | 30 | if event_type not in EVENT_INFO: 31 | raise ValueError(f"Unknown event type: {event_type}") 32 | 33 | event_name, event_filename = EVENT_INFO[event_type] 34 | if filename: 35 | event_filename = filename 36 | 37 | event_path = Path(__file__).parent / "events" / f"{event_filename}.json" 38 | event = Adapter.payload_to_event(id, event_name, event_path.read_bytes()) 39 | 40 | assert isinstance(event, event_type) 41 | return cast("T", event) 42 | -------------------------------------------------------------------------------- /examples/noneflow-results.yml: -------------------------------------------------------------------------------- 1 | name: NoneFlow 2 | 3 | on: 4 | pull_request_target: 5 | types: [closed] 6 | pull_request_review: 7 | types: [submitted] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} 11 | cancel-in-progress: false 12 | 13 | jobs: 14 | noneflow: 15 | runs-on: ubuntu-latest 16 | name: noneflow 17 | steps: 18 | - name: Generate token 19 | id: generate-token 20 | uses: tibdex/github-app-token@v2 21 | with: 22 | app_id: ${{ secrets.APP_ID }} 23 | private_key: ${{ secrets.APP_KEY }} 24 | 25 | - name: Checkout Code 26 | uses: actions/checkout@v4 27 | with: 28 | token: ${{ steps.generate-token.outputs.token }} 29 | 30 | - name: NoneFlow 31 | uses: docker://ghcr.io/nonebot/noneflow:latest 32 | with: 33 | config: > 34 | { 35 | "base": "master", 36 | "plugin_path": "assets/plugins.json5", 37 | "bot_path": "assets/bots.json5", 38 | "adapter_path": "assets/adapters.json5" 39 | } 40 | env: 41 | APP_ID: ${{ secrets.APP_ID }} 42 | PRIVATE_KEY: ${{ secrets.APP_KEY }} 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | noneflow 4 |

5 | 6 |
7 | 8 | # NoneFlow 9 | 10 | _✨ NoneBot 工作流管理机器人 ✨_ 11 | 12 | [![codecov](https://codecov.io/gh/nonebot/noneflow/graph/badge.svg?token=BOIBTOCWCH)](https://codecov.io/gh/nonebot/noneflow) 13 | [![Powered by NoneBot](https://img.shields.io/badge/Powered%20%20by-NoneBot-red)](https://github.com/nonebot/nonebot2) 14 | 15 |
16 | 17 | 18 | ## 主要功能 19 | 20 | 根据 插件/协议/机器人 发布(带 Plugin/Adapter/Bot 标题)议题,自动修改对应文件,并创建拉取请求。 21 | 22 | ## 自动处理 23 | 24 | - 商店发布议题创建后,自动根据议题内容创建拉取请求 25 | - 相关议题修改时,自动修改已创建的拉取请求,如果没有创建则重新创建 26 | - 拉取请求关闭时,自动关闭对应议题,并删除对应分支 27 | - 已经创建的拉取请求在其他拉取请求合并后,自动解决冲突 28 | - 自动检查是否符合发布要求 29 | - 审查通过后自动合并 30 | 31 | ### 发布要求 32 | 33 | - 项目主页能够访问 34 | - 项目发布至 PyPI 35 | - 插件能够正常加载 36 | 37 | ## 使用方法 38 | 39 | - [自动处理商店发布议题和测试插件](examples/noneflow.yml) 40 | - [定时测试商店内插件](examples/store-test.yml) 41 | - [自动处理商店信息修改/删除议题](examples/noneflow-registry.yml) 42 | - [自动合并插件配置修改](examples/noneflow-results.yml) 43 | 44 | ## 测试 45 | 46 | 在 [noneflow-test](https://github.com/nonebot/noneflow-test) 仓库中测试。 47 | -------------------------------------------------------------------------------- /tests/providers/store_test/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from pytest_mock import MockerFixture 5 | from respx import MockRouter 6 | 7 | 8 | @pytest.fixture 9 | def mocked_store_data( 10 | tmp_path: Path, mocker: MockerFixture, mocked_api: MockRouter 11 | ) -> dict[str, Path]: 12 | from src.providers.store_test import store 13 | 14 | plugin_test_path = tmp_path / "plugin_test" 15 | plugin_test_path.mkdir() 16 | 17 | paths = { 18 | "adapters": plugin_test_path / "adapters.json", 19 | "bots": plugin_test_path / "bots.json", 20 | "drivers": plugin_test_path / "drivers.json", 21 | "plugins": plugin_test_path / "plugins.json", 22 | "results": plugin_test_path / "results.json", 23 | "plugin_configs": plugin_test_path / "plugin_configs.json", 24 | } 25 | 26 | mocker.patch.object(store, "RESULTS_PATH", paths["results"]) 27 | mocker.patch.object(store, "ADAPTERS_PATH", paths["adapters"]) 28 | mocker.patch.object(store, "BOTS_PATH", paths["bots"]) 29 | mocker.patch.object(store, "DRIVERS_PATH", paths["drivers"]) 30 | mocker.patch.object(store, "PLUGINS_PATH", paths["plugins"]) 31 | mocker.patch.object(store, "PLUGIN_CONFIG_PATH", paths["plugin_configs"]) 32 | 33 | return paths 34 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/publish/templates/comment.md.jinja: -------------------------------------------------------------------------------- 1 | {% from "render_data.md.jinja" import render_data %} 2 | {% from "render_error.md.jinja" import render_error %} 3 | {# 发布测试评论内容 #} 4 | # 📃 商店发布检查结果 5 | 6 | > {{ title }} 7 | 8 | {% if card %} 9 | {{ card }} 10 | 11 | {% endif -%} 12 | 13 | **{{ "✅ 所有测试通过,一切准备就绪!" if valid else "⚠️ 在发布检查过程中,我们发现以下问题:"}}** 14 | 15 | {% if not valid %} 16 |

17 | {%- for error in errors %}
18 | 
  • ⚠️ {{ render_error(error) }}
  • 19 | {%- endfor %} 20 |
    21 | {% endif %} 22 | 23 | {% if data %} 24 |
    25 | 详情 26 |
    
    27 | {%- for key, value in data.items() %}
    28 | 
  • ✅ {{ render_data(key, value, skip_test) }}
  • 29 | {%- endfor %} 30 |
    31 |
    32 | {% endif %} 33 | 34 | {%- if history %} 35 |
    36 | 历史测试 37 |
    
    38 | {%- for status, action_url, time in history%}
    39 | 
  • {{"✅" if status else "⚠️"}} {{time|format_datetime}}
  • 40 | {%- endfor %} 41 |
    42 |
    43 | {% endif %} 44 | 45 | --- 46 | 47 | 💡 如需修改信息,请直接修改 issue,机器人会自动更新检查结果。 48 | 💡 当插件加载测试失败时,请发布新版本后勾选插件测试勾选框重新运行插件测试。 49 | 50 | {% if reuse %} 51 | ♻️ 评论已更新至最新检查结果 52 | {% endif %} 53 | 54 | 💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow) 55 | 56 | -------------------------------------------------------------------------------- /src/providers/docker_test/templates/runner.py.jinja: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from nonebot import init, load_plugin, logger, require 4 | from pydantic import BaseModel 5 | 6 | 7 | class SetEncoder(json.JSONEncoder): 8 | def default(self, o): 9 | if isinstance(o, set): 10 | return list(o) 11 | return json.JSONEncoder.default(self, o) 12 | 13 | 14 | init() 15 | plugin = load_plugin("{{ module_name }}") 16 | 17 | if not plugin: 18 | exit(1) 19 | else: 20 | if plugin.metadata: 21 | metadata = { 22 | "name": plugin.metadata.name, 23 | "desc": plugin.metadata.description, 24 | "usage": plugin.metadata.usage, 25 | "type": plugin.metadata.type, 26 | "homepage": plugin.metadata.homepage, 27 | "supported_adapters": plugin.metadata.supported_adapters, 28 | } 29 | with open("metadata.json", "w", encoding="utf-8") as f: 30 | try: 31 | f.write(f"{json.dumps(metadata, cls=SetEncoder)}") 32 | except Exception: 33 | f.write("{}") 34 | 35 | if plugin.metadata.config and not issubclass(plugin.metadata.config, BaseModel): 36 | logger.error("插件配置项不是 Pydantic BaseModel 的子类") 37 | exit(1) 38 | 39 | {% for dep in deps %} 40 | require("{{ dep }}") 41 | {% endfor %} 42 | -------------------------------------------------------------------------------- /tests/providers/test_json5.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from inline_snapshot import snapshot 4 | 5 | 6 | async def test_json5_dump(tmp_path: Path) -> None: 7 | """输出 JSON5 8 | 9 | 有尾随逗号 10 | """ 11 | from src.providers.utils import dump_json5 12 | 13 | data = [ 14 | { 15 | "name": "name", 16 | "desc": "desc", 17 | "author": "author", 18 | "homepage": "https://nonebot.dev", 19 | "tags": '[{"label": "test", "color": "#ffffff"}]', 20 | "author_id": 1, 21 | } 22 | ] 23 | 24 | test_file = tmp_path / "test.json5" 25 | dump_json5(test_file, data) 26 | 27 | assert test_file.read_text() == snapshot( 28 | """\ 29 | [ 30 | { 31 | "name": "name", 32 | "desc": "desc", 33 | "author": "author", 34 | "homepage": "https://nonebot.dev", 35 | "tags": "[{\\"label\\": \\"test\\", \\"color\\": \\"#ffffff\\"}]", 36 | "author_id": 1 37 | }, 38 | ] 39 | """ 40 | ) 41 | 42 | 43 | async def test_json5_dump_empty_list(tmp_path: Path) -> None: 44 | """空列表 45 | 46 | 末尾也应该有换行 47 | """ 48 | from src.providers.utils import dump_json5 49 | 50 | data = [] 51 | 52 | test_file = tmp_path / "test.json5" 53 | dump_json5(test_file, data) 54 | 55 | assert test_file.read_text() == snapshot("[]\n") 56 | -------------------------------------------------------------------------------- /tests/store/registry_results.json: -------------------------------------------------------------------------------- 1 | { 2 | "nonebot-plugin-datastore:nonebot_plugin_datastore": { 3 | "time": "2023-06-26T22:08:18.945584+08:00", 4 | "version": "1.3.0", 5 | "results": { 6 | "validation": true, 7 | "load": true, 8 | "metadata": true 9 | }, 10 | "config": "", 11 | "outputs": { 12 | "validation": null, 13 | "load": "datastore", 14 | "metadata": { 15 | "name": "数据存储", 16 | "description": "NoneBot 数据存储插件", 17 | "usage": "请参考文档", 18 | "type": "library", 19 | "homepage": "https://github.com/he0119/nonebot-plugin-datastore", 20 | "supported_adapters": null 21 | } 22 | } 23 | }, 24 | "nonebot-plugin-treehelp:nonebot_plugin_treehelp": { 25 | "time": "2023-06-26T22:20:41.833311+08:00", 26 | "version": "0.3.0", 27 | "results": { 28 | "validation": true, 29 | "load": true, 30 | "metadata": true 31 | }, 32 | "inputs": { 33 | "config": "TEST_CONFIG=true" 34 | }, 35 | "outputs": { 36 | "validation": null, 37 | "load": "treehelp", 38 | "metadata": { 39 | "name": "帮助", 40 | "description": "获取插件帮助信息", 41 | "usage": "获取插件列表\n/help\n获取插件树\n/help -t\n/help --tree\n获取某个插件的帮助\n/help 插件名\n获取某个插件的树\n/help --tree 插件名\n", 42 | "type": "application", 43 | "homepage": "https://github.com/he0119/nonebot-plugin-treehelp", 44 | "supported_adapters": null 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/plugins/github/utils.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from re import Pattern 3 | 4 | from nonebot import logger 5 | 6 | 7 | def run_shell_command(command: list[str]): 8 | """运行 shell 命令 9 | 10 | 如果遇到错误则抛出异常 11 | """ 12 | logger.info(f"运行命令: {command}") 13 | try: 14 | r = subprocess.run(command, check=True, capture_output=True) 15 | logger.debug(f"命令输出: \n{r.stdout.decode()}") 16 | except subprocess.CalledProcessError as e: 17 | logger.debug("命令运行失败") 18 | logger.debug(f"命令输出: \n{e.stdout.decode()}") 19 | logger.debug(f"命令错误: \n{e.stderr.decode()}") 20 | raise 21 | return r 22 | 23 | 24 | def commit_message(prefix: str, message: str, issue_number: int): 25 | """生成提交信息""" 26 | return f"{prefix} {message} (#{issue_number})" 27 | 28 | 29 | def extract_issue_info_from_issue( 30 | patterns: dict[str, Pattern[str] | list[Pattern[str]]], body: str 31 | ) -> dict[str, str | None]: 32 | """ 33 | 根据提供的正则表达式和议题内容来提取所需的信息 34 | """ 35 | matchers = {} 36 | for key, pattern in patterns.items(): 37 | if isinstance(pattern, list): 38 | matchers[key] = next( 39 | (p.search(body) for p in pattern if p.search(body)), None 40 | ) 41 | else: 42 | matchers[key] = pattern.search(body) 43 | 44 | # 如果未匹配到数据,则不添加进字典中 45 | # 这样可以让 Pydantic 在校验时报错 missing 46 | data = {key: match.group(1).strip() for key, match in matchers.items() if match} 47 | return data 48 | -------------------------------------------------------------------------------- /examples/noneflow-registry.yml: -------------------------------------------------------------------------------- 1 | name: NoneFlow 2 | 3 | on: 4 | issues: 5 | types: [opened, reopened, edited] 6 | issue_comment: 7 | types: [created] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.event.issue.number || github.run_id }} 11 | cancel-in-progress: false 12 | 13 | jobs: 14 | noneflow: 15 | runs-on: ubuntu-latest 16 | name: noneflow 17 | # do not run on not related issues 18 | if: | 19 | contains(github.event.issue.labels.*.name, 'Plugin') || 20 | contains(github.event.issue.labels.*.name, 'Adapter') || 21 | contains(github.event.issue.labels.*.name, 'Bot') 22 | steps: 23 | - name: Generate token 24 | id: generate-token 25 | uses: tibdex/github-app-token@v2 26 | with: 27 | app_id: ${{ secrets.APP_ID }} 28 | private_key: ${{ secrets.APP_KEY }} 29 | 30 | - name: Checkout Code 31 | uses: actions/checkout@v4 32 | with: 33 | token: ${{ steps.generate-token.outputs.token }} 34 | repository: nonebot/nonebot2 35 | 36 | - name: NoneFlow 37 | uses: docker://ghcr.io/nonebot/noneflow:latest 38 | with: 39 | config: > 40 | { 41 | "base": "master", 42 | "plugin_path": "assets/plugins.json5", 43 | "bot_path": "assets/bots.json5", 44 | "adapter_path": "assets/adapters.json5", 45 | "registry_repository": "nonebot/registry" 46 | } 47 | env: 48 | APP_ID: ${{ secrets.APP_ID }} 49 | PRIVATE_KEY: ${{ secrets.APP_KEY }} 50 | -------------------------------------------------------------------------------- /src/providers/validation/utils.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from src.providers.constants import STORE_ADAPTERS_URL 4 | from src.providers.utils import get_url, load_json_from_web 5 | 6 | from .constants import MESSAGE_TRANSLATIONS 7 | 8 | if TYPE_CHECKING: 9 | from pydantic_core import ErrorDetails 10 | 11 | 12 | def check_pypi(project_link: str) -> bool: 13 | """检查项目是否存在""" 14 | url = f"https://pypi.org/pypi/{project_link}/json" 15 | status_code, _ = check_url(url) 16 | return status_code == 200 17 | 18 | 19 | def check_url(url: str) -> tuple[int, str]: 20 | """检查网址是否可以访问 21 | 22 | 返回状态码,如果报错则返回 -1 23 | """ 24 | try: 25 | r = get_url(url) 26 | return r.status_code, "" 27 | except Exception as e: 28 | return -1, str(e) 29 | 30 | 31 | def get_adapters() -> set[str]: 32 | """获取适配器列表""" 33 | adapters = load_json_from_web(STORE_ADAPTERS_URL) 34 | return {adapter["module_name"] for adapter in adapters} 35 | 36 | 37 | def resolve_adapter_name(name: str) -> str: 38 | """解析适配器名称 39 | 40 | 例如:`~onebot.v11` -> `nonebot.adapters.onebot.v11` 41 | """ 42 | if name.startswith("~"): 43 | name = "nonebot.adapters." + name[1:] 44 | return name 45 | 46 | 47 | def translate_errors(errors: list["ErrorDetails"]) -> list["ErrorDetails"]: 48 | """翻译 Pydantic 错误信息""" 49 | new_errors: list[ErrorDetails] = [] 50 | for error in errors: 51 | translation = MESSAGE_TRANSLATIONS.get(error["type"]) 52 | if translation: 53 | ctx = error.get("ctx") 54 | error["msg"] = translation.format(**ctx) if ctx else translation 55 | new_errors.append(error) 56 | return new_errors 57 | -------------------------------------------------------------------------------- /tests/providers/validation/fields/test_name.py: -------------------------------------------------------------------------------- 1 | from inline_snapshot import snapshot 2 | from respx import MockRouter 3 | 4 | from tests.providers.validation.utils import generate_adapter_data 5 | 6 | 7 | async def test_name_too_long(mocked_api: MockRouter) -> None: 8 | """测试名称过长的情况""" 9 | from src.providers.validation import PublishType, validate_info 10 | 11 | data = generate_adapter_data( 12 | name="looooooooooooooooooooooooooooooooooooooooooooooooooooooooong" 13 | ) 14 | 15 | result = validate_info(PublishType.ADAPTER, data, []) 16 | 17 | assert not result.valid 18 | assert result.type == PublishType.ADAPTER 19 | assert result.valid_data == snapshot( 20 | { 21 | "module_name": "module_name", 22 | "project_link": "project_link", 23 | "time": "2023-09-01T00:00:00.000000Z", 24 | "version": "0.0.1", 25 | "desc": "desc", 26 | "author": "author", 27 | "homepage": "https://nonebot.dev", 28 | "author_id": 1, 29 | "tags": [{"label": "test", "color": "#ffffff"}], 30 | } 31 | ) 32 | assert result.info is None 33 | assert result.errors == snapshot( 34 | [ 35 | { 36 | "type": "string_too_long", 37 | "loc": ("name",), 38 | "msg": "字符串长度不能超过 50 个字符", 39 | "input": "looooooooooooooooooooooooooooooooooooooooooooooooooooooooong", 40 | "ctx": {"max_length": 50}, 41 | "url": "https://errors.pydantic.dev/2.12/v/string_too_long", 42 | } 43 | ] 44 | ) 45 | 46 | assert mocked_api["pypi_project_link"].called 47 | assert mocked_api["homepage"].called 48 | -------------------------------------------------------------------------------- /src/plugins/github/typing.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: UP040 2 | from typing import TypeAlias 3 | 4 | from githubkit.rest import ( 5 | PullRequest, 6 | PullRequestPropLabelsItems, 7 | PullRequestSimple, 8 | PullRequestSimplePropLabelsItems, 9 | WebhookIssueCommentCreatedPropIssueAllof0PropLabelsItems, 10 | WebhookIssuesEditedPropIssuePropLabelsItems, 11 | WebhookIssuesOpenedPropIssuePropLabelsItems, 12 | WebhookIssuesReopenedPropIssuePropLabelsItems, 13 | WebhookPullRequestReviewSubmittedPropPullRequestPropLabelsItems, 14 | ) 15 | from githubkit.typing import Missing 16 | from nonebot.adapters.github import ( 17 | IssueCommentCreated, 18 | IssuesEdited, 19 | IssuesOpened, 20 | IssuesReopened, 21 | PullRequestClosed, 22 | PullRequestReviewSubmitted, 23 | ) 24 | 25 | IssuesEvent: TypeAlias = ( 26 | IssuesOpened | IssuesReopened | IssuesEdited | IssueCommentCreated 27 | ) 28 | 29 | PullRequestEvent: TypeAlias = PullRequestClosed | PullRequestReviewSubmitted 30 | 31 | 32 | PullRequestLabels: TypeAlias = ( 33 | list[PullRequestSimplePropLabelsItems] 34 | | list[PullRequestPropLabelsItems] 35 | | list[WebhookPullRequestReviewSubmittedPropPullRequestPropLabelsItems] 36 | ) 37 | 38 | IssueLabels: TypeAlias = ( 39 | Missing[list[WebhookIssuesOpenedPropIssuePropLabelsItems]] 40 | | Missing[list[WebhookIssuesReopenedPropIssuePropLabelsItems]] 41 | | Missing[list[WebhookIssuesEditedPropIssuePropLabelsItems]] 42 | | list[WebhookIssueCommentCreatedPropIssueAllof0PropLabelsItems] 43 | ) 44 | 45 | LabelsItems: TypeAlias = PullRequestLabels | IssueLabels 46 | 47 | PullRequestList: TypeAlias = ( 48 | list[PullRequestSimple] | list[PullRequest] | list[PullRequest | PullRequestSimple] 49 | ) 50 | -------------------------------------------------------------------------------- /src/providers/store_test/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | import click 5 | 6 | from src.providers.logger import logger 7 | from src.providers.models import RegistryUpdatePayload 8 | 9 | from .store import StoreTest 10 | 11 | 12 | @click.group() 13 | @click.option("--debug/--no-debug", default=False) 14 | def cli(debug: bool): 15 | logger.setLevel("DEBUG" if debug else "INFO") 16 | 17 | 18 | @cli.command() 19 | def registry_update(): 20 | """商店更新""" 21 | # 通过环境变量传递插件配置 22 | payload = os.environ.get("REGISTRY_UPDATE_PAYLOAD") 23 | if not payload: 24 | logger.warning("未传入更新数据") 25 | return 26 | 27 | payload = RegistryUpdatePayload.model_validate_json(payload) 28 | data = payload.get_artifact_data() 29 | 30 | test = StoreTest() 31 | asyncio.run(test.registry_update(data)) 32 | 33 | 34 | @cli.command() 35 | @click.option("-l", "--limit", default=1, show_default=True, help="测试插件数量") 36 | @click.option("-o", "--offset", default=0, show_default=True, help="测试插件偏移量") 37 | @click.option("-f", "--force", default=False, is_flag=True, help="强制重新测试") 38 | @click.option("-k", "--key", default=None, show_default=True, help="测试插件标识符") 39 | @click.option( 40 | "-r", 41 | "--recent", 42 | default=False, 43 | is_flag=True, 44 | help="按测试时间倒序排列,优先测试最近测试的插件", 45 | ) 46 | def plugin_test(limit: int, offset: int, force: bool, key: str | None, recent: bool): 47 | """插件测试""" 48 | from .store import StoreTest 49 | 50 | test = StoreTest() 51 | 52 | if key: 53 | # 指定了 key,直接测试该插件 54 | asyncio.run(test.run_single_plugin(key, force)) 55 | else: 56 | # 没有指定 key,根据 recent 参数决定测试顺序 57 | asyncio.run(test.run(limit, offset, force, recent)) 58 | 59 | 60 | if __name__ == "__main__": 61 | cli() 62 | -------------------------------------------------------------------------------- /src/providers/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from zoneinfo import ZoneInfo 4 | 5 | TIME_ZONE = ZoneInfo("Asia/Shanghai") 6 | """ NoneFlow 统一的时区 """ 7 | 8 | BOT_KEY_TEMPLATE = "{name}:{homepage}" 9 | """ 机器人键名模板 """ 10 | 11 | PYPI_KEY_TEMPLATE = "{project_link}:{module_name}" 12 | """ 插件键名模板 """ 13 | 14 | # NoneBot 插件商店测试结果 15 | # https://github.com/nonebot/registry/tree/results 16 | REGISTRY_BASE_URL = ( 17 | os.environ.get("REGISTRY_BASE_URL") 18 | or "https://raw.githubusercontent.com/nonebot/registry/results" 19 | ) 20 | REGISTRY_RESULTS_URL = f"{REGISTRY_BASE_URL}/results.json" 21 | REGISTRY_ADAPTERS_URL = f"{REGISTRY_BASE_URL}/adapters.json" 22 | REGISTRY_BOTS_URL = f"{REGISTRY_BASE_URL}/bots.json" 23 | REGISTRY_DRIVERS_URL = f"{REGISTRY_BASE_URL}/drivers.json" 24 | REGISTRY_PLUGINS_URL = f"{REGISTRY_BASE_URL}/plugins.json" 25 | REGISTRY_PLUGIN_CONFIG_URL = f"{REGISTRY_BASE_URL}/plugin_configs.json" 26 | 27 | # NoneBot 插件商店 28 | # https://github.com/nonebot/nonebot2/tree/master/assets 29 | STORE_BASE_URL = ( 30 | os.environ.get("STORE_BASE_URL") 31 | or "https://raw.githubusercontent.com/nonebot/nonebot2/master/assets" 32 | ) 33 | STORE_ADAPTERS_URL = f"{STORE_BASE_URL}/adapters.json5" 34 | STORE_BOTS_URL = f"{STORE_BASE_URL}/bots.json5" 35 | STORE_DRIVERS_URL = f"{STORE_BASE_URL}/drivers.json5" 36 | STORE_PLUGINS_URL = f"{STORE_BASE_URL}/plugins.json5" 37 | 38 | # 商店测试镜像 39 | # https://github.com/orgs/nonebot/packages/container/package/nonetest 40 | DOCKER_IMAGES_VERSION = os.environ.get("DOCKER_IMAGES_VERSION") or "latest" 41 | DOCKER_IMAGES = f"ghcr.io/nonebot/nonetest:{DOCKER_IMAGES_VERSION}" 42 | DOCKER_BIND_RESULT_PATH = "/app/test_result.json" 43 | 44 | PLUGIN_TEST_DIR = Path("plugin_test") 45 | 46 | # Artifact 相关常量 47 | REGISTRY_DATA_NAME = "registry_data.json" 48 | """传递给 Registry 的数据文件名,会上传至 Artifact 存储""" 49 | -------------------------------------------------------------------------------- /tests/plugins/github/remove/render/test_remove_render_data.py: -------------------------------------------------------------------------------- 1 | from inline_snapshot import snapshot 2 | from nonebug import App 3 | 4 | 5 | async def test_render(app: App): 6 | from src.plugins.github.plugins.remove.render import render_comment 7 | from src.plugins.github.plugins.remove.validation import RemoveInfo 8 | from src.providers.validation.models import PublishType 9 | 10 | result = RemoveInfo(publish_type=PublishType.BOT, key="omg", name="omg") 11 | assert await render_comment(result, "nonebot/nonebot2") == snapshot( 12 | """\ 13 | # 📃 商店下架检查 14 | 15 | > Bot: remove omg 16 | 17 | **✅ 所有检查通过,一切准备就绪!** 18 | 19 | > 成功发起插件下架流程,对应的拉取请求 nonebot/nonebot2 已经创建。 20 | 21 | --- 22 | 23 | 💡 如需修改信息,请直接修改 issue,机器人会自动更新检查结果。 24 | 25 | 💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow) 26 | 27 | """ 28 | ) 29 | 30 | 31 | async def test_exception_author_info_no_eq(app: App): 32 | from pydantic_core import PydanticCustomError 33 | 34 | from src.plugins.github.plugins.remove.render import render_error 35 | 36 | exception = PydanticCustomError("author_info", "作者信息不匹配") 37 | 38 | assert await render_error(exception) == snapshot( 39 | """\ 40 | # 📃 商店下架检查 41 | 42 | > Error 43 | 44 | **⚠️ 在下架检查过程中,我们发现以下问题:** 45 | 46 | > ⚠️ 作者信息不匹配 47 | 48 | --- 49 | 50 | 💡 如需修改信息,请直接修改 issue,机器人会自动更新检查结果。 51 | 52 | 💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow) 53 | 54 | """ 55 | ) 56 | 57 | 58 | async def test_exception_package_not_found(app: App): 59 | from pydantic_core import PydanticCustomError 60 | 61 | from src.plugins.github.plugins.remove.render import render_error 62 | 63 | exception = PydanticCustomError("not_found", "没有包含对应主页链接的包") 64 | 65 | assert await render_error(exception) == snapshot( 66 | """\ 67 | # 📃 商店下架检查 68 | 69 | > Error 70 | 71 | **⚠️ 在下架检查过程中,我们发现以下问题:** 72 | 73 | > ⚠️ 没有包含对应主页链接的包 74 | 75 | --- 76 | 77 | 💡 如需修改信息,请直接修改 issue,机器人会自动更新检查结果。 78 | 79 | 💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow) 80 | 81 | """ 82 | ) 83 | -------------------------------------------------------------------------------- /src/providers/validation/__init__.py: -------------------------------------------------------------------------------- 1 | """验证数据是否符合规范""" 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from pydantic import ValidationError 6 | 7 | from .models import ( 8 | AdapterPublishInfo, 9 | BotPublishInfo, 10 | DriverPublishInfo, 11 | PluginPublishInfo, 12 | ) 13 | from .models import PublishInfoModels as PublishInfoModels 14 | from .models import PublishType as PublishType 15 | from .models import ValidationDict as ValidationDict 16 | from .utils import translate_errors 17 | 18 | if TYPE_CHECKING: 19 | from pydantic_core import ErrorDetails 20 | 21 | validation_model_map: dict[PublishType, type[PublishInfoModels]] = { 22 | PublishType.BOT: BotPublishInfo, 23 | PublishType.ADAPTER: AdapterPublishInfo, 24 | PublishType.PLUGIN: PluginPublishInfo, 25 | PublishType.DRIVER: DriverPublishInfo, 26 | } 27 | 28 | 29 | def validate_info( 30 | publish_type: PublishType, 31 | raw_data: dict[str, Any], 32 | previous_data: list[dict[str, Any]] | None, 33 | ) -> ValidationDict: 34 | """根据发布类型验证数据是否符合规范 35 | 36 | Args: 37 | publish_type (PublishType): 发布类型 38 | raw_data (dict[str, Any]): 原始数据 39 | previous_data (list[dict[str, Any]] | None): 当前商店数据,用于验证数据是否重复 40 | """ 41 | context = { 42 | "previous_data": previous_data, 43 | "valid_data": {}, # 用来存放验证通过的数据 44 | # 验证过程中可能需要用到的数据 45 | # 存放在 context 中方便 FieldValidator 使用 46 | "test_output": raw_data.get("test_output", ""), # 测试输出 47 | "skip_test": raw_data.get("skip_test", False), # 是否跳过测试 48 | } 49 | 50 | info: PublishInfoModels | None = None 51 | errors: list[ErrorDetails] = [] 52 | 53 | try: 54 | info = validation_model_map[publish_type].model_validate( 55 | raw_data, context=context 56 | ) 57 | except ValidationError as exc: 58 | errors = exc.errors() 59 | 60 | # 翻译错误 61 | errors = translate_errors(errors) 62 | 63 | return ValidationDict( 64 | type=publish_type, 65 | raw_data=raw_data, 66 | valid_data=context.get("valid_data", {}), 67 | info=info, 68 | errors=errors, 69 | ) 70 | -------------------------------------------------------------------------------- /tests/plugins/github/remove/utils/test_update_file.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from nonebug import App 4 | from pytest_mock import MockerFixture 5 | from respx import MockRouter 6 | 7 | from tests.plugins.github.utils import check_json_data 8 | 9 | 10 | async def test_update_file( 11 | app: App, mocker: MockerFixture, mocked_api: MockRouter, tmp_path: Path 12 | ) -> None: 13 | from src.plugins.github import plugin_config 14 | from src.plugins.github.plugins.remove.utils import update_file 15 | from src.plugins.github.plugins.remove.validation import RemoveInfo 16 | from src.providers.utils import dump_json5 17 | from src.providers.validation.models import PublishType 18 | 19 | mock_git_handler = mocker.MagicMock() 20 | 21 | data = [ 22 | { 23 | "name": "CoolQBot", 24 | "desc": "基于 NoneBot2 的聊天机器人", 25 | "author_id": 1, 26 | "homepage": "https://github.com/he0119/CoolQBot", 27 | "tags": [], 28 | "is_official": False, 29 | }, 30 | { 31 | "name": "CoolQBot2", 32 | "desc": "基于 NoneBot2 的聊天机器人", 33 | "author_id": 1, 34 | "homepage": "https://github.com/he0119/CoolQBot", 35 | "tags": [], 36 | "is_official": False, 37 | }, 38 | ] 39 | dump_json5(tmp_path / "bots.json5", data) 40 | 41 | check_json_data(plugin_config.input_config.bot_path, data) 42 | 43 | remove_info = RemoveInfo( 44 | publish_type=PublishType.BOT, 45 | key="CoolQBot:https://github.com/he0119/CoolQBot", 46 | name="CoolQBot", 47 | ) 48 | update_file(remove_info, mock_git_handler) 49 | 50 | check_json_data( 51 | plugin_config.input_config.bot_path, 52 | [ 53 | { 54 | "name": "CoolQBot2", 55 | "desc": "基于 NoneBot2 的聊天机器人", 56 | "author_id": 1, 57 | "homepage": "https://github.com/he0119/CoolQBot", 58 | "tags": [], 59 | "is_official": False, 60 | } 61 | ], 62 | ) 63 | 64 | mock_git_handler.add_file.assert_called_once_with(tmp_path / "bots.json5") 65 | -------------------------------------------------------------------------------- /src/providers/docker_test/templates/fake.py.jinja: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | from collections.abc import AsyncGenerator 3 | from nonebot import logger 4 | from nonebot.drivers import ( 5 | ASGIMixin, 6 | HTTPClientMixin, 7 | HTTPClientSession, 8 | HTTPVersion, 9 | Request, 10 | Response, 11 | WebSocketClientMixin, 12 | ) 13 | from nonebot.drivers import Driver as BaseDriver 14 | from nonebot.internal.driver.model import ( 15 | CookieTypes, 16 | HeaderTypes, 17 | QueryTypes, 18 | ) 19 | from typing_extensions import override 20 | 21 | 22 | class Driver(BaseDriver, ASGIMixin, HTTPClientMixin, WebSocketClientMixin): 23 | @property 24 | @override 25 | def type(self) -> str: 26 | return "fake" 27 | 28 | @property 29 | @override 30 | def logger(self): 31 | return logger 32 | 33 | @override 34 | def run(self, *args, **kwargs): 35 | super().run(*args, **kwargs) 36 | 37 | @property 38 | @override 39 | def server_app(self): 40 | return None 41 | 42 | @property 43 | @override 44 | def asgi(self): 45 | raise NotImplementedError 46 | 47 | @override 48 | def setup_http_server(self, setup): 49 | raise NotImplementedError 50 | 51 | @override 52 | def setup_websocket_server(self, setup): 53 | raise NotImplementedError 54 | 55 | @override 56 | async def request(self, setup: Request) -> Response: 57 | raise NotImplementedError 58 | 59 | @override 60 | async def websocket(self, setup: Request) -> Response: 61 | raise NotImplementedError 62 | 63 | @override 64 | async def stream_request( 65 | self, 66 | setup: Request, 67 | *, 68 | chunk_size: int = 1024, 69 | ) -> AsyncGenerator[Response, None]: 70 | raise NotImplementedError 71 | 72 | @override 73 | def get_session( 74 | self, 75 | params: QueryTypes = None, 76 | headers: HeaderTypes = None, 77 | cookies: CookieTypes = None, 78 | version: Union[str, HTTPVersion] = HTTPVersion.H11, 79 | timeout: Optional[float] = None, 80 | proxy: Optional[str] = None, 81 | ) -> HTTPClientSession: 82 | raise NotImplementedError 83 | -------------------------------------------------------------------------------- /tests/plugins/github/config/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def mock_results(tmp_path: Path): 8 | from src.providers.utils import dump_json 9 | 10 | plugins_path = tmp_path / "plugins.json" 11 | results_path = tmp_path / "results.json" 12 | plugin_configs_path = tmp_path / "plugin_configs.json" 13 | 14 | plugins = [ 15 | { 16 | "module_name": "nonebot_plugin_treehelp", 17 | "project_link": "nonebot-plugin-treehelp", 18 | "name": "帮助", 19 | "desc": "获取插件帮助信息", 20 | "author": "he0119", 21 | "homepage": "https://github.com/he0119/nonebot-plugin-treehelp", 22 | "tags": [{"label": "test", "color": "#ffffff"}], 23 | "is_official": False, 24 | "type": "application", 25 | "supported_adapters": None, 26 | "valid": False, 27 | "time": "2022-01-01T00:00:00Z", 28 | "version": "0.0.1", 29 | "skip_test": False, 30 | } 31 | ] 32 | results = { 33 | "nonebot-plugin-treehelp:nonebot_plugin_treehelp": { 34 | "time": "2022-01-01T00:00:00.420957+08:00", 35 | "config": "", 36 | "version": "0.0.1", 37 | "test_env": {"python==3.12": False}, 38 | "results": {"validation": False, "load": True, "metadata": True}, 39 | "outputs": { 40 | "validation": None, 41 | "load": "test_output", 42 | "metadata": { 43 | "name": "帮助", 44 | "desc": "获取插件帮助信息", 45 | "homepage": "https://github.com/he0119/nonebot-plugin-treehelp", 46 | "type": "application", 47 | "supported_adapters": None, 48 | }, 49 | }, 50 | } 51 | } 52 | plugin_configs = {"nonebot-plugin-treehelp:nonebot_plugin_treehelp": ""} 53 | 54 | dump_json(plugins_path, plugins) 55 | dump_json(results_path, results) 56 | dump_json(plugin_configs_path, plugin_configs) 57 | 58 | return { 59 | "plugins": plugins_path, 60 | "results": results_path, 61 | "plugin_configs": plugin_configs_path, 62 | } 63 | -------------------------------------------------------------------------------- /tests/providers/store_test/test_step_summary.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from inline_snapshot import snapshot 4 | from pytest_mock import MockerFixture 5 | from respx import MockRouter 6 | 7 | 8 | async def test_step_summary( 9 | mocked_store_data: dict[str, Path], mocked_api: MockRouter, mocker: MockerFixture 10 | ) -> None: 11 | """验证插件信息""" 12 | from src.providers.models import StoreTestResult 13 | from src.providers.store_test.store import StoreTest 14 | 15 | store_test = { 16 | "NOT_AC": StoreTestResult( 17 | config="", 18 | outputs={ 19 | "validation": None, 20 | "load": """\ 21 | 创建测试目录 plugin_test 22 | For further information visit https://errors.pydantic.dev/2.9/v/model_type\x1b[0m\ 23 | """, 24 | "metadata": None, 25 | }, 26 | results={"validation": True, "load": False, "metadata": False}, 27 | test_env={"unknown": True}, 28 | version="0.3.9", 29 | ), 30 | "TREEHELP": StoreTestResult( 31 | config="", 32 | outputs={ 33 | "validation": None, 34 | "load": """\ 35 | 创建测试目录 plugin_test 36 | require("nonebot_plugin_alconna")\ 37 | """, 38 | "metadata": { 39 | "name": "TREEHELP", 40 | "desc": "订阅牛客/CF/AT平台的比赛信息", 41 | "usage": """\ 42 | /contest.list 获取所有/CF/牛客/AT平台的比赛信息 43 | /contest.subscribe 订阅CF/牛客/AT平台的比赛信息 44 | /contest.update 手动更新比赛信息 45 | """, 46 | "type": "application", 47 | "homepage": "https://nonebot.dev/", 48 | "supported_adapters": None, 49 | }, 50 | }, 51 | results={"validation": True, "load": True, "metadata": True}, 52 | test_env={"unknown": True}, 53 | version="0.2.0", 54 | ), 55 | } 56 | 57 | store = StoreTest() 58 | assert snapshot( 59 | """\ 60 | # 📃 商店测试结果 61 | 62 | > 📅 2023-08-23 09:22:14 CST 63 | > ♻️ 共测试 2 个插件 64 | > ✅ 更新成功:1 个 65 | > ❌ 更新失败:1 个 66 | 67 | ## 通过测试插件列表 68 | 69 | - TREEHELP 70 | 71 | ## 未通过测试插件列表 72 | 73 | - NOT_AC 74 | """ 75 | ) == store.generate_github_summary(results=store_test) 76 | -------------------------------------------------------------------------------- /tests/plugins/github/publish/utils/test_get_pull_requests_by_label.py: -------------------------------------------------------------------------------- 1 | from nonebug import App 2 | from pytest_mock import MockerFixture 3 | 4 | from tests.plugins.github.utils import get_github_bot 5 | 6 | 7 | async def test_get_pull_requests_by_label(app: App, mocker: MockerFixture) -> None: 8 | """测试获取指定标签的拉取请求""" 9 | from src.plugins.github.plugins.publish.depends import get_pull_requests_by_label 10 | from src.providers.models import RepoInfo 11 | from src.providers.validation.models import PublishType 12 | 13 | mock_label = mocker.MagicMock() 14 | mock_label.name = "Bot" 15 | 16 | mock_pull = mocker.MagicMock() 17 | mock_pull.labels = [mock_label] 18 | 19 | mock_pulls_resp = mocker.MagicMock() 20 | mock_pulls_resp.parsed_data = [mock_pull] 21 | 22 | async with app.test_api() as ctx: 23 | _adapter, bot = get_github_bot(ctx) 24 | 25 | ctx.should_call_api( 26 | "rest.pulls.async_list", 27 | {"owner": "owner", "repo": "repo", "state": "open"}, 28 | mock_pulls_resp, 29 | ) 30 | 31 | pulls = await get_pull_requests_by_label( 32 | bot, RepoInfo(owner="owner", repo="repo"), PublishType.BOT 33 | ) 34 | assert pulls[0] == mock_pull 35 | 36 | 37 | async def test_get_pull_requests_by_label_not_match( 38 | app: App, mocker: MockerFixture 39 | ) -> None: 40 | """测试获取指定标签的拉取请求,但是没有匹配的""" 41 | from src.plugins.github.plugins.publish.depends import get_pull_requests_by_label 42 | from src.providers.models import RepoInfo 43 | from src.providers.validation.models import PublishType 44 | 45 | mock_label = mocker.MagicMock() 46 | mock_label.name = "Some" 47 | 48 | mock_pull = mocker.MagicMock() 49 | mock_pull.labels = [mock_label] 50 | 51 | mock_pulls_resp = mocker.MagicMock() 52 | mock_pulls_resp.parsed_data = [mock_pull] 53 | 54 | async with app.test_api() as ctx: 55 | _adapter, bot = get_github_bot(ctx) 56 | 57 | ctx.should_call_api( 58 | "rest.pulls.async_list", 59 | {"owner": "owner", "repo": "repo", "state": "open"}, 60 | mock_pulls_resp, 61 | ) 62 | 63 | pulls = await get_pull_requests_by_label( 64 | bot, RepoInfo(owner="owner", repo="repo"), PublishType.BOT 65 | ) 66 | assert pulls == [] 67 | -------------------------------------------------------------------------------- /examples/noneflow.yml: -------------------------------------------------------------------------------- 1 | name: NoneFlow 2 | 3 | on: 4 | issues: 5 | types: [opened, reopened, edited] 6 | issue_comment: 7 | types: [created] 8 | pull_request_target: 9 | types: [closed] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.event.issue.number && format('publish/issue{0}', github.event.issue.number) || github.head_ref || github.run_id }} 15 | cancel-in-progress: ${{ startsWith(github.head_ref, 'publish/issue')}} 16 | 17 | jobs: 18 | noneflow: 19 | runs-on: ubuntu-latest 20 | name: noneflow 21 | # do not run on forked PRs, do not run on not related issues, do not run on pr comments 22 | if: | 23 | !( 24 | ( 25 | github.event.pull_request && 26 | ( 27 | github.event.pull_request.head.repo.fork || 28 | !( 29 | contains(github.event.pull_request.labels.*.name, 'Plugin') || 30 | contains(github.event.pull_request.labels.*.name, 'Adapter') || 31 | contains(github.event.pull_request.labels.*.name, 'Bot') 32 | ) 33 | ) 34 | ) || 35 | ( 36 | github.event_name == 'issue_comment' && github.event.issue.pull_request 37 | ) 38 | ) 39 | steps: 40 | - name: Generate token 41 | id: generate-token 42 | uses: tibdex/github-app-token@v2 43 | with: 44 | app_id: ${{ secrets.APP_ID }} 45 | private_key: ${{ secrets.APP_KEY }} 46 | 47 | - name: Checkout Code 48 | uses: actions/checkout@v4 49 | with: 50 | token: ${{ steps.generate-token.outputs.token }} 51 | 52 | - name: NoneFlow 53 | uses: docker://ghcr.io/nonebot/noneflow:latest 54 | with: 55 | config: > 56 | { 57 | "base": "master", 58 | "plugin_path": "assets/plugins.json5", 59 | "bot_path": "assets/bots.json5", 60 | "adapter_path": "assets/adapters.json5", 61 | "registry_repository": "nonebot/registry", 62 | "artifact_path": "artifact" 63 | } 64 | env: 65 | APP_ID: ${{ secrets.APP_ID }} 66 | PRIVATE_KEY: ${{ secrets.APP_KEY }} 67 | 68 | - name: Upload Artifact 69 | uses: actions/upload-artifact@v4 70 | with: 71 | name: noneflow 72 | path: artifact/* 73 | if-no-files-found: ignore 74 | -------------------------------------------------------------------------------- /tests/providers/validation/fields/test_homepage.py: -------------------------------------------------------------------------------- 1 | from inline_snapshot import snapshot 2 | from respx import MockRouter 3 | 4 | from tests.providers.validation.utils import generate_bot_data 5 | 6 | 7 | async def test_homepage_failed_http_exception(mocked_api: MockRouter) -> None: 8 | """测试验证失败的情况,HTTP 请求报错""" 9 | from src.providers.validation import PublishType, validate_info 10 | 11 | data = generate_bot_data(homepage="exception") 12 | 13 | result = validate_info(PublishType.BOT, data, []) 14 | 15 | assert not result.valid 16 | assert result.type == PublishType.BOT 17 | assert result.valid_data == snapshot( 18 | { 19 | "name": "name", 20 | "desc": "desc", 21 | "author": "author", 22 | "author_id": 1, 23 | "tags": [{"label": "test", "color": "#ffffff"}], 24 | } 25 | ) 26 | assert result.info is None 27 | assert result.errors == snapshot( 28 | [ 29 | { 30 | "type": "homepage", 31 | "loc": ("homepage",), 32 | "msg": "项目主页无法访问", 33 | "input": "exception", 34 | "ctx": {"status_code": -1, "msg": "Mock Error"}, 35 | } 36 | ] 37 | ) 38 | 39 | assert mocked_api["exception"].called 40 | 41 | 42 | async def test_homepage_failed_empty_homepage(mocked_api: MockRouter) -> None: 43 | """主页为空字符串的情况""" 44 | from src.providers.validation import PublishType, validate_info 45 | 46 | data = generate_bot_data(homepage="") 47 | 48 | result = validate_info(PublishType.BOT, data, []) 49 | 50 | assert not result.valid 51 | assert result.type == PublishType.BOT 52 | assert result.valid_data == snapshot( 53 | { 54 | "name": "name", 55 | "desc": "desc", 56 | "author": "author", 57 | "author_id": 1, 58 | "tags": [{"label": "test", "color": "#ffffff"}], 59 | } 60 | ) 61 | assert result.info is None 62 | assert result.errors == snapshot( 63 | [ 64 | { 65 | "type": "string_pattern_mismatch", 66 | "loc": ("homepage",), 67 | "msg": "字符串应满足格式 '^(https?://.*|/docs/.*)$'", 68 | "input": "", 69 | "ctx": {"pattern": "^(https?://.*|/docs/.*)$"}, 70 | "url": "https://errors.pydantic.dev/2.12/v/string_pattern_mismatch", 71 | } 72 | ] 73 | ) 74 | 75 | assert not mocked_api["homepage"].called 76 | assert not mocked_api["homepage_failed"].called 77 | -------------------------------------------------------------------------------- /src/plugins/github/handlers/git.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from nonebot import logger 4 | from pydantic import BaseModel 5 | 6 | from src.plugins.github.utils import run_shell_command 7 | 8 | 9 | class GitHandler(BaseModel): 10 | """Git 操作""" 11 | 12 | def checkout_branch(self, branch_name: str, update: bool = False): 13 | """检出分支""" 14 | 15 | run_shell_command(["git", "checkout", branch_name]) 16 | if update: 17 | run_shell_command(["git", "pull"]) 18 | 19 | def checkout_remote_branch(self, branch_name: str): 20 | """检出远程分支""" 21 | 22 | run_shell_command(["git", "fetch", "origin", branch_name]) 23 | run_shell_command(["git", "checkout", branch_name]) 24 | 25 | def add_file(self, file_path: str | Path): 26 | """添加文件到暂存区""" 27 | if isinstance(file_path, Path): 28 | file_path = str(file_path) 29 | 30 | run_shell_command(["git", "add", file_path]) 31 | 32 | def add_all_files(self): 33 | """添加所有文件到暂存区""" 34 | 35 | run_shell_command(["git", "add", "-A"]) 36 | 37 | def commit_and_push(self, message: str, branch_name: str, author: str): 38 | """提交并推送""" 39 | 40 | # 设置用户信息,假装是作者提交的 41 | run_shell_command(["git", "config", "--global", "user.name", author]) 42 | user_email = f"{author}@users.noreply.github.com" 43 | run_shell_command(["git", "config", "--global", "user.email", user_email]) 44 | 45 | run_shell_command(["git", "commit", "-m", message]) 46 | try: 47 | run_shell_command(["git", "fetch", "origin"]) 48 | r = run_shell_command(["git", "diff", f"origin/{branch_name}", branch_name]) 49 | if r.stdout: 50 | raise Exception 51 | else: 52 | logger.info("检测到本地分支与远程分支一致,跳过推送") 53 | except Exception: 54 | logger.info("检测到本地分支与远程分支不一致,尝试强制推送") 55 | run_shell_command(["git", "push", "origin", branch_name, "-f"]) 56 | 57 | def remote_branch_exists(self, branch_name: str) -> bool: 58 | """检查远程分支是否存在""" 59 | result = run_shell_command( 60 | ["git", "ls-remote", "--heads", "origin", branch_name] 61 | ) 62 | return bool(result.stdout.decode().strip()) 63 | 64 | def delete_remote_branch(self, branch_name: str): 65 | """删除远程分支""" 66 | 67 | run_shell_command(["git", "push", "origin", "--delete", branch_name]) 68 | 69 | def switch_branch(self, branch_name: str): 70 | """切换分支""" 71 | 72 | run_shell_command(["git", "switch", "-C", branch_name]) 73 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | dependencyDashboard: true, 4 | extends: [ 5 | "config:recommended", 6 | "schedule:weekly", 7 | ":semanticCommitTypeAll(chore)", 8 | ], 9 | labels: [ 10 | "dependencies" 11 | ], 12 | rangeStrategy: "bump", 13 | "pre-commit": { 14 | enabled: true, 15 | }, 16 | packageRules: [ 17 | { 18 | groupName: "Python dependencies", 19 | // https://docs.renovatebot.com/modules/manager/pep621/ 20 | matchManagers: [ 21 | "pep621" 22 | ], 23 | matchDepTypes: [ 24 | "project.dependencies" 25 | ], 26 | matchUpdateTypes: [ 27 | "minor", 28 | "patch" 29 | ], 30 | description: "Weekly update of Python dependencies", 31 | }, 32 | { 33 | groupName: "Python dev-dependencies", 34 | // https://docs.renovatebot.com/modules/manager/pep621/ 35 | matchManagers: [ 36 | "pep621" 37 | ], 38 | matchDepTypes: [ 39 | "tool.uv.dev-dependencies" 40 | ], 41 | matchUpdateTypes: [ 42 | "minor", 43 | "patch" 44 | ], 45 | description: "Weekly update of Python dev-dependencies", 46 | }, 47 | { 48 | groupName: "GitHub actions dependencies", 49 | // https://docs.renovatebot.com/modules/manager/github-actions/ 50 | matchManagers: [ 51 | "github-actions" 52 | ], 53 | description: "Weekly update of GitHub actions dependencies", 54 | }, 55 | { 56 | groupName: "Docker dependencies", 57 | // https://docs.renovatebot.com/modules/manager/dockerfile/ 58 | matchManagers: [ 59 | "dockerfile" 60 | ], 61 | description: "Weekly update of Docker dependencies", 62 | }, 63 | { 64 | // 更新 Python 版本时,需要同时更新这几个文件 65 | groupName: "Python version", 66 | matchPackageNames: [ 67 | "python" 68 | ], 69 | // https://docs.renovatebot.com/modules/manager/dockerfile/ 70 | // https://docs.renovatebot.com/modules/manager/pyenv/ 71 | // https://docs.renovatebot.com/modules/manager/pep621/ 72 | matchManagers: [ 73 | "dockerfile", 74 | "pyenv", 75 | "pep621" 76 | ], 77 | description: "Weekly update of Python version", 78 | }, 79 | { 80 | groupName: "pre-commit dependencies", 81 | // https://docs.renovatebot.com/modules/manager/pre-commit/ 82 | matchManagers: [ 83 | "pre-commit" 84 | ], 85 | description: "Weekly update of pre-commit dependencies", 86 | }, 87 | ], 88 | } 89 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/publish/templates/render_error.md.jinja: -------------------------------------------------------------------------------- 1 | {%- macro render_tag_error(index, message, dt_message) %} 2 | 第 {{ index + 1 }} 个标签{{ message }}。
    {{ dt_message }}
    3 | {%- endmacro %} 4 | 5 | {%- macro detail_message(summary, detail) %} 6 |
    {{ summary }}{{ detail }}
    7 | {%- endmacro %} 8 | 9 | {%- macro render_error(error) %} 10 | {%- set loc = error.loc %} 11 | {%- set type = error.type %} 12 | {%- set ctx = error.ctx %} 13 | {%- set name = loc|loc_to_name %} 14 | {%- set input = error.input %} 15 | {%- if loc|length == 3 and loc[0] == "tags" %} 16 | {%- if type == "missing" %} 17 | {{ render_tag_error(loc[1], "缺少第" ~ loc[2] ~ " 字段", "请确保标签字段完整。") }} 18 | {%- elif type == "string_too_long" %} 19 | {{ render_tag_error(loc[1], "名称过长", "请确保标签名称不超过 10 个字符。") }} 20 | {%- elif type == "color_error" %} 21 | {{ render_tag_error(loc[1], "颜色错误", "请确保标签颜色符合十六进制颜色码规则。") }} 22 | {%- endif %} 23 | {%- elif loc|length == 2 and loc[0] == "tags" and type == "model_type" %} 24 | {{ render_tag_error(loc[1], "格式错误", "请确保标签为字典。") }} 25 | {%- elif type == "homepage" %} 26 | {%- if ctx.status_code != -1 %} 27 | 项目 主页 返回状态码 {{ ctx.status_code }}。
    请确保你的项目主页可访问。
    28 | {%- else %} 29 | 项目 主页 访问出错。{{ detail_message("错误信息", ctx.error) }} 30 | {%- endif %} 31 | {%- elif type == "project_link.not_found" %} 32 | 项目 {{ input }} 未发布至 PyPI。
    请将你的项目发布至 PyPI。
    33 | {%- elif type == "project_link.name" %} 34 | PyPI 项目名 {{ input }} 不符合规范。
    请确保项目名正确。
    35 | {%- elif type == "module_name" %} 36 | 包名 {{ input }} 不符合规范。
    请确保包名正确。
    37 | {%- elif type == "duplication" %} 38 | {{ error.msg }}
    请确保没有重复发布。
    39 | {%- elif type == "plugin.test" %} 40 | 插件加载测试未通过。{{ detail_message("测试输出", ctx.output) }} 41 | {%- elif type == "plugin.metadata" %} 42 | 无法获取到插件元数据。
    {{ "请填写插件元数据" if ctx.load else "请确保插件正常加载" }}。
    43 | {%- elif type == "plugin.type" %} 44 | 插件类型 {{ input }} 不符合规范。
    请确保插件类型正确,当前仅支持 application 与 library。
    45 | {%- elif type == "supported_adapters.missing" %} 46 | 适配器 {{ ', '.join(ctx.missing_adapters) }} 不存在。
    请确保适配器模块名称正确。
    47 | {%- elif type == "missing" %} 48 | {{ name }}: 无法匹配到数据。
    请确保填写该数据项。
    49 | {%- elif type == "model_type" %} 50 | {{ name }}: 格式错误。
    请确保其为字典。
    51 | {%- elif type == "list_type" %} 52 | {{ name }}: 格式错误。
    请确保其为列表。
    53 | {%- elif type == "set_type" %} 54 | {{ name }}: 格式错误。
    请确保其为集合。
    55 | {%- elif type == "json_type" %} 56 | {{ name }}: 解码失败。
    请确保其为 JSON 格式。
    57 | {%- elif type == "string_too_long" %} 58 | {{ name }}: 字符过多。
    请确保其不超过 {{ ctx.max_length }} 个字符。
    59 | {%- else %} 60 | {{ name }}: {{ error.msg }} 61 | {%- endif %} 62 | {%- endmacro %} 63 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/publish/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from src.plugins.github.constants import ISSUE_PATTERN 4 | 5 | COMMIT_MESSAGE_PREFIX = ":beers: publish" 6 | 7 | BRANCH_NAME_PREFIX = "publish/issue" 8 | 9 | # 基本信息 10 | PROJECT_LINK_PATTERN = re.compile(ISSUE_PATTERN.format("PyPI 项目名")) 11 | TAGS_PATTERN = re.compile(ISSUE_PATTERN.format("标签")) 12 | # 机器人 13 | BOT_NAME_PATTERN = re.compile(ISSUE_PATTERN.format("机器人名称")) 14 | BOT_DESC_PATTERN = re.compile(ISSUE_PATTERN.format("机器人描述")) 15 | BOT_HOMEPAGE_PATTERN = re.compile(ISSUE_PATTERN.format("机器人项目仓库/主页链接")) 16 | # 插件 17 | PLUGIN_MODULE_NAME_PATTERN = re.compile(ISSUE_PATTERN.format("插件模块名")) 18 | PLUGIN_MODULE_IMPORT_PATTERN = re.compile(ISSUE_PATTERN.format("插件 import 包名")) 19 | PLUGIN_NAME_STRING = "插件名称" 20 | 21 | PLUGIN_NAME_PATTERN = re.compile(ISSUE_PATTERN.format(PLUGIN_NAME_STRING)) 22 | PLUGIN_DESC_STRING = "插件描述" 23 | PLUGIN_DESC_PATTERN = re.compile(ISSUE_PATTERN.format(PLUGIN_DESC_STRING)) 24 | PLUGIN_HOMEPAGE_STRING = "插件项目仓库/主页链接" 25 | PLUGIN_HOMEPAGE_PATTERN = re.compile(ISSUE_PATTERN.format(PLUGIN_HOMEPAGE_STRING)) 26 | PLUGIN_TYPE_STRING = "插件类型" 27 | PLUGIN_TYPE_PATTERN = re.compile(ISSUE_PATTERN.format(PLUGIN_TYPE_STRING)) 28 | PLUGIN_CONFIG_PATTERN = re.compile(r"### 插件配置项\s+```(?:\w+)?\s?([\s\S]*?)```") 29 | PLUGIN_TEST_STRING = "插件测试" 30 | PLUGIN_TEST_PATTERN = re.compile(ISSUE_PATTERN.format(PLUGIN_TEST_STRING)) 31 | PLUGIN_TEST_BUTTON_TIPS = "如需重新运行插件测试,请勾选左侧勾选框" 32 | PLUGIN_TEST_BUTTON_STRING = f"- [ ] {PLUGIN_TEST_BUTTON_TIPS}" 33 | PLUGIN_TEST_BUTTON_IN_PROGRESS_STRING = "- [x] 🔥插件测试中,请稍候" 34 | PLUGIN_SUPPORTED_ADAPTERS_STRING = "插件支持的适配器" 35 | PLUGIN_SUPPORTED_ADAPTERS_PATTERN = re.compile( 36 | ISSUE_PATTERN.format(PLUGIN_SUPPORTED_ADAPTERS_STRING) 37 | ) 38 | PLUGIN_STRING_LIST = [ 39 | PLUGIN_NAME_STRING, 40 | PLUGIN_DESC_STRING, 41 | PLUGIN_HOMEPAGE_STRING, 42 | PLUGIN_TYPE_STRING, 43 | PLUGIN_SUPPORTED_ADAPTERS_STRING, 44 | ] 45 | # 协议 46 | ADAPTER_NAME_PATTERN = re.compile(ISSUE_PATTERN.format("适配器名称")) 47 | ADAPTER_DESC_PATTERN = re.compile(ISSUE_PATTERN.format("适配器描述")) 48 | ADAPTER_MODULE_NAME_PATTERN = re.compile(ISSUE_PATTERN.format("适配器 import 包名")) 49 | ADAPTER_HOMEPAGE_PATTERN = re.compile(ISSUE_PATTERN.format("适配器项目仓库/主页链接")) 50 | 51 | 52 | WORKFLOW_HISTORY_PATTERN = re.compile( 53 | r'
  • (⚠️|✅)\s*([^<]+?CST)
  • ' 54 | ) 55 | 56 | WORKFLOW_HISTORY_TEMPLATE = """
  • {status} {time}
  • """ 57 | 58 | # 评论卡片模板 59 | COMMENT_CARD_TEMPLATE = """[![{name}](https://img.shields.io/badge/{head}-{content}-{color}?style=for-the-badge)]({url})""" 60 | 61 | # 发布信息项对应的中文名 62 | LOC_NAME_MAP = { 63 | "name": "名称", 64 | "desc": "描述", 65 | "project_link": "PyPI 项目名", 66 | "module_name": "import 包名", 67 | "tags": "标签", 68 | "homepage": "项目仓库/主页链接", 69 | "type": "插件类型", 70 | "supported_adapters": "插件支持的适配器", 71 | "metadata": "插件测试元数据", 72 | "load": "插件是否成功加载", 73 | "version": "版本号", 74 | "time": "发布时间", 75 | } 76 | 77 | # 工作流程构件名称 78 | ARTIFACT_NAME = "noneflow" 79 | -------------------------------------------------------------------------------- /tests/plugins/github/publish/utils/test_get_type.py: -------------------------------------------------------------------------------- 1 | from pytest_mock import MockerFixture 2 | 3 | 4 | async def test_get_type_by_labels(mocker: MockerFixture): 5 | """通过标签获取发布类型""" 6 | from src.plugins.github.depends.utils import get_type_by_labels 7 | from src.providers.validation.models import PublishType 8 | 9 | mock_label = mocker.MagicMock() 10 | mock_label.name = "Bot" 11 | 12 | publish_type = get_type_by_labels([mock_label]) 13 | 14 | assert publish_type == PublishType.BOT 15 | 16 | mock_label.name = "Plugin" 17 | 18 | publish_type = get_type_by_labels([mock_label]) 19 | 20 | assert publish_type == PublishType.PLUGIN 21 | 22 | mock_label.name = "Adapter" 23 | 24 | publish_type = get_type_by_labels([mock_label]) 25 | 26 | assert publish_type == PublishType.ADAPTER 27 | 28 | 29 | async def test_get_type_by_labels_wrong(mocker: MockerFixture): 30 | from src.plugins.github.depends.utils import get_type_by_labels 31 | 32 | mock_label = mocker.MagicMock() 33 | mock_label.name = "Something" 34 | 35 | publish_type = get_type_by_labels([mock_label, "Something"]) 36 | 37 | assert publish_type is None 38 | 39 | 40 | async def test_get_type_by_title(): 41 | """通过标题获取发布类型""" 42 | from src.plugins.github.plugins.publish.utils import get_type_by_title 43 | from src.providers.validation.models import PublishType 44 | 45 | title = "Bot: test" 46 | publish_type = get_type_by_title(title) 47 | 48 | assert publish_type == PublishType.BOT 49 | 50 | title = "Adapter: test" 51 | publish_type = get_type_by_title(title) 52 | 53 | assert publish_type == PublishType.ADAPTER 54 | 55 | title = "Plugin: test" 56 | publish_type = get_type_by_title(title) 57 | 58 | assert publish_type == PublishType.PLUGIN 59 | 60 | 61 | async def test_get_type_by_title_wrong(): 62 | from src.plugins.github.plugins.publish.utils import get_type_by_title 63 | 64 | title = "Something: test" 65 | publish_type = get_type_by_title(title) 66 | 67 | assert publish_type is None 68 | 69 | 70 | async def test_get_type_by_commit_message(): 71 | """通过提交信息获取发布类型""" 72 | from src.plugins.github.plugins.publish.utils import get_type_by_commit_message 73 | from src.providers.validation.models import PublishType 74 | 75 | message = ":beers: publish bot test" 76 | 77 | publish_type = get_type_by_commit_message(message) 78 | 79 | assert publish_type == PublishType.BOT 80 | 81 | message = ":beers: publish adapter test" 82 | 83 | publish_type = get_type_by_commit_message(message) 84 | 85 | assert publish_type == PublishType.ADAPTER 86 | 87 | message = ":beers: publish plugin test" 88 | 89 | publish_type = get_type_by_commit_message(message) 90 | 91 | assert publish_type == PublishType.PLUGIN 92 | 93 | 94 | async def test_get_type_by_commit_message_wrong(): 95 | from src.plugins.github.plugins.publish.utils import get_type_by_commit_message 96 | 97 | message = "Something: publish bot test" 98 | 99 | publish_type = get_type_by_commit_message(message) 100 | 101 | assert publish_type is None 102 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from contextlib import contextmanager 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING, cast 6 | 7 | import nonebot 8 | from nonebot import logger 9 | from nonebot.adapters.github import Adapter as GITHUBAdapter 10 | from nonebot.adapters.github import Event 11 | from nonebot.message import handle_event 12 | 13 | if TYPE_CHECKING: 14 | from nonebot.drivers.none import Driver 15 | 16 | 17 | @contextmanager 18 | def ensure_cwd(cwd: Path): 19 | current_cwd = Path.cwd() 20 | try: 21 | os.chdir(cwd) 22 | yield 23 | finally: 24 | os.chdir(current_cwd) 25 | 26 | 27 | async def handle_github_action_event(): 28 | """处理 GitHub Action 事件""" 29 | driver = cast("Driver", nonebot.get_driver()) 30 | try: 31 | config = driver.config 32 | # 从环境变量中获取事件信息 33 | # 读取到的 gitub_run_id 会因为 nonebot 配置加载机制转成 int,需要转回 str 34 | event_id = str(config.github_run_id) 35 | event_name = config.github_event_name 36 | github_event_path = Path(config.github_event_path) 37 | # 生成事件 38 | if event := Adapter.payload_to_event( 39 | event_id, event_name, github_event_path.read_text(encoding="utf-8") 40 | ): 41 | bot = nonebot.get_bot() 42 | await handle_event(bot, event) 43 | except Exception: 44 | logger.exception("处理 GitHub Action 事件时出现异常") 45 | 46 | 47 | handle_event_task = None 48 | 49 | 50 | class Adapter(GITHUBAdapter): 51 | def _setup(self): 52 | self.driver.on_startup(self._startup) 53 | 54 | async def _startup(self): 55 | driver = cast("Driver", self.driver) 56 | try: 57 | await super()._startup() 58 | except Exception: 59 | logger.exception("启动 GitHub 适配器时出现异常") 60 | driver.exit(True) 61 | return 62 | 63 | # 完成启动后创建任务处理 GitHub Action 事件 64 | handle_event_task = asyncio.create_task(handle_github_action_event()) 65 | # 处理完成之后就退出 66 | handle_event_task.add_done_callback(lambda _: driver.exit(True)) 67 | 68 | @classmethod 69 | def payload_to_event( 70 | cls, event_id: str, event_name: str, payload: str | bytes 71 | ) -> Event | None: 72 | # webhook 事件中没有 pull_request_target,但是 actions 里有 73 | # githubkit.exception.WebhookTypeNotFound: pull_request_target 74 | if event_name == "pull_request_target": 75 | event_name = "pull_request" 76 | 77 | return super().payload_to_event(event_id, event_name, payload) 78 | 79 | 80 | with ensure_cwd(Path(__file__).parent): 81 | app_id = os.environ.get("APP_ID") 82 | private_key = os.environ.get("PRIVATE_KEY") 83 | # https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context 84 | # 如果设置时,值总是为 "1" 85 | runner_debug = os.environ.get("RUNNER_DEBUG", "0") 86 | 87 | nonebot.init( 88 | driver="~none", 89 | github_apps=[{"app_id": app_id, "private_key": private_key}], 90 | log_level="DEBUG" if runner_debug == "1" else "INFO", 91 | ) 92 | 93 | driver = nonebot.get_driver() 94 | driver.register_adapter(Adapter) 95 | 96 | nonebot.load_plugins("src/plugins") 97 | 98 | 99 | if __name__ == "__main__": 100 | nonebot.run() 101 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/resolve/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import logger, on_type 2 | from nonebot.adapters.github import GitHubBot, PullRequestClosed 3 | from nonebot.params import Depends 4 | 5 | from src.plugins.github.constants import PUBLISH_LABEL, REMOVE_LABEL 6 | from src.plugins.github.depends import ( 7 | get_installation_id, 8 | get_related_issue_handler, 9 | get_related_issue_number, 10 | get_type_by_labels_name, 11 | setup_git, 12 | ) 13 | from src.plugins.github.handlers import GithubHandler, IssueHandler 14 | from src.plugins.github.plugins.publish.utils import ( 15 | resolve_conflict_pull_requests as resolve_conflict_publish_pull_requests, 16 | ) 17 | from src.plugins.github.plugins.remove.utils import ( 18 | resolve_conflict_pull_requests as resolve_conflict_remove_pull_requests, 19 | ) 20 | from src.plugins.github.typing import PullRequestLabels, PullRequestList 21 | from src.providers.validation.models import PublishType 22 | 23 | 24 | def is_publish(labels: PullRequestLabels) -> bool: 25 | return any(label.name == PUBLISH_LABEL for label in labels) 26 | 27 | 28 | def is_remove(labels: PullRequestLabels) -> bool: 29 | return any(label.name == REMOVE_LABEL for label in labels) 30 | 31 | 32 | async def resolve_conflict_pull_requests( 33 | handler: GithubHandler, pull_requests: PullRequestList 34 | ): 35 | for pull_request in pull_requests: 36 | if is_remove(pull_request.labels): 37 | await resolve_conflict_remove_pull_requests(handler, [pull_request]) 38 | elif is_publish(pull_request.labels): 39 | await resolve_conflict_publish_pull_requests(handler, [pull_request]) 40 | 41 | 42 | async def pr_close_rule( 43 | publish_type: PublishType | None = Depends(get_type_by_labels_name), 44 | related_issue_number: int | None = Depends(get_related_issue_number), 45 | ) -> bool: 46 | if publish_type is None: 47 | logger.info("拉取请求与流程无关,已跳过") 48 | return False 49 | 50 | if not related_issue_number: 51 | logger.error("无法获取相关的议题编号") 52 | return False 53 | 54 | return True 55 | 56 | 57 | pr_close_matcher = on_type(PullRequestClosed, rule=pr_close_rule, priority=10) 58 | 59 | 60 | @pr_close_matcher.handle(parameterless=[Depends(setup_git)]) 61 | async def handle_pr_close( 62 | event: PullRequestClosed, 63 | bot: GitHubBot, 64 | installation_id: int = Depends(get_installation_id), 65 | publish_type: PublishType = Depends(get_type_by_labels_name), 66 | handler: IssueHandler = Depends(get_related_issue_handler), 67 | ) -> None: 68 | async with bot.as_installation(installation_id): 69 | reason = "completed" if event.payload.pull_request.merged else "not_planned" 70 | if handler.issue.state == "open" or handler.issue.state_reason != reason: 71 | await handler.close_issue(reason) 72 | logger.info(f"议题 #{handler.issue.number} 已关闭") 73 | 74 | try: 75 | handler.delete_remote_branch(event.payload.pull_request.head.ref) 76 | logger.info("已删除对应分支") 77 | except Exception: 78 | logger.info("对应分支不存在或已删除") 79 | 80 | if not event.payload.pull_request.merged: 81 | logger.info("发布的拉取请求未合并,已跳过") 82 | await pr_close_matcher.finish() 83 | 84 | pull_requests = await handler.get_pull_requests_by_label(publish_type.value) 85 | 86 | await resolve_conflict_pull_requests(handler, pull_requests) 87 | -------------------------------------------------------------------------------- /tests/plugins/github/publish/utils/test_ensure_issue_content.py: -------------------------------------------------------------------------------- 1 | from inline_snapshot import snapshot 2 | from nonebug import App 3 | from pytest_mock import MockerFixture 4 | 5 | from tests.plugins.github.utils import ( 6 | MockIssue, 7 | get_github_bot, 8 | ) 9 | 10 | 11 | async def test_ensure_issue_content(app: App, mocker: MockerFixture): 12 | """确保议题内容完整""" 13 | from src.plugins.github.handlers import IssueHandler 14 | from src.plugins.github.plugins.publish.utils import ensure_issue_content 15 | from src.providers.models import RepoInfo 16 | 17 | async with app.test_api() as ctx: 18 | _adapter, bot = get_github_bot(ctx) 19 | issue = MockIssue(body="什么都没有", number=1) 20 | handler = IssueHandler( 21 | bot=bot, 22 | repo_info=RepoInfo(owner="owner", repo="repo"), 23 | issue=issue.as_mock(mocker), 24 | ) 25 | 26 | ctx.should_call_api( 27 | "rest.issues.async_update", 28 | { 29 | "owner": "owner", 30 | "repo": "repo", 31 | "issue_number": 1, 32 | "body": snapshot( 33 | """\ 34 | ### 插件名称 35 | 36 | ### 插件描述 37 | 38 | ### 插件项目仓库/主页链接 39 | 40 | ### 插件类型 41 | 42 | ### 插件支持的适配器 43 | 44 | 什么都没有\ 45 | """ 46 | ), 47 | }, 48 | True, 49 | ) 50 | 51 | await ensure_issue_content(handler) 52 | 53 | 54 | async def test_ensure_issue_content_partial(app: App, mocker: MockerFixture): 55 | """确保议题内容被补全""" 56 | from src.plugins.github.handlers import IssueHandler 57 | from src.plugins.github.plugins.publish.utils import ensure_issue_content 58 | from src.providers.models import RepoInfo 59 | 60 | async with app.test_api() as ctx: 61 | _adapter, bot = get_github_bot(ctx) 62 | 63 | issue = MockIssue(body="### 插件名称\n\nname\n\n### 插件类型\n", number=1) 64 | handler = IssueHandler( 65 | bot=bot, 66 | repo_info=RepoInfo(owner="owner", repo="repo"), 67 | issue=issue.as_mock(mocker), 68 | ) 69 | 70 | ctx.should_call_api( 71 | "rest.issues.async_update", 72 | { 73 | "owner": "owner", 74 | "repo": "repo", 75 | "issue_number": 1, 76 | "body": "### 插件描述\n\n### 插件项目仓库/主页链接\n\n### 插件支持的适配器\n\n### 插件名称\n\nname\n\n### 插件类型\n", 77 | }, 78 | True, 79 | ) 80 | 81 | await ensure_issue_content(handler) 82 | 83 | 84 | async def test_ensure_issue_content_complete(app: App, mocker: MockerFixture): 85 | """确保议题内容已经补全之后不会再次补全""" 86 | from src.plugins.github.handlers import IssueHandler 87 | from src.plugins.github.plugins.publish.utils import ensure_issue_content 88 | from src.providers.models import RepoInfo 89 | 90 | async with app.test_api() as ctx: 91 | _adapter, bot = get_github_bot(ctx) 92 | issue = MockIssue( 93 | body="### 插件描述\n\n### 插件项目仓库/主页链接\n\n### 插件支持的适配器\n\n### 插件名称\n\nname\n\n### 插件类型\n", 94 | number=1, 95 | ) 96 | handler = IssueHandler( 97 | bot=bot, 98 | repo_info=RepoInfo(owner="owner", repo="repo"), 99 | issue=issue.as_mock(mocker), 100 | ) 101 | 102 | await ensure_issue_content(handler) 103 | -------------------------------------------------------------------------------- /tests/providers/validation/test_driver.py: -------------------------------------------------------------------------------- 1 | from inline_snapshot import snapshot 2 | from respx import MockRouter 3 | 4 | from tests.providers.validation.utils import generate_driver_data 5 | 6 | 7 | async def test_driver_info_validation_success(mocked_api: MockRouter) -> None: 8 | """测试验证成功的情况""" 9 | from src.providers.validation import DriverPublishInfo, PublishType, validate_info 10 | 11 | data = generate_driver_data() 12 | 13 | result = validate_info(PublishType.DRIVER, data, []) 14 | 15 | assert result.valid 16 | assert result.type == PublishType.DRIVER 17 | assert result.raw_data == snapshot( 18 | { 19 | "name": "name", 20 | "desc": "desc", 21 | "author": "author", 22 | "module_name": "module_name", 23 | "project_link": "project_link", 24 | "homepage": "https://nonebot.dev", 25 | "tags": '[{"label": "test", "color": "#ffffff"}]', 26 | "author_id": 1, 27 | "time": "2023-09-01T00:00:00.000000Z", 28 | "version": "0.0.1", 29 | } 30 | ) 31 | 32 | assert isinstance(result.info, DriverPublishInfo) 33 | assert result.errors == [] 34 | 35 | assert mocked_api["homepage"].called 36 | assert mocked_api["pypi_project_link"].called 37 | 38 | 39 | async def test_driver_info_validation_none(mocked_api: MockRouter) -> None: 40 | """内置驱动器 none 的情况""" 41 | from src.providers.validation import DriverPublishInfo, PublishType, validate_info 42 | 43 | data = generate_driver_data(project_link="", module_name="~none") 44 | 45 | result = validate_info(PublishType.DRIVER, data, []) 46 | 47 | assert result.valid 48 | assert result.type == PublishType.DRIVER 49 | assert result.raw_data == snapshot( 50 | { 51 | "name": "name", 52 | "desc": "desc", 53 | "author": "author", 54 | "module_name": "~none", 55 | "project_link": "", 56 | "homepage": "https://nonebot.dev", 57 | "tags": '[{"label": "test", "color": "#ffffff"}]', 58 | "author_id": 1, 59 | "time": "2024-10-31T13:47:14.152851Z", 60 | "version": "2.4.0", 61 | } 62 | ) 63 | 64 | assert isinstance(result.info, DriverPublishInfo) 65 | assert result.errors == [] 66 | 67 | assert mocked_api["homepage"].called 68 | assert mocked_api["pypi_nonebot2"].called 69 | 70 | 71 | async def test_driver_info_validation_fastapi(mocked_api: MockRouter) -> None: 72 | """内置驱动器 fastapi 的情况""" 73 | from src.providers.validation import DriverPublishInfo, PublishType, validate_info 74 | 75 | data = generate_driver_data( 76 | project_link="nonebot2[fastapi]", module_name="~fastapi" 77 | ) 78 | 79 | result = validate_info(PublishType.DRIVER, data, []) 80 | 81 | assert result.valid 82 | assert result.type == PublishType.DRIVER 83 | assert result.raw_data == snapshot( 84 | { 85 | "name": "name", 86 | "desc": "desc", 87 | "author": "author", 88 | "module_name": "~fastapi", 89 | "project_link": "nonebot2[fastapi]", 90 | "homepage": "https://nonebot.dev", 91 | "tags": '[{"label": "test", "color": "#ffffff"}]', 92 | "author_id": 1, 93 | "time": "2024-10-31T13:47:14.152851Z", 94 | "version": "2.4.0", 95 | } 96 | ) 97 | 98 | assert isinstance(result.info, DriverPublishInfo) 99 | assert result.errors == [] 100 | 101 | assert mocked_api["homepage"].called 102 | assert mocked_api["pypi_nonebot2"].called 103 | -------------------------------------------------------------------------------- /tests/providers/validation/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | 5 | def exclude_none(data: dict[str, Any]) -> dict[str, Any]: 6 | """去除字典中的 None 值 7 | 8 | supported_adapters 字段除外 9 | """ 10 | return {k: v for k, v in data.items() if v is not None or k == "supported_adapters"} 11 | 12 | 13 | def generate_adapter_data( 14 | name: str | None = "name", 15 | desc: str | None = "desc", 16 | author: str | None = "author", 17 | module_name: str | None = "module_name", 18 | project_link: str | None = "project_link", 19 | homepage: str | None = "https://nonebot.dev", 20 | tags: list | None = [{"label": "test", "color": "#ffffff"}], 21 | author_id: int | None = 1, 22 | ): 23 | return exclude_none( 24 | { 25 | "name": name, 26 | "desc": desc, 27 | "author": author, 28 | "module_name": module_name, 29 | "project_link": project_link, 30 | "homepage": homepage, 31 | "tags": json.dumps(tags), 32 | "author_id": author_id, 33 | } 34 | ) 35 | 36 | 37 | def generate_bot_data( 38 | name: str | None = "name", 39 | desc: str | None = "desc", 40 | author: str | None = "author", 41 | homepage: str | None = "https://nonebot.dev", 42 | tags: list | str | None = [{"label": "test", "color": "#ffffff"}], 43 | author_id: int | None = 1, 44 | ): 45 | if isinstance(tags, list): 46 | tags = json.dumps(tags) 47 | return exclude_none( 48 | { 49 | "name": name, 50 | "desc": desc, 51 | "author": author, 52 | "homepage": homepage, 53 | "tags": tags, 54 | "author_id": author_id, 55 | } 56 | ) 57 | 58 | 59 | def generate_driver_data( 60 | module_name: str | None = "module_name", 61 | project_link: str | None = "project_link", 62 | name: str | None = "name", 63 | desc: str | None = "desc", 64 | author: str | None = "author", 65 | homepage: str | None = "https://nonebot.dev", 66 | tags: list | None = [{"label": "test", "color": "#ffffff"}], 67 | author_id: int | None = 1, 68 | ): 69 | return exclude_none( 70 | { 71 | "module_name": module_name, 72 | "project_link": project_link, 73 | "name": name, 74 | "desc": desc, 75 | "author": author, 76 | "homepage": homepage, 77 | "tags": json.dumps(tags), 78 | "author_id": author_id, 79 | } 80 | ) 81 | 82 | 83 | def generate_plugin_data( 84 | author: str | None = "author", 85 | module_name: str | None = "module_name", 86 | project_link: str | None = "project_link", 87 | tags: list | None = [{"label": "test", "color": "#ffffff"}], 88 | name: str | None = "name", 89 | desc: str | None = "desc", 90 | homepage: str | None = "https://nonebot.dev", 91 | type: str | None = "application", 92 | supported_adapters: Any = None, 93 | skip_test: bool | None = False, 94 | author_id: int | None = 1, 95 | test_output: str | None = "test_output", 96 | load: bool | None = True, 97 | metadata: bool | None = True, 98 | version: str | None = "0.0.1", 99 | ): 100 | return exclude_none( 101 | { 102 | "author": author, 103 | "module_name": module_name, 104 | "project_link": project_link, 105 | "tags": json.dumps(tags), 106 | "name": name, 107 | "desc": desc, 108 | "homepage": homepage, 109 | "type": type, 110 | "supported_adapters": supported_adapters, 111 | "skip_test": skip_test, 112 | "metadata": metadata, 113 | "author_id": author_id, 114 | "load": load, 115 | "version": version, 116 | "test_output": test_output, 117 | } 118 | ) 119 | -------------------------------------------------------------------------------- /tests/plugins/github/publish/utils/test_comment_issue.py: -------------------------------------------------------------------------------- 1 | from nonebug import App 2 | from pytest_mock import MockerFixture 3 | 4 | from tests.plugins.github.utils import get_github_bot 5 | 6 | 7 | async def test_comment_issue(app: App, mocker: MockerFixture): 8 | from src.plugins.github.handlers import GithubHandler 9 | from src.providers.models import RepoInfo 10 | 11 | render_comment = "test" 12 | 13 | mock_result = mocker.AsyncMock() 14 | mock_result.render_issue_comment.return_value = "test" 15 | 16 | mock_comment = mocker.MagicMock() 17 | mock_comment.body = "Bot: test" 18 | 19 | mock_list_comments_resp = mocker.MagicMock() 20 | mock_list_comments_resp.parsed_data = [mock_comment] 21 | 22 | async with app.test_api() as ctx: 23 | _adapter, bot = get_github_bot(ctx) 24 | 25 | ctx.should_call_api( 26 | "rest.issues.async_list_comments", 27 | {"owner": "owner", "repo": "repo", "issue_number": 1}, 28 | mock_list_comments_resp, 29 | ) 30 | ctx.should_call_api( 31 | "rest.issues.async_create_comment", 32 | { 33 | "owner": "owner", 34 | "repo": "repo", 35 | "issue_number": 1, 36 | "body": "test", 37 | }, 38 | True, 39 | ) 40 | 41 | handler = GithubHandler(bot=bot, repo_info=RepoInfo(owner="owner", repo="repo")) 42 | 43 | await handler.resuable_comment_issue(render_comment, 1) 44 | 45 | 46 | async def test_comment_issue_reuse(app: App, mocker: MockerFixture): 47 | from src.plugins.github.constants import NONEFLOW_MARKER 48 | from src.plugins.github.handlers import GithubHandler 49 | from src.providers.models import RepoInfo 50 | 51 | render_comment = "test" 52 | 53 | mock_result = mocker.AsyncMock() 54 | mock_result.render_issue_comment.return_value = "test" 55 | 56 | mock_comment = mocker.MagicMock() 57 | mock_comment.body = f"任意的东西\n{NONEFLOW_MARKER}\n" 58 | mock_comment.id = 123 59 | 60 | mock_list_comments_resp = mocker.MagicMock() 61 | mock_list_comments_resp.parsed_data = [mock_comment] 62 | 63 | async with app.test_api() as ctx: 64 | _adapter, bot = get_github_bot(ctx) 65 | 66 | ctx.should_call_api( 67 | "rest.issues.async_list_comments", 68 | {"owner": "owner", "repo": "repo", "issue_number": 1}, 69 | mock_list_comments_resp, 70 | ) 71 | ctx.should_call_api( 72 | "rest.issues.async_update_comment", 73 | { 74 | "owner": "owner", 75 | "repo": "repo", 76 | "comment_id": 123, 77 | "body": "test", 78 | }, 79 | True, 80 | ) 81 | 82 | handler = GithubHandler(bot=bot, repo_info=RepoInfo(owner="owner", repo="repo")) 83 | 84 | await handler.resuable_comment_issue(render_comment, 1) 85 | 86 | 87 | async def test_comment_issue_reuse_same(app: App, mocker: MockerFixture): 88 | """测试评论内容相同时不会更新评论""" 89 | from src.plugins.github.handlers import GithubHandler 90 | from src.providers.models import RepoInfo 91 | 92 | render_comment = "test\n\n" 93 | 94 | mock_comment = mocker.MagicMock() 95 | mock_comment.body = "test\n\n" 96 | mock_comment.id = 123 97 | 98 | mock_list_comments_resp = mocker.MagicMock() 99 | mock_list_comments_resp.parsed_data = [mock_comment] 100 | 101 | async with app.test_api() as ctx: 102 | _adapter, bot = get_github_bot(ctx) 103 | 104 | ctx.should_call_api( 105 | "rest.issues.async_list_comments", 106 | {"owner": "owner", "repo": "repo", "issue_number": 1}, 107 | mock_list_comments_resp, 108 | ) 109 | 110 | handler = GithubHandler(bot=bot, repo_info=RepoInfo(owner="owner", repo="repo")) 111 | 112 | await handler.resuable_comment_issue(render_comment, 1) 113 | -------------------------------------------------------------------------------- /tests/plugins/github/utils/test_github_utils.py: -------------------------------------------------------------------------------- 1 | """测试 plugins/github/utils.py""" 2 | 3 | import re 4 | import subprocess 5 | from re import Pattern 6 | from unittest.mock import MagicMock 7 | 8 | import pytest 9 | from pytest_mock import MockerFixture 10 | 11 | 12 | def test_run_shell_command_success(mocker: MockerFixture): 13 | """测试 run_shell_command 命令执行成功的情况""" 14 | from src.plugins.github.utils import run_shell_command 15 | 16 | mock_result = MagicMock() 17 | mock_result.stdout.decode.return_value = "command output" 18 | 19 | mock_run = mocker.patch("subprocess.run", return_value=mock_result) 20 | 21 | result = run_shell_command(["git", "status"]) 22 | 23 | mock_run.assert_called_once_with(["git", "status"], check=True, capture_output=True) 24 | assert result == mock_result 25 | 26 | 27 | def test_run_shell_command_failure(mocker: MockerFixture): 28 | """测试 run_shell_command 命令执行失败时抛出异常""" 29 | from src.plugins.github.utils import run_shell_command 30 | 31 | # 创建一个模拟的 CalledProcessError 32 | error = subprocess.CalledProcessError(1, ["git", "status"]) 33 | error.stdout = b"standard output" 34 | error.stderr = b"error output" 35 | 36 | mock_run = mocker.patch("subprocess.run", side_effect=error) 37 | 38 | with pytest.raises(subprocess.CalledProcessError): 39 | run_shell_command(["git", "status"]) 40 | 41 | mock_run.assert_called_once_with(["git", "status"], check=True, capture_output=True) 42 | 43 | 44 | def test_commit_message(): 45 | """测试 commit_message 函数""" 46 | from src.plugins.github.utils import commit_message 47 | 48 | result = commit_message(":sparkles:", "add feature", 123) 49 | assert result == ":sparkles: add feature (#123)" 50 | 51 | 52 | def test_extract_issue_info_from_issue_single_pattern(): 53 | """测试 extract_issue_info_from_issue 使用单个正则表达式""" 54 | from src.plugins.github.utils import extract_issue_info_from_issue 55 | 56 | patterns: dict[str, Pattern[str] | list[Pattern[str]]] = { 57 | "name": re.compile(r"Name: (\w+)"), 58 | "version": re.compile(r"Version: ([\d.]+)"), 59 | } 60 | body = "Name: TestPlugin\nVersion: 1.0.0\n" 61 | 62 | result = extract_issue_info_from_issue(patterns, body) 63 | 64 | assert result == {"name": "TestPlugin", "version": "1.0.0"} 65 | 66 | 67 | def test_extract_issue_info_from_issue_list_pattern(): 68 | """测试 extract_issue_info_from_issue 使用正则表达式列表""" 69 | from src.plugins.github.utils import extract_issue_info_from_issue 70 | 71 | patterns: dict[str, Pattern[str] | list[Pattern[str]]] = { 72 | "module": [ 73 | re.compile(r"Module Name: (\w+)"), 74 | re.compile(r"Import Name: (\w+)"), 75 | ], 76 | } 77 | body = "Import Name: test_module\n" 78 | 79 | result = extract_issue_info_from_issue(patterns, body) 80 | 81 | assert result == {"module": "test_module"} 82 | 83 | 84 | def test_extract_issue_info_from_issue_no_match(): 85 | """测试 extract_issue_info_from_issue 未匹配时返回空字典""" 86 | from src.plugins.github.utils import extract_issue_info_from_issue 87 | 88 | patterns: dict[str, Pattern[str] | list[Pattern[str]]] = { 89 | "name": re.compile(r"Name: (\w+)"), 90 | } 91 | body = "No matching content here" 92 | 93 | result = extract_issue_info_from_issue(patterns, body) 94 | 95 | assert result == {} 96 | 97 | 98 | def test_extract_issue_info_from_issue_partial_match(): 99 | """测试 extract_issue_info_from_issue 部分匹配""" 100 | from src.plugins.github.utils import extract_issue_info_from_issue 101 | 102 | patterns: dict[str, Pattern[str] | list[Pattern[str]]] = { 103 | "name": re.compile(r"Name: (\w+)"), 104 | "version": re.compile(r"Version: ([\d.]+)"), 105 | } 106 | body = "Name: TestPlugin\n" 107 | 108 | result = extract_issue_info_from_issue(patterns, body) 109 | 110 | # 只返回匹配到的项 111 | assert result == {"name": "TestPlugin"} 112 | assert "version" not in result 113 | -------------------------------------------------------------------------------- /tests/plugins/github/config/utils/test_config_update_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from inline_snapshot import snapshot 5 | from nonebug import App 6 | from pytest_mock import MockerFixture 7 | 8 | from tests.plugins.github.utils import check_json_data 9 | 10 | 11 | async def test_update_file( 12 | app: App, mocker: MockerFixture, tmp_path: Path, mock_results: dict[str, Path] 13 | ) -> None: 14 | from src.plugins.github.plugins.config.utils import update_file 15 | from src.providers.validation.models import ( 16 | PluginPublishInfo, 17 | PublishType, 18 | ValidationDict, 19 | ) 20 | 21 | mock_git_handler = mocker.MagicMock() 22 | 23 | # 更改当前工作目录为临时目录 24 | os.chdir(tmp_path) 25 | 26 | raw_data = { 27 | "module_name": "nonebot_plugin_treehelp", 28 | "project_link": "nonebot-plugin-treehelp", 29 | "name": "帮助", 30 | "desc": "获取插件帮助信息", 31 | "author": "he0119", 32 | "author_id": 1, 33 | "homepage": "https://github.com/he0119/nonebot-plugin-treehelp", 34 | "tags": [], 35 | "is_official": False, 36 | "type": "application", 37 | "supported_adapters": None, 38 | "load": True, 39 | "skip_test": False, 40 | "test_config": "log_level=DEBUG", 41 | "test_output": "test_output", 42 | "time": "2023-01-01T00:00:00Z", 43 | "version": "1.0.0", 44 | } 45 | result = ValidationDict( 46 | type=PublishType.PLUGIN, 47 | raw_data=raw_data, 48 | valid_data=raw_data, 49 | info=PluginPublishInfo.model_construct(**raw_data), 50 | errors=[], 51 | ) 52 | update_file(result, mock_git_handler) 53 | 54 | check_json_data( 55 | mock_results["plugins"], 56 | snapshot( 57 | [ 58 | { 59 | "module_name": "nonebot_plugin_treehelp", 60 | "project_link": "nonebot-plugin-treehelp", 61 | "name": "帮助", 62 | "desc": "获取插件帮助信息", 63 | "author": "he0119", 64 | "homepage": "https://github.com/he0119/nonebot-plugin-treehelp", 65 | "tags": [], 66 | "is_official": False, 67 | "type": "application", 68 | "supported_adapters": None, 69 | "valid": True, 70 | "time": "2023-01-01T00:00:00Z", 71 | "version": "1.0.0", 72 | "skip_test": False, 73 | } 74 | ] 75 | ), 76 | ) 77 | check_json_data( 78 | mock_results["results"], 79 | snapshot( 80 | { 81 | "nonebot-plugin-treehelp:nonebot_plugin_treehelp": { 82 | "time": "2023-08-23T09:22:14.836035+08:00", 83 | "config": "log_level=DEBUG", 84 | "version": "1.0.0", 85 | "test_env": {"python==3.12": True}, 86 | "results": {"validation": True, "load": True, "metadata": True}, 87 | "outputs": { 88 | "validation": None, 89 | "load": "test_output", 90 | "metadata": { 91 | "name": "帮助", 92 | "description": "获取插件帮助信息", 93 | "homepage": "https://github.com/he0119/nonebot-plugin-treehelp", 94 | "type": "application", 95 | "supported_adapters": None, 96 | }, 97 | }, 98 | } 99 | } 100 | ), 101 | ) 102 | check_json_data( 103 | mock_results["plugin_configs"], 104 | snapshot( 105 | {"nonebot-plugin-treehelp:nonebot_plugin_treehelp": "log_level=DEBUG"} 106 | ), 107 | ) 108 | 109 | mock_git_handler.add_all_files.assert_called_once_with() 110 | -------------------------------------------------------------------------------- /tests/plugins/github/publish/process/test_auto_merge.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters.github import PullRequestReviewSubmitted 2 | from nonebug import App 3 | from pytest_mock import MockerFixture 4 | 5 | from tests.plugins.github.event import get_mock_event 6 | from tests.plugins.github.utils import assert_subprocess_run_calls, get_github_bot 7 | 8 | 9 | async def test_auto_merge( 10 | app: App, mocker: MockerFixture, mock_installation, mock_installation_token 11 | ) -> None: 12 | """测试审查后自动合并 13 | 14 | 可直接合并的情况 15 | """ 16 | from src.plugins.github.plugins.publish import auto_merge_matcher 17 | 18 | mock_subprocess_run = mocker.patch("subprocess.run") 19 | 20 | mock_pull = mocker.MagicMock() 21 | mock_pull.mergeable = True 22 | mock_pull_resp = mocker.MagicMock() 23 | mock_pull_resp.parsed_data = mock_pull 24 | 25 | async with app.test_matcher() as ctx: 26 | _adapter, bot = get_github_bot(ctx) 27 | event = get_mock_event(PullRequestReviewSubmitted) 28 | 29 | ctx.should_call_api( 30 | "rest.apps.async_get_repo_installation", 31 | {"owner": "he0119", "repo": "action-test"}, 32 | mock_installation, 33 | ) 34 | ctx.should_call_api( 35 | "rest.apps.async_create_installation_access_token", 36 | {"installation_id": mock_installation.parsed_data.id}, 37 | mock_installation_token, 38 | ) 39 | ctx.should_call_api( 40 | "rest.pulls.async_merge", 41 | { 42 | "owner": "he0119", 43 | "repo": "action-test", 44 | "pull_number": 100, 45 | "merge_method": "rebase", 46 | }, 47 | True, 48 | ) 49 | 50 | ctx.receive_event(bot, event) 51 | ctx.should_pass_rule(auto_merge_matcher) 52 | 53 | # 测试 git 命令 54 | assert_subprocess_run_calls( 55 | mock_subprocess_run, 56 | [["git", "config", "--global", "safe.directory", "*"]], 57 | ) 58 | 59 | 60 | async def test_auto_merge_not_publish(app: App, mocker: MockerFixture) -> None: 61 | """测试审查后自动合并 62 | 63 | 和发布无关 64 | """ 65 | from src.plugins.github.plugins.publish import auto_merge_matcher 66 | 67 | mock_subprocess_run = mocker.patch("subprocess.run") 68 | 69 | async with app.test_matcher() as ctx: 70 | _adapter, bot = get_github_bot(ctx) 71 | event = get_mock_event(PullRequestReviewSubmitted) 72 | event.payload.pull_request.labels = [] 73 | 74 | ctx.receive_event(bot, event) 75 | ctx.should_not_pass_rule(auto_merge_matcher) 76 | 77 | # 测试 git 命令 78 | mock_subprocess_run.assert_not_called() 79 | 80 | 81 | async def test_auto_merge_not_member(app: App, mocker: MockerFixture) -> None: 82 | """测试审查后自动合并 83 | 84 | 审核者不是仓库成员 85 | """ 86 | from src.plugins.github.plugins.publish import auto_merge_matcher 87 | 88 | mock_subprocess_run = mocker.patch("subprocess.run") 89 | 90 | async with app.test_matcher() as ctx: 91 | _adapter, bot = get_github_bot(ctx) 92 | event = get_mock_event(PullRequestReviewSubmitted) 93 | event.payload.review.author_association = "CONTRIBUTOR" 94 | 95 | ctx.receive_event(bot, event) 96 | ctx.should_not_pass_rule(auto_merge_matcher) 97 | 98 | # 测试 git 命令 99 | mock_subprocess_run.assert_not_called() 100 | 101 | 102 | async def test_auto_merge_not_approve(app: App, mocker: MockerFixture) -> None: 103 | """测试审查后自动合并 104 | 105 | 审核未通过 106 | """ 107 | from src.plugins.github.plugins.publish import auto_merge_matcher 108 | 109 | mock_subprocess_run = mocker.patch("subprocess.run") 110 | 111 | async with app.test_matcher() as ctx: 112 | _adapter, bot = get_github_bot(ctx) 113 | event = get_mock_event(PullRequestReviewSubmitted) 114 | event.payload.review.state = "commented" 115 | 116 | ctx.receive_event(bot, event) 117 | ctx.should_not_pass_rule(auto_merge_matcher) 118 | 119 | # 测试 git 命令 120 | mock_subprocess_run.assert_not_called() 121 | -------------------------------------------------------------------------------- /src/providers/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from functools import cache 4 | from pathlib import Path 5 | from typing import Any 6 | 7 | import httpx 8 | import pyjson5 9 | from pydantic_core import to_jsonable_python 10 | 11 | from src.providers.logger import logger 12 | 13 | 14 | def load_json_from_file(file_path: str | Path): 15 | """从文件加载 JSON5 文件""" 16 | with open(file_path, encoding="utf-8") as file: 17 | return pyjson5.decode_io(file) # type: ignore 18 | 19 | 20 | def load_json_from_web(url: str): 21 | """从网络加载 JSON5 文件""" 22 | r = httpx.get(url) 23 | if r.status_code != 200: 24 | raise ValueError(f"下载文件失败:{r.text}") 25 | return pyjson5.decode(r.text) 26 | 27 | 28 | def load_json(text: str): 29 | """从文本加载 JSON5""" 30 | return pyjson5.decode(text) 31 | 32 | 33 | def dumps_json(data: Any, minify: bool = True) -> str: 34 | """格式化对象""" 35 | if minify: 36 | data = json.dumps(data, ensure_ascii=False, separators=(",", ":")) 37 | else: 38 | data = json.dumps(data, ensure_ascii=False, indent=2) 39 | return data 40 | 41 | 42 | def dump_json(path: str | Path, data: Any, minify: bool = True) -> None: 43 | """保存 JSON 文件""" 44 | data = to_jsonable_python(data) 45 | 46 | with open(path, "w", encoding="utf-8") as f: 47 | if minify: 48 | # 为减少文件大小,还需手动设置 separators 49 | json.dump(data, f, ensure_ascii=False, separators=(",", ":")) 50 | else: 51 | json.dump(data, f, ensure_ascii=False, indent=2) 52 | 53 | 54 | def dump_json5(path: Path, data: Any) -> None: 55 | """保存 JSON5 文件 56 | 57 | 手动添加末尾的逗号和换行符 58 | """ 59 | data = to_jsonable_python(data) 60 | 61 | content = json.dumps(data, ensure_ascii=False, indent=2) 62 | # 手动添加末尾的逗号和换行符 63 | # 避免合并时出现冲突 64 | content = content.replace("}\n]", "},\n]") 65 | content += "\n" 66 | 67 | with open(path, "w", encoding="utf-8") as f: 68 | f.write(content) 69 | 70 | 71 | @cache 72 | def get_url(url: str) -> httpx.Response: 73 | """获取网址""" 74 | headers = { 75 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36" 76 | } 77 | return httpx.get(url, follow_redirects=True, headers=headers) 78 | 79 | 80 | def get_pypi_data(project_link: str) -> dict[str, Any]: 81 | """获取 PyPI 数据""" 82 | 83 | url = f"https://pypi.org/pypi/{project_link}/json" 84 | try: 85 | r = get_url(url) 86 | except Exception as e: 87 | raise ValueError(f"获取 PyPI 数据失败:{e}") 88 | if r.status_code != 200: 89 | raise ValueError(f"获取 PyPI 数据失败:{r.text}") 90 | return r.json() 91 | 92 | 93 | def get_pypi_name(project_link: str) -> str: 94 | """获取 PyPI 项目名""" 95 | data = get_pypi_data(project_link) 96 | return data["info"]["name"] 97 | 98 | 99 | def get_pypi_version(project_link: str) -> str | None: 100 | """获取插件的最新版本号""" 101 | try: 102 | data = get_pypi_data(project_link) 103 | except ValueError: 104 | return None 105 | return data["info"]["version"] 106 | 107 | 108 | def get_pypi_upload_time(project_link: str) -> str | None: 109 | """获取插件的上传时间""" 110 | try: 111 | data = get_pypi_data(project_link) 112 | except ValueError: 113 | return None 114 | return data["urls"][0]["upload_time_iso_8601"] 115 | 116 | 117 | def add_step_summary(summary: str): 118 | """添加作业摘要""" 119 | github_step_summary = os.environ.get("GITHUB_STEP_SUMMARY") 120 | if not github_step_summary: 121 | logger.warning("未找到 GITHUB_STEP_SUMMARY 环境变量") 122 | return 123 | with open(github_step_summary, "a", encoding="utf-8") as f: 124 | f.write(summary + "\n") 125 | logger.debug(f"已添加作业摘要:{summary}") 126 | 127 | 128 | def pypi_key_to_path(key: str) -> str: 129 | """将 PyPI 键名转换为路径字符""" 130 | return key.replace(":", "-").replace(".", "-").replace("_", "-") 131 | 132 | 133 | @cache 134 | def get_author_name(author_id: int) -> str: 135 | """通过作者的ID获取作者名字""" 136 | url = f"https://api.github.com/user/{author_id}" 137 | return load_json_from_web(url)["login"] 138 | -------------------------------------------------------------------------------- /tests/providers/docker_test/test_render_plugin_test.py: -------------------------------------------------------------------------------- 1 | from inline_snapshot import snapshot 2 | from nonebug import App 3 | 4 | 5 | async def test_render_runner(app: App): 6 | """测试生成 runner.py 文件内容""" 7 | from src.providers.docker_test.render import render_runner 8 | 9 | comment = await render_runner("nonebot_plugin_treehelp", ["nonebot_plugin_alconna"]) 10 | assert comment == snapshot( 11 | """\ 12 | import json 13 | 14 | from nonebot import init, load_plugin, logger, require 15 | from pydantic import BaseModel 16 | 17 | 18 | class SetEncoder(json.JSONEncoder): 19 | def default(self, o): 20 | if isinstance(o, set): 21 | return list(o) 22 | return json.JSONEncoder.default(self, o) 23 | 24 | 25 | init() 26 | plugin = load_plugin("nonebot_plugin_treehelp") 27 | 28 | if not plugin: 29 | exit(1) 30 | else: 31 | if plugin.metadata: 32 | metadata = { 33 | "name": plugin.metadata.name, 34 | "desc": plugin.metadata.description, 35 | "usage": plugin.metadata.usage, 36 | "type": plugin.metadata.type, 37 | "homepage": plugin.metadata.homepage, 38 | "supported_adapters": plugin.metadata.supported_adapters, 39 | } 40 | with open("metadata.json", "w", encoding="utf-8") as f: 41 | try: 42 | f.write(f"{json.dumps(metadata, cls=SetEncoder)}") 43 | except Exception: 44 | f.write("{}") 45 | 46 | if plugin.metadata.config and not issubclass(plugin.metadata.config, BaseModel): 47 | logger.error("插件配置项不是 Pydantic BaseModel 的子类") 48 | exit(1) 49 | 50 | require("nonebot_plugin_alconna") 51 | """ 52 | ) 53 | 54 | 55 | async def test_render_fake(app: App): 56 | """测试生成 fake.py 文件内容""" 57 | from src.providers.docker_test.render import render_fake 58 | 59 | comment = await render_fake() 60 | assert comment == snapshot( 61 | """\ 62 | from typing import Optional, Union 63 | from collections.abc import AsyncGenerator 64 | from nonebot import logger 65 | from nonebot.drivers import ( 66 | ASGIMixin, 67 | HTTPClientMixin, 68 | HTTPClientSession, 69 | HTTPVersion, 70 | Request, 71 | Response, 72 | WebSocketClientMixin, 73 | ) 74 | from nonebot.drivers import Driver as BaseDriver 75 | from nonebot.internal.driver.model import ( 76 | CookieTypes, 77 | HeaderTypes, 78 | QueryTypes, 79 | ) 80 | from typing_extensions import override 81 | 82 | 83 | class Driver(BaseDriver, ASGIMixin, HTTPClientMixin, WebSocketClientMixin): 84 | @property 85 | @override 86 | def type(self) -> str: 87 | return "fake" 88 | 89 | @property 90 | @override 91 | def logger(self): 92 | return logger 93 | 94 | @override 95 | def run(self, *args, **kwargs): 96 | super().run(*args, **kwargs) 97 | 98 | @property 99 | @override 100 | def server_app(self): 101 | return None 102 | 103 | @property 104 | @override 105 | def asgi(self): 106 | raise NotImplementedError 107 | 108 | @override 109 | def setup_http_server(self, setup): 110 | raise NotImplementedError 111 | 112 | @override 113 | def setup_websocket_server(self, setup): 114 | raise NotImplementedError 115 | 116 | @override 117 | async def request(self, setup: Request) -> Response: 118 | raise NotImplementedError 119 | 120 | @override 121 | async def websocket(self, setup: Request) -> Response: 122 | raise NotImplementedError 123 | 124 | @override 125 | async def stream_request( 126 | self, 127 | setup: Request, 128 | *, 129 | chunk_size: int = 1024, 130 | ) -> AsyncGenerator[Response, None]: 131 | raise NotImplementedError 132 | 133 | @override 134 | def get_session( 135 | self, 136 | params: QueryTypes = None, 137 | headers: HeaderTypes = None, 138 | cookies: CookieTypes = None, 139 | version: Union[str, HTTPVersion] = HTTPVersion.H11, 140 | timeout: Optional[float] = None, 141 | proxy: Optional[str] = None, 142 | ) -> HTTPClientSession: 143 | raise NotImplementedError 144 | """ 145 | ) 146 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/remove/utils.py: -------------------------------------------------------------------------------- 1 | from githubkit.exception import RequestFailed 2 | from nonebot import logger 3 | from pydantic_core import PydanticCustomError 4 | 5 | from src.plugins.github import plugin_config 6 | from src.plugins.github.constants import REMOVE_LABEL 7 | from src.plugins.github.depends.utils import ( 8 | extract_issue_number_from_ref, 9 | get_type_by_labels, 10 | ) 11 | from src.plugins.github.handlers import GitHandler, GithubHandler, IssueHandler 12 | from src.plugins.github.typing import PullRequestList 13 | from src.plugins.github.utils import commit_message 14 | from src.providers.utils import dump_json5 15 | from src.providers.validation.models import PublishType 16 | 17 | from .constants import COMMIT_MESSAGE_PREFIX 18 | from .validation import RemoveInfo, load_publish_data, validate_author_info 19 | 20 | 21 | def update_file(result: RemoveInfo, handler: GitHandler): 22 | """删除对应的包储存在 registry 里的数据""" 23 | logger.info("开始更新文件") 24 | 25 | match result.publish_type: 26 | case PublishType.PLUGIN: 27 | path = plugin_config.input_config.plugin_path 28 | case PublishType.BOT: 29 | path = plugin_config.input_config.bot_path 30 | case PublishType.ADAPTER: 31 | path = plugin_config.input_config.adapter_path 32 | case _: 33 | raise ValueError("不支持的删除类型") 34 | 35 | data = load_publish_data(result.publish_type) 36 | # 删除对应的数据项 37 | data.pop(result.key) 38 | dump_json5(path, list(data.values())) 39 | handler.add_file(path) 40 | logger.info(f"已更新 {path.name} 文件") 41 | 42 | 43 | async def process_pull_requests( 44 | handler: IssueHandler, 45 | store_handler: GithubHandler, 46 | result: RemoveInfo, 47 | branch_name: str, 48 | title: str, 49 | ): 50 | """ 51 | 根据发布信息合法性创建拉取请求 52 | """ 53 | message = commit_message(COMMIT_MESSAGE_PREFIX, result.name, handler.issue_number) 54 | 55 | # 切换分支 56 | handler.switch_branch(branch_name) 57 | # 更新文件并提交更改 58 | update_file(result, handler) 59 | store_handler.commit_and_push(message, branch_name, author=handler.author) 60 | # 创建拉取请求 61 | logger.info("开始创建拉取请求") 62 | 63 | try: 64 | pull_number = await store_handler.create_pull_request( 65 | plugin_config.input_config.base, 66 | title, 67 | branch_name, 68 | body=f"resolve {handler.repo_info}#{handler.issue_number}", 69 | ) 70 | await store_handler.add_labels( 71 | pull_number, [REMOVE_LABEL, result.publish_type.value] 72 | ) 73 | except RequestFailed: 74 | # 如果之前已经创建了拉取请求,则将其转换为草稿 75 | logger.info("该分支的拉取请求已创建,请前往查看") 76 | await store_handler.update_pull_request_status(title, branch_name) 77 | 78 | 79 | async def resolve_conflict_pull_requests( 80 | handler: GithubHandler, pulls: PullRequestList 81 | ): 82 | """根据关联的议题提交来解决冲突 83 | 84 | 直接重新提交之前分支中的内容 85 | """ 86 | 87 | for pull in pulls: 88 | issue_number = extract_issue_number_from_ref(pull.head.ref) 89 | if not issue_number: 90 | logger.error(f"无法获取 {pull.title} 对应的议题编号") 91 | continue 92 | 93 | logger.info(f"正在处理 {pull.title}") 94 | if pull.draft: 95 | logger.info("拉取请求为草稿,跳过处理") 96 | continue 97 | 98 | # 根据标签获取发布类型 99 | publish_type = get_type_by_labels(pull.labels) 100 | issue_handler = await handler.to_issue_handler(issue_number) 101 | 102 | if publish_type: 103 | # 验证作者信息 104 | try: 105 | result = await validate_author_info(issue_handler.issue, publish_type) 106 | except PydanticCustomError as e: 107 | logger.error(f"验证作者信息失败: {e}") 108 | continue 109 | 110 | # 每次切换前都要确保回到主分支 111 | handler.checkout_branch(plugin_config.input_config.base, update=True) 112 | # 切换到对应分支 113 | handler.switch_branch(pull.head.ref) 114 | # 更新文件 115 | update_file(result, issue_handler) 116 | 117 | # 生成提交信息并推送 118 | message = commit_message(COMMIT_MESSAGE_PREFIX, result.name, issue_number) 119 | issue_handler.commit_and_push(message, pull.head.ref) 120 | 121 | logger.info("拉取请求更新完毕") 122 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "noneflow" 3 | version = "4.4.5" 4 | description = "Manage publish related issues in nonebot2 project" 5 | authors = [{ name = "uy_sun", email = "hmy0119@gmail.com" }] 6 | readme = "README.md" 7 | license = { file = "LICENSE" } 8 | requires-python = ">=3.14.2" 9 | dependencies = [ 10 | "click>=8.3.1", 11 | "docker>=7.1.0", 12 | "githubkit>=0.14.0", 13 | "jinja2>=3.1.6", 14 | "nonebot-adapter-github>=0.6.0", 15 | "nonebot2>=2.4.4", 16 | "pydantic-extra-types>=2.10.6", 17 | "pyjson5>=2.0.0", 18 | "tzdata>=2025.2", 19 | ] 20 | 21 | [project.urls] 22 | Homepage = "https://github.com/nonebot/noneflow" 23 | Repository = "https://github.com/nonebot/noneflow.git" 24 | Issues = "https://github.com/nonebot/noneflow/issues" 25 | Changelog = "https://github.com/nonebot/noneflow/blob/main/CHANGELOG.md" 26 | 27 | [dependency-groups] 28 | dev = [ 29 | "inline-snapshot>=0.31.1", 30 | "nonebug>=0.4.3", 31 | "poethepoet>=0.38.0", 32 | "pytest-asyncio>=1.3.0", 33 | "pytest-cov>=7.0.0", 34 | "pytest-mock>=3.15.1", 35 | "pytest-xdist>=3.8.0", 36 | "respx>=0.22.0", 37 | ] 38 | 39 | [tool.uv.pip] 40 | universal = true 41 | 42 | [tool.poe.tasks] 43 | test = "pytest --cov=src --cov-report xml --junitxml=./junit.xml -n auto" 44 | bump = "bump-my-version bump" 45 | show-bump = "bump-my-version show-bump" 46 | snapshot-create = "pytest --inline-snapshot=create" 47 | snapshot-fix = "pytest --inline-snapshot=fix" 48 | store-test = "python -m src.providers.store_test" 49 | docker-test = "python -m src.providers.docker_test" 50 | 51 | [tool.pyright] 52 | pythonVersion = "3.13" 53 | pythonPlatform = "All" 54 | typeCheckingMode = "standard" 55 | 56 | [tool.ruff] 57 | line-length = 88 58 | target-version = "py313" 59 | 60 | [tool.ruff.lint] 61 | select = [ 62 | "F", # pyflakes 63 | "W", # pycodestyle warnings 64 | "E", # pycodestyle errors 65 | "UP", # pyupgrade 66 | "ASYNC", # flake8-async 67 | "C4", # flake8-comprehensions 68 | "T10", # flake8-debugger 69 | "T20", # flake8-print 70 | "PYI", # flake8-pyi 71 | "PT", # flake8-pytest-style 72 | "Q", # flake8-quotes 73 | "TC", # flake8-type-checking 74 | "DTZ", # flake8-datetimez 75 | "RUF", # Ruff-specific rules 76 | "I", # isort 77 | ] 78 | ignore = [ 79 | "E402", # module-import-not-at-top-of-file 80 | "E501", # line-too-long 81 | "RUF001", # ambiguous-unicode-character-string 82 | "RUF002", # ambiguous-unicode-character-docstring 83 | "RUF003", # ambiguous-unicode-character-comment 84 | "ASYNC230", # blocking-open-call-in-async-function 85 | ] 86 | 87 | [tool.ruff.lint.isort] 88 | known-third-party = ["docker"] 89 | 90 | [tool.pytest.ini_options] 91 | asyncio_mode = "auto" 92 | asyncio_default_fixture_loop_scope = "session" 93 | 94 | [tool.coverage.report] 95 | exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"] 96 | 97 | [tool.coverage.run] 98 | omit = ["*.jinja"] 99 | 100 | [tool.nonebot] 101 | adapters = [{ name = "GitHub", module_name = "nonebot.adapters.github" }] 102 | plugin_dirs = ["src/plugins"] 103 | 104 | [tool.inline-snapshot] 105 | hash-length = 15 106 | default-flags = ["report"] 107 | default-flags-tui = ["create", "review"] 108 | format-command = "ruff format --stdin-filename {filename}" 109 | skip-snapshot-updates-for-now = false 110 | 111 | [tool.inline-snapshot.shortcuts] 112 | review = ["review"] 113 | fix = ["create", "fix"] 114 | 115 | [tool.bumpversion] 116 | current_version = "4.4.5" 117 | commit = true 118 | message = "chore(release): {new_version}" 119 | 120 | [[tool.bumpversion.files]] 121 | filename = "pyproject.toml" 122 | search = "version = \"{current_version}\"" 123 | replace = "version = \"{new_version}\"" 124 | 125 | [[tool.bumpversion.files]] 126 | filename = "CHANGELOG.md" 127 | search = "## [Unreleased]" 128 | replace = "## [Unreleased]\n\n## [{new_version}] - {now:%Y-%m-%d}" 129 | 130 | [[tool.bumpversion.files]] 131 | filename = "CHANGELOG.md" 132 | regex = true 133 | search = "\\[Unreleased\\]: (https://.+?)v{current_version}\\.\\.\\.HEAD" 134 | replace = "[Unreleased]: \\1v{new_version}...HEAD\n[{new_version}]: \\1v{current_version}...v{new_version}" 135 | 136 | [[tool.bumpversion.files]] 137 | filename = "uv.lock" 138 | search = "name = \"noneflow\"\nversion = \"{current_version}\"" 139 | replace = "name = \"noneflow\"\nversion = \"{new_version}\"" 140 | -------------------------------------------------------------------------------- /tests/providers/docker_test/test_extract_version.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def test_extract_version(tmp_path: Path): 5 | """poetry show 的输出""" 6 | from src.providers.docker_test.plugin_test import extract_version 7 | 8 | output = """ 9 | name : nonebot2 10 | version : 2.0.1 11 | description : An asynchronous python bot framework. 12 | 13 | dependencies 14 | - httpx >=0.20.0,<1.0.0 15 | - loguru >=0.6.0,<1.0.0 16 | - pydantic >=1.10.0,<2.0.0 17 | - pygtrie >=2.4.1,<3.0.0 18 | - tomli >=2.0.1,<3.0.0 19 | - typing-extensions >=4.0.0,<5.0.0 20 | - yarl >=1.7.2,<2.0.0 21 | 22 | required by 23 | - nonebot-adapter-github >=2.0.0-beta.5,<3.0.0 24 | - nonebug >=2.0.0-rc.2,<3.0.0 25 | """ 26 | 27 | version = extract_version(output, "nonebot2") 28 | assert version == "2.0.1" 29 | 30 | 31 | def test_extract_version_resolve_failed(tmp_path: Path): 32 | """版本解析失败的情况""" 33 | from src.providers.docker_test.plugin_test import extract_version 34 | 35 | output = """ 36 | 项目 ELF-RSS 创建失败: 37 | Virtualenv 38 | Python: 3.12.7 39 | Implementation: CPython 40 | Path: NA 41 | Executable: NA 42 | 43 | Base 44 | Platform: linux 45 | OS: posix 46 | Python: 3.12.7 47 | Path: /usr/local 48 | Executable: /usr/local/bin/python3.12 49 | Using version ^2.6.25 for elf-rss 50 | 51 | Updating dependencies 52 | Resolving dependencies... 53 | Creating virtualenv elf-rss-elf-rss2 in /tmp/plugin_test/ELF-RSS-ELF_RSS2/.venv 54 | 55 | The current project's supported Python range (>=3.12,<3.13) is not compatible with some of the required packages Python requirement: 56 | - elf-rss requires Python <4.0.0,>=3.12.6, so it will not be satisfied for Python >=3.12,<3.12.6 57 | 58 | Because no versions of elf-rss match >2.6.25,<3.0.0 59 | and elf-rss (2.6.25) requires Python <4.0.0,>=3.12.6, elf-rss is forbidden. 60 | So, because elf-rss-elf-rss2 depends on elf-rss (^2.6.25), version solving failed. 61 | 62 | • Check your dependencies Python requirement: The Python requirement can be specified via the `python` or `markers` properties 63 | 64 | For elf-rss, a possible solution would be to set the `python` property to ">=3.12.6,<3.13" 65 | 66 | https://python-poetry.org/docs/dependency-specification/#python-restricted-dependencies, 67 | https://python-poetry.org/docs/dependency-specification/#using-environment-markers 68 | """ 69 | 70 | version = extract_version(output, "ELF-RSS") 71 | 72 | assert version == "2.6.25" 73 | 74 | version = extract_version(output, "nonebot2") 75 | 76 | assert version is None 77 | 78 | 79 | def test_extract_version_install_failed(tmp_path: Path): 80 | """安装插件失败的情况""" 81 | from src.providers.docker_test.plugin_test import extract_version 82 | 83 | output = """ 84 | 项目 nonebot-plugin-ncm 创建失败: 85 | Virtualenv 86 | Python: 3.12.7 87 | Implementation: CPython 88 | Path: NA 89 | Executable: NA 90 | 91 | Base 92 | Platform: linux 93 | OS: posix 94 | Python: 3.12.7 95 | Path: /usr/local 96 | Executable: /usr/local/bin/python3.12 97 | Using version ^1.6.16 for nonebot-plugin-ncm 98 | 99 | Updating dependencies 100 | Resolving dependencies... 101 | 102 | Package operations: 32 installs, 0 updates, 0 removals 103 | """ 104 | 105 | version = extract_version(output, "nonebot-plugin-ncm") 106 | 107 | assert version == "1.6.16" 108 | 109 | version = extract_version(output, "nonebot_plugin_ncm") 110 | 111 | assert version == "1.6.16" 112 | 113 | version = extract_version(output, "nonebot2") 114 | 115 | assert version is None 116 | 117 | 118 | def test_extract_version_install_failed_partial_output(tmp_path: Path): 119 | """安装插件失败的情况,输出不知道为什么只剩下了部分 120 | 121 | TODO: 弄清楚为什么输出不完整。 122 | """ 123 | from src.providers.docker_test.plugin_test import extract_version 124 | 125 | output = """ 126 | 项目 nonebot-plugin-todo-nlp 创建失败: 127 | 执行命令超时 128 | - Installing nonebot-plugin-todo-nlp (0.1.9) 129 | 130 | Writing lock file 131 | """ 132 | 133 | version = extract_version(output, "nonebot-plugin-todo-nlp") 134 | 135 | assert version == "0.1.9" 136 | 137 | version = extract_version(output, "nonebot2") 138 | 139 | assert version is None 140 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | junit.xml 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # poetry 103 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 104 | # This is especially recommended for binary packages to ensure reproducibility, and is more 105 | # commonly ignored for libraries. 106 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 107 | #poetry.lock 108 | 109 | # pdm 110 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 111 | #pdm.lock 112 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 113 | # in version control. 114 | # https://pdm.fming.dev/#use-with-ide 115 | .pdm.toml 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | #.idea/ 166 | 167 | ### Python Patch ### 168 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 169 | poetry.toml 170 | 171 | # ruff 172 | .ruff_cache/ 173 | 174 | # LSP config files 175 | pyrightconfig.json 176 | 177 | # End of https://www.toptal.com/developers/gitignore/api/python 178 | 179 | .vscode/ 180 | 181 | # Noneflow 182 | 183 | plugin_test/ 184 | artifact/ 185 | 186 | test_result.json -------------------------------------------------------------------------------- /src/providers/docker_test/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import traceback 3 | from typing import TypedDict 4 | 5 | import docker 6 | from pydantic import BaseModel, SkipValidation, field_validator 7 | 8 | from src.providers.constants import ( 9 | DOCKER_BIND_RESULT_PATH, 10 | DOCKER_IMAGES, 11 | PLUGIN_TEST_DIR, 12 | PYPI_KEY_TEMPLATE, 13 | ) 14 | from src.providers.utils import pypi_key_to_path 15 | 16 | 17 | class Metadata(TypedDict): 18 | """插件元数据""" 19 | 20 | name: str 21 | desc: str 22 | homepage: str | None 23 | type: str | None 24 | supported_adapters: list[str] | None 25 | 26 | 27 | class DockerTestResult(BaseModel): 28 | """Docker 测试结果""" 29 | 30 | run: bool 31 | """ 是否运行测试 """ 32 | load: bool 33 | """ 是否加载成功 """ 34 | output: str 35 | """ 测试输出 """ 36 | version: str | None = None 37 | """ 测试版本 """ 38 | config: str = "" 39 | """ 测试配置 """ 40 | test_env: str = "" 41 | """测试环境 42 | 43 | python==3.12 nonebot2==2.4.0 pydantic==2.10.0 44 | """ 45 | metadata: SkipValidation[Metadata] | None = None 46 | """ 插件元数据 """ 47 | 48 | @field_validator("config", mode="before") 49 | @classmethod 50 | def config_validator(cls, v: str | None): 51 | return v or "" 52 | 53 | 54 | class DockerPluginTest: 55 | def __init__(self, project_link: str, module_name: str, config: str = ""): 56 | self.project_link = project_link 57 | self.module_name = module_name 58 | self.config = config 59 | 60 | if not PLUGIN_TEST_DIR.exists(): 61 | PLUGIN_TEST_DIR.mkdir(parents=True, exist_ok=True) 62 | 63 | async def run(self, version: str) -> DockerTestResult: 64 | """运行 Docker 容器测试插件 65 | 66 | Args: 67 | version (str): 对应的 Python 版本 68 | 69 | Returns: 70 | DockerTestResult: 测试结果 71 | """ 72 | # 连接 Docker 环境 73 | client = docker.DockerClient(base_url="unix://var/run/docker.sock") 74 | key = PYPI_KEY_TEMPLATE.format( 75 | project_link=self.project_link, module_name=self.module_name 76 | ) 77 | plugin_test_result = PLUGIN_TEST_DIR / f"{pypi_key_to_path(key)}.json" 78 | 79 | # 创建文件,以确保 Docker 容器内可以写入 80 | plugin_test_result.touch(exist_ok=True) 81 | 82 | try: 83 | # 运行 Docker 容器,捕获输出。 容器内运行的代码拥有超时设限,此处无需设置超时 84 | output = client.containers.run( 85 | DOCKER_IMAGES, 86 | environment={ 87 | # 运行测试的 Python 版本 88 | "PYTHON_VERSION": version, 89 | # 插件信息 90 | "PROJECT_LINK": self.project_link, 91 | "MODULE_NAME": self.module_name, 92 | "PLUGIN_CONFIG": self.config, 93 | }, 94 | detach=False, 95 | remove=True, 96 | volumes={ 97 | plugin_test_result.resolve(strict=False).as_posix(): { 98 | "bind": DOCKER_BIND_RESULT_PATH, 99 | "mode": "rw", 100 | } 101 | }, 102 | ) 103 | 104 | try: 105 | # 若测试结果文件存在且可解析,则优先使用测试结果文件 106 | data = json.loads(plugin_test_result.read_text(encoding="utf-8")) 107 | except Exception as e: 108 | # 如果测试结果文件不存在或不可解析,则尝试使用容器输出内容 109 | # 这个时候才需要解码容器输出内容,避免不必要的解码开销 110 | try: 111 | data = json.loads(output.decode(encoding="utf-8")) 112 | except json.JSONDecodeError: 113 | data = { 114 | "run": True, 115 | "load": False, 116 | "output": f""" 117 | 测试结果文件解析失败,输出内容写入失败。 118 | {e} 119 | 输出内容:{output} 120 | """, 121 | } 122 | except Exception as e: 123 | # 格式化异常堆栈信息 124 | trackback = "".join(traceback.format_exception(type(e), e, e.__traceback__)) 125 | MAX_OUTPUT = 2000 126 | if len(trackback) > MAX_OUTPUT: 127 | trackback = trackback[-MAX_OUTPUT:] 128 | 129 | data = { 130 | "run": False, 131 | "load": False, 132 | "output": trackback, 133 | } 134 | return DockerTestResult(**data) 135 | -------------------------------------------------------------------------------- /src/providers/store_test/validation.py: -------------------------------------------------------------------------------- 1 | """测试并验证插件""" 2 | 3 | from typing import Any 4 | 5 | from src.providers.docker_test import DockerPluginTest 6 | from src.providers.logger import logger 7 | from src.providers.models import RegistryPlugin, StorePlugin, StoreTestResult 8 | from src.providers.utils import get_author_name, get_pypi_upload_time 9 | from src.providers.validation import ( 10 | PluginPublishInfo, 11 | PublishType, 12 | ValidationDict, 13 | validate_info, 14 | ) 15 | 16 | 17 | async def validate_plugin( 18 | store_plugin: StorePlugin, 19 | config: str, 20 | previous_plugin: RegistryPlugin | None = None, 21 | ): 22 | """验证插件 23 | 24 | 如果 previous_plugin 为 None,说明是首次验证插件 25 | 26 | 返回测试结果与验证后的插件数据 27 | 28 | 如果插件验证失败,返回的插件数据为 None 29 | """ 30 | # 需要从商店插件数据中获取的信息 31 | project_link = store_plugin.project_link 32 | module_name = store_plugin.module_name 33 | 34 | # 从 PyPI 获取信息 35 | pypi_time = get_pypi_upload_time(project_link) 36 | 37 | # 测试插件 38 | plugin_test_result = await DockerPluginTest(project_link, module_name, config).run( 39 | "3.12" 40 | ) 41 | 42 | plugin_test_load = plugin_test_result.load 43 | plugin_test_output = plugin_test_result.output 44 | plugin_test_version = plugin_test_result.version 45 | plugin_test_env = plugin_test_result.test_env 46 | plugin_metadata = plugin_test_result.metadata 47 | 48 | # 输出插件测试相关信息 49 | logger.info( 50 | f"插件 {project_link}({plugin_test_version}) 加载{'成功' if plugin_test_load else '失败'} {'插件已尝试加载' if plugin_test_result.run else '插件并未开始运行'}" 51 | ) 52 | logger.info(f"插件元数据:{plugin_metadata}") 53 | logger.info("插件测试输出:") 54 | logger.info(plugin_test_output) 55 | 56 | if previous_plugin is None: 57 | # 使用商店插件数据作为新的插件数据 58 | raw_data = store_plugin.model_dump() 59 | else: 60 | # 将上次的插件数据作为新的插件数据 61 | raw_data: dict[str, Any] = previous_plugin.model_dump() 62 | # 还需要同步商店中的数据,如 author_id, tags 和 is_official 63 | raw_data.update( 64 | { 65 | "author_id": store_plugin.author_id, 66 | "tags": store_plugin.tags, 67 | "is_official": store_plugin.is_official, 68 | } 69 | ) 70 | 71 | # 当跳过测试的插件首次通过加载测试,则不再标记为跳过测试 72 | should_skip: bool = False if plugin_test_load else raw_data.get("skip_test", False) 73 | raw_data["skip_test"] = should_skip 74 | raw_data["load"] = plugin_test_load 75 | raw_data["test_output"] = plugin_test_output 76 | raw_data["version"] = plugin_test_version 77 | 78 | # 使用最新的插件元数据更新插件信息 79 | raw_data["metadata"] = bool(plugin_metadata) 80 | if plugin_metadata: 81 | raw_data.update(plugin_metadata) 82 | 83 | # 通过 Github API 获取插件作者名称 84 | try: 85 | author_name = get_author_name(store_plugin.author_id) 86 | except Exception: 87 | # 若无法请求,试图从上次的插件数据中获取 88 | author_name = previous_plugin.author if previous_plugin else "" 89 | raw_data["author"] = author_name 90 | 91 | # 更新插件信息 92 | raw_data["time"] = pypi_time 93 | 94 | # 验证插件信息 95 | result: ValidationDict = validate_info(PublishType.PLUGIN, raw_data, []) 96 | 97 | if result.valid: 98 | assert isinstance(result.info, PluginPublishInfo) 99 | new_plugin = RegistryPlugin.from_publish_info(result.info) 100 | else: 101 | # 如果验证失败则使用以前的数据 102 | data = previous_plugin.model_dump() if previous_plugin else {} 103 | data.update(result.valid_data) 104 | # 顺便更新作者名与验证结果 105 | data.update( 106 | { 107 | "author": author_name, 108 | "valid": result.valid, 109 | } 110 | ) 111 | new_plugin = RegistryPlugin(**data) 112 | 113 | validation_result = result.valid 114 | validation_output = ( 115 | None if result.valid else {"data": result.valid_data, "errors": result.errors} 116 | ) 117 | 118 | test_result = StoreTestResult( 119 | version=plugin_test_version, 120 | results={ 121 | "validation": validation_result, 122 | "load": plugin_test_load, 123 | "metadata": bool(plugin_metadata), 124 | }, 125 | config=config, 126 | outputs={ 127 | "validation": validation_output, 128 | "load": plugin_test_output, 129 | "metadata": plugin_metadata, 130 | }, 131 | test_env={plugin_test_env: True}, 132 | ) 133 | 134 | return test_result, new_plugin 135 | -------------------------------------------------------------------------------- /examples/store-test.yml: -------------------------------------------------------------------------------- 1 | name: "NoneBot Store Test" 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | offset: 7 | description: "Offset" 8 | required: false 9 | default: "0" 10 | limit: 11 | description: "Limit" 12 | required: false 13 | default: "1" 14 | args: 15 | description: "Args" 16 | required: false 17 | default: "" 18 | schedule: 19 | - cron: "0 */4 * * *" 20 | repository_dispatch: 21 | types: [registry_update] 22 | 23 | concurrency: 24 | group: "store-test" 25 | cancel-in-progress: false 26 | 27 | jobs: 28 | store_test: 29 | runs-on: ubuntu-latest 30 | name: NoneBot2 plugin test 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | with: 35 | repository: nonebot/noneflow 36 | fetch-depth: 0 37 | 38 | - name: Checkout latest noneflow version 39 | run: git checkout `git describe --abbrev=0 --tags` 40 | 41 | - name: Install the latest version of uv 42 | uses: astral-sh/setup-uv@v3 43 | with: 44 | enable-cache: true 45 | 46 | - name: Test plugin 47 | if: ${{ !github.event.client_payload.artifact_id }} 48 | run: uv run --no-dev -m src.providers.store_test plugin-test --offset ${{ github.event.inputs.offset || 0 }} --limit ${{ github.event.inputs.limit || 50 }} ${{ github.event.inputs.args }} 49 | 50 | - name: Update registry 51 | if: ${{ github.event.client_payload.artifact_id }} 52 | run: uv run --no-dev -m src.providers.store_test registry-update 53 | env: 54 | REGISTRY_UPDATE_PAYLOAD: ${{ toJson(github.event.client_payload) }} 55 | APP_ID: ${{ secrets.APP_ID }} 56 | PRIVATE_KEY: ${{ secrets.APP_KEY }} 57 | 58 | - name: Upload results 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: results 62 | path: | 63 | ${{ github.workspace }}/plugin_test/results.json 64 | ${{ github.workspace }}/plugin_test/adapters.json 65 | ${{ github.workspace }}/plugin_test/bots.json 66 | ${{ github.workspace }}/plugin_test/drivers.json 67 | ${{ github.workspace }}/plugin_test/plugins.json 68 | ${{ github.workspace }}/plugin_test/plugin_configs.json 69 | 70 | upload_results: 71 | runs-on: ubuntu-latest 72 | name: Upload results 73 | needs: store_test 74 | permissions: 75 | contents: write 76 | steps: 77 | - name: Checkout 78 | uses: actions/checkout@v4 79 | with: 80 | ref: results 81 | 82 | - name: Download results 83 | uses: actions/download-artifact@v4 84 | with: 85 | name: results 86 | path: ${{ github.workspace }} 87 | 88 | - name: Push results 89 | run: | 90 | git config user.name github-actions[bot] 91 | git config user.email github-actions[bot]@users.noreply.github.com 92 | git add . 93 | git diff-index --quiet HEAD || git commit -m "chore: update test results" 94 | git push 95 | 96 | upload_results_netlify: 97 | runs-on: ubuntu-latest 98 | name: Upload results to netlify 99 | needs: store_test 100 | permissions: 101 | contents: read 102 | deployments: write 103 | statuses: write 104 | steps: 105 | - name: Checkout 106 | uses: actions/checkout@v4 107 | with: 108 | ref: main 109 | 110 | - name: Setup Node Environment 111 | uses: ./.github/actions/setup-node 112 | 113 | - name: Download results 114 | uses: actions/download-artifact@v4 115 | with: 116 | name: results 117 | path: ${{ github.workspace }}/public 118 | 119 | - name: Build Website 120 | run: pnpm build 121 | env: 122 | VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} 123 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 124 | 125 | - name: Get Branch Name 126 | run: echo "BRANCH_NAME=$(echo ${GITHUB_REF#refs/heads/})" >> $GITHUB_ENV 127 | 128 | - name: Deploy to Netlify 129 | uses: nwtgck/actions-netlify@v3 130 | with: 131 | publish-dir: "./dist" 132 | production-deploy: true 133 | github-token: ${{ secrets.GITHUB_TOKEN }} 134 | deploy-message: "Deploy ${{ env.BRANCH_NAME }}@${{ github.sha }}" 135 | enable-commit-comment: false 136 | alias: ${{ env.BRANCH_NAME }} 137 | env: 138 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 139 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 140 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/remove/validation.py: -------------------------------------------------------------------------------- 1 | from githubkit.rest import Issue 2 | from pydantic import BaseModel 3 | from pydantic_core import PydanticCustomError 4 | 5 | from src.plugins.github import plugin_config 6 | from src.plugins.github.utils import extract_issue_info_from_issue 7 | from src.providers.constants import BOT_KEY_TEMPLATE, PYPI_KEY_TEMPLATE 8 | from src.providers.models import AuthorInfo 9 | from src.providers.utils import load_json_from_file 10 | from src.providers.validation.models import PublishType 11 | 12 | from .constants import ( 13 | REMOVE_BOT_HOMEPAGE_PATTERN, 14 | REMOVE_BOT_NAME_PATTERN, 15 | REMOVE_PLUGIN_IMPORT_NAME_PATTERN, 16 | REMOVE_PLUGIN_MODULE_NAME_PATTERN, 17 | REMOVE_PLUGIN_PROJECT_LINK_PATTERN, 18 | ) 19 | 20 | 21 | def load_publish_data(publish_type: PublishType): 22 | """加载对应类型的文件数据""" 23 | match publish_type: 24 | case PublishType.ADAPTER: 25 | return { 26 | PYPI_KEY_TEMPLATE.format( 27 | project_link=adapter["project_link"], 28 | module_name=adapter["module_name"], 29 | ): adapter 30 | for adapter in load_json_from_file( 31 | plugin_config.input_config.adapter_path 32 | ) 33 | } 34 | case PublishType.BOT: 35 | return { 36 | BOT_KEY_TEMPLATE.format( 37 | name=bot["name"], 38 | homepage=bot["homepage"], 39 | ): bot 40 | for bot in load_json_from_file(plugin_config.input_config.bot_path) 41 | } 42 | case PublishType.PLUGIN: 43 | return { 44 | PYPI_KEY_TEMPLATE.format( 45 | project_link=plugin["project_link"], 46 | module_name=plugin["module_name"], 47 | ): plugin 48 | for plugin in load_json_from_file( 49 | plugin_config.input_config.plugin_path 50 | ) 51 | } 52 | case PublishType.DRIVER: 53 | raise ValueError("不支持的删除类型") 54 | 55 | 56 | class RemoveInfo(BaseModel): 57 | publish_type: PublishType 58 | key: str 59 | name: str 60 | 61 | 62 | async def validate_author_info(issue: Issue, publish_type: PublishType) -> RemoveInfo: 63 | """ 64 | 通过议题获取作者 ID,然后验证待删除的数据项是否属于该作者 65 | """ 66 | 67 | body = issue.body if issue.body else "" 68 | author_id = AuthorInfo.from_issue(issue).author_id 69 | 70 | match publish_type: 71 | case PublishType.PLUGIN | PublishType.ADAPTER: 72 | raw_data = extract_issue_info_from_issue( 73 | { 74 | "module_name": [ 75 | REMOVE_PLUGIN_MODULE_NAME_PATTERN, 76 | REMOVE_PLUGIN_IMPORT_NAME_PATTERN, 77 | ], 78 | "project_link": REMOVE_PLUGIN_PROJECT_LINK_PATTERN, 79 | }, 80 | body, 81 | ) 82 | module_name = raw_data.get("module_name") 83 | project_link = raw_data.get("project_link") 84 | if module_name is None or project_link is None: 85 | raise PydanticCustomError( 86 | "info_not_found", "未填写数据项或填写格式有误" 87 | ) 88 | 89 | key = PYPI_KEY_TEMPLATE.format( 90 | project_link=project_link, module_name=module_name 91 | ) 92 | case PublishType.BOT: 93 | raw_data = extract_issue_info_from_issue( 94 | { 95 | "name": REMOVE_BOT_NAME_PATTERN, 96 | "homepage": REMOVE_BOT_HOMEPAGE_PATTERN, 97 | }, 98 | body, 99 | ) 100 | name = raw_data.get("name") 101 | homepage = raw_data.get("homepage") 102 | 103 | if name is None or homepage is None: 104 | raise PydanticCustomError( 105 | "info_not_found", "未填写数据项或填写格式有误" 106 | ) 107 | 108 | key = BOT_KEY_TEMPLATE.format(name=name, homepage=homepage) 109 | case _: 110 | raise PydanticCustomError("not_support", "暂不支持的移除类型") 111 | 112 | data = load_publish_data(publish_type) 113 | 114 | if key not in data: 115 | raise PydanticCustomError("not_found", "不存在对应信息的包") 116 | 117 | remove_item = data[key] 118 | if remove_item.get("author_id") != author_id: 119 | raise PydanticCustomError("author_info", "作者信息验证不匹配") 120 | 121 | return RemoveInfo( 122 | publish_type=publish_type, 123 | name=remove_item.get("name") or remove_item.get("module_name") or "", 124 | key=key, 125 | ) 126 | -------------------------------------------------------------------------------- /src/plugins/github/handlers/issue.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from githubkit.rest import Issue 4 | from pydantic import ConfigDict 5 | 6 | from src.plugins.github.constants import SKIP_COMMENT 7 | from src.providers.models import AuthorInfo 8 | 9 | from .github import GithubHandler 10 | 11 | 12 | class IssueHandler(GithubHandler): 13 | """Issue 的相关 Github/Git 操作""" 14 | 15 | model_config = ConfigDict(arbitrary_types_allowed=True) 16 | 17 | issue: Issue 18 | 19 | @property 20 | def issue_number(self) -> int: 21 | return self.issue.number 22 | 23 | @property 24 | def author_info(self) -> AuthorInfo: 25 | return AuthorInfo.from_issue(self.issue) 26 | 27 | @property 28 | def author(self) -> str: 29 | return self.author_info.author 30 | 31 | @property 32 | def author_id(self) -> int: 33 | return self.author_info.author_id 34 | 35 | async def update_issue_title(self, title: str, issue_number: int | None = None): 36 | """修改议题标题""" 37 | if issue_number is None: 38 | issue_number = self.issue_number 39 | 40 | # 只有修改的议题标题和原标题不一致时才会进行修改 41 | # 并且需要是同一个议题 42 | if self.issue_number == issue_number and self.issue.title == title: 43 | return 44 | 45 | await super().update_issue_title(title, issue_number) 46 | 47 | # 更新缓存属性,避免重复或错误操作 48 | self.issue.title = title 49 | 50 | async def update_issue_body(self, body: str, issue_number: int | None = None): 51 | """更新议题内容""" 52 | if issue_number is None: 53 | issue_number = self.issue_number 54 | 55 | if self.issue_number == issue_number and self.issue.body == body: 56 | return 57 | 58 | await super().update_issue_body(body, issue_number) 59 | 60 | # 更新缓存属性,避免重复或错误操作 61 | self.issue.body = body 62 | 63 | async def close_issue( 64 | self, 65 | reason: Literal["completed", "not_planned", "reopened"], 66 | issue_number: int | None = None, 67 | ): 68 | """关闭议题""" 69 | if issue_number is None: 70 | issue_number = self.issue_number 71 | 72 | if ( 73 | self.issue and self.issue.state == "open" 74 | ) or self.issue.state_reason != reason: 75 | await super().close_issue(reason, issue_number) 76 | 77 | async def create_pull_request( 78 | self, 79 | base_branch: str, 80 | title: str, 81 | branch_name: str, 82 | body: str = "", 83 | ): 84 | if not body: 85 | body = f"resolve #{self.issue_number}" 86 | 87 | return await super().create_pull_request(base_branch, title, branch_name, body) 88 | 89 | async def should_skip_test(self) -> bool: 90 | """判断评论是否包含跳过的标记""" 91 | comments = await self.list_comments() 92 | for comment in comments: 93 | author_association = comment.author_association 94 | if comment.body == SKIP_COMMENT and author_association in [ 95 | "OWNER", 96 | "MEMBER", 97 | ]: 98 | return True 99 | return False 100 | 101 | async def list_comments(self, issue_number: int | None = None): 102 | """拉取所有评论""" 103 | if issue_number is None: 104 | issue_number = self.issue_number 105 | 106 | return await super().list_comments(issue_number) 107 | 108 | async def get_self_comment(self, issue_number: int | None = None): 109 | """获取自己的评论""" 110 | if issue_number is None: 111 | issue_number = self.issue_number 112 | 113 | return await super().get_self_comment(issue_number) 114 | 115 | async def comment_issue( 116 | self, comment: str, issue_number: int | None = None, self_comment=None 117 | ): 118 | """发布评论""" 119 | if issue_number is None: 120 | issue_number = self.issue_number 121 | 122 | await super().comment_issue(comment, issue_number, self_comment) 123 | 124 | async def resuable_comment_issue( 125 | self, comment: str, issue_number: int | None = None 126 | ): 127 | """发布评论,若之前已评论过,则会进行复用""" 128 | if issue_number is None: 129 | issue_number = self.issue_number 130 | 131 | self_comment = await self.get_self_comment(issue_number) 132 | await self.comment_issue(comment, issue_number, self_comment) 133 | 134 | def commit_and_push( 135 | self, 136 | message: str, 137 | branch_name: str, 138 | author: str | None = None, 139 | ): 140 | if author is None: 141 | author = self.author 142 | 143 | return super().commit_and_push(message, branch_name, author) 144 | -------------------------------------------------------------------------------- /tests/plugins/github/publish/utils/test_history_workflow.py: -------------------------------------------------------------------------------- 1 | from inline_snapshot import snapshot 2 | from nonebug import App 3 | 4 | 5 | async def test_history_workflow(app: App): 6 | """测试从评论中提取历史工作流信息""" 7 | from src.plugins.github.plugins.publish.utils import ( 8 | get_history_workflow_from_comment, 9 | ) 10 | from src.providers.constants import TIME_ZONE 11 | 12 | CONTENT = """ 13 | # 📃 商店发布检查结果 14 | 15 | > Plugin: nonebot-plugin-emojilike-automonkey 16 | 17 | **✅ 所有测试通过,一切准备就绪!** 18 | 19 | 20 |
    21 | 详情 22 |
  • ✅ 项目 主页 返回状态码 200。
  • ✅ 项目 nonebot-plugin-emojilike-automonkey 已发布至 PyPI。
  • ✅ 标签: emoji-#6677ff。
  • ✅ 插件类型: application。
  • ✅ 插件支持的适配器: nonebot.adapters.onebot.v11。
  • ✅ 插件 加载测试 通过。
  • ✅ 版本号: 0.0.12。
  • ✅ 发布时间:2025-03-28 02:03:18 CST。
  • 23 |
    24 | 25 |
    26 | 历史测试 27 |
    
     28 | 
  • ⚠️ 2025-03-28 02:21:18 CST
  • 2025-03-28 02:21:18 CST
  • 2025-03-28 02:22:18 CST
  • ⚠️ 2025-03-28 02:22:18 CST
  • 29 |
    30 |
    31 | 32 | --- 33 | 34 | 💡 如需修改信息,请直接修改 issue,机器人会自动更新检查结果。 35 | 💡 当插件加载测试失败时,请发布新版本后勾选插件测试勾选框重新运行插件测试。 36 | 37 | ♻️ 评论已更新至最新检查结果 38 | 39 | 💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow) 40 | 41 | """ 42 | history = [ 43 | (valid, url, time.astimezone(TIME_ZONE).strftime("%Y-%m-%d %H:%M:%S %Z")) 44 | for valid, url, time in await get_history_workflow_from_comment(CONTENT) 45 | ] 46 | assert history == snapshot( 47 | [ 48 | ( 49 | False, 50 | "https://github.com/nonebot/nonebot2/actions/runs/14156878699", 51 | "2025-03-28 02:21:18 CST", 52 | ), 53 | ( 54 | True, 55 | "https://github.com/nonebot/nonebot2/actions/runs/14156878699", 56 | "2025-03-28 02:21:18 CST", 57 | ), 58 | ( 59 | False, 60 | "https://github.com/nonebot/nonebot2/actions/runs/14156878699", 61 | "2025-03-28 02:22:18 CST", 62 | ), 63 | ] 64 | ) 65 | 66 | 67 | async def test_history_workflow_different_repo(app: App): 68 | """测试指向不同仓库的历史工作流""" 69 | from src.plugins.github.plugins.publish.utils import ( 70 | get_history_workflow_from_comment, 71 | ) 72 | from src.providers.constants import TIME_ZONE 73 | 74 | CONTENT = """ 75 | # 📃 商店发布检查结果 76 | 77 | > Plugin: 明日方舟干员插件 78 | 79 | [![主页](https://img.shields.io/badge/HOMEPAGE-200-green?style=for-the-badge)](https://github.com/xingzhiyou/nonebot-plugin-ark-roulette) [![测试结果](https://img.shields.io/badge/RESULT-OK-green?style=for-the-badge)](https://github.com/nonebot/noneflow-test/actions/runs/15612654853) 80 | 81 | **✅ 所有测试通过,一切准备就绪!** 82 | 83 | 84 |
    85 | 详情 86 |
  • ✅ 项目 主页 返回状态码 200。
  • ✅ 项目 nonebot-plugin-ark-roulette 已发布至 PyPI。
  • ✅ 插件类型: application。
  • ✅ 插件支持的适配器: 所有。
  • ✅ 插件 加载测试 通过。
  • ✅ 版本号: 2.0.1。
  • ✅ 发布时间:2025-05-20 03:58:54 CST。
  • 87 |
    88 |
    89 | 历史测试 90 |
  • 2025-06-12 22:01:37 CST
  • 91 |
    92 | 93 | --- 94 | 95 | 💡 如需修改信息,请直接修改 issue,机器人会自动更新检查结果。 96 | 💡 当插件加载测试失败时,请发布新版本后勾选插件测试勾选框重新运行插件测试。 97 | 98 | ♻️ 评论已更新至最新检查结果 99 | 100 | 💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow) 101 | 102 | 103 | """ 104 | history = [ 105 | (valid, url, time.astimezone(TIME_ZONE).strftime("%Y-%m-%d %H:%M:%S %Z")) 106 | for valid, url, time in await get_history_workflow_from_comment(CONTENT) 107 | ] 108 | assert history == snapshot( 109 | [ 110 | ( 111 | True, 112 | "https://github.com/nonebot/noneflow-test/actions/runs/15612654853", 113 | "2025-06-12 22:01:37 CST", 114 | ) 115 | ] 116 | ) 117 | -------------------------------------------------------------------------------- /tests/providers/validation/test_adapter.py: -------------------------------------------------------------------------------- 1 | from inline_snapshot import snapshot 2 | from respx import MockRouter 3 | 4 | from tests.providers.validation.utils import generate_adapter_data 5 | 6 | 7 | async def test_adapter_info_validation_success(mocked_api: MockRouter) -> None: 8 | """测试验证成功的情况""" 9 | from src.providers.validation import AdapterPublishInfo, PublishType, validate_info 10 | 11 | data = generate_adapter_data() 12 | 13 | result = validate_info(PublishType.ADAPTER, data, []) 14 | 15 | assert result.valid 16 | assert result.type == PublishType.ADAPTER 17 | assert result.raw_data == snapshot( 18 | { 19 | "name": "name", 20 | "desc": "desc", 21 | "author": "author", 22 | "module_name": "module_name", 23 | "project_link": "project_link", 24 | "homepage": "https://nonebot.dev", 25 | "tags": '[{"label": "test", "color": "#ffffff"}]', 26 | "author_id": 1, 27 | "time": "2023-09-01T00:00:00.000000Z", 28 | "version": "0.0.1", 29 | } 30 | ) 31 | 32 | assert isinstance(result.info, AdapterPublishInfo) 33 | assert result.errors == [] 34 | 35 | assert mocked_api["homepage"].called 36 | 37 | 38 | async def test_adapter_info_validation_failed(mocked_api: MockRouter) -> None: 39 | """测试验证失败的情况""" 40 | from src.providers.validation import PublishType, validate_info 41 | from src.providers.validation.models import ValidationDict 42 | 43 | data = generate_adapter_data( 44 | module_name="module_name/", 45 | project_link="project_link_failed", 46 | homepage="https://www.baidu.com", 47 | tags=[ 48 | {"label": "test", "color": "#ffffff"}, 49 | {"label": "testtoolong", "color": "#fffffff"}, 50 | ], 51 | ) 52 | 53 | result = validate_info(PublishType.ADAPTER, data, []) 54 | 55 | assert result == snapshot( 56 | ValidationDict( 57 | errors=[ 58 | { 59 | "type": "module_name", 60 | "loc": ("module_name",), 61 | "msg": "包名不符合规范", 62 | "input": "module_name/", 63 | }, 64 | { 65 | "type": "project_link.not_found", 66 | "loc": ("project_link",), 67 | "msg": "PyPI 项目名不存在", 68 | "input": "project_link_failed", 69 | }, 70 | { 71 | "type": "string_type", 72 | "loc": ("time",), 73 | "msg": "值不是合法的字符串", 74 | "input": None, 75 | "url": "https://errors.pydantic.dev/2.12/v/string_type", 76 | }, 77 | { 78 | "type": "string_type", 79 | "loc": ("version",), 80 | "msg": "值不是合法的字符串", 81 | "input": None, 82 | "url": "https://errors.pydantic.dev/2.12/v/string_type", 83 | }, 84 | { 85 | "type": "homepage", 86 | "loc": ("homepage",), 87 | "msg": "项目主页无法访问", 88 | "input": "https://www.baidu.com", 89 | "ctx": {"status_code": 404, "msg": ""}, 90 | }, 91 | { 92 | "type": "string_too_long", 93 | "loc": ("tags", 1, "label"), 94 | "msg": "字符串长度不能超过 10 个字符", 95 | "input": "testtoolong", 96 | "ctx": {"max_length": 10}, 97 | "url": "https://errors.pydantic.dev/2.12/v/string_too_long", 98 | }, 99 | { 100 | "type": "color_error", 101 | "loc": ("tags", 1, "color"), 102 | "msg": "颜色格式不正确", 103 | "input": "#fffffff", 104 | }, 105 | ], 106 | raw_data={ 107 | "name": "name", 108 | "desc": "desc", 109 | "author": "author", 110 | "module_name": "module_name/", 111 | "project_link": "project_link_failed", 112 | "homepage": "https://www.baidu.com", 113 | "tags": '[{"label": "test", "color": "#ffffff"}, {"label": "testtoolong", "color": "#fffffff"}]', 114 | "author_id": 1, 115 | "time": None, 116 | "version": None, 117 | }, 118 | type=PublishType.ADAPTER, 119 | valid_data={ 120 | "name": "name", 121 | "desc": "desc", 122 | "author": "author", 123 | "author_id": 1, 124 | }, 125 | ) 126 | ) 127 | 128 | assert mocked_api["homepage_failed"].called 129 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | release: 9 | types: [published] 10 | 11 | jobs: 12 | test: 13 | name: Test 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v6 18 | 19 | - name: Setup uv 20 | uses: astral-sh/setup-uv@v7 21 | with: 22 | enable-cache: true 23 | 24 | - name: Run tests 25 | run: uv run poe test 26 | 27 | - name: Upload test results to Codecov 28 | if: ${{ !cancelled() }} 29 | uses: codecov/test-results-action@v1 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | 33 | - name: Upload coverage to Codecov 34 | uses: codecov/codecov-action@v5 35 | with: 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | 38 | noneflow-docker: 39 | name: NoneFlow Docker 40 | runs-on: ubuntu-latest 41 | needs: test 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v6 45 | 46 | - name: Setup Docker 47 | uses: docker/setup-buildx-action@v3 48 | 49 | - name: Login to Github Container Registry 50 | if: github.event_name != 'pull_request' 51 | uses: docker/login-action@v3 52 | with: 53 | registry: ghcr.io 54 | username: ${{ github.repository_owner }} 55 | password: ${{ secrets.GITHUB_TOKEN }} 56 | 57 | - name: Generate Tags 58 | uses: docker/metadata-action@v5 59 | id: metadata 60 | with: 61 | images: ghcr.io/nonebot/noneflow 62 | tags: | 63 | type=semver,pattern={{version}} 64 | type=ref,event=branch 65 | 66 | - name: Cache buildkit 67 | uses: actions/cache@v4 68 | id: cache 69 | with: 70 | path: | 71 | var-cache-apt 72 | var-lib-apt 73 | root-cache-uv 74 | key: cache-${{ hashFiles('docker/noneflow.dockerfile') }} 75 | 76 | - name: Inject cache into docker 77 | uses: reproducible-containers/buildkit-cache-dance@v3.3.0 78 | with: 79 | cache-map: | 80 | { 81 | "var-cache-apt": "/var/cache/apt", 82 | "var-lib-apt": "/var/lib/apt", 83 | "root-cache-uv": "/root/.cache/uv" 84 | } 85 | skip-extraction: ${{ steps.cache.outputs.cache-hit }} 86 | 87 | - name: Build and Publish 88 | uses: docker/build-push-action@v6 89 | with: 90 | context: . 91 | file: ./docker/noneflow.dockerfile 92 | push: ${{ github.event_name != 'pull_request' }} 93 | tags: ${{ steps.metadata.outputs.tags }} 94 | labels: ${{ steps.metadata.outputs.labels }} 95 | cache-from: type=gha 96 | cache-to: type=gha,mode=max 97 | 98 | nonetest-docker: 99 | name: NoneTest Docker 100 | runs-on: ubuntu-latest 101 | needs: test 102 | steps: 103 | - name: Checkout 104 | uses: actions/checkout@v6 105 | 106 | - name: Setup Docker 107 | uses: docker/setup-buildx-action@v3 108 | 109 | - name: Login to Github Container Registry 110 | if: github.event_name != 'pull_request' 111 | uses: docker/login-action@v3 112 | with: 113 | registry: ghcr.io 114 | username: ${{ github.repository_owner }} 115 | password: ${{ secrets.GITHUB_TOKEN }} 116 | 117 | - name: Generate Tags 118 | uses: docker/metadata-action@v5 119 | id: metadata 120 | with: 121 | images: ghcr.io/nonebot/nonetest 122 | tags: | 123 | type=semver,pattern={{version}} 124 | type=ref,event=branch 125 | 126 | - name: Cache buildkit 127 | uses: actions/cache@v4 128 | id: cache 129 | with: 130 | path: | 131 | var-cache-apt 132 | var-lib-apt 133 | root-cache-uv 134 | key: cache-${{ hashFiles('docker/nonetest.dockerfile') }} 135 | 136 | - name: Inject cache into docker 137 | uses: reproducible-containers/buildkit-cache-dance@v3.3.0 138 | with: 139 | cache-map: | 140 | { 141 | "var-cache-apt": "/var/cache/apt", 142 | "var-lib-apt": "/var/lib/apt", 143 | "root-cache-uv": "/root/.cache/uv" 144 | } 145 | skip-extraction: ${{ steps.cache.outputs.cache-hit }} 146 | 147 | - name: Build and Publish 148 | uses: docker/build-push-action@v6 149 | with: 150 | context: . 151 | file: ./docker/nonetest.dockerfile 152 | push: ${{ github.event_name != 'pull_request' }} 153 | tags: ${{ steps.metadata.outputs.tags }} 154 | labels: ${{ steps.metadata.outputs.labels }} 155 | cache-from: type=gha 156 | cache-to: type=gha,mode=max 157 | -------------------------------------------------------------------------------- /tests/plugins/github/config/process/test_config_auto_merge.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters.github import PullRequestReviewSubmitted 2 | from nonebug import App 3 | from pytest_mock import MockerFixture 4 | 5 | from tests.plugins.github.event import get_mock_event 6 | from tests.plugins.github.utils import ( 7 | assert_subprocess_run_calls, 8 | get_github_bot, 9 | mock_subprocess_run_with_side_effect, 10 | ) 11 | 12 | 13 | def get_issue_labels(labels: list[str]): 14 | from githubkit.rest import ( 15 | WebhookPullRequestReviewSubmittedPropPullRequestPropLabelsItems as Label, 16 | ) 17 | 18 | return [ 19 | Label.model_construct( 20 | **{ 21 | "color": "2A2219", 22 | "default": False, 23 | "description": "", 24 | "id": 2798075966, 25 | "name": label, 26 | "node_id": "MDU6TGFiZWwyNzk4MDc1OTY2", 27 | "url": "https://api.github.com/repos/he0119/action-test/labels/Config", 28 | } 29 | ) 30 | for label in labels 31 | ] 32 | 33 | 34 | async def test_config_auto_merge( 35 | app: App, mocker: MockerFixture, mock_installation, mock_installation_token 36 | ) -> None: 37 | """测试审查后自动合并 38 | 39 | 可直接合并的情况 40 | """ 41 | from src.plugins.github.plugins.config import auto_merge_matcher 42 | 43 | mock_subprocess_run = mock_subprocess_run_with_side_effect(mocker) 44 | 45 | async with app.test_matcher() as ctx: 46 | _adapter, bot = get_github_bot(ctx) 47 | event = get_mock_event(PullRequestReviewSubmitted) 48 | event.payload.pull_request.labels = get_issue_labels(["Config", "Plugin"]) 49 | 50 | ctx.should_call_api( 51 | "rest.apps.async_get_repo_installation", 52 | {"owner": "he0119", "repo": "action-test"}, 53 | mock_installation, 54 | ) 55 | ctx.should_call_api( 56 | "rest.apps.async_create_installation_access_token", 57 | {"installation_id": mock_installation.parsed_data.id}, 58 | mock_installation_token, 59 | ) 60 | ctx.should_call_api( 61 | "rest.pulls.async_merge", 62 | { 63 | "owner": "he0119", 64 | "repo": "action-test", 65 | "pull_number": 100, 66 | "merge_method": "rebase", 67 | }, 68 | True, 69 | ) 70 | 71 | ctx.receive_event(bot, event) 72 | ctx.should_pass_rule(auto_merge_matcher) 73 | 74 | # 测试 git 命令 75 | assert_subprocess_run_calls( 76 | mock_subprocess_run, 77 | [["git", "config", "--global", "safe.directory", "*"]], 78 | ) 79 | 80 | 81 | async def test_auto_merge_not_remove(app: App, mocker: MockerFixture) -> None: 82 | """测试审查后自动合并 83 | 84 | 和配置无关 85 | """ 86 | from src.plugins.github.plugins.config import auto_merge_matcher 87 | 88 | mock_subprocess_run = mock_subprocess_run_with_side_effect(mocker) 89 | 90 | async with app.test_matcher() as ctx: 91 | _adapter, bot = get_github_bot(ctx) 92 | event = get_mock_event(PullRequestReviewSubmitted) 93 | event.payload.pull_request.labels = [] 94 | 95 | ctx.receive_event(bot, event) 96 | ctx.should_not_pass_rule(auto_merge_matcher) 97 | 98 | # 测试 git 命令 99 | mock_subprocess_run.assert_not_called() 100 | 101 | 102 | async def test_auto_merge_not_member(app: App, mocker: MockerFixture) -> None: 103 | """测试审查后自动合并 104 | 105 | 审核者不是仓库成员 106 | """ 107 | from src.plugins.github.plugins.config import auto_merge_matcher 108 | 109 | mock_subprocess_run = mock_subprocess_run_with_side_effect(mocker) 110 | 111 | async with app.test_matcher() as ctx: 112 | _adapter, bot = get_github_bot(ctx) 113 | event = get_mock_event(PullRequestReviewSubmitted) 114 | event.payload.review.author_association = "CONTRIBUTOR" 115 | event.payload.pull_request.labels = get_issue_labels(["Config", "Plugin"]) 116 | 117 | ctx.receive_event(bot, event) 118 | ctx.should_not_pass_rule(auto_merge_matcher) 119 | 120 | # 测试 git 命令 121 | mock_subprocess_run.assert_not_called() 122 | 123 | 124 | async def test_auto_merge_not_approve(app: App, mocker: MockerFixture) -> None: 125 | """测试审查后自动合并 126 | 127 | 审核未通过 128 | """ 129 | from src.plugins.github.plugins.config import auto_merge_matcher 130 | 131 | mock_subprocess_run = mock_subprocess_run_with_side_effect(mocker) 132 | 133 | async with app.test_matcher() as ctx: 134 | _adapter, bot = get_github_bot(ctx) 135 | event = get_mock_event(PullRequestReviewSubmitted) 136 | event.payload.pull_request.labels = get_issue_labels(["Config", "Plugin"]) 137 | event.payload.review.state = "commented" 138 | 139 | ctx.receive_event(bot, event) 140 | ctx.should_not_pass_rule(auto_merge_matcher) 141 | 142 | # 测试 git 命令 143 | 144 | mock_subprocess_run.assert_not_called() 145 | -------------------------------------------------------------------------------- /tests/plugins/github/handlers/test_git_handler.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import _Call, call 2 | 3 | import pytest 4 | from pytest_mock import MockerFixture 5 | 6 | 7 | @pytest.fixture 8 | def mock_run_shell_command(mocker: MockerFixture): 9 | return mocker.patch("src.plugins.github.handlers.git.run_shell_command") 10 | 11 | 12 | async def test_checkout_branch(mock_run_shell_command): 13 | from src.plugins.github.handlers.git import GitHandler 14 | 15 | git_handler = GitHandler() 16 | git_handler.checkout_branch("main") 17 | 18 | mock_run_shell_command.assert_has_calls( 19 | [ 20 | call(["git", "checkout", "main"]), 21 | ] 22 | ) 23 | 24 | 25 | async def test_checkout_branch_with_update(mock_run_shell_command): 26 | from src.plugins.github.handlers.git import GitHandler 27 | 28 | git_handler = GitHandler() 29 | git_handler.checkout_branch("main", update=True) 30 | 31 | mock_run_shell_command.assert_has_calls( 32 | [ 33 | call(["git", "checkout", "main"]), 34 | call(["git", "pull"]), 35 | ] 36 | ) 37 | 38 | 39 | async def test_checkout_remote_branch(mock_run_shell_command): 40 | from src.plugins.github.handlers.git import GitHandler 41 | 42 | git_handler = GitHandler() 43 | git_handler.checkout_remote_branch("main") 44 | 45 | mock_run_shell_command.assert_has_calls( 46 | [ 47 | call(["git", "fetch", "origin", "main"]), 48 | call(["git", "checkout", "main"]), 49 | ] 50 | ) 51 | 52 | 53 | async def test_add_file(mock_run_shell_command): 54 | from src.plugins.github.handlers.git import GitHandler 55 | 56 | git_handler = GitHandler() 57 | git_handler.add_file("test.txt") 58 | 59 | mock_run_shell_command.assert_has_calls( 60 | [ 61 | call(["git", "add", "test.txt"]), 62 | ] 63 | ) 64 | 65 | 66 | async def test_add_all_files(mock_run_shell_command): 67 | from src.plugins.github.handlers.git import GitHandler 68 | 69 | git_handler = GitHandler() 70 | git_handler.add_all_files() 71 | 72 | mock_run_shell_command.assert_has_calls( 73 | [ 74 | call(["git", "add", "-A"]), 75 | ] 76 | ) 77 | 78 | 79 | async def test_commit_and_push(mock_run_shell_command): 80 | from src.plugins.github.handlers.git import GitHandler 81 | 82 | git_handler = GitHandler() 83 | git_handler.commit_and_push("commit message", "main", "author") 84 | 85 | mock_run_shell_command.assert_has_calls( 86 | [ 87 | call(["git", "config", "--global", "user.name", "author"]), 88 | call( 89 | [ 90 | "git", 91 | "config", 92 | "--global", 93 | "user.email", 94 | "author@users.noreply.github.com", 95 | ] 96 | ), 97 | call(["git", "commit", "-m", "commit message"]), 98 | call(["git", "fetch", "origin"]), 99 | call(["git", "diff", "origin/main", "main"]), 100 | _Call(("().stdout.__bool__", (), {})), 101 | call(["git", "push", "origin", "main", "-f"]), 102 | ], 103 | ) 104 | 105 | 106 | async def test_commit_and_push_diff_no_change(mock_run_shell_command): 107 | """本地分支与远程分支一致,跳过推送的情况""" 108 | from src.plugins.github.handlers.git import GitHandler 109 | 110 | # 本地分支与远程分支一致时 git diff 应该返回空字符串 111 | mock_run_shell_command.return_value.stdout = "" 112 | 113 | git_handler = GitHandler() 114 | git_handler.commit_and_push("commit message", "main", "author") 115 | 116 | mock_run_shell_command.assert_has_calls( 117 | [ 118 | call(["git", "config", "--global", "user.name", "author"]), 119 | call( 120 | [ 121 | "git", 122 | "config", 123 | "--global", 124 | "user.email", 125 | "author@users.noreply.github.com", 126 | ] 127 | ), 128 | call(["git", "commit", "-m", "commit message"]), 129 | call(["git", "fetch", "origin"]), 130 | call(["git", "diff", "origin/main", "main"]), 131 | ], 132 | ) 133 | 134 | 135 | async def test_delete_origin_branch(mock_run_shell_command): 136 | from src.plugins.github.handlers.git import GitHandler 137 | 138 | git_handler = GitHandler() 139 | git_handler.delete_remote_branch("main") 140 | 141 | mock_run_shell_command.assert_has_calls( 142 | [ 143 | call(["git", "push", "origin", "--delete", "main"]), 144 | ] 145 | ) 146 | 147 | 148 | async def test_switch_branch(mock_run_shell_command): 149 | from src.plugins.github.handlers.git import GitHandler 150 | 151 | git_handler = GitHandler() 152 | git_handler.switch_branch("main") 153 | 154 | mock_run_shell_command.assert_has_calls( 155 | [ 156 | call(["git", "switch", "-C", "main"]), 157 | ] 158 | ) 159 | -------------------------------------------------------------------------------- /tests/plugins/github/remove/process/test_remove_auto_merge.py: -------------------------------------------------------------------------------- 1 | from nonebot.adapters.github import PullRequestReviewSubmitted 2 | from nonebug import App 3 | from pytest_mock import MockerFixture 4 | 5 | from tests.plugins.github.event import get_mock_event 6 | from tests.plugins.github.utils import ( 7 | assert_subprocess_run_calls, 8 | get_github_bot, 9 | ) 10 | 11 | 12 | def get_issue_labels(labels: list[str]): 13 | from githubkit.rest import ( 14 | WebhookPullRequestReviewSubmittedPropPullRequestPropLabelsItems as Label, 15 | ) 16 | 17 | return [ 18 | Label.model_construct( 19 | **{ 20 | "color": "2A2219", 21 | "default": False, 22 | "description": "", 23 | "id": 2798075966, 24 | "name": label, 25 | "node_id": "MDU6TGFiZWwyNzk4MDc1OTY2", 26 | "url": "https://api.github.com/repos/he0119/action-test/labels/Remove", 27 | } 28 | ) 29 | for label in labels 30 | ] 31 | 32 | 33 | async def test_remove_auto_merge( 34 | app: App, mocker: MockerFixture, mock_installation, mock_installation_token 35 | ) -> None: 36 | """测试审查后自动合并 37 | 38 | 可直接合并的情况 39 | """ 40 | from src.plugins.github.plugins.remove import auto_merge_matcher 41 | 42 | mock_subprocess_run = mocker.patch("subprocess.run") 43 | 44 | mock_pull = mocker.MagicMock() 45 | mock_pull.mergeable = True 46 | mock_pull_resp = mocker.MagicMock() 47 | mock_pull_resp.parsed_data = mock_pull 48 | 49 | async with app.test_matcher() as ctx: 50 | _adapter, bot = get_github_bot(ctx) 51 | event = get_mock_event(PullRequestReviewSubmitted) 52 | event.payload.pull_request.labels = get_issue_labels(["Remove", "Plugin"]) 53 | 54 | ctx.should_call_api( 55 | "rest.apps.async_get_repo_installation", 56 | {"owner": "he0119", "repo": "action-test"}, 57 | mock_installation, 58 | ) 59 | ctx.should_call_api( 60 | "rest.apps.async_create_installation_access_token", 61 | {"installation_id": mock_installation.parsed_data.id}, 62 | mock_installation_token, 63 | ) 64 | ctx.should_call_api( 65 | "rest.pulls.async_merge", 66 | { 67 | "owner": "he0119", 68 | "repo": "action-test", 69 | "pull_number": 100, 70 | "merge_method": "rebase", 71 | }, 72 | True, 73 | ) 74 | 75 | ctx.receive_event(bot, event) 76 | ctx.should_pass_rule(auto_merge_matcher) 77 | 78 | # 测试 git 命令 79 | assert_subprocess_run_calls( 80 | mock_subprocess_run, 81 | [["git", "config", "--global", "safe.directory", "*"]], 82 | ) 83 | 84 | 85 | async def test_auto_merge_not_remove(app: App, mocker: MockerFixture) -> None: 86 | """测试审查后自动合并 87 | 88 | 和删除无关 89 | """ 90 | from src.plugins.github.plugins.remove import auto_merge_matcher 91 | 92 | mock_subprocess_run = mocker.patch("subprocess.run") 93 | 94 | async with app.test_matcher() as ctx: 95 | _adapter, bot = get_github_bot(ctx) 96 | event = get_mock_event(PullRequestReviewSubmitted) 97 | event.payload.pull_request.labels = [] 98 | 99 | ctx.receive_event(bot, event) 100 | ctx.should_not_pass_rule(auto_merge_matcher) 101 | 102 | # 测试 git 命令 103 | mock_subprocess_run.assert_not_called() 104 | 105 | 106 | async def test_auto_merge_not_member(app: App, mocker: MockerFixture) -> None: 107 | """测试审查后自动合并 108 | 109 | 审核者不是仓库成员 110 | """ 111 | from src.plugins.github.plugins.remove import auto_merge_matcher 112 | 113 | mock_subprocess_run = mocker.patch("subprocess.run") 114 | 115 | async with app.test_matcher() as ctx: 116 | _adapter, bot = get_github_bot(ctx) 117 | event = get_mock_event(PullRequestReviewSubmitted) 118 | event.payload.review.author_association = "CONTRIBUTOR" 119 | event.payload.pull_request.labels = get_issue_labels(["Remove", "Plugin"]) 120 | 121 | ctx.receive_event(bot, event) 122 | ctx.should_not_pass_rule(auto_merge_matcher) 123 | 124 | # 测试 git 命令 125 | mock_subprocess_run.assert_not_called() 126 | 127 | 128 | async def test_auto_merge_not_approve(app: App, mocker: MockerFixture) -> None: 129 | """测试审查后自动合并 130 | 131 | 审核未通过 132 | """ 133 | from src.plugins.github.plugins.remove import auto_merge_matcher 134 | 135 | mock_subprocess_run = mocker.patch("subprocess.run") 136 | 137 | async with app.test_matcher() as ctx: 138 | _adapter, bot = get_github_bot(ctx) 139 | event = get_mock_event(PullRequestReviewSubmitted) 140 | event.payload.pull_request.labels = get_issue_labels(["Remove", "Plugin"]) 141 | event.payload.review.state = "commented" 142 | 143 | ctx.receive_event(bot, event) 144 | ctx.should_not_pass_rule(auto_merge_matcher) 145 | 146 | # 测试 git 命令 147 | mock_subprocess_run.assert_not_called() 148 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/remove/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import logger, on_type 2 | from nonebot.adapters.github import GitHubBot 3 | from nonebot.adapters.github.event import ( 4 | IssueCommentCreated, 5 | IssuesEdited, 6 | IssuesOpened, 7 | IssuesReopened, 8 | PullRequestReviewSubmitted, 9 | ) 10 | from nonebot.params import Depends 11 | from pydantic_core import PydanticCustomError 12 | 13 | from src.plugins.github import plugin_config 14 | from src.plugins.github.constants import TITLE_MAX_LENGTH 15 | from src.plugins.github.depends import ( 16 | get_github_handler, 17 | get_installation_id, 18 | get_issue_handler, 19 | get_related_issue_number, 20 | get_type_by_labels_name, 21 | is_bot_triggered_workflow, 22 | is_remove_workflow, 23 | setup_git, 24 | ) 25 | from src.plugins.github.handlers import GithubHandler, IssueHandler 26 | from src.plugins.github.typing import IssuesEvent 27 | from src.providers.validation.models import PublishType 28 | 29 | from .constants import BRANCH_NAME_PREFIX 30 | from .render import render_comment, render_error 31 | from .utils import process_pull_requests 32 | from .validation import validate_author_info 33 | 34 | 35 | async def pr_close_rule( 36 | is_remove: bool = Depends(is_remove_workflow), 37 | related_issue_number: int | None = Depends(get_related_issue_number), 38 | ) -> bool: 39 | if not is_remove: 40 | logger.info("拉取请求与删除无关,已跳过") 41 | return False 42 | 43 | if not related_issue_number: 44 | logger.error("无法获取相关的议题编号") 45 | return False 46 | 47 | return True 48 | 49 | 50 | async def check_rule( 51 | event: IssuesEvent, 52 | is_remove: bool = Depends(is_remove_workflow), 53 | is_bot: bool = Depends(is_bot_triggered_workflow), 54 | ) -> bool: 55 | if is_bot: 56 | logger.info("机器人触发的工作流,已跳过") 57 | return False 58 | if event.payload.issue.pull_request: 59 | logger.info("评论在拉取请求下,已跳过") 60 | return False 61 | if not is_remove: 62 | logger.info("非删除工作流,已跳过") 63 | return False 64 | return True 65 | 66 | 67 | remove_check_matcher = on_type( 68 | (IssuesOpened, IssuesReopened, IssuesEdited, IssueCommentCreated), rule=check_rule 69 | ) 70 | 71 | 72 | @remove_check_matcher.handle(parameterless=[Depends(setup_git)]) 73 | async def handle_remove_check( 74 | bot: GitHubBot, 75 | installation_id: int = Depends(get_installation_id), 76 | handler: IssueHandler = Depends(get_issue_handler), 77 | publish_type: PublishType = Depends(get_type_by_labels_name), 78 | ): 79 | async with bot.as_installation(installation_id): 80 | if handler.issue.state != "open": 81 | logger.info("议题未开启,已跳过") 82 | await remove_check_matcher.finish() 83 | 84 | try: 85 | # 搜索包的信息和验证作者信息 86 | result = await validate_author_info(handler.issue, publish_type) 87 | except PydanticCustomError as err: 88 | logger.error(f"信息验证失败: {err}") 89 | await handler.resuable_comment_issue(await render_error(err)) 90 | await remove_check_matcher.finish() 91 | 92 | title = f"{result.publish_type}: Remove {result.name or 'Unknown'}"[ 93 | :TITLE_MAX_LENGTH 94 | ] 95 | branch_name = f"{BRANCH_NAME_PREFIX}{handler.issue_number}" 96 | 97 | # 根据 input_config 里的 remove 仓库来进行提交和 PR 98 | store_handler = GithubHandler( 99 | bot=handler.bot, repo_info=plugin_config.input_config.store_repository 100 | ) 101 | # 处理拉取请求和议题标题 102 | await process_pull_requests(handler, store_handler, result, branch_name, title) 103 | 104 | await handler.update_issue_title(title) 105 | 106 | # 获取 pull request 编号 107 | pull_number = ( 108 | await store_handler.get_pull_request_by_branch(branch_name) 109 | ).number 110 | 111 | # 评论议题 112 | comment = await render_comment( 113 | result, 114 | f"{plugin_config.input_config.store_repository}#{pull_number}", 115 | ) 116 | await handler.resuable_comment_issue(comment) 117 | 118 | 119 | async def review_submitted_rule( 120 | event: PullRequestReviewSubmitted, 121 | is_remove: bool = Depends(is_remove_workflow), 122 | ) -> bool: 123 | if not is_remove: 124 | logger.info("拉取请求与删除无关,已跳过") 125 | return False 126 | if event.payload.review.author_association not in ["OWNER", "MEMBER"]: 127 | logger.info("审查者不是仓库成员,已跳过") 128 | return False 129 | if event.payload.review.state != "approved": 130 | logger.info("未通过审查,已跳过") 131 | return False 132 | 133 | return True 134 | 135 | 136 | auto_merge_matcher = on_type(PullRequestReviewSubmitted, rule=review_submitted_rule) 137 | 138 | 139 | @auto_merge_matcher.handle(parameterless=[Depends(setup_git)]) 140 | async def handle_auto_merge( 141 | bot: GitHubBot, 142 | event: PullRequestReviewSubmitted, 143 | installation_id: int = Depends(get_installation_id), 144 | handler: GithubHandler = Depends(get_github_handler), 145 | ) -> None: 146 | async with bot.as_installation(installation_id): 147 | pull_number = event.payload.pull_request.number 148 | 149 | await handler.merge_pull_request(pull_number, "rebase") 150 | 151 | logger.info(f"已自动合并 #{pull_number}") 152 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/config/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from nonebot import logger 4 | 5 | from src.plugins.github.handlers import GitHandler, IssueHandler 6 | from src.plugins.github.plugins.publish.constants import ( 7 | PLUGIN_CONFIG_PATTERN, 8 | PLUGIN_MODULE_IMPORT_PATTERN, 9 | PLUGIN_MODULE_NAME_PATTERN, 10 | PROJECT_LINK_PATTERN, 11 | ) 12 | from src.plugins.github.plugins.publish.render import render_summary 13 | from src.plugins.github.plugins.publish.validation import add_step_summary, strip_ansi 14 | from src.plugins.github.utils import extract_issue_info_from_issue 15 | from src.providers.constants import PYPI_KEY_TEMPLATE 16 | from src.providers.docker_test import DockerPluginTest, Metadata 17 | from src.providers.models import AuthorInfo, RegistryPlugin, StoreTestResult 18 | from src.providers.utils import dump_json, load_json_from_file 19 | from src.providers.validation import PublishType, ValidationDict, validate_info 20 | from src.providers.validation.models import PluginPublishInfo 21 | 22 | 23 | async def validate_info_from_issue(handler: IssueHandler) -> ValidationDict: 24 | """从议题中获取插件信息,并且运行插件测试加载且获取插件元信息后进行验证""" 25 | body = handler.issue.body if handler.issue.body else "" 26 | 27 | # 从议题里提取插件所需信息 28 | raw_data: dict[str, Any] = extract_issue_info_from_issue( 29 | { 30 | "module_name": [PLUGIN_MODULE_NAME_PATTERN, PLUGIN_MODULE_IMPORT_PATTERN], 31 | "project_link": PROJECT_LINK_PATTERN, 32 | "test_config": PLUGIN_CONFIG_PATTERN, 33 | }, 34 | body, 35 | ) 36 | # 从历史插件中获取标签 37 | previous_plugins: dict[str, RegistryPlugin] = { 38 | PYPI_KEY_TEMPLATE.format( 39 | project_link=plugin["project_link"], module_name=plugin["module_name"] 40 | ): RegistryPlugin(**plugin) 41 | for plugin in load_json_from_file("plugins.json") 42 | } 43 | raw_data["tags"] = previous_plugins[PYPI_KEY_TEMPLATE.format(**raw_data)].tags 44 | # 更新作者信息 45 | raw_data.update(AuthorInfo.from_issue(handler.issue).model_dump()) 46 | 47 | module_name: str = raw_data.get("module_name", "") 48 | project_link: str = raw_data.get("project_link", "") 49 | test_config: str = raw_data.get("test_config", "") 50 | 51 | # 因为修改插件重新测试,所以上次的数据不需要加载,不然会报错重复 52 | previous_data = [] 53 | 54 | # 修改插件配置肯定是为了通过插件测试,所以一定不跳过测试 55 | raw_data["skip_test"] = False 56 | 57 | # 运行插件测试 58 | test = DockerPluginTest(project_link, module_name, test_config) 59 | test_result = await test.run("3.12") 60 | 61 | # 去除颜色字符 62 | test_output = strip_ansi(test_result.output) 63 | metadata = test_result.metadata 64 | if metadata: 65 | # 从插件测试结果中获得元数据 66 | raw_data.update(metadata) 67 | 68 | # 更新插件测试结果 69 | raw_data["version"] = test_result.version 70 | raw_data["load"] = test_result.load 71 | raw_data["test_output"] = test_output 72 | raw_data["metadata"] = bool(metadata) 73 | 74 | # 输出插件测试相关信息 75 | add_step_summary(await render_summary(test_result, test_output, project_link)) 76 | logger.info( 77 | f"插件 {project_link}({test_result.version}) 插件加载{'成功' if test_result.load else '失败'} {'插件已尝试加载' if test_result.run else '插件并未开始运行'}" 78 | ) 79 | logger.info(f"插件元数据:{metadata}") 80 | logger.info("插件测试输出:") 81 | logger.info(test_output) 82 | 83 | # 验证插件相关信息 84 | result = validate_info(PublishType.PLUGIN, raw_data, previous_data) 85 | 86 | if not result.valid_data.get("metadata"): 87 | # 如果没有跳过测试且缺少插件元数据,则跳过元数据相关的错误 88 | # 因为这个时候这些项都会报错,错误在此时没有意义 89 | metadata_keys = Metadata.__annotations__.keys() 90 | # 如果是重复报错,error["loc"] 是 () 91 | result.errors = [ 92 | error 93 | for error in result.errors 94 | if error["loc"] == () or error["loc"][0] not in metadata_keys 95 | ] 96 | # 元数据缺失时,需要删除元数据相关的字段 97 | for key in metadata_keys: 98 | result.valid_data.pop(key, None) 99 | 100 | return result 101 | 102 | 103 | def update_file(result: ValidationDict, handler: GitHandler) -> None: 104 | """更新文件""" 105 | if not isinstance(result.info, PluginPublishInfo): 106 | raise ValueError("仅支持修改插件配置") 107 | 108 | logger.info("正在更新配置文件和最新测试结果") 109 | 110 | # 读取文件 111 | previous_plugins: dict[str, RegistryPlugin] = { 112 | PYPI_KEY_TEMPLATE.format( 113 | project_link=plugin["project_link"], module_name=plugin["module_name"] 114 | ): RegistryPlugin(**plugin) 115 | for plugin in load_json_from_file("plugins.json") 116 | } 117 | previous_results: dict[str, StoreTestResult] = { 118 | key: StoreTestResult(**value) 119 | for key, value in load_json_from_file("results.json").items() 120 | } 121 | plugin_configs: dict[str, str] = load_json_from_file("plugin_configs.json") 122 | 123 | # 更新信息 124 | plugin = RegistryPlugin.from_publish_info(result.info) 125 | previous_plugins[plugin.key] = plugin 126 | previous_results[plugin.key] = StoreTestResult.from_info(result.info) 127 | plugin_configs[plugin.key] = result.info.test_config 128 | 129 | dump_json("plugins.json", list(previous_plugins.values())) 130 | dump_json("results.json", previous_results) 131 | dump_json("plugin_configs.json", plugin_configs, False) 132 | handler.add_all_files() 133 | 134 | logger.info("文件更新完成") 135 | -------------------------------------------------------------------------------- /src/plugins/github/plugins/publish/render.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from pathlib import Path 3 | 4 | import jinja2 5 | 6 | from src.plugins.github import plugin_config 7 | from src.providers.constants import TIME_ZONE 8 | from src.providers.docker_test import DockerTestResult 9 | from src.providers.utils import dumps_json 10 | from src.providers.validation import ValidationDict 11 | from src.providers.validation.models import PublishType 12 | 13 | from .constants import COMMENT_CARD_TEMPLATE, LOC_NAME_MAP 14 | 15 | 16 | def tags_to_str(tags: list[dict[str, str]]) -> str: 17 | """将标签列表转换为字符串""" 18 | return ", ".join([f"{tag['label']}-{tag['color']}" for tag in tags]) 19 | 20 | 21 | def supported_adapters_to_str(supported_adapters: list[str] | None) -> str: 22 | """将支持的适配器列表转换为字符串""" 23 | if supported_adapters is None: 24 | return "所有" 25 | return ", ".join(supported_adapters) 26 | 27 | 28 | def _loc_to_name(loc: str) -> str: 29 | """将 loc 转换为可读名称""" 30 | if loc in LOC_NAME_MAP: 31 | return LOC_NAME_MAP[loc] 32 | return loc 33 | 34 | 35 | def loc_to_name(loc: list[str | int]) -> str: 36 | """将 loc 转换为可读名称""" 37 | return " > ".join([_loc_to_name(str(item)) for item in loc]) 38 | 39 | 40 | def key_to_name(key: str) -> str: 41 | """将 key 转换为可读名称""" 42 | return _loc_to_name(key) 43 | 44 | 45 | def format_time(time: str) -> str: 46 | """格式化时间""" 47 | dt = datetime.fromisoformat(time) 48 | dt = dt.astimezone(tz=TIME_ZONE) 49 | return dt.strftime("%Y-%m-%d %H:%M:%S %Z") 50 | 51 | 52 | def format_datetime(dt: datetime) -> str: 53 | """格式化 datetime 对象为字符串""" 54 | dt = dt.astimezone(tz=TIME_ZONE) 55 | return dt.strftime("%Y-%m-%d %H:%M:%S %Z") 56 | 57 | 58 | env = jinja2.Environment( 59 | loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"), 60 | enable_async=True, 61 | lstrip_blocks=True, 62 | trim_blocks=True, 63 | autoescape=True, 64 | keep_trailing_newline=True, 65 | ) 66 | 67 | env.filters["tags_to_str"] = tags_to_str 68 | env.filters["supported_adapters_to_str"] = supported_adapters_to_str 69 | env.filters["loc_to_name"] = loc_to_name 70 | env.filters["key_to_name"] = key_to_name 71 | env.filters["format_time"] = format_time 72 | env.filters["format_datetime"] = format_datetime 73 | 74 | 75 | async def render_comment( 76 | result: ValidationDict, 77 | reuse: bool = False, 78 | history: list[tuple[bool, str, datetime]] | None = None, 79 | ) -> str: 80 | """将验证结果转换为评论内容""" 81 | title = f"{result.type}: {result.name}" 82 | 83 | valid_data = result.valid_data.copy() 84 | 85 | history = history or [] 86 | 87 | action_url = f"https://github.com/{plugin_config.github_repository}/actions/runs/{plugin_config.github_run_id}" 88 | if result.type == PublishType.PLUGIN: 89 | # https://github.com/he0119/action-test/actions/runs/4469672520 90 | # 仅在测试通过或跳过测试时显示 91 | # 如果 load 为 False 的时候 valid_data 里面没有 load 字段,所以直接用 raw_data 92 | if result.raw_data["load"] or result.raw_data["skip_test"]: 93 | valid_data["action_url"] = action_url 94 | 95 | # 如果 tags 字段为空则不显示 96 | if not valid_data.get("tags"): 97 | valid_data.pop("tags", None) 98 | 99 | # 仅显示必要字段 100 | display_keys = [ 101 | "homepage", 102 | "project_link", 103 | "tags", 104 | "type", 105 | "supported_adapters", 106 | "action_url", 107 | "version", 108 | "time", 109 | ] 110 | 111 | # 按照 display_keys 顺序展示数据 112 | data = {key: valid_data[key] for key in display_keys if key in valid_data} 113 | 114 | card: list[str] = [] 115 | if homepage := data.get("homepage"): 116 | card.append( 117 | COMMENT_CARD_TEMPLATE.format( 118 | name="主页", 119 | head="HOMEPAGE", 120 | content="200", 121 | color="green", 122 | url=homepage, 123 | ) 124 | ) 125 | if "action_url" in data: 126 | card.append( 127 | COMMENT_CARD_TEMPLATE.format( 128 | name="测试结果", 129 | head="RESULT", 130 | content="OK" if result.valid else "ERROR", 131 | color="green" if result.valid else "red", 132 | url=action_url, 133 | ) 134 | ) 135 | 136 | # 添加历史测试 137 | history.append((result.valid, action_url, datetime.now(tz=TIME_ZONE))) 138 | # 测试历史应该时间倒序排列 139 | history.sort(key=lambda x: x[2], reverse=True) 140 | # 限制最多显示 10 条历史 141 | history = history[:10] 142 | 143 | template = env.get_template("comment.md.jinja") 144 | return await template.render_async( 145 | card=" ".join(card), 146 | reuse=reuse, 147 | title=title, 148 | valid=result.valid, 149 | data=data, 150 | history=history, 151 | errors=result.errors, 152 | skip_test=result.skip_test, 153 | ) 154 | 155 | 156 | async def render_summary(test_result: DockerTestResult, output: str, project_link: str): 157 | """将测试结果转换为工作流总结""" 158 | template = env.get_template("summary.md.jinja") 159 | 160 | return await template.render_async( 161 | project_link=project_link, 162 | version=test_result.version, 163 | load=test_result.load, 164 | run=test_result.run, 165 | metadata=dumps_json(test_result.metadata, False) 166 | if test_result.metadata 167 | else {}, 168 | output=output, 169 | ) 170 | --------------------------------------------------------------------------------