diff --git a/src/plugins/github/depends/__init__.py b/src/plugins/github/depends/__init__.py index 93f0b3d6..0c13d115 100644 --- a/src/plugins/github/depends/__init__.py +++ b/src/plugins/github/depends/__init__.py @@ -10,7 +10,7 @@ ) from nonebot.params import Depends -from src.plugins.github.models import GithubHandler, RepoInfo +from src.plugins.github.models import GithubHandler, IssueHandler, RepoInfo 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 @@ -107,6 +107,35 @@ def get_github_handler(bot: GitHubBot, repo_info: RepoInfo = Depends(get_repo_in return GithubHandler(bot=bot, repo_info=repo_info) +async def get_issue_handler( + bot: GitHubBot, + installation_id: int = Depends(get_installation_id), + repo_info: RepoInfo = Depends(get_repo_info), + issue_number: int = Depends(get_issue_number), +): + async with bot.as_installation(installation_id): + # 因为 Actions 会排队,触发事件相关的议题在 Actions 执行时可能已经被关闭 + # 所以需要获取最新的议题状态 + issue = ( + await bot.rest.issues.async_get( + **repo_info.model_dump(), issue_number=issue_number + ) + ).parsed_data + + return IssueHandler(bot=bot, repo_info=repo_info, issue=issue) + + +async def get_related_issue_handler( + bot: GitHubBot, + installation_id: int = Depends(get_installation_id), + repo_info: RepoInfo = Depends(get_repo_info), + related_issue_number: int = Depends(get_related_issue_number), +): + return await get_issue_handler( + bot, installation_id, repo_info, related_issue_number + ) + + def get_type_by_labels_name( labels: list[str] = Depends(get_labels_name), ) -> PublishType | None: diff --git a/src/plugins/github/plugins/publish/__init__.py b/src/plugins/github/plugins/publish/__init__.py index 2a833eaf..ee83b474 100644 --- a/src/plugins/github/plugins/publish/__init__.py +++ b/src/plugins/github/plugins/publish/__init__.py @@ -19,7 +19,9 @@ bypass_git, get_github_handler, get_installation_id, + get_issue_handler, get_labels_name, + get_related_issue_handler, get_related_issue_number, get_repo_info, install_pre_commit_hooks, @@ -31,8 +33,6 @@ from src.providers.validation.models import PublishType, ValidationDict from .depends import ( - get_issue_handler, - get_related_issue_handler, get_type_by_labels_name, ) from .utils import ( @@ -250,7 +250,7 @@ async def handle_pull_request_and_update_issue( await handler.comment_issue(comment) -async def review_submiited_rule( +async def review_submitted_rule( event: PullRequestReviewSubmitted, publish_type: PublishType | None = Depends(get_type_by_labels_name), ) -> bool: @@ -268,7 +268,7 @@ async def review_submiited_rule( auto_merge_matcher = on_type( - PullRequestReviewSubmitted, rule=Rule(review_submiited_rule, publish_related_rule) + PullRequestReviewSubmitted, rule=Rule(review_submitted_rule, publish_related_rule) ) diff --git a/src/plugins/github/plugins/publish/depends.py b/src/plugins/github/plugins/publish/depends.py index dd4f8315..b28f1e05 100644 --- a/src/plugins/github/plugins/publish/depends.py +++ b/src/plugins/github/plugins/publish/depends.py @@ -3,14 +3,11 @@ from nonebot.params import Depends from src.plugins.github.depends import ( - get_installation_id, - get_issue_number, get_issue_title, - get_related_issue_number, get_repo_info, get_type_by_labels_name, ) -from src.plugins.github.models import IssueHandler, RepoInfo +from src.plugins.github.models import RepoInfo from src.plugins.github.plugins.publish import utils from src.providers.validation.models import PublishType @@ -33,32 +30,3 @@ async def get_pull_requests_by_label( for pull in pulls if publish_type.value in [label.name for label in pull.labels] ] - - -async def get_issue_handler( - bot: GitHubBot, - installation_id: int = Depends(get_installation_id), - repo_info: RepoInfo = Depends(get_repo_info), - issue_number: int = Depends(get_issue_number), -): - async with bot.as_installation(installation_id): - # 因为 Actions 会排队,触发事件相关的议题在 Actions 执行时可能已经被关闭 - # 所以需要获取最新的议题状态 - issue = ( - await bot.rest.issues.async_get( - **repo_info.model_dump(), issue_number=issue_number - ) - ).parsed_data - - return IssueHandler(bot=bot, repo_info=repo_info, issue=issue) - - -async def get_related_issue_handler( - bot: GitHubBot, - installation_id: int = Depends(get_installation_id), - repo_info: RepoInfo = Depends(get_repo_info), - related_issue_number: int = Depends(get_related_issue_number), -): - return await get_issue_handler( - bot, installation_id, repo_info, related_issue_number - ) diff --git a/src/plugins/github/plugins/remove/__init__.py b/src/plugins/github/plugins/remove/__init__.py index 0da8ab70..463f1505 100644 --- a/src/plugins/github/plugins/remove/__init__.py +++ b/src/plugins/github/plugins/remove/__init__.py @@ -6,6 +6,7 @@ IssuesOpened, IssuesReopened, PullRequestClosed, + PullRequestReviewSubmitted, ) from nonebot.params import Depends from pydantic_core import PydanticCustomError @@ -15,8 +16,10 @@ from src.plugins.github.depends import ( RepoInfo, bypass_git, + get_github_handler, get_installation_id, - get_issue_number, + get_issue_handler, + get_related_issue_handler, get_related_issue_number, get_repo_info, get_type_by_labels_name, @@ -60,22 +63,13 @@ async def handle_pr_close( event: PullRequestClosed, bot: GitHubBot, installation_id: int = Depends(get_installation_id), - repo_info: RepoInfo = Depends(get_repo_info), - related_issue_number: int = Depends(get_related_issue_number), + handler: IssueHandler = Depends(get_related_issue_handler), ) -> None: async with bot.as_installation(installation_id): - issue = ( - await bot.rest.issues.async_get( - **repo_info.model_dump(), issue_number=related_issue_number - ) - ).parsed_data - - handler = IssueHandler(bot=bot, repo_info=repo_info, issue=issue) - - if issue.state == "open": + if handler.issue.state == "open": reason = "completed" if event.payload.pull_request.merged else "not_planned" await handler.close_issue(reason) - logger.info(f"议题 #{related_issue_number} 已关闭") + logger.info(f"议题 #{handler.issue_number} 已关闭") try: handler.delete_origin_branch(event.payload.pull_request.head.ref) @@ -119,25 +113,17 @@ async def check_rule( async def handle_remove_check( bot: GitHubBot, installation_id: int = Depends(get_installation_id), - repo_info: RepoInfo = Depends(get_repo_info), - issue_number: int = Depends(get_issue_number), + handler: IssueHandler = Depends(get_issue_handler), publish_type: PublishType = Depends(get_type_by_labels_name), ): async with bot.as_installation(installation_id): - issue = ( - await bot.rest.issues.async_get( - **repo_info.model_dump(), issue_number=issue_number - ) - ).parsed_data - - if issue.state != "open": + if handler.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, publish_type) + result = await validate_author_info(handler.issue, publish_type) except PydanticCustomError as err: logger.error(f"信息验证失败: {err}") await handler.comment_issue(await render_error(err)) @@ -146,7 +132,7 @@ async def handle_remove_check( title = f"{result.publish_type}: Remove {result.name or 'Unknown'}"[ :TITLE_MAX_LENGTH ] - branch_name = f"{BRANCH_NAME_PREFIX}{issue_number}" + branch_name = f"{BRANCH_NAME_PREFIX}{handler.issue_number}" # 根据 input_config 里的 remove 仓库来进行提交和 PR store_handler = GithubHandler( @@ -168,3 +154,52 @@ async def handle_remove_check( pr_url=f"{plugin_config.input_config.store_repository}#{pull_number}", ) ) + + +async def review_submitted_rule( + event: PullRequestReviewSubmitted, + is_remove: bool = check_labels(REMOVE_LABEL), +) -> bool: + if not is_remove: + logger.info("拉取请求与删除无关,已跳过") + return False + if event.payload.review.author_association not in ["OWNER", "MEMBER"]: + logger.info("审查者不是仓库成员,已跳过") + return False + if event.payload.review.state != "approved": + logger.info("未通过审查,已跳过") + return False + + return True + + +auto_merge_matcher = on_type(PullRequestReviewSubmitted, rule=review_submitted_rule) + + +@auto_merge_matcher.handle( + parameterless=[Depends(bypass_git), Depends(install_pre_commit_hooks)] +) +async def handle_auto_merge( + bot: GitHubBot, + event: PullRequestReviewSubmitted, + installation_id: int = Depends(get_installation_id), + repo_info: RepoInfo = Depends(get_repo_info), + handler: GithubHandler = Depends(get_github_handler), +) -> None: + async with bot.as_installation(installation_id): + pull_request = ( + await bot.rest.pulls.async_get( + **repo_info.model_dump(), pull_number=event.payload.pull_request.number + ) + ).parsed_data + + if not pull_request.mergeable: + # 尝试处理冲突 + await resolve_conflict_pull_requests(handler, [pull_request]) + + await bot.rest.pulls.async_merge( + **repo_info.model_dump(), + pull_number=event.payload.pull_request.number, + merge_method="rebase", + ) + logger.info(f"已自动合并 #{event.payload.pull_request.number}") diff --git a/src/plugins/github/plugins/remove/utils.py b/src/plugins/github/plugins/remove/utils.py index 63ddebdc..c07a5998 100644 --- a/src/plugins/github/plugins/remove/utils.py +++ b/src/plugins/github/plugins/remove/utils.py @@ -2,7 +2,6 @@ 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 ( @@ -105,23 +104,15 @@ async def resolve_conflict_pull_requests( 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]) + # 再验证作者信息 + result = await validate_author_info(issue_handler.issue, publish_type) # 切换到对应分支 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) diff --git a/tests/github/remove/process/test_remove_auto_merge.py b/tests/github/remove/process/test_remove_auto_merge.py new file mode 100644 index 00000000..b3ca14ef --- /dev/null +++ b/tests/github/remove/process/test_remove_auto_merge.py @@ -0,0 +1,270 @@ +from pathlib import Path + +from nonebot.adapters.github import Adapter, PullRequestReviewSubmitted +from nonebug import App +from pytest_mock import MockerFixture + +from tests.github.utils import get_github_bot + + +def get_issue_labels(labels: list[str]): + from githubkit.rest import ( + WebhookPullRequestReviewSubmittedPropPullRequestPropLabelsItems as Label, + ) + + return [ + Label.model_construct( + **{ + "color": "2A2219", + "default": False, + "description": "", + "id": 2798075966, + "name": label, + "node_id": "MDU6TGFiZWwyNzk4MDc1OTY2", + "url": "https://api.github.com/repos/he0119/action-test/labels/Remove", + } + ) + for label in labels + ] + + +async def test_remove_auto_merge( + app: App, mocker: MockerFixture, mock_installation +) -> None: + """测试审查后自动合并 + + 可直接合并的情况 + """ + + mock_subprocess_run = mocker.patch("subprocess.run") + + mock_pull = mocker.MagicMock() + mock_pull.mergeable = True + mock_pull_resp = mocker.MagicMock() + mock_pull_resp.parsed_data = mock_pull + + async with app.test_matcher() as ctx: + adapter, bot = get_github_bot(ctx) + event_path = ( + Path(__file__).parent.parent.parent + / "events" + / "pull_request_review_submitted.json" + ) + + event = adapter.payload_to_event( + "1", "pull_request_review", event_path.read_bytes() + ) + + assert isinstance(event, PullRequestReviewSubmitted) + event.payload.pull_request.labels = get_issue_labels(["Remove", "Plugin"]) + + ctx.receive_event(bot, event) + ctx.should_call_api( + "rest.apps.async_get_repo_installation", + {"owner": "he0119", "repo": "action-test"}, + mock_installation, + ) + ctx.should_call_api( + "rest.pulls.async_get", + {"owner": "he0119", "repo": "action-test", "pull_number": 100}, + mock_pull_resp, + ) + ctx.should_call_api( + "rest.pulls.async_merge", + { + "owner": "he0119", + "repo": "action-test", + "pull_number": 100, + "merge_method": "rebase", + }, + True, + ) + + # 测试 git 命令 + 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 + any_order=True, + ) + + +async def test_auto_merge_need_rebase( + app: App, mocker: MockerFixture, mock_installation +) -> None: + """测试审查后自动合并 + + 需要 rebase 的情况 + """ + from src.plugins.github.models import GithubHandler, RepoInfo + from src.plugins.github.plugins.remove import auto_merge_matcher + + mock_subprocess_run = mocker.patch("subprocess.run") + mock_resolve_conflict_pull_requests = mocker.patch( + "src.plugins.github.plugins.remove.resolve_conflict_pull_requests" + ) + + mock_pull = mocker.MagicMock() + mock_pull.mergeable = False + mock_pull.head.ref = "remove/issue1" + mock_pull_resp = mocker.MagicMock() + mock_pull_resp.parsed_data = mock_pull + + async with app.test_matcher() as ctx: + adapter, bot = get_github_bot(ctx) + event_path = ( + Path(__file__).parent.parent.parent + / "events" + / "pull_request_review_submitted.json" + ) + event = Adapter.payload_to_event( + "1", "pull_request_review", event_path.read_bytes() + ) + assert isinstance(event, PullRequestReviewSubmitted) + event.payload.pull_request.labels = get_issue_labels(["Remove", "Plugin"]) + + ctx.receive_event(bot, event) + ctx.should_pass_rule(auto_merge_matcher) + + ctx.should_call_api( + "rest.apps.async_get_repo_installation", + {"owner": "he0119", "repo": "action-test"}, + mock_installation, + ) + ctx.should_call_api( + "rest.pulls.async_get", + {"owner": "he0119", "repo": "action-test", "pull_number": 100}, + mock_pull_resp, + ) + + ctx.should_call_api( + "rest.pulls.async_merge", + { + "owner": "he0119", + "repo": "action-test", + "pull_number": 100, + "merge_method": "rebase", + }, + True, + ) + + # 测试 git 命令 + mock_subprocess_run.assert_has_calls( + [ + mocker.call( + ["git", "config", "--global", "safe.directory", "*"], + check=True, + capture_output=True, + ) # type: ignore + ], + any_order=True, + ) + mock_resolve_conflict_pull_requests.assert_called_once_with( + GithubHandler(bot=bot, repo_info=RepoInfo(owner="he0119", repo="action-test")), + [mock_pull], + ) + + +async def test_auto_merge_not_remove(app: App, mocker: MockerFixture) -> None: + """测试审查后自动合并 + + 和删除无关 + """ + from src.plugins.github.plugins.remove import auto_merge_matcher + + mock_subprocess_run = mocker.patch("subprocess.run") + mock_resolve_conflict_pull_requests = mocker.patch( + "src.plugins.github.plugins.remove.resolve_conflict_pull_requests" + ) + + async with app.test_matcher() as ctx: + adapter, bot = get_github_bot(ctx) + event_path = ( + Path(__file__).parent.parent.parent + / "events" + / "pull_request_review_submitted.json" + ) + event = Adapter.payload_to_event( + "1", "pull_request_review", event_path.read_bytes() + ) + assert isinstance(event, PullRequestReviewSubmitted) + event.payload.pull_request.labels = [] + ctx.receive_event(bot, event) + ctx.should_not_pass_rule(auto_merge_matcher) + + # 测试 git 命令 + mock_subprocess_run.assert_not_called() + mock_resolve_conflict_pull_requests.assert_not_called() + + +async def test_auto_merge_not_member(app: App, mocker: MockerFixture) -> None: + """测试审查后自动合并 + + 审核者不是仓库成员 + """ + mock_subprocess_run = mocker.patch("subprocess.run") + mock_resolve_conflict_pull_requests = mocker.patch( + "src.plugins.github.plugins.remove.resolve_conflict_pull_requests" + ) + + async with app.test_matcher() as ctx: + adapter, bot = get_github_bot(ctx) + event_path = ( + Path(__file__).parent.parent.parent + / "events" + / "pull_request_review_submitted.json" + ) + event = Adapter.payload_to_event( + "1", "pull_request_review", event_path.read_bytes() + ) + assert isinstance(event, PullRequestReviewSubmitted) + event.payload.review.author_association = "CONTRIBUTOR" + event.payload.pull_request.labels = get_issue_labels(["Remove", "Plugin"]) + + ctx.receive_event(bot, event) + + # 测试 git 命令 + mock_subprocess_run.assert_not_called() + mock_resolve_conflict_pull_requests.assert_not_called() + + +async def test_auto_merge_not_approve(app: App, mocker: MockerFixture) -> None: + """测试审查后自动合并 + + 审核未通过 + """ + + mock_subprocess_run = mocker.patch("subprocess.run") + mock_resolve_conflict_pull_requests = mocker.patch( + "src.plugins.github.plugins.remove.resolve_conflict_pull_requests" + ) + + async with app.test_matcher() as ctx: + adapter, bot = get_github_bot(ctx) + event_path = ( + Path(__file__).parent.parent.parent + / "events" + / "pull_request_review_submitted.json" + ) + event = Adapter.payload_to_event( + "1", "pull_request_review", event_path.read_bytes() + ) + assert isinstance(event, PullRequestReviewSubmitted) + event.payload.pull_request.labels = get_issue_labels(["Remove", "Plugin"]) + event.payload.review.state = "commented" + + ctx.receive_event(bot, event) + + # 测试 git 命令 + + mock_subprocess_run.assert_not_called() + mock_resolve_conflict_pull_requests.assert_not_called() diff --git a/tests/github/remove/utils/test_remove_resolve_pull_requests.py b/tests/github/remove/utils/test_remove_resolve_pull_requests.py index 1c4fa3b3..beecba64 100644 --- a/tests/github/remove/utils/test_remove_resolve_pull_requests.py +++ b/tests/github/remove/utils/test_remove_resolve_pull_requests.py @@ -16,6 +16,15 @@ ) +def get_issue_labels(mocker: MockerFixture, labels: list[str]): + mocker_labels = [] + for label in labels: + mocker_label = mocker.MagicMock() + mocker_label.name = label + mocker_labels.append(mocker_label) + return mocker_labels + + def check_json_data(file: Path, data: Any) -> None: with open(file, encoding="utf-8") as f: assert json.load(f) == data @@ -51,10 +60,7 @@ async def test_resolve_conflict_pull_requests_bot( ).as_mock(mocker) mock_issue_repo.parsed_data = mock_issue - mock_label = mocker.MagicMock() - mock_label.name = "Bot" - - mock_pull.labels = [mock_label] + mock_pull.labels = get_issue_labels(mocker, ["Bot", "Remove"]) with open(tmp_path / "bots.json", "w", encoding="utf-8") as f: json.dump( @@ -89,9 +95,6 @@ async def test_resolve_conflict_pull_requests_bot( 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"], @@ -152,9 +155,6 @@ async def test_resolve_conflict_pull_requests_plugin( 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, @@ -165,7 +165,7 @@ async def test_resolve_conflict_pull_requests_plugin( ).as_mock(mocker) mock_issue_repo.parsed_data = mock_issue - mock_pull.labels = [mock_label] + mock_pull.labels = get_issue_labels(mocker, ["Plugin", "Remove"]) mock_pull.title = "Plugin: 帮助" mock_comment = mocker.MagicMock() @@ -205,9 +205,6 @@ async def test_resolve_conflict_pull_requests_plugin( 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"], @@ -254,3 +251,69 @@ async def test_resolve_conflict_pull_requests_plugin( plugin_config.input_config.plugin_path, snapshot([]), ) + + +async def test_resolve_conflict_pull_requests_not_found( + app: App, mocker: MockerFixture, mocked_api: MockRouter, tmp_path: Path, mock_pull +) -> None: + from pydantic_core import PydanticCustomError + + 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( + "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 = get_issue_labels(mocker, ["Plugin", "Remove"]) + 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( + [], + 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")) + + with pytest.raises(PydanticCustomError): + 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", "master"], 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 3daf554e..27ca2daf 100644 --- a/tests/github/utils.py +++ b/tests/github/utils.py @@ -75,10 +75,6 @@ def generate_issue_body_remove( ) -# def generate_issue_body_remove(homepage: str = "https://nonebot.dev"): -# return f"""### 项目主页\n\n{homepage}""" - - def check_json_data(file: Path, data: Any) -> None: with open(file) as f: assert json.load(f) == data