diff --git a/mergify_cli/__init__.py b/mergify_cli/__init__.py index 1dbd1c2..230f6a4 100755 --- a/mergify_cli/__init__.py +++ b/mergify_cli/__init__.py @@ -646,6 +646,28 @@ async def get_remote_changes( return remote_changes +def get_github_http_client(github_server: str, token: str) -> httpx.AsyncClient: + event_hooks: typing.Mapping[str, list[typing.Callable[..., typing.Any]]] = { + "request": [], + "response": [check_for_status], + } + if DEBUG: + event_hooks["request"].insert(0, log_httpx_request) + event_hooks["response"].insert(0, log_httpx_response) + + return httpx.AsyncClient( + base_url=github_server, + headers={ + "Accept": "application/vnd.github.v3+json", + "User-Agent": f"mergify_cli/{VERSION}", + "Authorization": f"token {token}", + }, + event_hooks=event_hooks, + follow_redirects=True, + timeout=5.0, + ) + + # TODO(charly): fix code to conform to linter (number of arguments, local # variables, statements, positional arguments, branches) async def stack_push( # noqa: PLR0912, PLR0913, PLR0915, PLR0917 @@ -681,7 +703,7 @@ async def stack_push( # noqa: PLR0912, PLR0913, PLR0915, PLR0917 console.log("[red] base branch and destination branch are the same [/]") sys.exit(1) - stack_prefix = f"{branch_prefix}/{dest_branch}" + stack_prefix = f"{branch_prefix}/{dest_branch}" if branch_prefix else dest_branch if not dry_run: if skip_rebase: @@ -709,17 +731,7 @@ async def stack_push( # noqa: PLR0912, PLR0913, PLR0915, PLR0917 event_hooks["request"].insert(0, log_httpx_request) event_hooks["response"].insert(0, log_httpx_response) - async with httpx.AsyncClient( - base_url=github_server, - headers={ - "Accept": "application/vnd.github.v3+json", - "User-Agent": f"mergify_cli/{VERSION}", - "Authorization": f"token {token}", - }, - event_hooks=event_hooks, - follow_redirects=True, - timeout=5.0, - ) as client: + async with get_github_http_client(github_server, token) as client: if author is None: r_author = await client.get("/user") author = r_author.json()["login"] @@ -891,6 +903,80 @@ async def _stack_push(args: argparse.Namespace) -> None: ) +@dataclasses.dataclass +class ChangeNode: + pull: github_types.PullRequest + up: ChangeNode | None = None + + +async def _stack_checkout(args: argparse.Namespace) -> None: + user, repo = args.repository.split("/") + + stack_branch = ( + f"{args.branch_prefix}/{args.dest_branch}" + if args.branch_prefix + else args.dest_branch + ) + + async with get_github_http_client(args.github_server, args.token) as client: + with console.status("Retrieving latest pushed stacks"): + remote_changes = await get_remote_changes( + client, + user, + repo, + stack_branch, + args.author, + ) + + root_node: ChangeNode | None = None + + nodes = { + pull["base"]["ref"]: ChangeNode(pull) + for pull in remote_changes.values() + if pull["state"] == "open" + } + + # Linking nodes and finding the base + for node in nodes.values(): + node.up = nodes.get(node.pull["head"]["ref"]) + + if not node.pull["base"]["ref"].startswith(stack_branch): + if root_node is not None: + console.print( + "Unexpected stack layout, two root commits found", + style="red", + ) + sys.exit(1) + root_node = node + + if root_node is None: + console.print("No stacked pull requests found") + sys.exit(0) + + console.log("Stacked pull requests:") + node = root_node + while True: + pull = node.pull + console.log( + f"* [b][white]#{pull['number']}[/] {pull['title']}[/] {pull['html_url']}", + ) + console.log(f" [grey42]{pull['base']['ref']} -> {pull['head']['ref']}[/]") + + if node.up is None: + break + node = node.up + + if args.dry_run: + return + + remote = args.trunk[0] + upstream = f"{remote}/{root_node.pull['base']['ref']}" + head_ref = f"{remote}/{node.pull['head']['ref']}" + await git("fetch", remote, node.pull["head"]["ref"]) + await git("checkout", "-b", args.branch, head_ref) + await git("branch", f"--set-upstream-to={upstream}") + + def register_stack_setup_parser( sub_parsers: argparse._SubParsersAction[typing.Any], ) -> None: @@ -913,6 +999,49 @@ def register_stack_edit_parser( parser.set_defaults(func=stack_edit) +async def register_stack_checkout_parser( + sub_parsers: argparse._SubParsersAction[typing.Any], +) -> 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=await get_default_branch_prefix(), + 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 get_trunk(), + help="Change the target branch of the stack.", + ) + + async def register_stack_push_parser( sub_parsers: argparse._SubParsersAction[typing.Any], ) -> None: @@ -1020,6 +1149,7 @@ async def parse_args(args: typing.MutableSequence[str]) -> argparse.Namespace: ) 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) diff --git a/mergify_cli/github_types.py b/mergify_cli/github_types.py index d8b0f68..b073ead 100644 --- a/mergify_cli/github_types.py +++ b/mergify_cli/github_types.py @@ -1,7 +1,7 @@ import typing -class HeadRef(typing.TypedDict): +class PullRequestRef(typing.TypedDict): sha: str ref: str @@ -11,7 +11,8 @@ class PullRequest(typing.TypedDict): number: str title: str body: str | None - head: HeadRef + base: PullRequestRef + head: PullRequestRef state: str draft: bool node_id: str diff --git a/mergify_cli/tests/test_mergify_cli.py b/mergify_cli/tests/test_mergify_cli.py index 904a750..b981e04 100644 --- a/mergify_cli/tests/test_mergify_cli.py +++ b/mergify_cli/tests/test_mergify_cli.py @@ -75,7 +75,7 @@ def git_mock( "push", "-f", "origin", - "current-branch:/current-branch/aio", + "current-branch:current-branch/aio", output="", ) @@ -225,7 +225,7 @@ async def test_stack_create( # First pull request is created assert len(post_pull1_mock.calls) == 1 assert json.loads(post_pull1_mock.calls.last.request.content) == { - "head": "/current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + "head": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", "base": "main", "title": "Title commit 1", "body": "Message commit 1", @@ -235,8 +235,8 @@ async def test_stack_create( # Second pull request is created assert len(post_pull2_mock.calls) == 1 assert json.loads(post_pull2_mock.calls.last.request.content) == { - "head": "/current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf51", - "base": "/current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + "head": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf51", + "base": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", "title": "Title commit 2", "body": "Message commit 2\n\nDepends-On: #1", "draft": False, @@ -317,7 +317,7 @@ async def test_stack_create_single_pull( # Pull request is created without stack comment assert len(post_pull_mock.calls) == 1 assert json.loads(post_pull_mock.calls.last.request.content) == { - "head": "/current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + "head": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", "base": "main", "title": "Title commit 1", "body": "Message commit 1", @@ -408,7 +408,7 @@ async def test_stack_update_no_rebase( # The pull request is updated assert len(patch_pull_mock.calls) == 1 assert json.loads(patch_pull_mock.calls.last.request.content) == { - "head": "/current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + "head": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", "base": "main", "title": "Title", "body": "Message", @@ -498,7 +498,7 @@ async def test_stack_update( # The pull request is updated assert len(patch_pull_mock.calls) == 1 assert json.loads(patch_pull_mock.calls.last.request.content) == { - "head": "/current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + "head": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", "base": "main", "title": "Title", "body": "Message", @@ -587,7 +587,7 @@ async def test_stack_update_keep_title_and_body( # The pull request is updated assert len(patch_pull_mock.calls) == 1 assert json.loads(patch_pull_mock.calls.last.request.content) == { - "head": "/current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + "head": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", "base": "main", "body": "DONT TOUCH ME", } diff --git a/mergify_cli/tests/utils.py b/mergify_cli/tests/utils.py index 2e6fcc2..cddc40c 100644 --- a/mergify_cli/tests/utils.py +++ b/mergify_cli/tests/utils.py @@ -82,6 +82,6 @@ def commit(self, commit: Commit) -> None: "push", "-f", "origin", - f"mergify-cli-tmp:/current-branch/{commit['change_id']}", + f"mergify-cli-tmp:current-branch/{commit['change_id']}", output="", )