diff --git a/src/plugins/github/depends/__init__.py b/src/plugins/github/depends/__init__.py index 1b173916..0e5201ff 100644 --- a/src/plugins/github/depends/__init__.py +++ b/src/plugins/github/depends/__init__.py @@ -9,8 +9,9 @@ from nonebot.params import Depends from src.plugins.github.models import GithubHandler, RepoInfo -from src.plugins.github.typing import IssuesEvent, PullRequestEvent +from src.plugins.github.typing import IssuesEvent, LabelsItems, PullRequestEvent from src.plugins.github.utils import run_shell_command +from src.providers.validation.models import PublishType from .utils import extract_issue_number_from_ref @@ -35,6 +36,18 @@ def get_labels(event: PullRequestEvent | IssuesEvent): return labels +def get_labels_name(labels: LabelsItems = Depends(get_labels)) -> list[str]: + """通过标签获取名称""" + label_names: list[str] = [] + if not labels: + return label_names + + for label in labels: + if label.name: + label_names.append(label.name) + return label_names + + def get_issue_title(event: IssuesEvent): """获取议题标题""" return event.payload.issue.title @@ -91,3 +104,13 @@ def is_bot_triggered_workflow(event: IssuesEvent): def get_github_handler(bot: GitHubBot, repo_info: RepoInfo = Depends(get_repo_info)): """获取 GitHub 处理器""" return GithubHandler(bot=bot, repo_info=repo_info) + + +def get_type_by_labels_name( + labels: list[str] = Depends(get_labels_name), +) -> PublishType | None: + """通过标签的名称获取类型""" + for type in PublishType: + if type.value in labels: + return type + return None diff --git a/src/plugins/github/depends/utils.py b/src/plugins/github/depends/utils.py index 8290f55e..fc777f84 100644 --- a/src/plugins/github/depends/utils.py +++ b/src/plugins/github/depends/utils.py @@ -1,8 +1,22 @@ import re +from src.plugins.github.typing import PullRequestLabels +from src.providers.validation.models import PublishType + def extract_issue_number_from_ref(ref: str) -> int | None: """从 Ref 中提取议题号""" match = re.search(r"(\w{4,10})\/issue(\d+)", ref) if match: return int(match.group(2)) + + +def get_type_by_labels(labels: PullRequestLabels) -> PublishType | None: + """通过拉取请求的标签获取发布类型""" + for label in labels: + if isinstance(label, str): + continue + for type in PublishType: + if label.name == type.value: + return type + return None diff --git a/src/plugins/github/plugins/publish/__init__.py b/src/plugins/github/plugins/publish/__init__.py index 65673120..5d84fd36 100644 --- a/src/plugins/github/plugins/publish/__init__.py +++ b/src/plugins/github/plugins/publish/__init__.py @@ -28,7 +28,7 @@ from src.plugins.github.plugins.publish.render import render_comment from src.providers.validation.models import PublishType, ValidationDict -from .depends import get_type_by_labels +from .depends import get_type_by_labels_name from .utils import ( ensure_issue_content, ensure_issue_plugin_test_button, @@ -44,7 +44,7 @@ async def pr_close_rule( - publish_type: PublishType | None = Depends(get_type_by_labels), + publish_type: PublishType | None = Depends(get_type_by_labels_name), related_issue_number: int | None = Depends(get_related_issue_number), ) -> bool: if publish_type is None: @@ -68,7 +68,7 @@ async def handle_pr_close( event: PullRequestClosed, bot: GitHubBot, installation_id: int = Depends(get_installation_id), - publish_type: PublishType = Depends(get_type_by_labels), + publish_type: PublishType = Depends(get_type_by_labels_name), repo_info: RepoInfo = Depends(get_repo_info), related_issue_number: int = Depends(get_related_issue_number), ) -> None: @@ -108,7 +108,7 @@ async def handle_pr_close( async def check_rule( event: IssuesOpened | IssuesReopened | IssuesEdited | IssueCommentCreated, - publish_type: PublishType | None = Depends(get_type_by_labels), + publish_type: PublishType | None = Depends(get_type_by_labels_name), is_bot: bool = Depends(is_bot_triggered_workflow), ) -> bool: if is_bot: @@ -138,7 +138,7 @@ async def handle_publish_plugin_check( installation_id: int = Depends(get_installation_id), repo_info: RepoInfo = Depends(get_repo_info), issue_number: int = Depends(get_issue_number), - publish_type: Literal[PublishType.PLUGIN] = Depends(get_type_by_labels), + publish_type: Literal[PublishType.PLUGIN] = Depends(get_type_by_labels_name), ) -> None: async with bot.as_installation(installation_id): # 因为 Actions 会排队,触发事件相关的议题在 Actions 执行时可能已经被关闭 @@ -181,7 +181,7 @@ async def handle_adapter_publish_check( installation_id: int = Depends(get_installation_id), repo_info: RepoInfo = Depends(get_repo_info), issue_number: int = Depends(get_issue_number), - publish_type: Literal[PublishType.ADAPTER] = Depends(get_type_by_labels), + publish_type: Literal[PublishType.ADAPTER] = Depends(get_type_by_labels_name), ) -> None: async with bot.as_installation(installation_id): # 因为 Actions 会排队,触发事件相关的议题在 Actions 执行时可能已经被关闭 @@ -215,7 +215,7 @@ async def handle_bot_publish_check( installation_id: int = Depends(get_installation_id), repo_info: RepoInfo = Depends(get_repo_info), issue_number: int = Depends(get_issue_number), - publish_type: Literal[PublishType.BOT] = Depends(get_type_by_labels), + publish_type: Literal[PublishType.BOT] = Depends(get_type_by_labels_name), ) -> None: async with bot.as_installation(installation_id): # 因为 Actions 会排队,触发事件相关的议题在 Actions 执行时可能已经被关闭 @@ -272,7 +272,7 @@ async def handle_pull_request_and_update_issue( async def review_submiited_rule( event: PullRequestReviewSubmitted, - publish_type: PublishType | None = Depends(get_type_by_labels), + publish_type: PublishType | None = Depends(get_type_by_labels_name), ) -> bool: if publish_type is None: logger.info("拉取请求与发布无关,已跳过") diff --git a/src/plugins/github/plugins/publish/depends.py b/src/plugins/github/plugins/publish/depends.py index 7cedc2f4..6bb7e1ec 100644 --- a/src/plugins/github/plugins/publish/depends.py +++ b/src/plugins/github/plugins/publish/depends.py @@ -2,18 +2,16 @@ from nonebot.adapters.github import Bot from nonebot.params import Depends -from src.plugins.github.depends import get_issue_title, get_labels, get_repo_info +from src.plugins.github.depends import ( + get_issue_title, + get_repo_info, + get_type_by_labels_name, +) from src.plugins.github.models import RepoInfo from src.plugins.github.plugins.publish import utils -from src.plugins.github.typing import LabelsItems from src.providers.validation.models import PublishType -def get_type_by_labels(labels: LabelsItems = Depends(get_labels)) -> PublishType | None: - """通过标签获取类型""" - return utils.get_type_by_labels(labels) - - def get_type_by_title(title: str = Depends(get_issue_title)) -> PublishType | None: """通过标题获取类型""" return utils.get_type_by_title(title) @@ -22,7 +20,7 @@ def get_type_by_title(title: str = Depends(get_issue_title)) -> PublishType | No async def get_pull_requests_by_label( bot: Bot, repo_info: RepoInfo = Depends(get_repo_info), - publish_type: PublishType = Depends(get_type_by_labels), + publish_type: PublishType = Depends(get_type_by_labels_name), ) -> list[PullRequestSimple]: pulls = ( await bot.rest.pulls.async_list(**repo_info.model_dump(), state="open") diff --git a/src/plugins/github/plugins/publish/utils.py b/src/plugins/github/plugins/publish/utils.py index 1416a8b0..61d7ad0e 100644 --- a/src/plugins/github/plugins/publish/utils.py +++ b/src/plugins/github/plugins/publish/utils.py @@ -6,9 +6,9 @@ from src.plugins.github import plugin_config from src.plugins.github.constants import ISSUE_FIELD_PATTERN, ISSUE_FIELD_TEMPLATE +from src.plugins.github.depends.utils import get_type_by_labels from src.plugins.github.models import IssueHandler, RepoInfo from src.plugins.github.models.github import GithubHandler -from src.plugins.github.typing import LabelsItems from src.plugins.github.utils import commit_message as _commit_message from src.plugins.github.utils import dump_json, load_json, run_shell_command from src.providers.models import RegistryUpdatePayload, to_store @@ -32,28 +32,9 @@ from githubkit.rest import ( PullRequest, PullRequestSimple, - PullRequestSimplePropLabelsItems, ) -def get_type_by_labels( - labels: LabelsItems | list["PullRequestSimplePropLabelsItems"], -) -> PublishType | None: - """通过标签获取类型""" - if not labels: - return None - - for label in labels: - if isinstance(label, str): - continue - if label.name == PublishType.BOT.value: - return PublishType.BOT - if label.name == PublishType.PLUGIN.value: - return PublishType.PLUGIN - if label.name == PublishType.ADAPTER.value: - return PublishType.ADAPTER - - def get_type_by_title(title: str) -> PublishType | None: """通过标题获取类型""" if title.startswith(f"{PublishType.BOT.value}:"): diff --git a/src/plugins/github/plugins/publish/validation.py b/src/plugins/github/plugins/publish/validation.py index 42fdac84..c630bfd0 100644 --- a/src/plugins/github/plugins/publish/validation.py +++ b/src/plugins/github/plugins/publish/validation.py @@ -8,7 +8,7 @@ from src.plugins.github import plugin_config from src.plugins.github.models import AuthorInfo from src.plugins.github.models.issue import IssueHandler -from src.plugins.github.utils import extract_publish_info_from_issue +from src.plugins.github.utils import extract_issue_info_from_issue from src.providers.constants import DOCKER_IMAGES from src.providers.docker_test import DockerPluginTest, Metadata from src.providers.validation import PublishType, ValidationDict, validate_info @@ -48,7 +48,7 @@ async def validate_plugin_info_from_issue( body = issue.body if issue.body else "" # 从议题里提取插件所需信息 - raw_data: dict[str, Any] = extract_publish_info_from_issue( + raw_data: dict[str, Any] = extract_issue_info_from_issue( { "module_name": PLUGIN_MODULE_NAME_PATTERN, "project_link": PROJECT_LINK_PATTERN, @@ -77,7 +77,7 @@ async def validate_plugin_info_from_issue( raw_data["skip_test"] = skip_test if skip_test: # 如果插件被跳过,则从议题获取插件信息 - metadata = extract_publish_info_from_issue( + metadata = extract_issue_info_from_issue( { "name": PLUGIN_NAME_PATTERN, "desc": PLUGIN_DESC_PATTERN, @@ -135,7 +135,7 @@ async def validate_plugin_info_from_issue( async def validate_adapter_info_from_issue(issue: Issue) -> ValidationDict: """从议题中提取适配器信息""" body = issue.body if issue.body else "" - raw_data: dict[str, Any] = extract_publish_info_from_issue( + raw_data: dict[str, Any] = extract_issue_info_from_issue( { "module_name": ADAPTER_MODULE_NAME_PATTERN, "project_link": PROJECT_LINK_PATTERN, @@ -157,7 +157,7 @@ async def validate_adapter_info_from_issue(issue: Issue) -> ValidationDict: async def validate_bot_info_from_issue(issue: Issue) -> ValidationDict: """从议题中提取机器人信息""" body = issue.body if issue.body else "" - raw_data: dict[str, Any] = extract_publish_info_from_issue( + raw_data: dict[str, Any] = extract_issue_info_from_issue( { "name": BOT_NAME_PATTERN, "desc": BOT_DESC_PATTERN, diff --git a/src/plugins/github/plugins/remove/__init__.py b/src/plugins/github/plugins/remove/__init__.py index d610bab9..dabcd6de 100644 --- a/src/plugins/github/plugins/remove/__init__.py +++ b/src/plugins/github/plugins/remove/__init__.py @@ -18,13 +18,16 @@ get_issue_number, get_related_issue_number, get_repo_info, + get_type_by_labels_name, install_pre_commit_hooks, is_bot_triggered_workflow, ) from src.plugins.github.models import IssueHandler +from src.plugins.github.typing import IssuesEvent +from src.providers.validation.models import PublishType from .constants import BRANCH_NAME_PREFIX, REMOVE_LABEL -from .depends import check_labels, get_name_by_labels +from .depends import check_labels from .render import render_comment, render_error from .utils import process_pull_reqeusts, resolve_conflict_pull_requests from .validation import validate_author_info @@ -87,8 +90,8 @@ async def handle_pr_close( async def check_rule( - event: IssuesOpened | IssuesReopened | IssuesEdited | IssueCommentCreated, - edit_type: list[str] = Depends(get_name_by_labels), + event: IssuesEvent, + is_remove: bool = check_labels(REMOVE_LABEL), is_bot: bool = Depends(is_bot_triggered_workflow), ) -> bool: if is_bot: @@ -97,8 +100,8 @@ async def check_rule( if event.payload.issue.pull_request: logger.info("评论在拉取请求下,已跳过") return False - if REMOVE_LABEL not in edit_type: - logger.info("议题与删除无关,已跳过") + if is_remove is False: + logger.info("非删除工作流,已跳过") return False return True @@ -116,6 +119,7 @@ async def handle_remove_check( installation_id: int = Depends(get_installation_id), repo_info: RepoInfo = Depends(get_repo_info), issue_number: int = Depends(get_issue_number), + publish_type: PublishType = Depends(get_type_by_labels_name), ): async with bot.as_installation(installation_id): issue = ( @@ -127,23 +131,24 @@ async def handle_remove_check( if issue.state != "open": logger.info("议题未开启,已跳过") await remove_check_matcher.finish() - handler = IssueHandler(bot=bot, repo_info=repo_info, issue=issue) try: # 搜索包的信息和验证作者信息 - result = await validate_author_info(issue) + result = await validate_author_info(issue, publish_type) except PydanticCustomError as err: logger.error(f"信息验证失败: {err}") await handler.comment_issue(await render_error(err)) await remove_check_matcher.finish() - title = f"{result.type}: Remove {result.name or 'Unknown'}"[:TITLE_MAX_LENGTH] + title = f"{result.publish_type}: Remove {result.name or 'Unknown'}"[ + :TITLE_MAX_LENGTH + ] branch_name = f"{BRANCH_NAME_PREFIX}{issue_number}" # 处理拉取请求和议题标题 await process_pull_reqeusts(handler, result, branch_name, title) - # 更新议题标题 + await handler.update_issue_title(title) await handler.comment_issue(await render_comment(result)) diff --git a/src/plugins/github/plugins/remove/constants.py b/src/plugins/github/plugins/remove/constants.py index 6ceed6b5..ab92d873 100644 --- a/src/plugins/github/plugins/remove/constants.py +++ b/src/plugins/github/plugins/remove/constants.py @@ -1,9 +1,16 @@ import re -from src.plugins.github import plugin_config from src.plugins.github.constants import ISSUE_PATTERN -from src.providers.validation.models import PublishType +# Bot +REMOVE_BOT_HOMEPAGE_PATTERN = re.compile( + ISSUE_PATTERN.format("机器人项目仓库/主页链接") +) +REMOVE_BOT_NAME_PATTERN = re.compile(ISSUE_PATTERN.format("机器人名称")) +# Plugin +REMOVE_PLUGIN_PROJECT_LINK_PATTERN = re.compile(ISSUE_PATTERN.format("PyPI 项目名")) +REMOVE_PLUGIN_MODULE_NAME_PATTERN = re.compile(ISSUE_PATTERN.format("import 包名")) +# Driver / Adapter REMOVE_HOMEPAGE_PATTERN = re.compile(ISSUE_PATTERN.format("项目主页")) BRANCH_NAME_PREFIX = "remove/issue" @@ -11,9 +18,3 @@ COMMIT_MESSAGE_PREFIX = ":hammer: remove" REMOVE_LABEL = "Remove" - -PUBLISH_PATH = { - PublishType.PLUGIN: plugin_config.input_config.plugin_path, - PublishType.ADAPTER: plugin_config.input_config.adapter_path, - PublishType.BOT: plugin_config.input_config.bot_path, -} diff --git a/src/plugins/github/plugins/remove/depends.py b/src/plugins/github/plugins/remove/depends.py index 1953515e..129a99f9 100644 --- a/src/plugins/github/plugins/remove/depends.py +++ b/src/plugins/github/plugins/remove/depends.py @@ -1,19 +1,6 @@ from nonebot.params import Depends -from src.plugins.github.depends import get_labels -from src.plugins.github.typing import LabelsItems - - -def get_name_by_labels(labels: LabelsItems = Depends(get_labels)) -> list[str]: - """通过标签获取名称""" - label_names: list[str] = [] - if not labels: - return label_names - - for label in labels: - if label.name: - label_names.append(label.name) - return label_names +from src.plugins.github.depends import get_labels_name def check_labels(labels: list[str] | str): @@ -22,8 +9,8 @@ def check_labels(labels: list[str] | str): labels = [labels] async def _check_labels( - has_labels: list[str] = Depends(get_name_by_labels), + has_labels: list[str] = Depends(get_labels_name), ) -> bool: - return all(label in has_labels for label in labels) + return any(label in has_labels for label in labels) return Depends(_check_labels) diff --git a/src/plugins/github/plugins/remove/render.py b/src/plugins/github/plugins/remove/render.py index ef1b16d3..bdb9ee6d 100644 --- a/src/plugins/github/plugins/remove/render.py +++ b/src/plugins/github/plugins/remove/render.py @@ -3,7 +3,7 @@ import jinja2 from pydantic_core import PydanticCustomError -from src.providers.validation import ValidationDict +from .validation import RemoveInfo env = jinja2.Environment( loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"), @@ -15,18 +15,17 @@ ) -async def render_comment(result: ValidationDict) -> str: +async def render_comment(result: RemoveInfo) -> str: """将验证结果转换为评论内容""" - title = f"{result.type}: remove {result.name}" + title = f"{result.publish_type}: remove {result.name}" template = env.get_template("comment.md.jinja") - return await template.render_async( - title=title, valid=result.valid, error=result.errors - ) + return await template.render_async(title=title, valid=True, error=[]) async def render_error(exception: PydanticCustomError): """将错误转换成评论内容""" - title = "Error" template = env.get_template("comment.md.jinja") - return await template.render_async(title=title, valid=False, error=exception) + return await template.render_async( + title="Error", valid=False, error=exception.message() + ) diff --git a/src/plugins/github/plugins/remove/templates/comment.md.jinja b/src/plugins/github/plugins/remove/templates/comment.md.jinja index 733fc703..99af0202 100644 --- a/src/plugins/github/plugins/remove/templates/comment.md.jinja +++ b/src/plugins/github/plugins/remove/templates/comment.md.jinja @@ -5,7 +5,7 @@ **{{ "✅ 所有检查通过,一切准备就绪!" if valid else "⚠️ 在下架检查过程中,我们发现以下问题:"}}** {% if not valid %} -> ⚠️ {{ error.type }}: {{ error.message_template }} +> ⚠️ {{ error }} {% else %} > 发起插件下架流程! {% endif %} diff --git a/src/plugins/github/plugins/remove/utils.py b/src/plugins/github/plugins/remove/utils.py index e38c7ef3..18025ab6 100644 --- a/src/plugins/github/plugins/remove/utils.py +++ b/src/plugins/github/plugins/remove/utils.py @@ -1,44 +1,52 @@ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING +from githubkit.exception import RequestFailed from nonebot import logger +from pydantic_core import PydanticCustomError from src.plugins.github import plugin_config -from src.plugins.github.depends.utils import extract_issue_number_from_ref -from src.plugins.github.models import AuthorInfo, GithubHandler, IssueHandler +from src.plugins.github.depends.utils import ( + extract_issue_number_from_ref, + get_type_by_labels, +) +from src.plugins.github.models import GithubHandler, IssueHandler from src.plugins.github.utils import ( commit_message, dump_json, - load_json, run_shell_command, ) -from src.providers.validation.models import ValidationDict +from src.providers.validation.models import PublishType -from .constants import COMMIT_MESSAGE_PREFIX, PUBLISH_PATH, REMOVE_LABEL +from .constants import COMMIT_MESSAGE_PREFIX, REMOVE_LABEL +from .validation import RemoveInfo, load_publish_data, validate_author_info if TYPE_CHECKING: from githubkit.rest import PullRequest, PullRequestSimple -def update_file(remove_data: dict[str, Any]): +def update_file(type: PublishType, key: str): """删除对应的包储存在 registry 里的数据""" logger.info("开始更新文件") - for path in PUBLISH_PATH.values(): - data = load_json(path) - - # 删除对应的数据 - new_data = [item for item in data if item != remove_data] - - if data == new_data: - continue + match type: + case PublishType.PLUGIN: + path = plugin_config.input_config.plugin_path + case PublishType.BOT: + path = plugin_config.input_config.bot_path + case PublishType.ADAPTER: + path = plugin_config.input_config.adapter_path + case _: + raise ValueError("不支持的删除类型") - # 如果数据发生变化则更新文件 - dump_json(path, new_data) - logger.info(f"已更新 {path.name} 文件") + data = load_publish_data(type) + # 删除对应的数据项 + data.pop(key) + dump_json(path, data) + logger.info(f"已更新 {path.name} 文件") async def process_pull_reqeusts( - handler: IssueHandler, result: ValidationDict, branch_name: str, title: str + handler: IssueHandler, result: RemoveInfo, branch_name: str, title: str ): """ 根据发布信息合法性创建拉取请求 @@ -48,12 +56,21 @@ async def process_pull_reqeusts( # 切换分支 run_shell_command(["git", "switch", "-C", branch_name]) # 更新文件并提交更改 - update_file(result.store_data) + update_file(result.publish_type, result.key) handler.commit_and_push(message, branch_name) # 创建拉取请求 - await handler.create_pull_request( - plugin_config.input_config.base, title, branch_name, [REMOVE_LABEL] - ) + logger.info("开始创建拉取请求") + try: + await handler.create_pull_request( + plugin_config.input_config.base, + title, + branch_name, + [REMOVE_LABEL, result.publish_type.value], + ) + except RequestFailed: + # 如果之前已经创建了拉取请求,则将其转换为草稿 + logger.info("该分支的拉取请求已创建,请前往查看") + await handler.update_pull_request_status(title, branch_name) async def resolve_conflict_pull_requests( @@ -63,14 +80,6 @@ async def resolve_conflict_pull_requests( 直接重新提交之前分支中的内容 """ - logger.info("开始解决冲突") - # 获取远程分支 - run_shell_command(["git", "fetch", "origin"]) - - # 读取主分支的数据 - main_data = {} - for type, path in PUBLISH_PATH.items(): - main_data[type] = load_json(path) for pull in pulls: issue_number = extract_issue_number_from_ref(pull.head.ref) @@ -83,36 +92,32 @@ async def resolve_conflict_pull_requests( logger.info("拉取请求为草稿,跳过处理") continue - run_shell_command(["git", "checkout", pull.head.ref]) - # 读取拉取请求分支的数据 - pull_data = {} - for type, path in PUBLISH_PATH.items(): - pull_data[type] = load_json(path) - - for type, data in pull_data.items(): - if data != main_data[type]: - logger.info(f"{type} 数据发生变化,开始解决冲突") - - # 筛选出拉取请求要删除的元素 - remove_items = [item for item in main_data[type] if item not in data] - - logger.info(f"找到冲突的 {type} 数据 {remove_items}") - - # 切换到主分支 - run_shell_command(["git", "checkout", plugin_config.input_config.base]) - # 删除之前拉取的远程分支 - run_shell_command(["git", "branch", "-D", pull.head.ref]) - # 同步 main 分支到新的分支上 - run_shell_command(["git", "switch", "-C", pull.head.ref]) - # 更新文件并提交更改 - for item in remove_items: - update_file(item) - message = commit_message( - COMMIT_MESSAGE_PREFIX, pull.title, issue_number - ) - - author = AuthorInfo.from_issue(await handler.get_issue(pull.number)) - - handler.commit_and_push(message, pull.head.ref, author.author) - - logger.info(f"{pull.title} 处理完毕") + # 根据标签获取发布类型 + publish_type = get_type_by_labels(pull.labels) + issue_handler = await handler.to_issue_handler(issue_number) + + if publish_type: + # 需要先获取远程分支,否则无法切换到对应分支 + run_shell_command(["git", "fetch", "origin"]) + # 因为当前分支为触发处理冲突的分支,所以需要切换到每个拉取请求对应的分支 + run_shell_command(["git", "checkout", pull.head.ref]) + + try: + result = await validate_author_info(issue_handler.issue, publish_type) + except PydanticCustomError: + # 报错代表在此分支找不到对应数据 + # 则尝试处理其它分支 + logger.info("拉取请求无冲突,无需处理") + continue + + # 回到主分支 + run_shell_command(["git", "checkout", plugin_config.input_config.base]) + # 切换到对应分支 + run_shell_command(["git", "switch", "-C", pull.head.ref]) + # 更新文件 + update_file(publish_type, result.key) + message = commit_message(COMMIT_MESSAGE_PREFIX, result.name, issue_number) + + issue_handler.commit_and_push(message, pull.head.ref) + + logger.info("拉取请求更新完毕") diff --git a/src/plugins/github/plugins/remove/validation.py b/src/plugins/github/plugins/remove/validation.py index e5c9a867..9046872f 100644 --- a/src/plugins/github/plugins/remove/validation.py +++ b/src/plugins/github/plugins/remove/validation.py @@ -1,48 +1,116 @@ from githubkit.rest import Issue -from nonebot import logger +from pydantic import BaseModel from pydantic_core import PydanticCustomError -from src.plugins.github.utils import extract_publish_info_from_issue -from src.providers.validation.models import ValidationDict +from src.plugins.github import plugin_config +from src.plugins.github.models import AuthorInfo +from src.plugins.github.utils import extract_issue_info_from_issue, load_json +from src.providers.store_test.constants import BOT_KEY_TEMPLATE, PYPI_KEY_TEMPLATE +from src.providers.validation.models import PublishType -from .constants import PUBLISH_PATH, REMOVE_HOMEPAGE_PATTERN -from .utils import load_json +from .constants import ( + REMOVE_BOT_HOMEPAGE_PATTERN, + REMOVE_BOT_NAME_PATTERN, + REMOVE_PLUGIN_MODULE_NAME_PATTERN, + REMOVE_PLUGIN_PROJECT_LINK_PATTERN, +) -async def validate_author_info(issue: Issue) -> ValidationDict: +def load_publish_data(publish_type: PublishType): + """加载对应类型的文件数据""" + match publish_type: + case PublishType.ADAPTER: + return { + PYPI_KEY_TEMPLATE.format( + project_link=adapter["project_link"], + module_name=adapter["module_name"], + ): adapter + for adapter in load_json(plugin_config.input_config.adapter_path) + } + case PublishType.BOT: + return { + BOT_KEY_TEMPLATE.format( + name=bot["name"], + homepage=bot["homepage"], + ): bot + for bot in load_json(plugin_config.input_config.bot_path) + } + case PublishType.PLUGIN: + return { + PYPI_KEY_TEMPLATE.format( + project_link=plugin["project_link"], + module_name=plugin["module_name"], + ): plugin + for plugin in load_json(plugin_config.input_config.plugin_path) + } + case PublishType.DRIVER: + raise ValueError("不支持的删除类型") + + +class RemoveInfo(BaseModel): + publish_type: PublishType + key: str + name: str + + +async def validate_author_info(issue: Issue, publish_type: PublishType) -> RemoveInfo: """ - 根据主页链接与作者信息找到对应的包的信息 + 通过议题获取作者 ID,然后验证待删除的数据项是否属于该作者 """ - homepage = extract_publish_info_from_issue( - {"homepage": REMOVE_HOMEPAGE_PATTERN}, issue.body or "" - ).get("homepage") - author = issue.user.login if issue.user else "" - author_id = issue.user.id if issue.user else 0 - - for type, path in PUBLISH_PATH.items(): - if not path.exists(): - logger.info(f"{type} 数据文件不存在,跳过") - continue - - data: list[dict[str, str]] = load_json(path) - for item in data: - if item.get("homepage") == homepage: - logger.info(f"找到匹配的 {type} 数据 {item}") - - # author_id 暂时没有储存到数据里, 所以暂时不校验 - if item.get("author") == author or ( - item.get("author_id") is not None - and item.get("author_id") == author_id - ): - return ValidationDict( - valid=True, - data=item, - type=type, - name=item.get("name") or item.get("module_name") or "", - author=author, - author_id=author_id, - errors=[], - ) - raise PydanticCustomError("author_info", "作者信息不匹配") - raise PydanticCustomError("not_found", "没有包含对应主页链接的包") + body = issue.body if issue.body else "" + author_id = AuthorInfo.from_issue(issue).author_id + + match publish_type: + case PublishType.PLUGIN: + raw_data = extract_issue_info_from_issue( + { + "module_name": REMOVE_PLUGIN_MODULE_NAME_PATTERN, + "project_link": REMOVE_PLUGIN_PROJECT_LINK_PATTERN, + }, + body, + ) + module_name = raw_data.get("module_name") + project_link = raw_data.get("project_link") + if module_name is None or project_link is None: + raise PydanticCustomError( + "info_not_found", "未填写数据项或填写格式有误" + ) + + key = PYPI_KEY_TEMPLATE.format( + project_link=project_link, module_name=module_name + ) + case PublishType.BOT | PublishType.ADAPTER: + raw_data = extract_issue_info_from_issue( + { + "name": REMOVE_BOT_NAME_PATTERN, + "homepage": REMOVE_BOT_HOMEPAGE_PATTERN, + }, + body, + ) + name = raw_data.get("name") + homepage = raw_data.get("homepage") + + if name is None or homepage is None: + raise PydanticCustomError( + "info_not_found", "未填写数据项或填写格式有误" + ) + + key = BOT_KEY_TEMPLATE.format(name=name, homepage=homepage) + case _: + raise PydanticCustomError("not_support", "暂不支持的移除类型") + + data = load_publish_data(publish_type) + + if key not in data: + raise PydanticCustomError("not_found", "不存在对应信息的包") + + remove_item = data[key] + if remove_item.get("author_id") != author_id: + raise PydanticCustomError("author_info", "作者信息验证不匹配") + + return RemoveInfo( + publish_type=publish_type, + name=remove_item.get("name") or remove_item.get("module_name") or "", + key=key, + ) diff --git a/src/plugins/github/typing.py b/src/plugins/github/typing.py index 9a92cd69..d5d0f32d 100644 --- a/src/plugins/github/typing.py +++ b/src/plugins/github/typing.py @@ -3,6 +3,7 @@ from githubkit.rest import ( PullRequestPropLabelsItems, + PullRequestSimplePropLabelsItems, WebhookIssueCommentCreatedPropIssueAllof0PropLabelsItems, WebhookIssuesEditedPropIssuePropLabelsItems, WebhookIssuesOpenedPropIssuePropLabelsItems, @@ -25,11 +26,18 @@ PullRequestEvent: TypeAlias = PullRequestClosed | PullRequestReviewSubmitted -LabelsItems: TypeAlias = ( - list[PullRequestPropLabelsItems] + +PullRequestLabels: TypeAlias = ( + list[PullRequestSimplePropLabelsItems] + | list[PullRequestPropLabelsItems] | list[WebhookPullRequestReviewSubmittedPropPullRequestPropLabelsItems] - | Missing[list[WebhookIssuesOpenedPropIssuePropLabelsItems]] +) + +IssueLabels: TypeAlias = ( + Missing[list[WebhookIssuesOpenedPropIssuePropLabelsItems]] | Missing[list[WebhookIssuesReopenedPropIssuePropLabelsItems]] | Missing[list[WebhookIssuesEditedPropIssuePropLabelsItems]] | list[WebhookIssueCommentCreatedPropIssueAllof0PropLabelsItems] ) + +LabelsItems: TypeAlias = PullRequestLabels | IssueLabels diff --git a/src/plugins/github/utils.py b/src/plugins/github/utils.py index 87b72ee2..12a66a56 100644 --- a/src/plugins/github/utils.py +++ b/src/plugins/github/utils.py @@ -44,7 +44,7 @@ def dump_json(path: Path, data: Any, indent: int = 4): f.write("\n") -def extract_publish_info_from_issue( +def extract_issue_info_from_issue( patterns: dict[str, Pattern[str]], body: str ) -> dict[str, str | None]: """ diff --git a/src/providers/models.py b/src/providers/models.py index c9b432f9..ed326e7f 100644 --- a/src/providers/models.py +++ b/src/providers/models.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field from src.providers.docker_test import Metadata +from src.providers.store_test.constants import BOT_KEY_TEMPLATE, PYPI_KEY_TEMPLATE from src.providers.validation.models import ( AdapterPublishInfo, BotPublishInfo, @@ -29,6 +30,12 @@ class StoreAdapter(BaseModel): tags: list[Tag] is_official: bool + @property + def key(self): + return PYPI_KEY_TEMPLATE.format( + project_link=self.project_link, module_name=self.module_name + ) + @classmethod def from_publish_info(cls, publish_info: AdapterPublishInfo) -> Self: return cls( @@ -53,6 +60,10 @@ class StoreBot(BaseModel): tags: list[Tag] is_official: bool + @property + def key(self): + return BOT_KEY_TEMPLATE.format(name=self.name, homepage=self.homepage) + @classmethod def from_publish_info(cls, publish_info: BotPublishInfo) -> Self: return cls( @@ -77,6 +88,12 @@ class StoreDriver(BaseModel): tags: list[Tag] is_official: bool + @property + def key(self): + return PYPI_KEY_TEMPLATE.format( + project_link=self.project_link, module_name=self.module_name + ) + @classmethod def from_publish_info(cls, publish_info: DriverPublishInfo) -> Self: return cls( @@ -100,6 +117,12 @@ class StorePlugin(BaseModel): tags: list[Tag] is_official: bool + @property + def key(self): + return PYPI_KEY_TEMPLATE.format( + project_link=self.project_link, module_name=self.module_name + ) + @classmethod def from_publish_info(cls, publish_info: PluginPublishInfo) -> Self: return cls( @@ -144,6 +167,12 @@ class Adapter(BaseModel): tags: list[Tag] is_official: bool + @property + def key(self): + return PYPI_KEY_TEMPLATE.format( + project_link=self.project_link, module_name=self.module_name + ) + @classmethod def from_publish_info(cls, publish_info: AdapterPublishInfo) -> Self: return cls( diff --git a/tests/github/remove/conftest.py b/tests/github/remove/conftest.py deleted file mode 100644 index f04087f5..00000000 --- a/tests/github/remove/conftest.py +++ /dev/null @@ -1,23 +0,0 @@ -from pathlib import Path - -import pytest -from nonebug.app import App -from pytest_mock import MockerFixture - - -@pytest.fixture(autouse=True) -def _remove_mock(app: App, tmp_path: Path, mocker: MockerFixture): - from src.providers.validation.models import PublishType - - bot_path = tmp_path / "bots.json" - plugin_path = tmp_path / "plugins.json" - adapter_path = tmp_path / "adapters.json" - - mocker.patch.dict( - "src.plugins.github.plugins.remove.constants.PUBLISH_PATH", - { - PublishType.PLUGIN: plugin_path, - PublishType.ADAPTER: adapter_path, - PublishType.BOT: bot_path, - }, - ) diff --git a/tests/github/remove/process/test_remove_check.py b/tests/github/remove/process/test_remove_check.py index 84448785..556f0861 100644 --- a/tests/github/remove/process/test_remove_check.py +++ b/tests/github/remove/process/test_remove_check.py @@ -1,9 +1,8 @@ import json from pathlib import Path -import pytest from inline_snapshot import snapshot -from nonebot.adapters.github import Adapter, IssuesOpened +from nonebot.adapters.github import Adapter, IssueCommentCreated, IssuesOpened from nonebug import App from pytest_mock import MockerFixture from respx import MockRouter @@ -22,8 +21,7 @@ def get_remove_labels(): return get_issue_labels(["Remove"]) -@pytest.mark.skip("需要修复") -async def test_process_remove_check( +async def test_process_remove_bot_check( app: App, mocker: MockerFixture, mocked_api: MockRouter, @@ -34,7 +32,7 @@ async def test_process_remove_check( from src.plugins.github import plugin_config from src.plugins.github.plugins.remove import remove_check_matcher - bot_data = [ + data = [ { "name": "TESTBOT", "desc": "desc", @@ -50,11 +48,11 @@ async def test_process_remove_check( "subprocess.run", side_effect=lambda *args, **kwargs: mocker.MagicMock() ) + remove_type = "Bot" mock_issue = MockIssue( - body=generate_issue_body_remove("https://vv.nonebot.dev"), + body=generate_issue_body_remove(remove_type, "TESTBOT:https://vv.nonebot.dev"), user=MockUser(login="test", id=20), ).as_mock(mocker) - mock_event = mocker.MagicMock() mock_event.issue = mock_issue @@ -72,16 +70,16 @@ async def test_process_remove_check( mock_pulls_resp.parsed_data = mock_pull with open(tmp_path / "bots.json", "w") as f: - json.dump(bot_data, f) + json.dump(data, f) - check_json_data(plugin_config.input_config.bot_path, bot_data) + check_json_data(plugin_config.input_config.bot_path, data) async with app.test_matcher(remove_check_matcher) as ctx: adapter, bot = get_github_bot(ctx) event_path = Path(__file__).parent.parent.parent / "events" / "issue-open.json" event = Adapter.payload_to_event("1", "issues", event_path.read_bytes()) assert isinstance(event, IssuesOpened) - event.payload.issue.labels = get_remove_labels() + event.payload.issue.labels = get_issue_labels(["Remove", remove_type]) ctx.should_call_api( "rest.apps.async_get_repo_installation", @@ -113,7 +111,7 @@ async def test_process_remove_check( "owner": "he0119", "repo": "action-test", "issue_number": 2, - "labels": ["Remove"], + "labels": ["Remove", "Bot"], }, True, ) @@ -218,6 +216,204 @@ async def test_process_remove_check( ) +async def test_process_remove_plugin_check( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + tmp_path: Path, + mock_installation, +): + """测试正常的删除流程""" + from src.plugins.github import plugin_config + from src.plugins.github.plugins.remove import remove_check_matcher + + data = [ + { + "module_name": "module_name", + "project_link": "project_link", + "name": "test", + "desc": "desc", + "author_id": 20, + "homepage": "https://nonebot.dev", + "tags": [{"label": "test", "color": "#ffffff"}], + "is_official": False, + } + ] + + mock_subprocess_run = mocker.patch( + "subprocess.run", side_effect=lambda *args, **kwargs: mocker.MagicMock() + ) + + remove_type = "Plugin" + mock_issue = MockIssue( + body=generate_issue_body_remove(remove_type, "project_link:module_name"), + user=MockUser(login="test", id=20), + ).as_mock(mocker) + mock_event = mocker.MagicMock() + mock_event.issue = mock_issue + + mock_issues_resp = mocker.MagicMock() + mock_issues_resp.parsed_data = mock_issue + + mock_comment = mocker.MagicMock() + mock_comment.body = "Bot: test" + mock_list_comments_resp = mocker.MagicMock() + mock_list_comments_resp.parsed_data = [mock_comment] + + mock_pull = mocker.MagicMock() + mock_pull.number = 2 + mock_pulls_resp = mocker.MagicMock() + mock_pulls_resp.parsed_data = mock_pull + + with open(tmp_path / "plugins.json", "w") as f: + json.dump(data, f) + + check_json_data(plugin_config.input_config.plugin_path, data) + + async with app.test_matcher(remove_check_matcher) as ctx: + adapter, bot = get_github_bot(ctx) + event_path = Path(__file__).parent.parent.parent / "events" / "issue-open.json" + event = Adapter.payload_to_event("1", "issues", event_path.read_bytes()) + assert isinstance(event, IssuesOpened) + event.payload.issue.labels = get_issue_labels(["Remove", remove_type]) + + ctx.should_call_api( + "rest.apps.async_get_repo_installation", + {"owner": "he0119", "repo": "action-test"}, + mock_installation, + ) + ctx.should_call_api( + "rest.issues.async_get", + {"owner": "he0119", "repo": "action-test", "issue_number": 80}, + mock_issues_resp, + ) + ctx.should_call_api( + "rest.pulls.async_create", + snapshot( + { + "owner": "he0119", + "repo": "action-test", + "title": "Plugin: Remove test", + "body": "resolve #80", + "base": "master", + "head": "remove/issue80", + } + ), + mock_pulls_resp, + ) + ctx.should_call_api( + "rest.issues.async_add_labels", + snapshot( + { + "owner": "he0119", + "repo": "action-test", + "issue_number": 2, + "labels": ["Remove", "Plugin"], + } + ), + True, + ) + ctx.should_call_api( + "rest.issues.async_update", + snapshot( + { + "owner": "he0119", + "repo": "action-test", + "issue_number": 80, + "title": "Plugin: Remove test", + } + ), + True, + ) + ctx.should_call_api( + "rest.issues.async_list_comments", + {"owner": "he0119", "repo": "action-test", "issue_number": 80}, + mock_list_comments_resp, + ) + ctx.should_call_api( + "rest.issues.async_create_comment", + { + "owner": "he0119", + "repo": "action-test", + "issue_number": 80, + "body": snapshot( + """\ +# 📃 商店下架检查 + +> Plugin: remove test + +**✅ 所有检查通过,一切准备就绪!** + +> 发起插件下架流程! + +--- + +💡 如需修改信息,请直接修改 issue,机器人会自动更新检查结果。 + +💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow) + +""" + ), + }, + True, + ) + + ctx.receive_event(bot, event) + + mock_subprocess_run.assert_has_calls( + [ + mocker.call( + ["git", "config", "--global", "safe.directory", "*"], + check=True, + capture_output=True, + ), + mocker.call( + ["pre-commit", "install", "--install-hooks"], + check=True, + capture_output=True, + ), + mocker.call( + ["git", "switch", "-C", "remove/issue80"], + check=True, + capture_output=True, + ), + mocker.call( + ["git", "config", "--global", "user.name", snapshot("test")], + check=True, + capture_output=True, + ), + mocker.call( + [ + "git", + "config", + "--global", + "user.email", + "test@users.noreply.github.com", + ], + check=True, + capture_output=True, + ), + mocker.call(["git", "add", "-A"], check=True, capture_output=True), + mocker.call( + ["git", "commit", "-m", snapshot(":hammer: remove test (#80)")], + check=True, + capture_output=True, + ), + mocker.call(["git", "fetch", "origin"], check=True, capture_output=True), + mocker.call( + ["git", "diff", "origin/remove/issue80", "remove/issue80"], + check=True, + capture_output=True, + ), + mocker.call( + ["git", "push", "origin", "remove/issue80", "-f"], + check=True, + capture_output=True, + ), + ] # type: ignore + ) + + async def test_process_remove_not_found_check( app: App, mocker: MockerFixture, @@ -233,8 +429,11 @@ async def test_process_remove_not_found_check( "subprocess.run", side_effect=lambda *args, **kwargs: mocker.MagicMock() ) + remove_type = "Bot" mock_issue = MockIssue( - body=generate_issue_body_remove("https://notfound.nonebot.dev"), + body=generate_issue_body_remove( + type=remove_type, key="TESTBOT:https://notfound.nonebot.dev" + ), user=MockUser(login="test", id=20), ).as_mock(mocker) @@ -264,7 +463,7 @@ async def test_process_remove_not_found_check( event_path = Path(__file__).parent.parent.parent / "events" / "issue-open.json" event = Adapter.payload_to_event("1", "issues", event_path.read_bytes()) assert isinstance(event, IssuesOpened) - event.payload.issue.labels = get_remove_labels() + event.payload.issue.labels = get_issue_labels(["Remove", remove_type]) ctx.should_call_api( "rest.apps.async_get_repo_installation", @@ -295,7 +494,7 @@ async def test_process_remove_not_found_check( **⚠️ 在下架检查过程中,我们发现以下问题:** -> ⚠️ not_found: 没有包含对应主页链接的包 +> ⚠️ 不存在对应信息的包 --- @@ -327,7 +526,7 @@ async def test_process_remove_not_found_check( ) -async def test_process_remove_author_eq_check( +async def test_process_remove_author_info_not_eq( app: App, mocker: MockerFixture, mocked_api: MockRouter, @@ -354,8 +553,133 @@ async def test_process_remove_author_eq_check( "subprocess.run", side_effect=lambda *args, **kwargs: mocker.MagicMock() ) + remove_type = "Bot" + mock_issue = MockIssue( + body=generate_issue_body_remove( + type=remove_type, key="TESTBOT:https://vv.nonebot.dev" + ), + user=MockUser(login="test", id=20), + ).as_mock(mocker) + + mock_event = mocker.MagicMock() + mock_event.issue = mock_issue + + mock_issues_resp = mocker.MagicMock() + mock_issues_resp.parsed_data = mock_issue + + mock_comment = mocker.MagicMock() + mock_comment.body = "Bot: test" + mock_list_comments_resp = mocker.MagicMock() + mock_list_comments_resp.parsed_data = [mock_comment] + + mock_pull = mocker.MagicMock() + mock_pull.number = 2 + mock_pulls_resp = mocker.MagicMock() + mock_pulls_resp.parsed_data = mock_pull + + with open(tmp_path / "bots.json", "w") as f: + json.dump(bot_data, f) + + check_json_data(plugin_config.input_config.bot_path, bot_data) + + async with app.test_matcher(remove_check_matcher) as ctx: + adapter, bot = get_github_bot(ctx) + event_path = Path(__file__).parent.parent.parent / "events" / "issue-open.json" + event = Adapter.payload_to_event("1", "issues", event_path.read_bytes()) + assert isinstance(event, IssuesOpened) + event.payload.issue.labels = get_issue_labels(["Remove", remove_type]) + + ctx.should_call_api( + "rest.apps.async_get_repo_installation", + {"owner": "he0119", "repo": "action-test"}, + mock_installation, + ) + ctx.should_call_api( + "rest.issues.async_get", + {"owner": "he0119", "repo": "action-test", "issue_number": 80}, + mock_issues_resp, + ) + ctx.should_call_api( + "rest.issues.async_list_comments", + {"owner": "he0119", "repo": "action-test", "issue_number": 80}, + mock_list_comments_resp, + ) + ctx.should_call_api( + "rest.issues.async_create_comment", + { + "owner": "he0119", + "repo": "action-test", + "issue_number": 80, + "body": snapshot( + """\ +# 📃 商店下架检查 + +> Error + +**⚠️ 在下架检查过程中,我们发现以下问题:** + +> ⚠️ 作者信息验证不匹配 + +--- + +💡 如需修改信息,请直接修改 issue,机器人会自动更新检查结果。 + +💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow) + +""" + ), + }, + True, + ) + + ctx.receive_event(bot, event) + + mock_subprocess_run.assert_has_calls( + [ + mocker.call( + ["git", "config", "--global", "safe.directory", "*"], + check=True, + capture_output=True, + ), + mocker.call( + ["pre-commit", "install", "--install-hooks"], + check=True, + capture_output=True, + ), + ] # type: ignore + ) + + +async def test_process_remove_issue_info_not_found( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + tmp_path: Path, + mock_installation, +): + """删除包时无法从议题获取信息的测试""" + from src.plugins.github import plugin_config + from src.plugins.github.plugins.remove import remove_check_matcher + + bot_data = [ + { + "name": "TESTBOT", + "desc": "desc", + "author": "test1", + "author_id": 1, + "homepage": "https://vv.nonebot.dev", + "tags": [], + "is_official": False, + } + ] + + mock_subprocess_run = mocker.patch( + "subprocess.run", side_effect=lambda *args, **kwargs: mocker.MagicMock() + ) + + remove_type = "Bot" mock_issue = MockIssue( - body=generate_issue_body_remove("https://vv.nonebot.dev"), + body=generate_issue_body_remove(type=remove_type, key="TESTBOT:"), user=MockUser(login="test", id=20), ).as_mock(mocker) @@ -385,7 +709,111 @@ async def test_process_remove_author_eq_check( event_path = Path(__file__).parent.parent.parent / "events" / "issue-open.json" event = Adapter.payload_to_event("1", "issues", event_path.read_bytes()) assert isinstance(event, IssuesOpened) - event.payload.issue.labels = get_remove_labels() + event.payload.issue.labels = get_issue_labels(["Remove", remove_type]) + + ctx.should_call_api( + "rest.apps.async_get_repo_installation", + {"owner": "he0119", "repo": "action-test"}, + mock_installation, + ) + ctx.should_call_api( + "rest.issues.async_get", + {"owner": "he0119", "repo": "action-test", "issue_number": 80}, + mock_issues_resp, + ) + ctx.should_call_api( + "rest.issues.async_list_comments", + {"owner": "he0119", "repo": "action-test", "issue_number": 80}, + mock_list_comments_resp, + ) + ctx.should_call_api( + "rest.issues.async_create_comment", + { + "owner": "he0119", + "repo": "action-test", + "issue_number": 80, + "body": snapshot( + """\ +# 📃 商店下架检查 + +> Error + +**⚠️ 在下架检查过程中,我们发现以下问题:** + +> ⚠️ 未填写数据项或填写格式有误 + +--- + +💡 如需修改信息,请直接修改 issue,机器人会自动更新检查结果。 + +💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow) + +""" + ), + }, + True, + ) + + ctx.receive_event(bot, event) + + mock_subprocess_run.assert_has_calls( + [ + mocker.call( + ["git", "config", "--global", "safe.directory", "*"], + check=True, + capture_output=True, + ), + mocker.call( + ["pre-commit", "install", "--install-hooks"], + check=True, + capture_output=True, + ), + ] # type: ignore + ) + + +async def test_process_remove_driver( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + tmp_path: Path, + mock_installation, +): + """不支持驱动器类型的删除""" + from src.plugins.github.plugins.remove import remove_check_matcher + + mock_subprocess_run = mocker.patch( + "subprocess.run", side_effect=lambda *args, **kwargs: mocker.MagicMock() + ) + + remove_type = "Driver" + mock_issue = MockIssue( + body=generate_issue_body_remove(type=remove_type, key="TESTBOT:"), + user=MockUser(login="test", id=20), + ).as_mock(mocker) + + mock_event = mocker.MagicMock() + mock_event.issue = mock_issue + + mock_issues_resp = mocker.MagicMock() + mock_issues_resp.parsed_data = mock_issue + + mock_comment = mocker.MagicMock() + mock_comment.body = "Bot: test" + mock_list_comments_resp = mocker.MagicMock() + mock_list_comments_resp.parsed_data = [mock_comment] + + mock_pull = mocker.MagicMock() + mock_pull.number = 2 + mock_pulls_resp = mocker.MagicMock() + mock_pulls_resp.parsed_data = mock_pull + + async with app.test_matcher(remove_check_matcher) as ctx: + adapter, bot = get_github_bot(ctx) + event_path = Path(__file__).parent.parent.parent / "events" / "issue-open.json" + event = Adapter.payload_to_event("1", "issues", event_path.read_bytes()) + assert isinstance(event, IssuesOpened) + event.payload.issue.labels = get_issue_labels(["Remove", remove_type]) ctx.should_call_api( "rest.apps.async_get_repo_installation", @@ -416,7 +844,7 @@ async def test_process_remove_author_eq_check( **⚠️ 在下架检查过程中,我们发现以下问题:** -> ⚠️ author_info: 作者信息不匹配 +> ⚠️ 暂不支持的移除类型 --- @@ -446,3 +874,45 @@ async def test_process_remove_author_eq_check( ), ] # type: ignore ) + + +async def test_process_not_remove_label( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + tmp_path: Path, +): + """测试没有删除标签的情况""" + from src.plugins.github.plugins.remove import remove_check_matcher + + remove_type = "Driver" + + async with app.test_matcher(remove_check_matcher) as ctx: + adapter, bot = get_github_bot(ctx) + event_path = Path(__file__).parent.parent.parent / "events" / "issue-open.json" + event = Adapter.payload_to_event("1", "issues", event_path.read_bytes()) + assert isinstance(event, IssuesOpened) + event.payload.issue.labels = get_issue_labels([remove_type]) + + ctx.receive_event(bot, event) + + +async def test_process_trigger_by_bot( + app: App, + mocker: MockerFixture, + mocked_api: MockRouter, + tmp_path: Path, +): + """测试 Bot 触发工作流的情况""" + from src.plugins.github.plugins.remove import remove_check_matcher + + async with app.test_matcher(remove_check_matcher) as ctx: + adapter, bot = get_github_bot(ctx) + event_path = ( + Path(__file__).parent.parent.parent / "events" / "issue-comment.json" + ) + event = Adapter.payload_to_event("1", "issue_comment", event_path.read_bytes()) + assert isinstance(event, IssueCommentCreated) + event.payload.sender.type = "Bot" + + ctx.receive_event(bot, event) diff --git a/tests/github/remove/process/test_remove_pull_request.py b/tests/github/remove/process/test_remove_pull_request.py index 73b2beb5..9ed2d0a6 100644 --- a/tests/github/remove/process/test_remove_pull_request.py +++ b/tests/github/remove/process/test_remove_pull_request.py @@ -1,7 +1,6 @@ from pathlib import Path from unittest.mock import MagicMock -import pytest from nonebot.adapters.github import Adapter, PullRequestClosed from nonebug import App from pytest_mock import MockerFixture @@ -27,7 +26,6 @@ def get_remove_labels(): ] -@pytest.mark.skip("需要修复") async def test_remove_process_pull_request( app: App, mocker: MockerFixture, mock_installation: MagicMock ) -> None: @@ -36,7 +34,10 @@ async def test_remove_process_pull_request( mock_subprocess_run = mocker.patch("subprocess.run") - mock_issue = MockIssue(body=generate_issue_body_remove()).as_mock(mocker) + remove_type = "Bot" + mock_issue = MockIssue( + body=generate_issue_body_remove(type=remove_type), number=76 + ).as_mock(mocker) mock_issues_resp = mocker.MagicMock() mock_issues_resp.parsed_data = mock_issue @@ -103,7 +104,6 @@ async def test_remove_process_pull_request( check=True, capture_output=True, ), - mocker.call(["git", "fetch", "origin"], check=True, capture_output=True), ], # type: ignore any_order=True, ) @@ -129,7 +129,6 @@ async def test_not_remove(app: App, mocker: MockerFixture) -> None: mock_subprocess_run.assert_not_called() -@pytest.mark.skip("需要修复") async def test_process_remove_pull_request_not_merged( app: App, mocker: MockerFixture, mock_installation ) -> None: @@ -140,7 +139,10 @@ async def test_process_remove_pull_request_not_merged( mock_subprocess_run = mocker.patch("subprocess.run") - mock_issue = MockIssue(body=generate_issue_body_remove()).as_mock(mocker) + remove_type = "Bot" + mock_issue = MockIssue( + body=generate_issue_body_remove(type=remove_type, key="TEST:omg"), number=76 + ).as_mock(mocker) mock_issues_resp = mocker.MagicMock() mock_issues_resp.parsed_data = mock_issue diff --git a/tests/github/remove/render/test_remove_render_data.py b/tests/github/remove/render/test_remove_render_data.py index ab8628d5..a090c2f4 100644 --- a/tests/github/remove/render/test_remove_render_data.py +++ b/tests/github/remove/render/test_remove_render_data.py @@ -4,12 +4,10 @@ async def test_render(app: App): from src.plugins.github.plugins.remove.render import render_comment - from src.providers.validation.models import PublishType, ValidationDict + from src.plugins.github.plugins.remove.validation import RemoveInfo + from src.providers.validation.models import PublishType - result = ValidationDict( - type=PublishType.BOT, - raw_data={"name": "omg"}, - ) + result = RemoveInfo(publish_type=PublishType.BOT, key="omg", name="omg") assert await render_comment(result) == snapshot( """\ # 📃 商店下架检查 @@ -45,7 +43,7 @@ async def test_exception_author_info_no_eq(app: App): **⚠️ 在下架检查过程中,我们发现以下问题:** -> ⚠️ author_info: 作者信息不匹配 +> ⚠️ 作者信息不匹配 --- @@ -72,7 +70,7 @@ async def test_exception_package_not_found(app: App): **⚠️ 在下架检查过程中,我们发现以下问题:** -> ⚠️ not_found: 没有包含对应主页链接的包 +> ⚠️ 没有包含对应主页链接的包 --- diff --git a/tests/github/remove/utils/test_remove_resolve_pull_requests.py b/tests/github/remove/utils/test_remove_resolve_pull_requests.py new file mode 100644 index 00000000..20b60fef --- /dev/null +++ b/tests/github/remove/utils/test_remove_resolve_pull_requests.py @@ -0,0 +1,256 @@ +import json +from pathlib import Path +from typing import Any + +import pytest +from inline_snapshot import snapshot +from nonebug import App +from pytest_mock import MockerFixture +from respx import MockRouter + +from tests.github.utils import ( + MockIssue, + MockUser, + generate_issue_body_remove, + get_github_bot, +) + + +def check_json_data(file: Path, data: Any) -> None: + with open(file, encoding="utf-8") as f: + assert json.load(f) == data + + +@pytest.fixture +def mock_pull(mocker: MockerFixture): + mock_pull = mocker.MagicMock() + mock_pull.head.ref = "remove/issue1" + mock_pull.draft = False + + return mock_pull + + +async def test_resolve_conflict_pull_requests_bot( + app: App, mocker: MockerFixture, mocked_api: MockRouter, tmp_path: Path, mock_pull +) -> None: + from src.plugins.github import plugin_config + from src.plugins.github.models import GithubHandler, RepoInfo + from src.plugins.github.plugins.remove.utils import resolve_conflict_pull_requests + + mock_subprocess_run = mocker.patch("subprocess.run") + mock_result = mocker.MagicMock() + mock_subprocess_run.side_effect = lambda *args, **kwargs: mock_result + + mock_issue_repo = mocker.MagicMock() + mock_issue = MockIssue( + number=1, + body=generate_issue_body_remove( + "Bot", "CoolQBot:https://github.com/he0119/CoolQBot" + ), + user=MockUser(login="he0119", id=1), + ).as_mock(mocker) + mock_issue_repo.parsed_data = mock_issue + + mock_label = mocker.MagicMock() + mock_label.name = "Bot" + + mock_pull.labels = [mock_label] + + with open(tmp_path / "bots.json", "w", encoding="utf-8") as f: + json.dump( + [ + { + "name": "CoolQBot", + "desc": "基于 NoneBot2 的聊天机器人", + "author_id": 1, + "homepage": "https://github.com/he0119/CoolQBot", + "tags": [], + "is_official": False, + } + ], + f, + ensure_ascii=False, + ) + + async with app.test_api() as ctx: + adapter, bot = get_github_bot(ctx) + + handler = GithubHandler(bot=bot, repo_info=RepoInfo(owner="owner", repo="repo")) + + ctx.should_call_api( + "rest.issues.async_get", + snapshot({"owner": "owner", "repo": "repo", "issue_number": 1}), + mock_issue_repo, + ) + + await resolve_conflict_pull_requests(handler, [mock_pull]) + + # 测试 git 命令 + mock_subprocess_run.assert_has_calls( + [ + mocker.call(["git", "fetch", "origin"], check=True, capture_output=True), + mocker.call( + ["git", "checkout", "remove/issue1"], check=True, capture_output=True + ), + mocker.call(["git", "checkout", "master"], check=True, capture_output=True), + mocker.call( + ["git", "switch", "-C", "remove/issue1"], + check=True, + capture_output=True, + ), + mocker.call( + ["git", "config", "--global", "user.name", "he0119"], + check=True, + capture_output=True, + ), + mocker.call( + [ + "git", + "config", + "--global", + "user.email", + "he0119@users.noreply.github.com", + ], + check=True, + capture_output=True, + ), + mocker.call(["git", "add", "-A"], check=True, capture_output=True), + mocker.call( + ["git", "commit", "-m", ":hammer: remove CoolQBot (#1)"], + check=True, + capture_output=True, + ), + mocker.call(["git", "fetch", "origin"], check=True, capture_output=True), + mocker.call( + ["git", "diff", "origin/remove/issue1", "remove/issue1"], + check=True, + capture_output=True, + ), + mocker.call( + ["git", "push", "origin", "remove/issue1", "-f"], + check=True, + capture_output=True, + ), + ] # type: ignore + ) + + # 检查文件是否正确 + check_json_data( + plugin_config.input_config.bot_path, + snapshot({}), + ) + + +async def test_resolve_conflict_pull_requests_plugin( + app: App, mocker: MockerFixture, mocked_api: MockRouter, tmp_path: Path, mock_pull +) -> None: + from src.plugins.github import plugin_config + from src.plugins.github.models import GithubHandler, RepoInfo + from src.plugins.github.plugins.remove.utils import resolve_conflict_pull_requests + + mock_subprocess_run = mocker.patch("subprocess.run") + mock_result = mocker.MagicMock() + mock_subprocess_run.side_effect = lambda *args, **kwargs: mock_result + + mock_label = mocker.MagicMock() + mock_label.name = "Plugin" + + mock_issue_repo = mocker.MagicMock() + mock_issue = MockIssue( + number=1, + body=generate_issue_body_remove( + "Plugin", "nonebot-plugin-treehelp:nonebot_plugin_treehelp" + ), + user=MockUser(login="he0119", id=1), + ).as_mock(mocker) + mock_issue_repo.parsed_data = mock_issue + + mock_pull.labels = [mock_label] + mock_pull.title = "Plugin: 帮助" + + mock_comment = mocker.MagicMock() + mock_comment.body = "Plugin: test" + mock_list_comments_resp = mocker.MagicMock() + mock_list_comments_resp.parsed_data = [mock_comment] + + with open(tmp_path / "plugins.json", "w", encoding="utf-8") as f: + json.dump( + [ + { + "module_name": "nonebot_plugin_treehelp", + "project_link": "nonebot-plugin-treehelp", + "author_id": 1, + "tags": [], + "is_official": True, + } + ], + f, + ensure_ascii=False, + ) + + async with app.test_api() as ctx: + adapter, bot = get_github_bot(ctx) + + ctx.should_call_api( + "rest.issues.async_get", + snapshot({"owner": "owner", "repo": "repo", "issue_number": 1}), + mock_issue_repo, + ) + + handler = GithubHandler(bot=bot, repo_info=RepoInfo(owner="owner", repo="repo")) + + await resolve_conflict_pull_requests(handler, [mock_pull]) + + # 测试 git 命令 + mock_subprocess_run.assert_has_calls( + [ + mocker.call(["git", "fetch", "origin"], check=True, capture_output=True), + mocker.call( + ["git", "checkout", "remove/issue1"], check=True, capture_output=True + ), + mocker.call(["git", "checkout", "master"], check=True, capture_output=True), + mocker.call( + ["git", "switch", "-C", "remove/issue1"], + check=True, + capture_output=True, + ), + mocker.call( + ["git", "config", "--global", "user.name", "he0119"], + check=True, + capture_output=True, + ), + mocker.call( + [ + "git", + "config", + "--global", + "user.email", + "he0119@users.noreply.github.com", + ], + check=True, + capture_output=True, + ), + mocker.call(["git", "add", "-A"], check=True, capture_output=True), + mocker.call( + ["git", "commit", "-m", ":hammer: remove nonebot_plugin_treehelp (#1)"], + check=True, + capture_output=True, + ), + mocker.call(["git", "fetch", "origin"], check=True, capture_output=True), + mocker.call( + ["git", "diff", "origin/remove/issue1", "remove/issue1"], + check=True, + capture_output=True, + ), + mocker.call( + ["git", "push", "origin", "remove/issue1", "-f"], + check=True, + capture_output=True, + ), + ] # type: ignore + ) + + check_json_data( + plugin_config.input_config.plugin_path, + snapshot({}), + ) diff --git a/tests/github/utils.py b/tests/github/utils.py index 6aec3681..3daf554e 100644 --- a/tests/github/utils.py +++ b/tests/github/utils.py @@ -58,8 +58,25 @@ def generate_issue_body_plugin_test_button(body: str, selected: bool): return f"""{body}\n\n### 插件测试\n\n- [{'x' if selected else ' '}] {PLUGIN_TEST_BUTTON_TIPS}""" -def generate_issue_body_remove(homepage: str = "https://nonebot.dev"): - return f"""### 项目主页\n\n{homepage}""" +def generate_issue_body_remove( + type: Literal["Plugin", "Adapter", "Bot", "Driver"], + key: str = "https://nonebot.dev", +): + match type: + case "Bot": + return ( + """### 机器人名称\n\n{}\n\n### 机器人项目仓库/主页链接\n\n{}""".format( + *key.split(":", 1) + ) + ) + case _: + return """### PyPI 项目名\n\n{}\n\n### import 包名\n\n{}""".format( + *key.split(":", 1) + ) + + +# def generate_issue_body_remove(homepage: str = "https://nonebot.dev"): +# return f"""### 项目主页\n\n{homepage}""" def check_json_data(file: Path, data: Any) -> None: @@ -178,7 +195,7 @@ def get_issue_labels(labels: list[str]): "color": "2AAAAA", "default": False, "description": "", - "id": 2798075966, + "id": 27980759601, "name": label, "node_id": "MDU6TGFiZWwyNzk4MDc1OTY2", "url": f"https://api.github.com/repos/he0119/action-test/labels/{label}",