diff --git a/.github/workflows/docker-test.yml b/.github/workflows/docker-test.yml index fc1ff77d..db5b487c 100644 --- a/.github/workflows/docker-test.yml +++ b/.github/workflows/docker-test.yml @@ -5,7 +5,7 @@ on: paths: - './docker/**' -job: +jobs: plugin-test-docker: name: Docker runs-on: ubuntu-latest diff --git a/src/plugins/publish/__init__.py b/src/plugins/publish/__init__.py index 97db9039..3c549bc8 100644 --- a/src/plugins/publish/__init__.py +++ b/src/plugins/publish/__init__.py @@ -25,6 +25,7 @@ ) from .models import RepoInfo from .utils import ( + process_pr_and_issue_title, comment_issue, commit_and_push, create_pull_request, @@ -36,6 +37,8 @@ should_skip_plugin_test, trigger_registry_update, update_file, +) +from .validation import ( validate_adapter_info_from_issue, validate_bot_info_from_issue, validate_plugin_info_from_issue, @@ -202,45 +205,15 @@ async def handle_publish_plugin_check( # 分支命名示例 publish/issue123 branch_name = f"{BRANCH_NAME_PREFIX}{issue_number}" - if result.valid: - # 创建新分支 - run_shell_command(["git", "switch", "-C", branch_name]) - # 更新文件并提交更改 - update_file(result) - commit_and_push(result, branch_name, issue_number) - # 创建拉取请求 - await create_pull_request( - bot, repo_info, result, branch_name, issue_number, title - ) - else: - # 如果之前已经创建了拉取请求,则将其转换为草稿 - pulls = ( - await bot.rest.pulls.async_list( - **repo_info.model_dump(), head=f"{repo_info.owner}:{branch_name}" - ) - ).parsed_data - if pulls and (pull := pulls[0]) and not pull.draft: - await bot.async_graphql( - query="""mutation convertPullRequestToDraft($pullRequestId: ID!) { - convertPullRequestToDraft(input: {pullRequestId: $pullRequestId}) { - clientMutationId - } - }""", - variables={"pullRequestId": pull.node_id}, - ) - logger.info("发布没通过检查,已将之前的拉取请求转换为草稿") - else: - logger.info("发布没通过检查,暂不创建拉取请求") + + await process_pr_and_issue_title( + bot, repo_info, result, branch_name, issue_number, title, issue + ) # 修改议题标题 # 需要等创建完拉取请求并打上标签后执行 # 不然会因为修改议题触发 Actions 导致标签没有正常打上 await ensure_issue_test_button(bot, repo_info, issue_number, issue.body or "") - if issue.title != title: - await bot.rest.issues.async_update( - **repo_info.model_dump(), issue_number=issue_number, title=title - ) - logger.info(f"议题标题已修改为 {title}") await comment_issue(bot, repo_info, issue_number, result) @@ -277,44 +250,10 @@ async def handle_adapter_publish_check( # 分支命名示例 publish/issue123 branch_name = f"{BRANCH_NAME_PREFIX}{issue_number}" - if result.valid: - # 创建新分支 - run_shell_command(["git", "switch", "-C", branch_name]) - # 更新文件并提交更改 - update_file(result) - commit_and_push(result, branch_name, issue_number) - # 创建拉取请求 - await create_pull_request( - bot, repo_info, result, branch_name, issue_number, title - ) - else: - # 如果之前已经创建了拉取请求,则将其转换为草稿 - pulls = ( - await bot.rest.pulls.async_list( - **repo_info.model_dump(), head=f"{repo_info.owner}:{branch_name}" - ) - ).parsed_data - if pulls and (pull := pulls[0]) and not pull.draft: - await bot.async_graphql( - query="""mutation convertPullRequestToDraft($pullRequestId: ID!) { - convertPullRequestToDraft(input: {pullRequestId: $pullRequestId}) { - clientMutationId - } - }""", - variables={"pullRequestId": pull.node_id}, - ) - logger.info("发布没通过检查,已将之前的拉取请求转换为草稿") - else: - logger.info("发布没通过检查,暂不创建拉取请求") - # 修改议题标题 - # 需要等创建完拉取请求并打上标签后执行 - # 不然会因为修改议题触发 Actions 导致标签没有正常打上 - if issue.title != title: - await bot.rest.issues.async_update( - **repo_info.model_dump(), issue_number=issue_number, title=title - ) - logger.info(f"议题标题已修改为 {title}") + await process_pr_and_issue_title( + bot, repo_info, result, branch_name, issue_number, title, issue + ) await comment_issue(bot, repo_info, issue_number, result) @@ -352,44 +291,10 @@ async def handle_bot_publish_check( # 分支命名示例 publish/issue123 branch_name = f"{BRANCH_NAME_PREFIX}{issue_number}" - if result.valid: - # 创建新分支 - run_shell_command(["git", "switch", "-C", branch_name]) - # 更新文件并提交更改 - update_file(result) - commit_and_push(result, branch_name, issue_number) - # 创建拉取请求 - await create_pull_request( - bot, repo_info, result, branch_name, issue_number, title - ) - else: - # 如果之前已经创建了拉取请求,则将其转换为草稿 - pulls = ( - await bot.rest.pulls.async_list( - **repo_info.model_dump(), head=f"{repo_info.owner}:{branch_name}" - ) - ).parsed_data - if pulls and (pull := pulls[0]) and not pull.draft: - await bot.async_graphql( - query="""mutation convertPullRequestToDraft($pullRequestId: ID!) { - convertPullRequestToDraft(input: {pullRequestId: $pullRequestId}) { - clientMutationId - } - }""", - variables={"pullRequestId": pull.node_id}, - ) - logger.info("发布没通过检查,已将之前的拉取请求转换为草稿") - else: - logger.info("发布没通过检查,暂不创建拉取请求") - # 修改议题标题 - # 需要等创建完拉取请求并打上标签后执行 - # 不然会因为修改议题触发 Actions 导致标签没有正常打上 - if issue.title != title: - await bot.rest.issues.async_update( - **repo_info.model_dump(), issue_number=issue_number, title=title - ) - logger.info(f"议题标题已修改为 {title}") + await process_pr_and_issue_title( + bot, repo_info, result, branch_name, issue_number, title, issue + ) await comment_issue(bot, repo_info, issue_number, result) diff --git a/src/plugins/publish/utils.py b/src/plugins/publish/utils.py index 4a15420a..7c580072 100644 --- a/src/plugins/publish/utils.py +++ b/src/plugins/publish/utils.py @@ -2,51 +2,33 @@ import json import re import subprocess -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from githubkit.exception import RequestFailed from githubkit.typing import Missing from nonebot import logger from nonebot.adapters.github import Bot, GitHubBot +from .validation import validate_plugin_info_from_issue from src.utils.validation import ( PublishType, ValidationDict, - extract_publish_info_from_issue, - validate_info, ) -from src.utils.constants import DOCKER_IMAGES -from src.utils.docker_test import DockerPluginTest -from src.utils.plugin_test import strip_ansi -from src.utils.store_test.models import DockerTestResult, Metadata from .config import plugin_config from .constants import ( - ADAPTER_DESC_PATTERN, - ADAPTER_HOMEPAGE_PATTERN, - ADAPTER_MODULE_NAME_PATTERN, - ADAPTER_NAME_PATTERN, - BOT_DESC_PATTERN, - BOT_HOMEPAGE_PATTERN, - BOT_NAME_PATTERN, BRANCH_NAME_PREFIX, COMMIT_MESSAGE_PREFIX, ISSUE_FIELD_PATTERN, ISSUE_FIELD_TEMPLATE, NONEFLOW_MARKER, PLUGIN_CONFIG_PATTERN, - PLUGIN_DESC_PATTERN, - PLUGIN_HOMEPAGE_PATTERN, PLUGIN_MODULE_NAME_PATTERN, - PLUGIN_NAME_PATTERN, PLUGIN_STRING_LIST, - PLUGIN_SUPPORTED_ADAPTERS_PATTERN, PLUGIN_TEST_BUTTON_STRING, PLUGIN_TEST_BUTTON_PATTERN, PLUGIN_TEST_STRING, - PLUGIN_TYPE_PATTERN, PROJECT_LINK_PATTERN, SKIP_PLUGIN_TEST_COMMENT, - TAGS_PATTERN, ) from .models import RepoInfo from .render import render_comment @@ -172,134 +154,6 @@ def extract_name_from_title(title: str, publish_type: PublishType) -> str | None return match.group(1) -async def validate_plugin_info_from_issue(issue: "Issue") -> ValidationDict: - """从议题中提取插件信息""" - body: str = issue.body if issue.body else "" - author: str | None = issue.user.login if issue.user else "" - raw_data: dict[str, Any] = extract_publish_info_from_issue( - { - "module_name": PLUGIN_MODULE_NAME_PATTERN, - "project_link": PROJECT_LINK_PATTERN, - "test_config": PLUGIN_CONFIG_PATTERN, - "tags": TAGS_PATTERN, - }, - body, - ) - test_config: str = raw_data["test_config"] - module_name: str = raw_data["module_name"] - project_link: str = raw_data["project_link"] - - with plugin_config.input_config.plugin_path.open("r", encoding="utf-8") as f: - previous_data: list[dict[str, str]] = json.load(f) - - plugin_test_result: DockerTestResult = await DockerPluginTest( - DOCKER_IMAGES, project_link, module_name, test_config - ).run("3.10") - plugin_test_metadata: Metadata | None = plugin_test_result.metadata - plugin_test_output: str = strip_ansi("".join(plugin_test_result.outputs)) - - logger.info(f"插件测试结果: {plugin_test_result}") - logger.info(f"插件元数据: {plugin_test_metadata}") - raw_data.update( - { - "load": plugin_test_result.load, - "result": plugin_test_result, - "output": plugin_test_output, - "metadata": plugin_test_metadata, - "previous_data": previous_data, - "author": author, - } - ) - # 如果插件测试被跳过,则从议题中获取信息 - if plugin_config.skip_plugin_test: - plugin_info = extract_publish_info_from_issue( - { - "name": PLUGIN_NAME_PATTERN, - "desc": PLUGIN_DESC_PATTERN, - "homepage": PLUGIN_HOMEPAGE_PATTERN, - "type": PLUGIN_TYPE_PATTERN, - "supported_adapters": PLUGIN_SUPPORTED_ADAPTERS_PATTERN, - }, - body, - ) - raw_data.update(plugin_info) - elif plugin_test_metadata: - raw_data.update(plugin_test_metadata) - raw_data["desc"] = raw_data.get("description") - else: - # 插件缺少元数据 - # 可能为插件测试未通过,或者插件未按规范编写 - raw_data["name"] = project_link - - # 如果升级至 pydantic 2 后,可以使用 validation-context - validation_context = { - "previous_data": raw_data.get("previous_data"), - "skip_plugin_test": raw_data.get("skip_plugin_test"), - "plugin_test_output": raw_data.get("plugin_test_output"), - } - - validate_data = validate_info(PublishType.PLUGIN, raw_data, validation_context) - - # 如果是插件,还需要额外验证插件加载测试结果 - if ( - validate_data.data.get("metadata") is None - and not plugin_config.skip_plugin_test - ): - # 如果没有跳过测试且缺少插件元数据,则跳过元数据相关的错误 - # 因为这个时候这些项都会报错,错误在此时没有意义 - metadata_keys = ["name", "desc", "homepage", "type", "supported_adapters"] - validate_data.errors = [ - error - for error in validate_data.errors - if error["loc"][0] not in metadata_keys - ] - # 元数据缺失时,需要删除元数据相关的字段 - for key in metadata_keys: - validate_data.data.pop(key, None) - - return validate_data - - -async def validate_bot_info_from_issue(issue: "Issue") -> ValidationDict: - body = issue.body if issue.body else "" - author = issue.user.login if issue.user else "" - - raw_data: dict[str, str] = extract_publish_info_from_issue( - { - "name": BOT_NAME_PATTERN, - "desc": BOT_DESC_PATTERN, - "homepage": BOT_HOMEPAGE_PATTERN, - "tags": TAGS_PATTERN, - }, - body, - ) - raw_data["author"] = author - - return validate_info(PublishType.BOT, raw_data) - - -async def validate_adapter_info_from_issue(issue: "Issue") -> ValidationDict: - body = issue.body if issue.body else "" - author = issue.user.login if issue.user else "" - raw_data: dict[str, Any] = extract_publish_info_from_issue( - { - "module_name": ADAPTER_MODULE_NAME_PATTERN, - "project_link": PROJECT_LINK_PATTERN, - "name": ADAPTER_NAME_PATTERN, - "desc": ADAPTER_DESC_PATTERN, - "homepage": ADAPTER_HOMEPAGE_PATTERN, - "tags": TAGS_PATTERN, - }, - body, - ) - with plugin_config.input_config.adapter_path.open("r", encoding="utf-8") as f: - previous_data: list[dict[str, str]] = json.load(f) - raw_data["author"] = author - raw_data["previous_data"] = previous_data - - return validate_info(PublishType.ADAPTER, raw_data) - - async def resolve_conflict_pull_requests( pulls: list["PullRequestSimple"] | list["PullRequest"], ): @@ -454,6 +308,7 @@ async def create_pull_request( head=branch_name, ) pull = resp.parsed_data + # 自动给拉取请求添加标签 await bot.rest.issues.async_add_labels( **repo_info.model_dump(), @@ -578,6 +433,57 @@ async def should_skip_plugin_publish( return False +async def process_pr_and_issue_title( + bot: Bot, + repo_info: RepoInfo, + result: ValidationDict, + branch_name: str, + issue_number: int, + title: str, + issue: "Issue", +): + """ + 根据发布信息合法性创建拉取请求或将请求改为草稿,并修改议题标题 + """ + if result.valid: + run_shell_command(["git", "switch", "-C", branch_name]) + # 更新文件并提交更改 + update_file(result) + commit_and_push(result, branch_name, issue_number) + # 创建拉取请求 + await create_pull_request( + bot, repo_info, result, branch_name, issue_number, title + ) + else: + # 如果之前已经创建了拉取请求,则将其转换为草稿 + pulls = ( + await bot.rest.pulls.async_list( + **repo_info.model_dump(), head=f"{repo_info.owner}:{branch_name}" + ) + ).parsed_data + if pulls and (pull := pulls[0]) and not pull.draft: + await bot.async_graphql( + query="""mutation convertPullRequestToDraft($pullRequestId: ID!) { + convertPullRequestToDraft(input: {pullRequestId: $pullRequestId}) { + clientMutationId + } + }""", + variables={"pullRequestId": pull.node_id}, + ) + logger.info("发布没通过检查,已将之前的拉取请求转换为草稿") + else: + logger.info("发布没通过检查,暂不创建拉取请求") + + # 修改议题标题 + # 需要等创建完拉取请求并打上标签后执行 + # 不然会因为修改议题触发 Actions 导致标签没有正常打上 + if issue.title != title: + await bot.rest.issues.async_update( + **repo_info.model_dump(), issue_number=issue_number, title=title + ) + logger.info(f"议题标题已修改为 {title}") + + async def trigger_registry_update( bot: GitHubBot, repo_info: RepoInfo, publish_type: PublishType, issue: "Issue" ): diff --git a/src/plugins/publish/validation.py b/src/plugins/publish/validation.py new file mode 100644 index 00000000..96b23a02 --- /dev/null +++ b/src/plugins/publish/validation.py @@ -0,0 +1,176 @@ +import json +import re +from typing import TYPE_CHECKING, Any + +from nonebot import logger +from src.utils.validation import ( + PublishType, + ValidationDict, + extract_publish_info_from_issue, + validate_info, +) +from src.utils.constants import DOCKER_IMAGES +from src.utils.docker_test import DockerPluginTest +from src.utils.store_test.models import DockerTestResult, Metadata + +from .config import plugin_config +from .constants import ( + ADAPTER_DESC_PATTERN, + ADAPTER_HOMEPAGE_PATTERN, + ADAPTER_MODULE_NAME_PATTERN, + ADAPTER_NAME_PATTERN, + BOT_DESC_PATTERN, + BOT_HOMEPAGE_PATTERN, + BOT_NAME_PATTERN, + PLUGIN_CONFIG_PATTERN, + PLUGIN_DESC_PATTERN, + PLUGIN_HOMEPAGE_PATTERN, + PLUGIN_MODULE_NAME_PATTERN, + PLUGIN_NAME_PATTERN, + PLUGIN_SUPPORTED_ADAPTERS_PATTERN, + PLUGIN_TYPE_PATTERN, + PROJECT_LINK_PATTERN, + TAGS_PATTERN, +) + +if TYPE_CHECKING: + from githubkit.rest import Issue + + +def strip_ansi(text: str | None) -> str: + """去除 ANSI 转义字符""" + if not text: + return "" + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + return ansi_escape.sub("", text) + + +async def validate_plugin_info_from_issue(issue: "Issue") -> ValidationDict: + """从议题中提取插件信息""" + body: str = issue.body if issue.body else "" + author: str | None = issue.user.login if issue.user else "" + + if issue.user: + logger.info("Issue用户信息:" + str(issue.user.model_dump())) + raw_data: dict[str, Any] = extract_publish_info_from_issue( + { + "module_name": PLUGIN_MODULE_NAME_PATTERN, + "project_link": PROJECT_LINK_PATTERN, + "test_config": PLUGIN_CONFIG_PATTERN, + "tags": TAGS_PATTERN, + }, + body, + ) + test_config: str = raw_data["test_config"] + module_name: str = raw_data["module_name"] + project_link: str = raw_data["project_link"] + + with plugin_config.input_config.plugin_path.open("r", encoding="utf-8") as f: + previous_data: list[dict[str, str]] = json.load(f) + + plugin_test_result: DockerTestResult = await DockerPluginTest( + DOCKER_IMAGES, project_link, module_name, test_config + ).run("3.10") + plugin_test_metadata: Metadata | None = plugin_test_result.metadata + plugin_test_output: str = strip_ansi("".join(plugin_test_result.outputs)) + + logger.info(f"插件测试结果: {plugin_test_result}") + logger.info(f"插件元数据: {plugin_test_metadata}") + raw_data.update( + { + "load": plugin_test_result.load, + "result": plugin_test_result, + "output": plugin_test_output, + "metadata": plugin_test_metadata, + "previous_data": previous_data, + "author": author, + } + ) + # 如果插件测试被跳过,则从议题中获取信息 + if plugin_config.skip_plugin_test: + plugin_info = extract_publish_info_from_issue( + { + "name": PLUGIN_NAME_PATTERN, + "desc": PLUGIN_DESC_PATTERN, + "homepage": PLUGIN_HOMEPAGE_PATTERN, + "type": PLUGIN_TYPE_PATTERN, + "supported_adapters": PLUGIN_SUPPORTED_ADAPTERS_PATTERN, + }, + body, + ) + raw_data.update(plugin_info) + elif plugin_test_metadata: + raw_data.update(plugin_test_metadata) + raw_data["desc"] = raw_data.get("description") + else: + # 插件缺少元数据 + # 可能为插件测试未通过,或者插件未按规范编写 + raw_data["name"] = project_link + + # 如果升级至 pydantic 2 后,可以使用 validation-context + validation_context = { + "previous_data": raw_data.get("previous_data"), + "skip_plugin_test": raw_data.get("skip_plugin_test"), + "plugin_test_output": raw_data.get("plugin_test_output"), + } + + validate_data = validate_info(PublishType.PLUGIN, raw_data, validation_context) + + # 如果是插件,还需要额外验证插件加载测试结果 + if ( + validate_data.data.get("metadata") is None + and not plugin_config.skip_plugin_test + ): + # 如果没有跳过测试且缺少插件元数据,则跳过元数据相关的错误 + # 因为这个时候这些项都会报错,错误在此时没有意义 + metadata_keys = ["name", "desc", "homepage", "type", "supported_adapters"] + validate_data.errors = [ + error + for error in validate_data.errors + if error["loc"][0] not in metadata_keys + ] + # 元数据缺失时,需要删除元数据相关的字段 + for key in metadata_keys: + validate_data.data.pop(key, None) + + return validate_data + + +async def validate_bot_info_from_issue(issue: "Issue") -> ValidationDict: + body = issue.body if issue.body else "" + author = issue.user.login if issue.user else "" + + raw_data: dict[str, str] = extract_publish_info_from_issue( + { + "name": BOT_NAME_PATTERN, + "desc": BOT_DESC_PATTERN, + "homepage": BOT_HOMEPAGE_PATTERN, + "tags": TAGS_PATTERN, + }, + body, + ) + raw_data["author"] = author + + return validate_info(PublishType.BOT, raw_data) + + +async def validate_adapter_info_from_issue(issue: "Issue") -> ValidationDict: + body = issue.body if issue.body else "" + author = issue.user.login if issue.user else "" + raw_data: dict[str, Any] = extract_publish_info_from_issue( + { + "module_name": ADAPTER_MODULE_NAME_PATTERN, + "project_link": PROJECT_LINK_PATTERN, + "name": ADAPTER_NAME_PATTERN, + "desc": ADAPTER_DESC_PATTERN, + "homepage": ADAPTER_HOMEPAGE_PATTERN, + "tags": TAGS_PATTERN, + }, + body, + ) + with plugin_config.input_config.adapter_path.open("r", encoding="utf-8") as f: + previous_data: list[dict[str, str]] = json.load(f) + raw_data["author"] = author + raw_data["previous_data"] = previous_data + + return validate_info(PublishType.ADAPTER, raw_data) diff --git a/src/utils/plugin_test.py b/src/utils/plugin_test.py deleted file mode 100644 index 858ccb81..00000000 --- a/src/utils/plugin_test.py +++ /dev/null @@ -1,434 +0,0 @@ -"""插件加载测试 - -测试代码修改自 ,谢谢 [Lan 佬](https://github.com/Lancercmd)。 - -在 GitHub Actions 中运行,通过 GitHub Event 文件获取所需信息。并将测试结果保存至 GitHub Action 的输出文件中。 - -当前会输出 RESULT, OUTPUT, METADATA 三个数据,分别对应测试结果、测试输出、插件元数据。 - -经测试可以直接在 Python 3.10+ 环境下运行,无需额外依赖。 -""" -# ruff: noqa: T201, ASYNC101 - -import asyncio -import json -import os -import re -from asyncio import create_subprocess_shell, run, subprocess -from pathlib import Path -from urllib.request import urlopen - -# NoneBot Store -STORE_PLUGINS_URL = ( - "https://raw.githubusercontent.com/nonebot/nonebot2/master/assets/plugins.json" -) -# 匹配信息的正则表达式 -ISSUE_PATTERN = r"### {}\s+([^\s#].*?)(?=(?:\s+###|$))" -# 插件信息 -PROJECT_LINK_PATTERN = re.compile(ISSUE_PATTERN.format("PyPI 项目名")) -MODULE_NAME_PATTERN = re.compile(ISSUE_PATTERN.format("插件 import 包名")) -CONFIG_PATTERN = re.compile(r"### 插件配置项\s+```(?:\w+)?\s?([\s\S]*?)```") - -FAKE_SCRIPT = """from typing import Optional, Union - -from nonebot import logger -from nonebot.drivers import ( - ASGIMixin, - HTTPClientMixin, - HTTPClientSession, - HTTPVersion, - Request, - Response, - WebSocketClientMixin, -) -from nonebot.drivers import Driver as BaseDriver -from nonebot.internal.driver.model import ( - CookieTypes, - HeaderTypes, - QueryTypes, -) -from typing_extensions import override - - -class Driver(BaseDriver, ASGIMixin, HTTPClientMixin, WebSocketClientMixin): - @property - @override - def type(self) -> str: - return "fake" - - @property - @override - def logger(self): - return logger - - @override - def run(self, *args, **kwargs): - super().run(*args, **kwargs) - - @property - @override - def server_app(self): - return None - - @property - @override - def asgi(self): - raise NotImplementedError - - @override - def setup_http_server(self, setup): - raise NotImplementedError - - @override - def setup_websocket_server(self, setup): - raise NotImplementedError - - @override - async def request(self, setup: Request) -> Response: - raise NotImplementedError - - @override - async def websocket(self, setup: Request) -> Response: - raise NotImplementedError - - @override - def get_session( - self, - params: QueryTypes = None, - headers: HeaderTypes = None, - cookies: CookieTypes = None, - version: Union[str, HTTPVersion] = HTTPVersion.H11, - timeout: Optional[float] = None, - proxy: Optional[str] = None, - ) -> HTTPClientSession: - raise NotImplementedError -""" - -RUNNER_SCRIPT = """import json -import os - -from nonebot import init, load_plugin, logger, require -from pydantic import BaseModel - - -class SetEncoder(json.JSONEncoder): - def default(self, obj): - if isinstance(obj, set): - return list(obj) - return json.JSONEncoder.default(self, obj) - - -init() -plugin = load_plugin("{}") - -if not plugin: - exit(1) -else: - if plugin.metadata: - metadata = {{ - "name": plugin.metadata.name, - "description": plugin.metadata.description, - "usage": plugin.metadata.usage, - "type": plugin.metadata.type, - "homepage": plugin.metadata.homepage, - "supported_adapters": plugin.metadata.supported_adapters, - }} - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf8") as f: - f.write(f"METADATA< str: - """去除 ANSI 转义字符""" - if not text: - return "" - ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") - return ansi_escape.sub("", text) - - -def get_plugin_list() -> dict[str, str]: - """获取插件列表 - - 通过 package_name 获取 module_name - """ - with urlopen(STORE_PLUGINS_URL) as response: - plugins = json.loads(response.read()) - - return {plugin["project_link"]: plugin["module_name"] for plugin in plugins} - - -class PluginTest: - def __init__( - self, project_link: str, module_name: str, config: str | None = None - ) -> None: - self.project_link = project_link - self.module_name = module_name - self.config = config - self._plugin_list = None - - self._create = False - self._run = False - self._deps = [] - - # 输出信息 - self._output_lines: list[str] = [] - - # 插件测试目录 - self.test_dir = Path("plugin_test") - # 通过环境变量获取 GITHUB 输出文件位置 - self.github_output_file = Path(os.environ.get("GITHUB_OUTPUT", "")) - self.github_step_summary_file = Path(os.environ.get("GITHUB_STEP_SUMMARY", "")) - - @property - def key(self) -> str: - """插件的标识符 - - project_link:module_name - 例:nonebot-plugin-test:nonebot_plugin_test - """ - return f"{self.project_link}:{self.module_name}" - - @property - def path(self) -> Path: - """插件测试目录""" - # 替换 : 为 -,防止文件名不合法 - key = self.key.replace(":", "-") - return self.test_dir / f"{key}-test" - - async def run(self): - # 运行前创建测试目录 - if not self.test_dir.exists(): - self.test_dir.mkdir() - - await self.create_poetry_project() - if self._create: - await self.show_package_info() - await self.show_plugin_dependencies() - await self.run_poetry_project() - - # 输出测试结果 - with open(self.github_output_file, "a", encoding="utf8") as f: - f.write(f"RESULT={self._run}\n") - # 输出测试输出 - output = "\n".join(self._output_lines) - # GitHub 不支持 ANSI 转义字符所以去掉 - ansiless_output = strip_ansi(output) - # 限制输出长度,防止评论过长,评论最大长度为 65536 - ansiless_output = ansiless_output[:50000] - with open(self.github_output_file, "a", encoding="utf8") as f: - f.write(f"OUTPUT<测试输出
{ansiless_output}
" - f.write(f"{summary}") - return self._run, output - - def get_env(self) -> dict[str, str]: - """获取环境变量""" - env = os.environ.copy() - # 删除虚拟环境变量,防止 poetry 使用运行当前脚本的虚拟环境 - env.pop("VIRTUAL_ENV", None) - # 启用 LOGURU 的颜色输出 - env["LOGURU_COLORIZE"] = "true" - # Poetry 配置 - # https://python-poetry.org/docs/configuration/#virtualenvsin-project - env["POETRY_VIRTUALENVS_IN_PROJECT"] = "true" - # https://python-poetry.org/docs/configuration/#virtualenvsprefer-active-python-experimental - env["POETRY_VIRTUALENVS_PREFER_ACTIVE_PYTHON"] = "true" - return env - - async def create_poetry_project(self) -> None: - if not self.path.exists(): - self.path.mkdir() - proc = await create_subprocess_shell( - f"""poetry init -n && sed -i "s/\\^/~/g" pyproject.toml && poetry env info --ansi && poetry add {self.project_link}""", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=self.path, - env=self.get_env(), - ) - stdout, stderr = await proc.communicate() - code = proc.returncode - - self._create = not code - if self._create: - print(f"项目 {self.project_link} 创建成功。") - for i in stdout.decode().strip().splitlines(): - print(f" {i}") - else: - self._log_output(f"项目 {self.project_link} 创建失败:") - for i in stderr.decode().strip().splitlines(): - self._log_output(f" {i}") - else: - self._log_output(f"项目 {self.project_link} 已存在,跳过创建。") - self._create = True - - async def show_package_info(self) -> None: - if self.path.exists(): - proc = await create_subprocess_shell( - f"poetry show {self.project_link} --ansi", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=self.path, - env=self.get_env(), - ) - stdout, _ = await proc.communicate() - code = proc.returncode - if not code: - self._log_output(f"插件 {self.project_link} 的信息如下:") - for i in stdout.decode().splitlines(): - self._log_output(f" {i}") - else: - self._log_output(f"插件 {self.project_link} 信息获取失败。") - - async def show_plugin_dependencies(self) -> None: - if self.path.exists(): - proc = await create_subprocess_shell( - "poetry export --without-hashes", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=self.path, - env=self.get_env(), - ) - stdout, _ = await proc.communicate() - code = proc.returncode - if not code: - self._log_output(f"插件 {self.project_link} 依赖的插件如下:") - for i in stdout.decode().strip().splitlines(): - module_name = self._get_plugin_module_name(i) - if module_name: - self._deps.append(module_name) - self._log_output(f" {', '.join(self._deps)}") - else: - self._log_output(f"插件 {self.project_link} 依赖获取失败。") - - async def run_poetry_project(self) -> None: - if self.path.exists(): - # 默认使用 fake 驱动 - with open(self.path / ".env", "w", encoding="utf8") as f: - f.write("DRIVER=fake") - # 如果提供了插件配置项,则写入配置文件 - if self.config is not None: - with open(self.path / ".env.prod", "w", encoding="utf8") as f: - f.write(self.config) - - with open(self.path / "fake.py", "w", encoding="utf8") as f: - f.write(FAKE_SCRIPT) - - with open(self.path / "runner.py", "w", encoding="utf8") as f: - f.write( - RUNNER_SCRIPT.format( - self.module_name, - "\n".join([f"require('{i}')" for i in self._deps]), - ) - ) - - try: - proc = await create_subprocess_shell( - "poetry run python runner.py", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=self.path, - env=self.get_env(), - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=600) - code = proc.returncode - except asyncio.TimeoutError: - proc.terminate() - stdout = b"" - stderr = "测试超时".encode() - code = 1 - - self._run = not code - - status = "正常" if self._run else "出错" - self._log_output(f"插件 {self.module_name} 加载{status}:") - - _out = stdout.decode().strip().splitlines() - _err = stderr.decode().strip().splitlines() - for i in _out: - self._log_output(f" {i}") - for i in _err: - self._log_output(f" {i}") - - def _log_output(self, output: str) -> None: - """记录输出,同时打印到控制台""" - print(output) - self._output_lines.append(output) - - @property - def plugin_list(self) -> dict[str, str]: - """获取插件列表""" - if self._plugin_list is None: - self._plugin_list = get_plugin_list() - return self._plugin_list - - def _get_plugin_module_name(self, require: str) -> str | None: - # anyio==3.6.2 ; python_version >= "3.11" and python_version < "4.0" - # pydantic[dotenv]==1.10.6 ; python_version >= "3.10" and python_version < "4.0" - match = re.match(r"^(.+?)(?:\[.+\])?==", require.strip()) - if match: - package_name = match.group(1) - # 不用包括自己 - if package_name in self.plugin_list and package_name != self.project_link: - return self.plugin_list[package_name] - - -async def main(): - event_path = os.environ.get("GITHUB_EVENT_PATH") - if not event_path: - print("未找到 GITHUB_EVENT_PATH,已跳过") - return - - with open(event_path, encoding="utf8") as f: - event = json.load(f) - - event_name = os.environ.get("GITHUB_EVENT_NAME") - if event_name not in ["issues", "issue_comment"]: - print(f"不支持的事件: {event_name},已跳过") - return - - issue = event["issue"] - - pull_request = issue.get("pull_request") - if pull_request: - print("评论在拉取请求下,已跳过") - return - - state = issue.get("state") - if state != "open": - print("议题未开启,已跳过") - return - - labels = issue.get("labels", []) - if not any(label["name"] == "Plugin" for label in labels): - print("议题与插件发布无关,已跳过") - return - - issue_body = issue.get("body") - project_link = PROJECT_LINK_PATTERN.search(issue_body) - module_name = MODULE_NAME_PATTERN.search(issue_body) - config = CONFIG_PATTERN.search(issue_body) - - if not project_link or not module_name: - print("议题中没有插件信息,已跳过") - return - - # 测试插件 - test = PluginTest( - project_link.group(1).strip(), - module_name.group(1).strip(), - config.group(1).strip() if config else None, - ) - await test.run() - - -if __name__ == "__main__": - run(main()) diff --git a/src/utils/store_test/utils.py b/src/utils/store_test/utils.py index 22e070a1..905d81a4 100644 --- a/src/utils/store_test/utils.py +++ b/src/utils/store_test/utils.py @@ -1,4 +1,5 @@ import json + from functools import cache from pathlib import Path from typing import Any @@ -51,3 +52,9 @@ def get_upload_time(project_link: str) -> str: """获取插件的上传时间""" data = get_pypi_data(project_link) return data["urls"][0]["upload_time_iso_8601"] + + +def get_user_id(name: str) -> int: + """获取用户信息""" + data = load_json(f"https://api.github.com/users/{name}") + return data["id"]