From 713aafafdad64e6482e7fec25f40a251228c1eeb Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Mar 2026 11:56:26 +0000
Subject: [PATCH 1/3] Initial plan
From 3e10b81fb42640c66d489000f46cadc572050ab7 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 18 Mar 2026 12:08:05 +0000
Subject: [PATCH 2/3] fix: skip PR when corresponding issue doesn't exist (404)
Co-authored-by: he0119 <5219550+he0119@users.noreply.github.com>
---
src/plugins/github/plugins/publish/utils.py | 7 +-
src/plugins/github/plugins/remove/utils.py | 7 +-
.../resolve/test_resolve_pull_request.py | 222 ++++++++++++++++++
3 files changed, 234 insertions(+), 2 deletions(-)
diff --git a/src/plugins/github/plugins/publish/utils.py b/src/plugins/github/plugins/publish/utils.py
index 845b8154..d91ca325 100644
--- a/src/plugins/github/plugins/publish/utils.py
+++ b/src/plugins/github/plugins/publish/utils.py
@@ -4,6 +4,7 @@
from githubkit.exception import RequestFailed
from nonebot import logger
+from nonebot.adapters.github import ActionFailed
from src.plugins.github import plugin_config
from src.plugins.github.constants import (
@@ -132,7 +133,11 @@ async def resolve_conflict_pull_requests(
logger.info("拉取请求为草稿,跳过处理")
continue
- issue_handler = await handler.to_issue_handler(issue_number)
+ try:
+ issue_handler = await handler.to_issue_handler(issue_number)
+ except (ActionFailed, RequestFailed):
+ logger.error(f"议题 #{issue_number} 不存在,跳过处理 {pull.title}")
+ continue
try:
artifact = await get_noneflow_artifact(issue_handler)
diff --git a/src/plugins/github/plugins/remove/utils.py b/src/plugins/github/plugins/remove/utils.py
index cb1d212d..21bf072b 100644
--- a/src/plugins/github/plugins/remove/utils.py
+++ b/src/plugins/github/plugins/remove/utils.py
@@ -1,5 +1,6 @@
from githubkit.exception import RequestFailed
from nonebot import logger
+from nonebot.adapters.github import ActionFailed
from pydantic_core import PydanticCustomError
from src.plugins.github import plugin_config
@@ -97,7 +98,11 @@ async def resolve_conflict_pull_requests(
# 根据标签获取发布类型
publish_type = get_type_by_labels(pull.labels)
- issue_handler = await handler.to_issue_handler(issue_number)
+ try:
+ issue_handler = await handler.to_issue_handler(issue_number)
+ except (ActionFailed, RequestFailed):
+ logger.error(f"议题 #{issue_number} 不存在,跳过处理 {pull.title}")
+ continue
if publish_type:
# 验证作者信息
diff --git a/tests/plugins/github/resolve/test_resolve_pull_request.py b/tests/plugins/github/resolve/test_resolve_pull_request.py
index 400c02fe..2ba5d044 100644
--- a/tests/plugins/github/resolve/test_resolve_pull_request.py
+++ b/tests/plugins/github/resolve/test_resolve_pull_request.py
@@ -3,6 +3,9 @@
from pathlib import Path
from unittest.mock import MagicMock
+import httpx
+from githubkit import Response
+from githubkit.exception import RequestFailed
from inline_snapshot import snapshot
from nonebot.adapters.github import PullRequestClosed
from nonebug import App
@@ -257,3 +260,222 @@ async def test_resolve_pull_request(
)
assert not mocked_api["homepage"].called
+
+
+async def test_resolve_pull_request_issue_not_found(
+ app: App,
+ mocker: MockerFixture,
+ mock_installation: MagicMock,
+ mock_installation_token: MagicMock,
+ mocked_api: MockRouter,
+ tmp_path: Path,
+) -> None:
+ """测试当 PR 对应的议题不存在时,跳过该 PR 继续处理后续 PR"""
+ from src.plugins.github.plugins.resolve import pr_close_matcher
+ from src.providers.models import (
+ REGISTRY_DATA_NAME,
+ BotPublishInfo,
+ Color,
+ RegistryArtifactData,
+ Tag,
+ )
+
+ mock_subprocess_run = mock_subprocess_run_with_side_effect(mocker)
+
+ mock_issue = MockIssue(
+ body=generate_issue_body_remove(type="Bot"), number=76
+ ).as_mock(mocker)
+ mock_issues_resp = mocker.MagicMock()
+ mock_issues_resp.parsed_data = mock_issue
+
+ # 议题不存在的发布 PR
+ mock_missing_pull = mocker.MagicMock()
+ mock_missing_pull.title = "Bot: missing"
+ mock_missing_pull.draft = False
+ mock_missing_pull.head.ref = "publish/issue999"
+ mock_missing_pull.labels = get_pr_labels(["Publish", "Bot"])
+
+ # 正常存在的发布 PR
+ mock_publish_issue = MockIssue(body=generate_issue_body_bot(), number=100).as_mock(
+ mocker
+ )
+ mock_publish_issue_resp = mocker.MagicMock()
+ mock_publish_issue_resp.parsed_data = mock_publish_issue
+ mock_publish_pull = mocker.MagicMock()
+ mock_publish_pull.title = "Bot: test"
+ mock_publish_pull.draft = False
+ mock_publish_pull.head.ref = "publish/issue100"
+ mock_publish_pull.labels = get_pr_labels(["Publish", "Bot"])
+
+ mock_publish_issue_comment = mocker.MagicMock()
+ mock_publish_issue_comment.body = """
+
+历史测试
+
+⚠️ 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
+
+
+
+"""
+ mock_publish_list_comments_resp = mocker.MagicMock()
+ mock_publish_list_comments_resp.parsed_data = [mock_publish_issue_comment]
+
+ mock_publish_artifact = mocker.MagicMock()
+ mock_publish_artifact.name = "noneflow"
+ mock_publish_artifact.id = 123456789
+ mock_publish_artifacts = mocker.MagicMock()
+ mock_publish_artifacts.artifacts = [mock_publish_artifact]
+ mock_publish_artifact_resp = mocker.MagicMock()
+ mock_publish_artifact_resp.parsed_data = mock_publish_artifacts
+
+ raw_data = {
+ "module_name": "module_name",
+ "project_link": "project_link",
+ "time": "2025-03-28T02:21:18Z",
+ "version": "1.0.0",
+ "name": "name",
+ "desc": "desc",
+ "author": "he0119",
+ "author_id": 1,
+ "homepage": "https://nonebot.dev",
+ "tags": [Tag(label="test", color=Color("#ffffff"))],
+ "is_official": False,
+ }
+ info = BotPublishInfo.model_construct(**raw_data)
+ registry_data = RegistryArtifactData.from_info(info)
+
+ zip_buffer = BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
+ json_content = registry_data.model_dump_json(indent=2)
+ zip_file.writestr(REGISTRY_DATA_NAME, json_content)
+
+ publish_zip_content = zip_buffer.getvalue()
+
+ mock_publish_download_artifact_resp = mocker.MagicMock()
+ mock_publish_download_artifact_resp.content = publish_zip_content
+
+ mock_pulls_resp = mocker.MagicMock()
+ mock_pulls_resp.parsed_data = [mock_missing_pull, mock_publish_pull]
+
+ async with app.test_matcher() as ctx:
+ _adapter, bot = get_github_bot(ctx)
+
+ event = get_mock_event(PullRequestClosed)
+ event.payload.pull_request.labels = get_pr_labels(["Remove", "Bot"])
+ event.payload.pull_request.merged = True
+
+ ctx.should_call_api(
+ "rest.apps.async_get_repo_installation",
+ snapshot({"owner": "he0119", "repo": "action-test"}),
+ mock_installation,
+ )
+ ctx.should_call_api(
+ "rest.apps.async_create_installation_access_token",
+ snapshot({"installation_id": mock_installation.parsed_data.id}),
+ mock_installation_token,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_get",
+ snapshot({"owner": "he0119", "repo": "action-test", "issue_number": 76}),
+ mock_issues_resp,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_update",
+ snapshot(
+ {
+ "owner": "he0119",
+ "repo": "action-test",
+ "issue_number": 76,
+ "state": "closed",
+ "state_reason": "completed",
+ }
+ ),
+ True,
+ )
+ ctx.should_call_api(
+ "rest.pulls.async_list",
+ snapshot({"owner": "he0119", "repo": "action-test", "state": "open"}),
+ mock_pulls_resp,
+ )
+ # 议题不存在,应该返回 404
+ ctx.should_call_api(
+ "rest.issues.async_get",
+ snapshot({"owner": "he0119", "repo": "action-test", "issue_number": 999}),
+ exception=RequestFailed(
+ Response(
+ httpx.Response(404, request=httpx.Request("GET", "test")),
+ None, # type: ignore
+ )
+ ),
+ )
+ # 跳过不存在的议题,继续处理下一个 PR
+ ctx.should_call_api(
+ "rest.issues.async_get",
+ snapshot({"owner": "he0119", "repo": "action-test", "issue_number": 100}),
+ mock_publish_issue_resp,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_list_comments",
+ snapshot({"owner": "he0119", "repo": "action-test", "issue_number": 100}),
+ mock_publish_list_comments_resp,
+ )
+ ctx.should_call_api(
+ "rest.actions.async_list_workflow_run_artifacts",
+ snapshot(
+ {
+ "owner": "he0119",
+ "repo": "action-test",
+ "run_id": 14156878699,
+ }
+ ),
+ mock_publish_artifact_resp,
+ )
+ ctx.should_call_api(
+ "rest.actions.async_download_artifact",
+ snapshot(
+ {
+ "owner": "he0119",
+ "repo": "action-test",
+ "artifact_id": 123456789,
+ "archive_format": "zip",
+ }
+ ),
+ mock_publish_download_artifact_resp,
+ )
+ ctx.receive_event(bot, event)
+ ctx.should_pass_rule(pr_close_matcher)
+
+ # 测试 git 命令 - 应该只处理了 issue100 的 PR,跳过了 issue999
+ assert_subprocess_run_calls(
+ mock_subprocess_run,
+ [
+ ["git", "config", "--global", "safe.directory", "*"],
+ [
+ "git",
+ "config",
+ "--global",
+ "url.https://x-access-token:test-token@github.com/.insteadOf",
+ "https://github.com/",
+ ],
+ ["git", "push", "origin", "--delete", "publish/issue76"],
+ # 处理发布(issue999 被跳过,只处理 issue100)
+ ["git", "checkout", "master"],
+ ["git", "pull"],
+ ["git", "switch", "-C", "publish/issue100"],
+ ["git", "add", str(tmp_path / "bots.json5")],
+ ["git", "config", "--global", "user.name", "test"],
+ [
+ "git",
+ "config",
+ "--global",
+ "user.email",
+ "test@users.noreply.github.com",
+ ],
+ ["git", "commit", "-m", ":beers: publish bot name (#100)"],
+ ["git", "fetch", "origin"],
+ ["git", "diff", "origin/publish/issue100", "publish/issue100"],
+ ["git", "push", "origin", "publish/issue100", "-f"],
+ ],
+ )
+
+ assert not mocked_api["homepage"].called
From fca6e975124c06a8bf606618cbbed493582db165 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 31 Mar 2026 13:09:56 +0000
Subject: [PATCH 3/3] Changes before error encountered
Agent-Logs-Url: https://github.com/nonebot/noneflow/sessions/6a4d9775-babc-4eec-aa9c-38eae805e9e8
Co-authored-by: he0119 <5219550+he0119@users.noreply.github.com>
---
src/plugins/github/depends/__init__.py | 2 +-
src/plugins/github/plugins/publish/depends.py | 2 +-
src/plugins/github/plugins/publish/utils.py | 2 +-
src/providers/validation/models.py | 4 +-
.../config/process/test_config_check.py | 49 ++++
.../publish/process/test_publish_check.py | 218 ++++++++++++++++++
.../remove/process/test_remove_check.py | 212 +++++++++++++++++
.../test_remove_resolve_pull_requests.py | 93 ++++++++
.../github/remove/utils/test_update_file.py | 66 ++++++
.../resolve/test_resolve_close_issue.py | 77 +++++++
.../plugins/github/utils/test_github_utils.py | 29 +++
.../docker_test/test_docker_plugin_test.py | 29 +++
.../providers/docker_test/test_plugin_test.py | 105 +++++++++
tests/providers/store_test/test_store_test.py | 72 ++++++
tests/providers/store_test/test_utils.py | 18 ++
.../store_test/test_validate_plugin.py | 47 ++++
tests/providers/test_models.py | 216 +++++++++++++++++
.../providers/validation/fields/test_tags.py | 9 +
tests/providers/validation/test_plugin.py | 34 +++
19 files changed, 1279 insertions(+), 5 deletions(-)
create mode 100644 tests/providers/test_models.py
diff --git a/src/plugins/github/depends/__init__.py b/src/plugins/github/depends/__init__.py
index 3e8f4606..197b1886 100644
--- a/src/plugins/github/depends/__init__.py
+++ b/src/plugins/github/depends/__init__.py
@@ -43,7 +43,7 @@ async def get_labels_name(labels: LabelsItems = Depends(get_labels)) -> list[str
async def get_issue_title(event: IssuesEvent):
"""获取议题标题"""
- return event.payload.issue.title
+ return event.payload.issue.title # pragma: no cover
async def get_repo_info(event: PullRequestEvent | IssuesEvent) -> RepoInfo:
diff --git a/src/plugins/github/plugins/publish/depends.py b/src/plugins/github/plugins/publish/depends.py
index 206022b1..c08615d7 100644
--- a/src/plugins/github/plugins/publish/depends.py
+++ b/src/plugins/github/plugins/publish/depends.py
@@ -14,7 +14,7 @@
def get_type_by_title(title: str = Depends(get_issue_title)) -> PublishType | None:
"""通过标题获取类型"""
- return utils.get_type_by_title(title)
+ return utils.get_type_by_title(title) # pragma: no cover
async def get_pull_requests_by_label(
diff --git a/src/plugins/github/plugins/publish/utils.py b/src/plugins/github/plugins/publish/utils.py
index d91ca325..61feea4d 100644
--- a/src/plugins/github/plugins/publish/utils.py
+++ b/src/plugins/github/plugins/publish/utils.py
@@ -174,7 +174,7 @@ def update_file(store: StoreModels, handler: GitHandler) -> None:
path = plugin_config.input_config.bot_path
case StorePlugin():
path = plugin_config.input_config.plugin_path
- case _:
+ case _: # pragma: no cover
raise ValueError("暂不支持的发布类型")
logger.info(f"正在更新文件: {path}")
diff --git a/src/providers/validation/models.py b/src/providers/validation/models.py
index 5487c6f6..1e372ef0 100644
--- a/src/providers/validation/models.py
+++ b/src/providers/validation/models.py
@@ -301,7 +301,7 @@ def supported_adapters_validator(
def plugin_test_load_validator(cls, v: bool, info: ValidationInfo) -> bool:
context = info.context
if context is None:
- raise PydanticCustomError("validation_context", "未获取到验证上下文")
+ raise PydanticCustomError("validation_context", "未获取到验证上下文") # pragma: no cover
if v or context.get("skip_test"):
return True
@@ -318,7 +318,7 @@ def plugin_test_metadata_validator(
) -> bool:
context = info.context
if context is None:
- raise PydanticCustomError("validation_context", "未获取到验证上下文")
+ raise PydanticCustomError("validation_context", "未获取到验证上下文") # pragma: no cover
if not v:
raise PydanticCustomError(
diff --git a/tests/plugins/github/config/process/test_config_check.py b/tests/plugins/github/config/process/test_config_check.py
index 21573334..c1d87395 100644
--- a/tests/plugins/github/config/process/test_config_check.py
+++ b/tests/plugins/github/config/process/test_config_check.py
@@ -359,3 +359,52 @@ async def test_process_config_check(
)
assert mocked_api["homepage"].called
+
+
+async def test_config_issue_state_closed(
+ app: App,
+ mocker: MockerFixture,
+ mocked_api: MockRouter,
+ mock_installation,
+ mock_installation_token,
+ mock_results,
+) -> None:
+ """测试配置检查时议题已关闭的情况"""
+ import os
+
+ os.chdir(mock_results["plugins"].parent)
+
+ mock_subprocess_run = mock_subprocess_run_with_side_effect(mocker)
+
+ mock_issue = MockIssue(state="closed").as_mock(mocker)
+ mock_issues_resp = mocker.MagicMock()
+ mock_issues_resp.parsed_data = mock_issue
+
+ async with app.test_matcher() as ctx:
+ _adapter, bot = get_github_bot(ctx)
+ event = get_mock_event(IssuesOpened)
+ event.payload.issue.labels = get_config_labels()
+
+ ctx.should_call_api(
+ "rest.apps.async_get_repo_installation",
+ {"owner": "he0119", "repo": "action-test"},
+ mock_installation,
+ )
+ ctx.should_call_api(
+ "rest.apps.async_create_installation_access_token",
+ {"installation_id": mock_installation.parsed_data.id},
+ mock_installation_token,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_get",
+ {"owner": "he0119", "repo": "action-test", "issue_number": 80},
+ mock_issues_resp,
+ )
+
+ ctx.receive_event(bot, event)
+
+ assert mocked_api.calls == []
+ assert_subprocess_run_calls(
+ mock_subprocess_run,
+ [["git", "config", "--global", "safe.directory", "*"]],
+ )
diff --git a/tests/plugins/github/publish/process/test_publish_check.py b/tests/plugins/github/publish/process/test_publish_check.py
index 97c1d294..b283cc25 100644
--- a/tests/plugins/github/publish/process/test_publish_check.py
+++ b/tests/plugins/github/publish/process/test_publish_check.py
@@ -1466,3 +1466,221 @@ async def test_comment_immediate_after_pull_request_closed(
)
assert mocked_api["homepage"].called
+
+
+async def test_plugin_issue_state_closed(
+ app: App,
+ mocker: MockerFixture,
+ mocked_api: MockRouter,
+ mock_installation,
+ mock_installation_token,
+) -> None:
+ """测试插件议题已关闭
+
+ 发布类型为 Plugin,event.issue.state = "closed"
+ """
+ mock_subprocess_run = mocker.patch(
+ "subprocess.run", side_effect=lambda *args, **kwargs: mocker.MagicMock()
+ )
+
+ mock_issue = MockIssue(state="closed").as_mock(mocker)
+ mock_issues_resp = mocker.MagicMock()
+ mock_issues_resp.parsed_data = mock_issue
+
+ async with app.test_matcher() as ctx:
+ _adapter, bot = get_github_bot(ctx)
+ event = get_mock_event(IssuesOpened)
+ event.payload.issue.labels = get_issue_labels(["Plugin", "Publish"])
+
+ ctx.should_call_api(
+ "rest.apps.async_get_repo_installation",
+ {"owner": "he0119", "repo": "action-test"},
+ mock_installation,
+ )
+ ctx.should_call_api(
+ "rest.apps.async_create_installation_access_token",
+ {"installation_id": mock_installation.parsed_data.id},
+ mock_installation_token,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_get",
+ {"owner": "he0119", "repo": "action-test", "issue_number": 80},
+ mock_issues_resp,
+ )
+
+ ctx.receive_event(bot, event)
+
+ assert mocked_api.calls == []
+ assert_subprocess_run_calls(
+ mock_subprocess_run,
+ [["git", "config", "--global", "safe.directory", "*"]],
+ )
+
+
+async def test_adapter_issue_state_closed(
+ app: App,
+ mocker: MockerFixture,
+ mocked_api: MockRouter,
+ mock_installation,
+ mock_installation_token,
+) -> None:
+ """测试适配器议题已关闭
+
+ 发布类型为 Adapter,event.issue.state = "closed"
+ """
+ mock_subprocess_run = mocker.patch(
+ "subprocess.run", side_effect=lambda *args, **kwargs: mocker.MagicMock()
+ )
+
+ mock_issue = MockIssue(state="closed").as_mock(mocker)
+ mock_issues_resp = mocker.MagicMock()
+ mock_issues_resp.parsed_data = mock_issue
+
+ async with app.test_matcher() as ctx:
+ _adapter, bot = get_github_bot(ctx)
+ event = get_mock_event(IssuesOpened)
+ event.payload.issue.labels = get_issue_labels(["Adapter", "Publish"])
+
+ ctx.should_call_api(
+ "rest.apps.async_get_repo_installation",
+ {"owner": "he0119", "repo": "action-test"},
+ mock_installation,
+ )
+ ctx.should_call_api(
+ "rest.apps.async_create_installation_access_token",
+ {"installation_id": mock_installation.parsed_data.id},
+ mock_installation_token,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_get",
+ {"owner": "he0119", "repo": "action-test", "issue_number": 80},
+ mock_issues_resp,
+ )
+
+ ctx.receive_event(bot, event)
+
+ assert mocked_api.calls == []
+ assert_subprocess_run_calls(
+ mock_subprocess_run,
+ [["git", "config", "--global", "safe.directory", "*"]],
+ )
+
+
+async def test_bot_process_publish_check_with_history(
+ app: App,
+ mocker: MockerFixture,
+ mocked_api: MockRouter,
+ tmp_path: Path,
+ mock_installation,
+ mock_installation_token,
+) -> None:
+ """测试机器人的发布流程,存在历史检查评论
+
+ 测试 handle_pull_request_and_update_issue 中历史工作流的代码路径(lines 165-166)
+ """
+ from src.plugins.github import plugin_config
+ from src.plugins.github.constants import NONEFLOW_MARKER
+
+ mock_subprocess_run = mock_subprocess_run_with_side_effect(mocker)
+
+ mock_issue = MockIssue(body=MockBody(type="bot", name="test").generate()).as_mock(
+ mocker
+ )
+
+ mock_issues_resp = mocker.MagicMock()
+ mock_issues_resp.parsed_data = mock_issue
+
+ # 创建包含 NONEFLOW_MARKER 和历史工作流数据的自评论
+ mock_self_comment = mocker.MagicMock()
+ mock_self_comment.id = 123
+ mock_self_comment.body = f"""\
+# 📃 商店发布检查结果
+
+> Bot: test
+
+**✅ 所有测试通过,一切准备就绪!**
+
+
+历史测试
+✅ 2023-08-22 09:22:14 CST
+
+
+---
+
+💡 如需修改信息,请直接修改 issue,机器人会自动更新检查结果。
+
+💪 Powered by [NoneFlow](https://github.com/nonebot/noneflow)
+{NONEFLOW_MARKER}
+"""
+
+ mock_list_comments_resp = mocker.MagicMock()
+ mock_list_comments_resp.parsed_data = [mock_self_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.json5", "w") as f:
+ json.dump([], f)
+
+ async with app.test_matcher() as ctx:
+ _adapter, bot = get_github_bot(ctx)
+ event = get_mock_event(IssuesOpened)
+
+ ctx.should_call_api(
+ "rest.apps.async_get_repo_installation",
+ {"owner": "he0119", "repo": "action-test"},
+ mock_installation,
+ )
+ ctx.should_call_api(
+ "rest.apps.async_create_installation_access_token",
+ {"installation_id": mock_installation.parsed_data.id},
+ mock_installation_token,
+ )
+ 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_update_comment",
+ {
+ "owner": "he0119",
+ "repo": "action-test",
+ "comment_id": 123,
+ "body": mocker.ANY,
+ },
+ True,
+ )
+ ctx.should_call_api(
+ "rest.pulls.async_create",
+ {
+ "owner": "he0119",
+ "repo": "action-test",
+ "title": "Bot: test",
+ "body": "resolve #80",
+ "base": "master",
+ "head": "publish/issue80",
+ },
+ mock_pulls_resp,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_add_labels",
+ {
+ "owner": "he0119",
+ "repo": "action-test",
+ "issue_number": 2,
+ "labels": ["Publish", "Bot"],
+ },
+ True,
+ )
+
+ ctx.receive_event(bot, event)
+
+ assert mocked_api["homepage"].called
diff --git a/tests/plugins/github/remove/process/test_remove_check.py b/tests/plugins/github/remove/process/test_remove_check.py
index e2988e06..7ab7187f 100644
--- a/tests/plugins/github/remove/process/test_remove_check.py
+++ b/tests/plugins/github/remove/process/test_remove_check.py
@@ -838,3 +838,215 @@ async def test_process_trigger_by_bot(app: App):
ctx.receive_event(bot, event)
ctx.should_not_pass_rule()
+
+
+async def test_process_remove_issue_state_closed(
+ app: App,
+ mocker: MockerFixture,
+ mocked_api: MockRouter,
+ mock_installation,
+ mock_installation_token,
+) -> None:
+ """测试议题已关闭的情况"""
+ mock_subprocess_run = mock_subprocess_run_with_side_effect(mocker)
+
+ mock_issue = MockIssue(state="closed").as_mock(mocker)
+ mock_issues_resp = mocker.MagicMock()
+ mock_issues_resp.parsed_data = mock_issue
+
+ async with app.test_matcher() as ctx:
+ _adapter, bot = get_github_bot(ctx)
+ event = get_mock_event(IssuesOpened)
+ event.payload.issue.labels = get_issue_labels(["Remove", "Bot"])
+
+ ctx.should_call_api(
+ "rest.apps.async_get_repo_installation",
+ {"owner": "he0119", "repo": "action-test"},
+ mock_installation,
+ )
+ ctx.should_call_api(
+ "rest.apps.async_create_installation_access_token",
+ {"installation_id": mock_installation.parsed_data.id},
+ mock_installation_token,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_get",
+ {"owner": "he0119", "repo": "action-test", "issue_number": 80},
+ mock_issues_resp,
+ )
+
+ ctx.receive_event(bot, event)
+
+ assert mocked_api.calls == []
+ assert_subprocess_run_calls(
+ mock_subprocess_run,
+ [["git", "config", "--global", "safe.directory", "*"]],
+ )
+
+
+async def test_pr_close_rule_not_remove():
+ """测试 pr_close_rule 当非删除工作流时返回 False"""
+ from src.plugins.github.plugins.remove import pr_close_rule
+
+ result = await pr_close_rule(is_remove=False, related_issue_number=None)
+ assert result is False
+
+
+async def test_pr_close_rule_no_issue_number():
+ """测试 pr_close_rule 当无相关议题编号时返回 False"""
+ from src.plugins.github.plugins.remove import pr_close_rule
+
+ result = await pr_close_rule(is_remove=True, related_issue_number=None)
+ assert result is False
+
+
+async def test_pr_close_rule_success():
+ """测试 pr_close_rule 当满足条件时返回 True"""
+ from src.plugins.github.plugins.remove import pr_close_rule
+
+ result = await pr_close_rule(is_remove=True, related_issue_number=76)
+ assert result is True
+
+
+async def test_process_remove_bot_check_pr_already_exists(
+ app: App,
+ mocker: MockerFixture,
+ mocked_api: MockRouter,
+ tmp_path: Path,
+ mock_installation,
+ mock_installation_token,
+) -> None:
+ """测试删除时拉取请求已存在的情况(RequestFailed)"""
+ import httpx
+ from githubkit.exception import RequestFailed
+ from githubkit.response import Response
+
+ from src.plugins.github import plugin_config
+ from src.providers.utils import dump_json5
+
+ data = [
+ {
+ "name": "TESTBOT",
+ "desc": "desc",
+ "author": "test",
+ "author_id": 20,
+ "homepage": "https://vv.nonebot.dev",
+ "tags": [],
+ "is_official": False,
+ }
+ ]
+
+ mock_subprocess_run = mock_subprocess_run_with_side_effect(mocker)
+
+ remove_type = "Bot"
+ mock_issue = MockIssue(
+ body=generate_issue_body_remove(remove_type, "TESTBOT:https://vv.nonebot.dev"),
+ user=MockUser(login="test", id=20),
+ ).as_mock(mocker)
+
+ 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_pull.title = "Bot: Remove TESTBOT"
+ mock_pull.state = "open"
+ mock_pull.draft = False
+ mock_pull.node_id = "node_id"
+ mock_pulls_resp = mocker.MagicMock()
+ mock_pulls_resp.parsed_data = mock_pull
+
+ mock_pulls_resp_list = mocker.MagicMock()
+ mock_pulls_resp_list.parsed_data = [mock_pull]
+
+ dump_json5(tmp_path / "bots.json5", data)
+
+ async with app.test_matcher() as ctx:
+ _adapter, bot = get_github_bot(ctx)
+ event = get_mock_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.apps.async_create_installation_access_token",
+ {"installation_id": mock_installation.parsed_data.id},
+ mock_installation_token,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_get",
+ {"owner": "he0119", "repo": "action-test", "issue_number": 80},
+ mock_issues_resp,
+ )
+ # PR 创建失败,已存在
+ ctx.should_call_api(
+ "rest.pulls.async_create",
+ snapshot(
+ {
+ "owner": "owner",
+ "repo": "store",
+ "title": "Bot: Remove TESTBOT",
+ "body": "resolve he0119/action-test#80",
+ "base": "master",
+ "head": "remove/issue80",
+ }
+ ),
+ None,
+ exception=RequestFailed(
+ Response(
+ httpx.Response(
+ 422, request=httpx.Request("POST", "https://api.github.com")
+ ),
+ None, # type: ignore
+ )
+ ),
+ )
+ # 获取已存在的 PR
+ ctx.should_call_api(
+ "rest.pulls.async_list",
+ {"owner": "owner", "repo": "store", "head": "owner:remove/issue80"},
+ mock_pulls_resp_list,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_update",
+ snapshot(
+ {
+ "owner": "he0119",
+ "repo": "action-test",
+ "issue_number": 80,
+ "title": "Bot: Remove TESTBOT",
+ }
+ ),
+ True,
+ )
+ # 再次获取 PR 编号用于评论
+ ctx.should_call_api(
+ "rest.pulls.async_list",
+ {"owner": "owner", "repo": "store", "head": "owner:remove/issue80"},
+ mock_pulls_resp_list,
+ )
+ 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": mocker.ANY,
+ },
+ True,
+ )
+
+ ctx.receive_event(bot, event)
diff --git a/tests/plugins/github/remove/utils/test_remove_resolve_pull_requests.py b/tests/plugins/github/remove/utils/test_remove_resolve_pull_requests.py
index aa7d1f5e..93d9cabe 100644
--- a/tests/plugins/github/remove/utils/test_remove_resolve_pull_requests.py
+++ b/tests/plugins/github/remove/utils/test_remove_resolve_pull_requests.py
@@ -255,3 +255,96 @@ async def test_resolve_conflict_pull_requests_not_found(
plugin_config.input_config.plugin_path,
snapshot([]),
)
+
+
+async def test_resolve_conflict_pull_requests_no_issue_number(
+ app: App, mocker: MockerFixture, mocked_api: MockRouter, tmp_path: Path
+) -> None:
+ """测试无法从分支名获取议题编号的情况"""
+ from src.plugins.github.handlers import GithubHandler
+ from src.plugins.github.plugins.remove.utils import resolve_conflict_pull_requests
+ from src.providers.models import RepoInfo
+
+ mock_subprocess_run = mocker.patch("subprocess.run")
+
+ # 创建一个没有有效议题编号的 pull request
+ mock_pull = mocker.MagicMock()
+ mock_pull.head.ref = "invalid-branch-name"
+ mock_pull.title = "Invalid PR"
+
+ async with app.test_api() as ctx:
+ _adapter, bot = get_github_bot(ctx)
+
+ handler = GithubHandler(bot=bot, repo_info=RepoInfo(owner="owner", repo="repo"))
+
+ await resolve_conflict_pull_requests(handler, [mock_pull])
+
+ # 没有找到议题编号,不会做任何处理
+ mock_subprocess_run.assert_not_called()
+
+
+async def test_resolve_conflict_pull_requests_draft(
+ app: App, mocker: MockerFixture, mocked_api: MockRouter, tmp_path: Path
+) -> None:
+ """测试草稿状态的拉取请求"""
+ from src.plugins.github.handlers import GithubHandler
+ from src.plugins.github.plugins.remove.utils import resolve_conflict_pull_requests
+ from src.providers.models import RepoInfo
+
+ mock_subprocess_run = mocker.patch("subprocess.run")
+
+ # 创建一个草稿状态的 pull request
+ mock_pull = mocker.MagicMock()
+ mock_pull.head.ref = "remove/issue1"
+ mock_pull.title = "Bot: test"
+ mock_pull.draft = True
+
+ async with app.test_api() as ctx:
+ _adapter, bot = get_github_bot(ctx)
+
+ handler = GithubHandler(bot=bot, repo_info=RepoInfo(owner="owner", repo="repo"))
+
+ await resolve_conflict_pull_requests(handler, [mock_pull])
+
+ # 草稿状态跳过处理
+ mock_subprocess_run.assert_not_called()
+
+
+async def test_resolve_conflict_pull_requests_issue_not_found(
+ app: App, mocker: MockerFixture, mocked_api: MockRouter, tmp_path: Path, mock_pull
+) -> None:
+ """测试议题不存在的情况"""
+ import httpx
+ from githubkit.exception import RequestFailed
+ from githubkit.response import Response
+
+ from src.plugins.github.handlers import GithubHandler
+ from src.plugins.github.plugins.remove.utils import resolve_conflict_pull_requests
+ from src.providers.models import RepoInfo
+
+ mock_subprocess_run = mocker.patch("subprocess.run")
+
+ mock_pull.labels = get_issue_labels(mocker, ["Bot", "Remove"])
+ mock_pull.title = "Bot: test"
+
+ 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}),
+ None,
+ exception=RequestFailed(
+ Response(
+ httpx.Response(404, request=httpx.Request("GET", "https://api.github.com")),
+ None, # type: ignore
+ )
+ ),
+ )
+
+ await resolve_conflict_pull_requests(handler, [mock_pull])
+
+ # 议题不存在,跳过处理
+ mock_subprocess_run.assert_not_called()
diff --git a/tests/plugins/github/remove/utils/test_update_file.py b/tests/plugins/github/remove/utils/test_update_file.py
index 2eda6f50..ab5e5d45 100644
--- a/tests/plugins/github/remove/utils/test_update_file.py
+++ b/tests/plugins/github/remove/utils/test_update_file.py
@@ -62,3 +62,69 @@ async def test_update_file(
)
mock_git_handler.add_file.assert_called_once_with(tmp_path / "bots.json5")
+
+
+async def test_update_file_unsupported_type(
+ app: App, mocker: MockerFixture, mocked_api: MockRouter, tmp_path: Path
+) -> None:
+ """测试不支持的删除类型"""
+ import pytest
+
+ from src.plugins.github.plugins.remove.utils import update_file
+ from src.plugins.github.plugins.remove.validation import RemoveInfo
+ from src.providers.validation.models import PublishType
+
+ mock_git_handler = mocker.MagicMock()
+
+ # 使用 DRIVER 类型,这是不支持的删除类型
+ remove_info = RemoveInfo(
+ publish_type=PublishType.DRIVER,
+ key="test:key",
+ name="test",
+ )
+
+ with pytest.raises(ValueError, match="不支持的删除类型"):
+ update_file(remove_info, mock_git_handler)
+
+
+async def test_update_file_adapter(
+ app: App, mocker: MockerFixture, mocked_api: MockRouter, tmp_path: Path
+) -> None:
+ """测试删除适配器"""
+ from src.plugins.github import plugin_config
+ from src.plugins.github.plugins.remove.utils import update_file
+ from src.plugins.github.plugins.remove.validation import RemoveInfo
+ from src.providers.utils import dump_json5
+ from src.providers.validation.models import PublishType
+
+ mock_git_handler = mocker.MagicMock()
+
+ data = [
+ {
+ "module_name": "nonebot.adapters.test",
+ "project_link": "nonebot-adapter-test",
+ "name": "Test Adapter",
+ "desc": "Test",
+ "author_id": 1,
+ "homepage": "https://nonebot.dev",
+ "tags": [],
+ "is_official": False,
+ }
+ ]
+ dump_json5(tmp_path / "adapters.json5", data)
+
+ check_json_data(plugin_config.input_config.adapter_path, data)
+
+ remove_info = RemoveInfo(
+ publish_type=PublishType.ADAPTER,
+ key="nonebot-adapter-test:nonebot.adapters.test",
+ name="Test Adapter",
+ )
+ update_file(remove_info, mock_git_handler)
+
+ check_json_data(
+ plugin_config.input_config.adapter_path,
+ [],
+ )
+
+ mock_git_handler.add_file.assert_called_once_with(tmp_path / "adapters.json5")
diff --git a/tests/plugins/github/resolve/test_resolve_close_issue.py b/tests/plugins/github/resolve/test_resolve_close_issue.py
index 82fbc8a4..1ed56263 100644
--- a/tests/plugins/github/resolve/test_resolve_close_issue.py
+++ b/tests/plugins/github/resolve/test_resolve_close_issue.py
@@ -179,3 +179,80 @@ async def test_resolve_close_issue_already_closed(
assert not mocked_api["homepage"].called
mock_resolve_conflict_pull_requests.assert_not_awaited()
+
+
+async def test_resolve_close_issue_delete_branch_failed(
+ app: App,
+ mocker: MockerFixture,
+ mock_installation: MagicMock,
+ mock_installation_token: MagicMock,
+ mocked_api: MockRouter,
+) -> None:
+ """测试删除分支失败的情况"""
+ import subprocess
+
+ from src.plugins.github.plugins.resolve import pr_close_matcher
+
+ def side_effect(*args, **kwargs):
+ command_str = " ".join(args[0])
+ if "push" in command_str and "--delete" in command_str:
+ raise subprocess.CalledProcessError(
+ returncode=1,
+ cmd=args[0],
+ stdout=b"",
+ stderr=b"error: remote ref does not exist",
+ )
+ return mocker.MagicMock()
+
+ mock_subprocess_run = mocker.patch("subprocess.run", side_effect=side_effect)
+ mock_resolve_conflict_pull_requests = mocker.patch(
+ "src.plugins.github.plugins.resolve.resolve_conflict_pull_requests"
+ )
+
+ mock_issue = MockIssue(
+ body=generate_issue_body_remove(type="Bot"), number=76
+ ).as_mock(mocker)
+ mock_issues_resp = mocker.MagicMock()
+ mock_issues_resp.parsed_data = mock_issue
+
+ async with app.test_matcher() as ctx:
+ _adapter, bot = get_github_bot(ctx)
+
+ event = get_mock_event(PullRequestClosed)
+ event.payload.pull_request.labels = get_pr_labels(["Publish", "Bot"])
+ event.payload.pull_request.merged = False
+
+ ctx.should_call_api(
+ "rest.apps.async_get_repo_installation",
+ snapshot({"owner": "he0119", "repo": "action-test"}),
+ mock_installation,
+ )
+ ctx.should_call_api(
+ "rest.apps.async_create_installation_access_token",
+ snapshot({"installation_id": mock_installation.parsed_data.id}),
+ mock_installation_token,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_get",
+ snapshot({"owner": "he0119", "repo": "action-test", "issue_number": 76}),
+ mock_issues_resp,
+ )
+ ctx.should_call_api(
+ "rest.issues.async_update",
+ snapshot(
+ {
+ "owner": "he0119",
+ "repo": "action-test",
+ "issue_number": 76,
+ "state": "closed",
+ "state_reason": "not_planned",
+ }
+ ),
+ mock_issues_resp,
+ )
+ ctx.receive_event(bot, event)
+ ctx.should_pass_rule(pr_close_matcher)
+
+ # 分支删除失败,不影响后续流程
+ assert not mocked_api["homepage"].called
+ mock_resolve_conflict_pull_requests.assert_not_awaited()
diff --git a/tests/plugins/github/utils/test_github_utils.py b/tests/plugins/github/utils/test_github_utils.py
index 4dea16dc..f1d87287 100644
--- a/tests/plugins/github/utils/test_github_utils.py
+++ b/tests/plugins/github/utils/test_github_utils.py
@@ -110,3 +110,32 @@ def test_extract_issue_info_from_issue_partial_match():
# 只返回匹配到的项
assert result == {"name": "TestPlugin"}
assert "version" not in result
+
+
+def test_publish_config_empty_registry():
+ """测试 PublishConfig 当 registry_repository 为空时返回 None"""
+ import pytest
+ from pathlib import Path
+ from pydantic import ValidationError
+
+ from src.plugins.github.config import PublishConfig
+
+ # 当 registry_repository 为空字符串时,check_repositorys 返回 None,
+ # 但字段类型是 RepoInfo(非 Optional),所以 Pydantic 会抛出 ValidationError
+ with pytest.raises(ValidationError):
+ PublishConfig(
+ base="master",
+ plugin_path=Path("plugins.json5"),
+ bot_path=Path("bots.json5"),
+ adapter_path=Path("adapters.json5"),
+ registry_repository="", # empty string -> validator returns None
+ )
+
+
+async def test_get_labels_name_empty():
+ """测试 get_labels_name 当标签列表为空时返回空列表"""
+ from src.plugins.github.depends import get_labels_name
+
+ # 传入 None 或空列表时,应该返回空列表
+ result = await get_labels_name(labels=None)
+ assert result == []
diff --git a/tests/providers/docker_test/test_docker_plugin_test.py b/tests/providers/docker_test/test_docker_plugin_test.py
index 3c41ff79..2d5588ee 100644
--- a/tests/providers/docker_test/test_docker_plugin_test.py
+++ b/tests/providers/docker_test/test_docker_plugin_test.py
@@ -460,3 +460,32 @@ async def test_docker_plugin_test_file_invalid_and_output_invalid(
}
},
)
+
+
+async def test_docker_plugin_test_exception_long_traceback(
+ mocked_api: MockRouter,
+ mocker: MockerFixture,
+ tmp_path: Path,
+):
+ """插件测试时报错,且错误堆栈超过 2000 字符"""
+ from src.providers.docker_test import DockerPluginTest
+
+ mocker.patch("src.providers.docker_test.PLUGIN_TEST_DIR", tmp_path)
+
+ # 构造超过 2000 字符的异常
+ long_message = "x" * 3000
+
+ mocked_run = mocker.Mock()
+ mocked_run.side_effect = Exception(long_message)
+ mocked_client = mocker.Mock()
+ mocked_client.containers.run = mocked_run
+ mocked_docker = mocker.patch("docker.DockerClient")
+ mocked_docker.return_value = mocked_client
+
+ test = DockerPluginTest("project_link", "module_name")
+ result = await test.run("3.12")
+
+ # 输出应该被截断到 2000 字符
+ assert len(result.output) <= 2000
+ assert result.run is False
+ assert result.load is False
diff --git a/tests/providers/docker_test/test_plugin_test.py b/tests/providers/docker_test/test_plugin_test.py
index 94d7e35b..f13845d7 100644
--- a/tests/providers/docker_test/test_plugin_test.py
+++ b/tests/providers/docker_test/test_plugin_test.py
@@ -592,3 +592,108 @@ async def test_get_plugin_list(mocker: MockerFixture):
}
mock_get.assert_called_once()
+
+
+def test_plugin_test_key():
+ """测试 PluginTest 的 key 属性"""
+ from src.providers.docker_test.plugin_test import PluginTest
+
+ test = PluginTest("3.12", "project_link", "module_name")
+ assert test.key == "project_link:module_name"
+
+
+def test_plugin_test_env():
+ """测试 PluginTest 的 env 属性"""
+ import os
+
+ from src.providers.docker_test.plugin_test import PluginTest
+
+ test = PluginTest("3.12", "project_link", "module_name")
+ env = test.env
+
+ assert "LOGURU_COLORIZE" in env
+ assert env["LOGURU_COLORIZE"] == "true"
+ assert env["POETRY_VIRTUALENVS_IN_PROJECT"] == "true"
+ assert env["POETRY_VIRTUALENVS_PREFER_ACTIVE_PYTHON"] == "true"
+ # VIRTUAL_ENV 应该被移除(如果存在的话)
+ # 只验证其他字段存在
+ assert isinstance(env, dict)
+
+
+async def test_plugin_test_command(mocker: MockerFixture, tmp_path: Path):
+ """测试 PluginTest.command 方法"""
+ from src.providers.docker_test.plugin_test import PluginTest
+
+ test = PluginTest("3.12", "project_link", "module_name")
+ test_dir = tmp_path / "plugin_test"
+ test_dir.mkdir()
+ mocker.patch.object(test, "_test_dir", test_dir)
+
+ success, stdout, stderr = await test.command("echo hello")
+
+ assert success is True
+ assert "hello" in stdout
+
+
+async def test_plugin_test_write_result_file(mocker: MockerFixture, tmp_path: Path):
+ """测试 PluginTest.run 写入测试结果文件"""
+ from src.providers.docker_test.plugin_test import PluginTest
+
+ test = PluginTest("3.12", "project_link", "module_name")
+ test_dir = tmp_path / "plugin_test"
+ test_dir.mkdir()
+ result_path = tmp_path / "test_result.json"
+ mocker.patch.object(test, "_test_dir", test_dir)
+ mocker.patch("src.providers.docker_test.plugin_test.DOCKER_BIND_RESULT_PATH", str(result_path))
+
+ # Mock command 函数
+ async def mock_command(cmd: str, timeout: int = 300): # noqa: ASYNC109
+ if cmd == "poetry show project_link":
+ return (True, "name : project_link\nversion : 1.0.0", "")
+ if cmd == "poetry export --without-hashes":
+ return (True, "", "")
+ if cmd == "poetry run python --version":
+ return (True, "Python 3.12.5", "")
+ if cmd == "poetry run python runner.py":
+ return (True, "", "")
+ return (False, "", "")
+
+ mocker.patch.object(test, "command", mock_command)
+ mocker.patch(
+ "src.providers.docker_test.plugin_test.get_plugin_list", return_value={}
+ )
+
+ result = await test.run()
+
+ # 结果文件应该被写入
+ assert result_path.exists()
+
+
+async def test_plugin_test_command_timeout(mocker: MockerFixture, tmp_path: Path):
+ """测试 PluginTest.command 方法超时的情况"""
+ import asyncio
+
+ from src.providers.docker_test.plugin_test import PluginTest
+
+ test = PluginTest("3.12", "project_link", "module_name")
+ test_dir = tmp_path / "plugin_test"
+ test_dir.mkdir()
+ mocker.patch.object(test, "_test_dir", test_dir)
+
+ # 模拟 asyncio.wait_for 超时
+ async def mock_wait_for(coro, timeout):
+ raise TimeoutError("command timed out")
+
+ mocker.patch("asyncio.wait_for", mock_wait_for)
+
+ # 模拟进程对象
+ mock_proc = mocker.AsyncMock()
+ mock_proc.stdout.read = mocker.AsyncMock(return_value=b"partial output")
+ mock_proc.stderr.read = mocker.AsyncMock(return_value=b"")
+
+ mocker.patch("asyncio.create_subprocess_shell", return_value=mock_proc)
+
+ success, stdout, stderr = await test.command("sleep 100", timeout=1)
+
+ assert success is False
+ assert "超时" in stdout
diff --git a/tests/providers/store_test/test_store_test.py b/tests/providers/store_test/test_store_test.py
index f0b8d0ad..7c8fc60e 100644
--- a/tests/providers/store_test/test_store_test.py
+++ b/tests/providers/store_test/test_store_test.py
@@ -418,3 +418,75 @@ async def test_store_test_with_key_raise(
assert mocked_store_data["results"].read_text(encoding="utf-8") == snapshot(
'{"nonebot-plugin-datastore:nonebot_plugin_datastore":{"time":"2023-06-26T22:08:18.945584+08:00","config":"","version":"1.3.0","test_env":null,"results":{"validation":true,"load":true,"metadata":true},"outputs":{"validation":null,"load":"datastore","metadata":{"name":"数据存储","description":"NoneBot 数据存储插件","usage":"请参考文档","type":"library","homepage":"https://github.com/he0119/nonebot-plugin-datastore","supported_adapters":null}}},"nonebot-plugin-treehelp:nonebot_plugin_treehelp":{"time":"2023-06-26T22:20:41.833311+08:00","config":"","version":"0.3.0","test_env":null,"results":{"validation":true,"load":true,"metadata":true},"outputs":{"validation":null,"load":"treehelp","metadata":{"name":"帮助","description":"获取插件帮助信息","usage":"获取插件列表\\n/help\\n获取插件树\\n/help -t\\n/help --tree\\n获取某个插件的帮助\\n/help 插件名\\n获取某个插件的树\\n/help --tree 插件名\\n","type":"application","homepage":"https://github.com/he0119/nonebot-plugin-treehelp","supported_adapters":null}}}}'
)
+
+
+async def test_store_test_should_skip_git_plugin(
+ mocked_store_data: dict[str, Path], mocked_api: MockRouter
+) -> None:
+ """测试 Git 插件跳过测试"""
+ from src.providers.store_test.store import StoreTest
+
+ test = StoreTest()
+ # git+http 开头的插件应该跳过
+ assert test.should_skip("git+https://github.com/test/plugin") is True
+
+
+async def test_store_test_should_not_skip_with_force(
+ mocked_store_data: dict[str, Path], mocked_api: MockRouter
+) -> None:
+ """测试强制测试时不跳过"""
+ from src.providers.store_test.store import StoreTest
+
+ test = StoreTest()
+ # force=True 时即使版本未更新也不跳过(针对已存在的插件)
+ assert (
+ test.should_skip("nonebot-plugin-datastore:nonebot_plugin_datastore", force=True)
+ is False
+ )
+
+
+async def test_store_test_should_skip_version_fetch_error(
+ mocked_store_data: dict[str, Path], mocked_api: MockRouter, mocker: MockerFixture
+) -> None:
+ """测试版本获取失败时跳过测试"""
+ from src.providers.store_test.store import StoreTest
+
+ # 让 get_pypi_version 抛出 ValueError(通过 mock)
+ mocker.patch(
+ "src.providers.store_test.store.get_pypi_version",
+ side_effect=ValueError("获取版本失败"),
+ )
+
+ test = StoreTest()
+ assert (
+ test.should_skip("nonebot-plugin-treehelp:nonebot_plugin_treehelp") is True
+ )
+
+
+async def test_store_test_dump_data_creates_dir(
+ mocked_api: MockRouter, mocker, tmp_path: Path
+) -> None:
+ """测试 dump_data 当目录不存在时创建目录"""
+ import src.providers.store_test.store as store_module
+ from src.providers.store_test.store import StoreTest
+
+ test_dir = tmp_path / "new_test_dir"
+ mocker.patch.object(store_module, "TEST_DIR", test_dir)
+ mocker.patch.object(store_module, "ADAPTERS_PATH", test_dir / "adapters.json")
+ mocker.patch.object(store_module, "BOTS_PATH", test_dir / "bots.json")
+ mocker.patch.object(store_module, "DRIVERS_PATH", test_dir / "drivers.json")
+ mocker.patch.object(store_module, "PLUGINS_PATH", test_dir / "plugins.json")
+ mocker.patch.object(store_module, "RESULTS_PATH", test_dir / "results.json")
+ mocker.patch.object(
+ store_module, "PLUGIN_CONFIG_PATH", test_dir / "plugin_configs.json"
+ )
+
+ test = StoreTest()
+
+ # 确保目录不存在
+ assert not test_dir.exists()
+
+ # 调用 dump_data 应该创建目录
+ test.dump_data()
+
+ assert test_dir.exists()
diff --git a/tests/providers/store_test/test_utils.py b/tests/providers/store_test/test_utils.py
index c10f4930..5ebc06c1 100644
--- a/tests/providers/store_test/test_utils.py
+++ b/tests/providers/store_test/test_utils.py
@@ -20,3 +20,21 @@ async def test_get_pypi_data_failed(mocked_api: MockRouter):
with pytest.raises(ValueError, match="获取 PyPI 数据失败:"):
get_pypi_data("project_link_failed")
+
+
+def test_dumps_json_minify():
+ """测试 dumps_json 压缩模式(minify=True)"""
+ from src.providers.utils import dumps_json
+
+ result = dumps_json({"key": "value"})
+ assert result == '{"key":"value"}'
+
+
+def test_add_step_summary_no_env(monkeypatch: pytest.MonkeyPatch):
+ """测试 add_step_summary 未设置 GITHUB_STEP_SUMMARY 环境变量"""
+ from src.providers.utils import add_step_summary
+
+ monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
+
+ # 不应抛出异常,只是记录警告
+ add_step_summary("test summary")
diff --git a/tests/providers/store_test/test_validate_plugin.py b/tests/providers/store_test/test_validate_plugin.py
index 1a66a15d..2d76bae0 100644
--- a/tests/providers/store_test/test_validate_plugin.py
+++ b/tests/providers/store_test/test_validate_plugin.py
@@ -476,3 +476,50 @@ async def test_validate_plugin_failed_with_previous(
)
assert mocked_api["homepage"].called
+
+
+async def test_validate_plugin_get_author_name_failed(
+ mocked_api: MockRouter, mocker: MockerFixture
+) -> None:
+ """测试 validate_plugin 时获取作者名失败,从上次记录中获取"""
+ from src.providers.models import RegistryPlugin, StorePlugin
+ from src.providers.store_test.validation import validate_plugin
+
+ output_path = Path(__file__).parent / "output.json"
+ mock_docker_result(output_path, mocker)
+
+ # 模拟 get_author_name 抛出异常
+ mocker.patch(
+ "src.providers.store_test.validation.get_author_name",
+ side_effect=Exception("Network error"),
+ )
+
+ plugin = StorePlugin(
+ module_name="module_name",
+ project_link="project_link",
+ author_id=1,
+ tags=[],
+ is_official=True,
+ )
+
+ previous_plugin = RegistryPlugin(
+ module_name="module_name",
+ project_link="project_link",
+ name="name",
+ desc="desc",
+ author="previous_author",
+ homepage="https://nonebot.dev",
+ tags=[],
+ is_official=True,
+ type="application",
+ supported_adapters=None,
+ valid=True,
+ time="2023-09-01T00:00:00.000000Z",
+ version="0.0.1",
+ skip_test=False,
+ )
+
+ result, new_plugin = await validate_plugin(plugin, "", previous_plugin)
+
+ # 应该使用上次记录中的作者名
+ assert new_plugin.author == "previous_author"
diff --git a/tests/providers/test_models.py b/tests/providers/test_models.py
new file mode 100644
index 00000000..a2cd3ae7
--- /dev/null
+++ b/tests/providers/test_models.py
@@ -0,0 +1,216 @@
+"""测试 providers/models.py 中未覆盖的代码路径"""
+
+import pytest
+from pathlib import Path
+from respx import MockRouter
+
+
+def test_store_adapter_key(mocked_api: MockRouter):
+ """测试 StoreAdapter 的 key 属性"""
+ from src.providers.models import StoreAdapter
+
+ adapter = StoreAdapter(
+ module_name="module_name",
+ project_link="project_link",
+ name="name",
+ desc="desc",
+ author_id=1,
+ homepage="https://nonebot.dev",
+ tags=[],
+ is_official=False,
+ )
+ assert adapter.key == "project_link:module_name"
+
+
+def test_store_bot_key():
+ """测试 StoreBot 的 key 属性"""
+ from src.providers.models import StoreBot
+
+ bot = StoreBot(
+ name="name",
+ desc="desc",
+ author_id=1,
+ homepage="https://nonebot.dev",
+ tags=[],
+ is_official=False,
+ )
+ assert bot.key == "name:https://nonebot.dev"
+
+
+def test_store_driver_key_and_from_publish_info(mocked_api: MockRouter):
+ """测试 StoreDriver 的 key 属性和 from_publish_info 方法"""
+ from src.providers.models import StoreDriver
+ from src.providers.validation import PublishType, validate_info
+ from tests.providers.validation.utils import generate_driver_data
+
+ data = generate_driver_data()
+ result = validate_info(PublishType.DRIVER, data, [])
+ assert result.info is not None
+
+ driver = StoreDriver.from_publish_info(result.info)
+ assert driver.key == "project_link:module_name"
+
+
+def test_store_plugin_key():
+ """测试 StorePlugin 的 key 属性"""
+ from src.providers.models import StorePlugin
+
+ plugin = StorePlugin(
+ module_name="module_name",
+ project_link="project_link",
+ author_id=1,
+ tags=[],
+ is_official=False,
+ )
+ assert plugin.key == "project_link:module_name"
+
+
+def test_to_store_driver(mocked_api: MockRouter):
+ """测试 to_store 函数处理 DriverPublishInfo"""
+ from src.providers.models import StoreDriver, to_store
+ from src.providers.validation import PublishType, validate_info
+ from tests.providers.validation.utils import generate_driver_data
+
+ data = generate_driver_data()
+ result = validate_info(PublishType.DRIVER, data, [])
+ assert result.info is not None
+
+ store = to_store(result.info)
+ assert isinstance(store, StoreDriver)
+
+
+def test_to_registry_driver(mocked_api: MockRouter):
+ """测试 to_registry 函数处理 DriverPublishInfo"""
+ from src.providers.models import RegistryDriver, to_registry
+ from src.providers.validation import PublishType, validate_info
+ from tests.providers.validation.utils import generate_driver_data
+
+ data = generate_driver_data()
+ result = validate_info(PublishType.DRIVER, data, [])
+ assert result.info is not None
+
+ registry = to_registry(result.info)
+ assert isinstance(registry, RegistryDriver)
+
+
+def test_registry_plugin_metadata(mocked_api: MockRouter):
+ """测试 RegistryPlugin 的 metadata 属性"""
+ from src.providers.models import RegistryPlugin
+
+ plugin = RegistryPlugin(
+ module_name="module_name",
+ project_link="project_link",
+ name="name",
+ desc="desc",
+ author="author",
+ homepage="https://nonebot.dev",
+ tags=[],
+ is_official=False,
+ type="application",
+ supported_adapters=None,
+ valid=True,
+ time="2023-09-01T00:00:00.000000Z",
+ version="0.0.1",
+ skip_test=False,
+ )
+ metadata = plugin.metadata
+ assert metadata["name"] == "name"
+ assert metadata["desc"] == "desc"
+ assert metadata["homepage"] == "https://nonebot.dev"
+ assert metadata["type"] == "application"
+ assert metadata["supported_adapters"] is None
+
+
+def test_repo_info_from_issue():
+ """测试 RepoInfo.from_issue 类方法"""
+ from unittest.mock import MagicMock
+
+ from src.providers.models import RepoInfo
+
+ mock_issue = MagicMock()
+ mock_issue.repository = MagicMock()
+ mock_issue.repository.owner.login = "owner"
+ mock_issue.repository.name = "repo"
+
+ repo_info = RepoInfo.from_issue(mock_issue)
+ assert repo_info.owner == "owner"
+ assert repo_info.repo == "repo"
+
+
+def test_registry_artifact_data_type_driver():
+ """测试 RegistryArtifactData.type 属性对于 StoreDriver 的情况"""
+ from src.providers.models import (
+ RegistryArtifactData,
+ RegistryDriver,
+ StoreDriver,
+ )
+ from src.providers.validation.models import PublishType
+
+ store = StoreDriver(
+ module_name="module_name",
+ project_link="project_link",
+ name="name",
+ desc="desc",
+ author_id=1,
+ homepage="https://nonebot.dev",
+ tags=[],
+ is_official=False,
+ )
+ registry = RegistryDriver(
+ module_name="module_name",
+ project_link="project_link",
+ name="name",
+ desc="desc",
+ author="author",
+ homepage="https://nonebot.dev",
+ tags=[],
+ is_official=False,
+ time="2023-09-01T00:00:00.000000Z",
+ version="0.0.1",
+ )
+
+ artifact_data = RegistryArtifactData(store=store, registry=registry)
+ assert artifact_data.type == PublishType.DRIVER
+
+
+def test_registry_artifact_data_save_not_dir(tmp_path: Path):
+ """测试 RegistryArtifactData.save 当路径不是目录时抛出异常"""
+ from src.providers.models import RegistryArtifactData, RegistryBot, StoreBot
+
+ store = StoreBot(
+ name="name",
+ desc="desc",
+ author_id=1,
+ homepage="https://nonebot.dev",
+ tags=[],
+ is_official=False,
+ )
+ registry = RegistryBot(
+ name="name",
+ desc="desc",
+ author="author",
+ homepage="https://nonebot.dev",
+ tags=[],
+ is_official=False,
+ )
+
+ artifact_data = RegistryArtifactData(store=store, registry=registry)
+
+ # 创建一个文件(不是目录)
+ file_path = tmp_path / "not_a_dir"
+ file_path.write_text("content")
+
+ with pytest.raises(ValueError, match="不是一个目录"):
+ artifact_data.save(file_path)
+
+
+def test_registry_artifact_data_from_artifact_no_env(monkeypatch: pytest.MonkeyPatch):
+ """测试 RegistryArtifactData.from_artifact 当环境变量未设置时抛出异常"""
+ from src.providers.models import RegistryArtifactData, RepoInfo
+
+ monkeypatch.delenv("APP_ID", raising=False)
+ monkeypatch.delenv("PRIVATE_KEY", raising=False)
+
+ repo_info = RepoInfo(owner="owner", repo="repo")
+ with pytest.raises(ValueError, match="APP_ID 或 PRIVATE_KEY 未设置"):
+ RegistryArtifactData.from_artifact(repo_info, 123)
diff --git a/tests/providers/validation/fields/test_tags.py b/tests/providers/validation/fields/test_tags.py
index 5aca2f0f..a5338157 100644
--- a/tests/providers/validation/fields/test_tags.py
+++ b/tests/providers/validation/fields/test_tags.py
@@ -341,3 +341,12 @@ async def test_tags_json_not_dict(mocked_api: MockRouter) -> None:
assert mocked_api["pypi_project_link"].called
assert mocked_api["homepage"].called
+
+
+
+def test_tag_color_hex():
+ """测试 Tag 的 color_hex 属性"""
+ from src.providers.validation.models import Tag
+
+ tag = Tag(label="test", color="#ffffff")
+ assert tag.color_hex == "#ffffff"
diff --git a/tests/providers/validation/test_plugin.py b/tests/providers/validation/test_plugin.py
index 8e085fdb..5ef88550 100644
--- a/tests/providers/validation/test_plugin.py
+++ b/tests/providers/validation/test_plugin.py
@@ -283,3 +283,37 @@ async def test_plugin_info_validation_plugin_invalid_metadata(
)
assert not mocked_api["homepage"].called
+
+
+async def test_plugin_info_validation_no_context() -> None:
+ """测试没有验证上下文时,load 和 metadata 验证器的报错"""
+ import pytest
+ from pydantic import ValidationError
+
+ from src.providers.validation.models import PluginPublishInfo
+
+ with pytest.raises(ValidationError) as exc_info:
+ PluginPublishInfo.model_validate(
+ {
+ "name": "name",
+ "desc": "desc",
+ "author": "author",
+ "module_name": "module_name",
+ "project_link": "project_link",
+ "homepage": "https://nonebot.dev",
+ "tags": [],
+ "author_id": 1,
+ "time": "2023-09-01T00:00:00.000000Z",
+ "version": "0.0.1",
+ "load": True,
+ "metadata": True,
+ "skip_test": False,
+ "test_output": "",
+ "type": "application",
+ "supported_adapters": None,
+ }
+ )
+ # 验证错误包含 validation_context 类型的错误
+ errors = exc_info.value.errors()
+ error_types = [e["type"] for e in errors]
+ assert "validation_context" in error_types