diff --git a/mergify_cli/cli.py b/mergify_cli/cli.py index b63484b..5df9ffb 100644 --- a/mergify_cli/cli.py +++ b/mergify_cli/cli.py @@ -15,35 +15,18 @@ from __future__ import annotations -import argparse import asyncio import os -import sys -import typing from urllib import parse +import click +import click.decorators +import click_default_group + from mergify_cli import VERSION from mergify_cli import console from mergify_cli import utils -from mergify_cli.stack import checkout -from mergify_cli.stack import edit -from mergify_cli.stack import github_action_auto_rebase -from mergify_cli.stack import push -from mergify_cli.stack import setup - - -def trunk_type(trunk: str) -> tuple[str, str]: - result = trunk.split("/", maxsplit=1) - if len(result) != 2: - msg = "Trunk is invalid. It must be origin/branch-name [/]" - raise argparse.ArgumentTypeError(msg) - return result[0], result[1] - - -def GitHubToken(v: str) -> str: # noqa: N802 - if not v: - raise ValueError - return v +from mergify_cli.stack import cli as stack_cli_mod async def get_default_github_server() -> str: @@ -62,19 +45,6 @@ async def get_default_github_server() -> str: return url.geturl() -async def get_default_keep_pr_title_body() -> bool: - try: - result = await utils.git( - "config", - "--get", - "mergify-cli.stack-keep-pr-title-body", - ) - except utils.CommandError: - return False - - return result == "true" - - async def get_default_token() -> str: token = os.environ.get("GITHUB_TOKEN", "") if not token: @@ -90,257 +60,38 @@ async def get_default_token() -> str: return token -async def _stack_push(args: argparse.Namespace) -> None: - if args.setup: - # backward compat - await setup.stack_setup() - return - - await push.stack_push( - args.github_server, - args.token, - args.skip_rebase, - args.next_only, - args.branch_prefix, - args.dry_run, - args.trunk, - args.draft, - args.keep_pull_request_title_and_body, - args.only_update_existing_pulls, - args.author, - ) - - -async def _stack_checkout(args: argparse.Namespace) -> None: - user, repo = args.repository.split("/") - - await checkout.stack_checkout( - args.github_server, - args.token, - user, - repo, - args.branch_prefix, - args.branch, - args.author, - args.trunk, - args.dry_run, - ) - - -def register_stack_setup_parser( - sub_parsers: argparse._SubParsersAction[typing.Any], -) -> None: - parser = sub_parsers.add_parser( - "setup", - description="Configure the git hooks", - help="Initial installation of the required git commit-msg hook", - ) - parser.set_defaults(func=lambda _: setup.stack_setup) - - -def register_stack_edit_parser( - sub_parsers: argparse._SubParsersAction[typing.Any], -) -> None: - parser = sub_parsers.add_parser( - "edit", - description="Edit the stack history", - help="Edit the stack history", - ) - parser.set_defaults(func=lambda _: edit.stack_edit) - - -async def _stack_github_action_auto_rebase(args: argparse.Namespace) -> None: - await github_action_auto_rebase.stack_github_action_auto_rebase( - args.github_server, - args.token, - ) - - -def register_stack_github_action_autorebase( - sub_parsers: argparse._SubParsersAction[typing.Any], -) -> None: - parser = sub_parsers.add_parser( - "github-action-auto-rebase", - description="Autorebase a pull requests stack", - help="Checkout a pull requests stack", - ) - parser.set_defaults(func=_stack_github_action_auto_rebase) - - -async def register_stack_checkout_parser( - sub_parsers: argparse._SubParsersAction[typing.Any], +@click.group( + cls=click_default_group.DefaultGroup, + default="stack", + default_if_no_args=True, +) +@click.option("--debug", is_flag=True, default=False, help="debug mode") +@click.version_option(VERSION) +@click.option( + "--github-server", + default=asyncio.run(get_default_github_server()), +) +@click.option( + "--token", + default=asyncio.run(get_default_token()), + help="GitHub personal access token", +) +@click.pass_context +def cli( + ctx: click.Context, + debug: bool, + github_server: str, + token: str, ) -> None: - parser = sub_parsers.add_parser( - "checkout", - description="Checkout a pull requests stack", - help="Checkout a pull requests stack", - ) - parser.set_defaults(func=_stack_checkout) - parser.add_argument( - "--author", - help="Set the author of the stack (default: the author of the token)", - ) - parser.add_argument( - "--repository", - "--repo", - help="Set the repository where the stack is located (eg: owner/repo)", - ) - parser.add_argument( - "--branch", - help="Branch used to create stacked PR.", - ) - parser.add_argument( - "--branch-prefix", - default=None, - help="Branch prefix used to create stacked PR. " - "Default fetched from git config if added with `git config --add mergify-cli.stack-branch-prefix some-prefix`", - ) - parser.add_argument( - "--dry-run", - "-n", - action="store_true", - help="Only show what is going to be done", - ) - parser.add_argument( - "--trunk", - "-t", - type=trunk_type, - default=await utils.get_trunk(), - help="Change the target branch of the stack.", - ) - - -async def register_stack_push_parser( - sub_parsers: argparse._SubParsersAction[typing.Any], -) -> None: - parser = sub_parsers.add_parser( - "push", - description="Push/sync the pull requests stack", - help="Push/sync the pull requests stack", - ) - parser.set_defaults(func=_stack_push) - - # Backward compat - parser.add_argument( - "--setup", - action="store_true", - help="Initial installation of the required git commit-msg hook", - ) - - parser.add_argument( - "--dry-run", - "-n", - action="store_true", - help="Only show what is going to be done", - ) - parser.add_argument( - "--next-only", - "-x", - action="store_true", - help="Only rebase and update the next pull request of the stack", - ) - parser.add_argument( - "--skip-rebase", - "-R", - action="store_true", - help="Skip stack rebase", - ) - parser.add_argument( - "--draft", - "-d", - action="store_true", - help="Create stacked pull request as draft", - ) - parser.add_argument( - "--keep-pull-request-title-and-body", - "-k", - action="store_true", - default=await get_default_keep_pr_title_body(), - help="Don't update the title and body of already opened pull requests. " - "Default fetched from git config if added with `git config --add mergify-cli.stack-keep-pr-title-body true`", - ) - parser.add_argument( - "--author", - help="Set the author of the stack (default: the author of the token)", - ) - - parser.add_argument( - "--trunk", - "-t", - type=trunk_type, - default=await utils.get_trunk(), - help="Change the target branch of the stack.", - ) - parser.add_argument( - "--branch-prefix", - default=None, - help="Branch prefix used to create stacked PR. " - "Default fetched from git config if added with `git config --add mergify-cli.stack-branch-prefix some-prefix`", - ) - parser.add_argument( - "--only-update-existing-pulls", - "-u", - action="store_true", - help="Only update existing pull requests, do not create new ones", - ) - - -async def parse_args(args: typing.MutableSequence[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument( - "--version", - "-V", - action="version", - version=f"%(prog)s {VERSION}", - help="display version", - ) - parser.add_argument("--debug", action="store_true", help="debug mode") - parser.add_argument( - "--token", - default=await get_default_token(), - type=GitHubToken, - help="GitHub personal access token", - ) - parser.add_argument("--dry-run", "-n", action="store_true") - parser.add_argument( - "--github-server", - action="store_true", - default=await get_default_github_server(), - ) - - sub_parsers = parser.add_subparsers(dest="action") - - stack_parser = sub_parsers.add_parser( - "stack", - description="Stacked Pull Requests CLI", - help="Create a pull requests stack", - ) - stack_sub_parsers = stack_parser.add_subparsers(dest="stack_action") - await register_stack_push_parser(stack_sub_parsers) - await register_stack_checkout_parser(stack_sub_parsers) - register_stack_edit_parser(stack_sub_parsers) - register_stack_setup_parser(stack_sub_parsers) - register_stack_github_action_autorebase(stack_sub_parsers) - - known_args, _ = parser.parse_known_args(args) - - # Default - if known_args.action is None: - args.insert(0, "stack") - - known_args, _ = parser.parse_known_args(args) - - if known_args.action == "stack" and known_args.stack_action is None: - args.insert(1, "push") - - return parser.parse_args(args) + ctx.obj = { + "debug": debug, + "github_server": github_server, + "token": token, + } -async def async_main() -> None: - args = await parse_args(sys.argv[1:]) - utils.set_debug(args.debug) - await args.func(args) +cli.add_command(stack_cli_mod.stack) def main() -> None: - asyncio.run(async_main()) + cli() diff --git a/mergify_cli/stack/checkout.py b/mergify_cli/stack/checkout.py index 09cd167..7dc5d0f 100644 --- a/mergify_cli/stack/checkout.py +++ b/mergify_cli/stack/checkout.py @@ -22,10 +22,15 @@ async def stack_checkout( # noqa: PLR0913, PLR0917 repo: str, branch_prefix: str | None, branch: str, - author: str, + author: str | None, trunk: tuple[str, str], dry_run: bool, ) -> None: + if author is None: + async with utils.get_github_http_client(github_server, token) as client: + r_author = await client.get("/user") + author = r_author.json()["login"] + if branch_prefix is None: branch_prefix = await utils.get_default_branch_prefix(author) diff --git a/mergify_cli/stack/cli.py b/mergify_cli/stack/cli.py new file mode 100644 index 0000000..881ff1c --- /dev/null +++ b/mergify_cli/stack/cli.py @@ -0,0 +1,207 @@ +import asyncio + +import click +import click_default_group + +from mergify_cli import utils +from mergify_cli.stack import checkout as stack_checkout_mod +from mergify_cli.stack import edit as stack_edit_mod +from mergify_cli.stack import ( + github_action_auto_rebase as stack_github_action_auto_rebase_mod, +) +from mergify_cli.stack import push as stack_push_mod +from mergify_cli.stack import setup as stack_setup_mod + + +def trunk_type( + _ctx: click.Context, + _param: click.Parameter, + value: str, +) -> tuple[str, str]: + result = value.split("/", maxsplit=1) + if len(result) != 2: + msg = "Trunk is invalid. It must be origin/branch-name [/]" + raise click.BadParameter(msg) + return result[0], result[1] + + +stack = click_default_group.DefaultGroup( + "stack", + default="push", + default_if_no_args=True, + help="Manage pull requests stack", +) + + +@stack.command(help="Configure the required git commit-msg hooks") +@utils.run_with_asyncio +async def setup() -> None: + await stack_setup_mod.stack_setup() + + +@stack.command(help="Edit the stack history") +@utils.run_with_asyncio +async def edit() -> None: + await stack_edit_mod.stack_edit() + + +@stack.command(help="Push/sync the pull requests stack") +@click.pass_context +@click.option( + "--setup", + is_flag=True, + default=False, + hidden=True, +) +@click.option("--dry-run", "-n", is_flag=True, default=False, help="dry run") +@click.option( + "--next-only", + "-x", + is_flag=True, + help="Only rebase and update the next pull request of the stack", +) +@click.option( + "--skip-rebase", + "-R", + is_flag=True, + help="Skip stack rebase", +) +@click.option( + "--draft", + "-d", + is_flag=True, + help="Create stacked pull request as draft", +) +@click.option( + "--keep-pull-request-title-and-body", + "-k", + is_flag=True, + default=lambda: asyncio.run(utils.get_default_keep_pr_title_body()), + help="Don't update the title and body of already opened pull requests. " + "Default fetched from git config if added with `git config --add mergify-cli.stack-keep-pr-title-body true`", +) +@click.option( + "--author", + help="Set the author of the stack (default: the author of the token)", +) +@click.option( + "--trunk", + "-t", + type=click.UNPROCESSED, + default=lambda: asyncio.run(utils.get_trunk()), + callback=trunk_type, + help="Change the target branch of the stack.", +) +@click.option( + "--branch-prefix", + default=None, + help="Branch prefix used to create stacked PR. " + "Default fetched from git config if added with `git config --add mergify-cli.stack-branch-prefix some-prefix`", +) +@click.option( + "--only-update-existing-pulls", + "-u", + is_flag=True, + help="Only update existing pull requests, do not create new ones", +) +@utils.run_with_asyncio +async def push( # noqa: PLR0913, PLR0917 + ctx: click.Context, + setup: bool, + dry_run: bool, + next_only: bool, + skip_rebase: bool, + draft: bool, + keep_pull_request_title_and_body: bool, + author: str, + trunk: tuple[str, str], + branch_prefix: str | None, + only_update_existing_pulls: bool, +) -> None: + if setup: + # backward compat + await stack_setup_mod.stack_setup() + return + + await stack_push_mod.stack_push( + ctx.obj["github_server"], + ctx.obj["token"], + skip_rebase, + next_only, + branch_prefix, + dry_run, + trunk, + draft, + keep_pull_request_title_and_body, + only_update_existing_pulls, + author, + ) + + +@stack.command(help="Checkout the pull requests stack") +@click.pass_context +@click.option( + "--author", + help="Set the author of the stack (default: the author of the token)", +) +@click.option( + "--repository", + "--repo", + help="Set the repository where the stack is located (eg: owner/repo)", +) +@click.option( + "--branch", + help="Branch used to create stacked PR.", +) +@click.option( + "--branch-prefix", + default=None, + help="Branch prefix used to create stacked PR. " + "Default fetched from git config if added with `git config --add mergify-cli.stack-branch-prefix some-prefix`", +) +@click.option( + "--dry-run", + "-n", + is_flag=True, + help="Only show what is going to be done", +) +@click.option( + "--trunk", + "-t", + type=click.UNPROCESSED, + default=lambda: asyncio.run(utils.get_trunk()), + callback=trunk_type, + help="Change the target branch of the stack.", +) +@utils.run_with_asyncio +async def checkout( # noqa: PLR0913, PLR0917 + ctx: click.Context, + author: str | None, + repository: str, + branch: str, + branch_prefix: str | None, + dry_run: bool, + trunk: tuple[str, str], +) -> None: + user, repo = repository.split("/") + await stack_checkout_mod.stack_checkout( + ctx.obj["github_server"], + ctx.obj["token"], + user, + repo, + branch_prefix, + branch, + author, + trunk, + dry_run, + ) + + +@stack.command(help="Autorebase a pull requests stack") +@click.pass_context +@utils.run_with_asyncio +async def github_action_auto_rebase(ctx: click.Context) -> None: + await stack_github_action_auto_rebase_mod.stack_github_action_auto_rebase( + ctx.obj["github_server"], + ctx.obj["token"], + ) diff --git a/mergify_cli/tests/test_mergify_cli.py b/mergify_cli/tests/test_mergify_cli.py index 2a443f3..08fc8af 100644 --- a/mergify_cli/tests/test_mergify_cli.py +++ b/mergify_cli/tests/test_mergify_cli.py @@ -22,7 +22,6 @@ import pytest import respx -from mergify_cli import cli from mergify_cli import utils from mergify_cli.stack import push from mergify_cli.tests import utils as test_utils @@ -85,17 +84,6 @@ def git_mock( yield git_mock_object -@pytest.mark.usefixtures("_git_repo") -async def test_cli_help(capsys: pytest.CaptureFixture[str]) -> None: - with pytest.raises(SystemExit, match="0"): - await cli.parse_args(["--help"]) - - stdout = capsys.readouterr().out - assert "usage: " in stdout - assert "positional arguments:" in stdout - assert "options:" in stdout - - @pytest.mark.usefixtures("_git_repo") async def test_get_branch_name() -> None: assert await utils.git_get_branch_name() == "main" @@ -638,7 +626,7 @@ async def test_stack_without_common_commit_raises_an_error( @pytest.mark.parametrize( ("default_arg_fct", "config_get_result", "expected_default"), [ - (cli.get_default_keep_pr_title_body, "true", True), + (utils.get_default_keep_pr_title_body, "true", True), ( lambda: utils.get_default_branch_prefix("author"), "dummy-prefix", @@ -656,31 +644,3 @@ async def test_defaults_config_args_set( ) -> None: with mock.patch.object(utils, "run_command", return_value=config_get_result): assert (await default_arg_fct()) == expected_default - - -@pytest.mark.parametrize( - "args", - [ - ["-R"], - ["stack", "-R"], - ["stack", "push", "-R"], - ], -) -async def test_default( - git_mock: test_utils.GitMock, - args: list[str], -) -> None: - git_mock.default_cli_args() - parsed = await cli.parse_args(args) - assert parsed.action == "stack" - assert parsed.stack_action == "push" - assert parsed.skip_rebase - - -async def test_parse_edit( - git_mock: test_utils.GitMock, -) -> None: - git_mock.default_cli_args() - parsed = await cli.parse_args(["stack", "edit"]) - assert parsed.action == "stack" - assert parsed.stack_action == "edit" diff --git a/mergify_cli/utils.py b/mergify_cli/utils.py index 7881b0d..1a03b10 100644 --- a/mergify_cli/utils.py +++ b/mergify_cli/utils.py @@ -17,6 +17,7 @@ import asyncio import dataclasses +import functools import sys import typing from urllib import parse @@ -113,6 +114,19 @@ async def get_default_branch_prefix(author: str) -> str: return result or f"stack/{author}" +async def get_default_keep_pr_title_body() -> bool: + try: + result = await git( + "config", + "--get", + "mergify-cli.stack-keep-pr-title-body", + ) + except CommandError: + return False + + return result == "true" + + async def get_trunk() -> str: try: branch_name = await git_get_branch_name() @@ -191,3 +205,26 @@ def get_github_http_client(github_server: str, token: str) -> httpx.AsyncClient: follow_redirects=True, timeout=5.0, ) + + +P = typing.ParamSpec("P") +R = typing.TypeVar("R") + + +def run_with_asyncio( + func: typing.Callable[ + P, + typing.Coroutine[typing.Any, typing.Any, R], + ], +) -> functools._Wrapped[ + P, + typing.Coroutine[typing.Any, typing.Any, R], + P, + R, +]: + @functools.wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + result = func(*args, **kwargs) + return asyncio.run(result) + + return wrapper diff --git a/poetry.lock b/poetry.lock index 311ccc5..92f6fcb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aiofiles" @@ -43,6 +43,37 @@ files = [ {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "click-default-group" +version = "1.2.4" +description = "click_default_group" +optional = false +python-versions = ">=2.7" +files = [ + {file = "click_default_group-1.2.4-py2.py3-none-any.whl", hash = "sha256:9b60486923720e7fc61731bdb32b617039aba820e22e1c88766b1125592eaa5f"}, + {file = "click_default_group-1.2.4.tar.gz", hash = "sha256:eb3f3c99ec0d456ca6cd2a7f08f7d4e91771bef51b01bdd9580cc6450fe1251e"}, +] + +[package.dependencies] +click = "*" + +[package.extras] +test = ["pytest"] + [[package]] name = "colorama" version = "0.4.6" @@ -511,6 +542,31 @@ files = [ {file = "types_aiofiles-24.1.0.20240626-py3-none-any.whl", hash = "sha256:7939eca4a8b4f9c6491b6e8ef160caee9a21d32e18534a57d5ed90aee47c66b4"}, ] +[[package]] +name = "types-click" +version = "7.1.8" +description = "Typing stubs for click" +optional = false +python-versions = "*" +files = [ + {file = "types-click-7.1.8.tar.gz", hash = "sha256:b6604968be6401dc516311ca50708a0a28baa7a0cb840efd7412f0dbbff4e092"}, + {file = "types_click-7.1.8-py3-none-any.whl", hash = "sha256:8cb030a669e2e927461be9827375f83c16b8178c365852c060a34e24871e7e81"}, +] + +[[package]] +name = "types-click-default-group" +version = "1.2.0.0" +description = "Typing stubs for click-default-group" +optional = false +python-versions = "*" +files = [ + {file = "types-click-default-group-1.2.0.0.tar.gz", hash = "sha256:672a198ab8e13c302c8bdd15908a3bb829a9c91b0cc2be734a86b9f753502e20"}, + {file = "types_click_default_group-1.2.0.0-py3-none-any.whl", hash = "sha256:5b13233b552a80859214bac34c6d95c5bc25f8735618f873be892939541c6a93"}, +] + +[package.dependencies] +click = ">=8.0.0" + [[package]] name = "typing-extensions" version = "4.8.0" @@ -525,4 +581,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.10" -content-hash = "800b68b6b6602723626dd1ed65d279e783891c45a817678f2e0c8ad219c23a7f" +content-hash = "3c8b3eac08e315e0bf7a21ab20ebe99b08f981370f4b3891ce3e80bcf081e510" diff --git a/pyproject.toml b/pyproject.toml index dc34631..7c4f956 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ python = ">=3.10" httpx = ">=0.20.0" rich = ">=10.11.0" aiofiles = ">=23.2.1,<25.0.0" +click = "^8.1.7" +click-default-group = "^1.2.4" [tool.poetry-version-plugin] source = "git-tag" @@ -24,6 +26,8 @@ poethepoet = ">=0.21,<0.31" pytest-asyncio = ">=0.23.2,<0.25.0" respx = ">=0.20.2,<0.22.0" types-aiofiles = ">=23.2.0.20240106,<25.0.0.0" +types-click = "^7.1.8" +types-click-default-group = "^1.2.0.0" [tool.poetry.scripts] mergify = 'mergify_cli.cli:main'