From 025ec408eb50d1ba5d1ccd2b6397a32b4ad4d1a5 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 23 Jan 2026 11:19:08 +0100 Subject: [PATCH] feat(stack): add `mergify stack list` command Add a new command to display the current stack's commits and their associated PRs. Shows PR status (open, draft, merged, no PR) with color-coded output. Supports --json flag for scripting and --trunk to specify target branch. Co-Authored-By: Claude Opus 4.5 Change-Id: I0a142447aacf2e96838b26d433e1749e94beaa6a --- mergify_cli/stack/cli.py | 32 ++ mergify_cli/stack/list.py | 237 ++++++++++++++ mergify_cli/tests/stack/test_list.py | 457 +++++++++++++++++++++++++++ 3 files changed, 726 insertions(+) create mode 100644 mergify_cli/stack/list.py create mode 100644 mergify_cli/tests/stack/test_list.py diff --git a/mergify_cli/stack/cli.py b/mergify_cli/stack/cli.py index 1730bd73..a9a55202 100644 --- a/mergify_cli/stack/cli.py +++ b/mergify_cli/stack/cli.py @@ -14,6 +14,7 @@ from mergify_cli.stack import ( github_action_auto_rebase as stack_github_action_auto_rebase_mod, ) +from mergify_cli.stack import list as stack_list_mod from mergify_cli.stack import push as stack_push_mod from mergify_cli.stack import session as stack_session_mod from mergify_cli.stack import setup as stack_setup_mod @@ -302,3 +303,34 @@ async def session(*, commit: str, launch: bool) -> None: if launch: stack_session_mod.launch_claude_session(session_id) + + +@stack.command(name="list", help="List the stack's commits and their associated PRs") # type: ignore[untyped-decorator] +@click.pass_context +@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( + "--json", + "output_json", + is_flag=True, + help="Output in JSON format for scripting", +) +@utils.run_with_asyncio +async def list_cmd( + ctx: click.Context, + *, + trunk: tuple[str, str], + output_json: bool, +) -> None: + await stack_list_mod.stack_list( + github_server=ctx.obj["github_server"], + token=ctx.obj["token"], + trunk=trunk, + output_json=output_json, + ) diff --git a/mergify_cli/stack/list.py b/mergify_cli/stack/list.py new file mode 100644 index 00000000..e03d4d24 --- /dev/null +++ b/mergify_cli/stack/list.py @@ -0,0 +1,237 @@ +# +# Copyright © 2021-2026 Mergify SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import annotations + +import dataclasses +import json +import sys +import typing + +from mergify_cli import console +from mergify_cli import utils +from mergify_cli.stack import changes +from mergify_cli.stack.push import LocalBranchInvalidError +from mergify_cli.stack.push import check_local_branch + + +StackEntryStatusT = typing.Literal["merged", "draft", "open", "no_pr"] + +_STATUS_DISPLAY: dict[StackEntryStatusT, tuple[str, str]] = { + "merged": ("merged", "purple"), + "draft": ("draft", "yellow"), + "open": ("open", "green"), + "no_pr": ("no PR", "dim"), +} + +if typing.TYPE_CHECKING: + from mergify_cli import github_types + + +@dataclasses.dataclass +class StackListEntry: + """A single entry in the stack list.""" + + commit_sha: str + title: str + change_id: str + status: StackEntryStatusT + pull_number: int | None = None + pull_url: str | None = None + + def to_dict(self) -> dict[str, typing.Any]: + return { + "commit_sha": self.commit_sha, + "title": self.title, + "change_id": self.change_id, + "status": self.status, + "pull_number": self.pull_number, + "pull_url": self.pull_url, + } + + +@dataclasses.dataclass +class StackListOutput: + """Output structure for the stack list command.""" + + branch: str + trunk: str + entries: list[StackListEntry] + + def to_dict(self) -> dict[str, typing.Any]: + return { + "branch": self.branch, + "trunk": self.trunk, + "entries": [e.to_dict() for e in self.entries], + } + + +def _get_entry_status( + pull: github_types.PullRequest | None, +) -> StackEntryStatusT: + """Determine the status of a stack entry based on its PR state.""" + if pull is None: + return "no_pr" + if pull["merged_at"]: + return "merged" + if pull["draft"]: + return "draft" + return "open" + + +def _get_status_display(status: StackEntryStatusT) -> tuple[str, str]: + """Return the display text and color for a status.""" + return _STATUS_DISPLAY[status] + + +def display_stack_list(output: StackListOutput) -> None: + """Display the stack list in human-readable format using rich console.""" + console.print( + f"\nStack on `[cyan]{output.branch}[/]` targeting `[cyan]{output.trunk}[/]`:\n", + ) + + if not output.entries: + console.print("No commits in stack", style="dim") + return + + for entry in output.entries: + status_text, status_color = _get_status_display(entry.status) + short_sha = entry.commit_sha[:7] + + # Format: * [status] #number Title (sha) + if entry.pull_number is not None: + console.print( + f"* [{status_color}]\\[{status_text}][/] " + f"[bold]#{entry.pull_number}[/] {entry.title} ({short_sha})", + ) + console.print(f" {entry.pull_url}\n") + else: + console.print( + f"* [{status_color}]\\[{status_text}][/] {entry.title} ({short_sha})\n", + ) + + +async def stack_list( + github_server: str, + token: str, + *, + trunk: tuple[str, str], + branch_prefix: str | None = None, + author: str | None = None, + output_json: bool = False, +) -> None: + """List the current stack's commits and their associated PRs. + + Args: + github_server: GitHub API server URL + token: GitHub personal access token + trunk: Tuple of (remote, branch) for the trunk + branch_prefix: Optional branch prefix for stack branches + author: Optional author filter (defaults to token owner) + output_json: If True, output JSON instead of human-readable format + """ + dest_branch = await utils.git_get_branch_name() + + if author is None: + async with utils.get_github_http_client(github_server, token) as client: + r_author = await client.get("/user") + author = typing.cast("str", r_author.json()["login"]) + + if branch_prefix is None: + branch_prefix = await utils.get_default_branch_prefix(author) + + try: + check_local_branch(branch_name=dest_branch, branch_prefix=branch_prefix) + except LocalBranchInvalidError as e: + console.print(f"[red] {e.message} [/]") + console.print( + "You should run `mergify stack list` on the branch you created in the first place", + ) + sys.exit(1) + + remote, base_branch = trunk + + user, repo = utils.get_slug( + await utils.git("config", "--get", f"remote.{remote}.url"), + ) + + if base_branch == dest_branch: + remote_url = await utils.git("remote", "get-url", remote) + console.print( + f"Your local branch `{dest_branch}` targets itself: `{remote}/{base_branch}` (at {remote_url}@{base_branch}).\n" + f"You should either fix the target branch or rename your local branch.\n\n" + f"* To fix the target branch: `git branch {dest_branch} --set-upstream-to={remote}/main>\n", + f"* To rename your local branch: `git branch -M {dest_branch} new-branch-name`", + style="red", + ) + sys.exit(1) + + stack_prefix = f"{branch_prefix}/{dest_branch}" if branch_prefix else dest_branch + + base_commit_sha = await utils.git( + "merge-base", + "--fork-point", + f"{remote}/{base_branch}", + ) + if not base_commit_sha: + console.print( + f"Common commit between `{remote}/{base_branch}` and `{dest_branch}` branches not found", + style="red", + ) + sys.exit(1) + + async with utils.get_github_http_client(github_server, token) as client: + remote_changes = await changes.get_remote_changes( + client, + user, + repo, + stack_prefix, + author, + ) + + stack_changes = await changes.get_changes( + base_commit_sha=base_commit_sha, + stack_prefix=stack_prefix, + base_branch=base_branch, + dest_branch=dest_branch, + remote_changes=remote_changes, + only_update_existing_pulls=False, + next_only=False, + ) + + # Build output structure + entries: list[StackListEntry] = [] + for local_change in stack_changes.locals: + status = _get_entry_status(local_change.pull) + entry = StackListEntry( + commit_sha=local_change.commit_sha, + title=local_change.title, + change_id=local_change.id, + status=status, + pull_number=int(local_change.pull["number"]) if local_change.pull else None, + pull_url=local_change.pull["html_url"] if local_change.pull else None, + ) + entries.append(entry) + + output = StackListOutput( + branch=dest_branch, + trunk=f"{remote}/{base_branch}", + entries=entries, + ) + + if output_json: + console.print(json.dumps(output.to_dict(), indent=2)) + else: + display_stack_list(output) diff --git a/mergify_cli/tests/stack/test_list.py b/mergify_cli/tests/stack/test_list.py new file mode 100644 index 00000000..40f9d462 --- /dev/null +++ b/mergify_cli/tests/stack/test_list.py @@ -0,0 +1,457 @@ +# +# Copyright © 2021-2026 Mergify SAS +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from mergify_cli.stack import list as stack_list_mod +from mergify_cli.tests import utils as test_utils + + +if TYPE_CHECKING: + import respx + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_list_with_prs( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test listing a stack with commits that have associated PRs.""" + # Add required git config mock + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + # Mock 2 commits on branch `current-branch` + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="Add user authentication", + message="Message commit 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50", + ), + ) + git_mock.commit( + test_utils.Commit( + sha="commit2_sha", + title="Implement login form", + message="Message commit 2", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf51", + ), + ) + + # Mock HTTP calls + respx_mock.get("/user").respond(200, json={"login": "author"}) + respx_mock.get("/search/issues").respond( + 200, + json={ + "items": [ + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/123", + }, + }, + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/124", + }, + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/123").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/123", + "number": "123", + "title": "Add user authentication", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + }, + ) + respx_mock.get("/repos/user/repo/pulls/124").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/124", + "number": "124", + "title": "Implement login form", + "head": { + "sha": "commit2_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf51", + }, + "state": "open", + "merged_at": None, + "draft": True, + "node_id": "", + }, + ) + + await stack_list_mod.stack_list( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + captured = capsys.readouterr() + assert "current-branch" in captured.out + assert "origin/main" in captured.out + assert "#123" in captured.out + assert "Add user authentication" in captured.out + assert "#124" in captured.out + assert "Implement login form" in captured.out + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_list_no_prs( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test listing a stack with commits that have no PRs yet.""" + # Add required git config mock + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="Add logout functionality", + message="Message commit 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50", + ), + ) + + # Mock HTTP calls - no PRs found + respx_mock.get("/user").respond(200, json={"login": "author"}) + respx_mock.get("/search/issues").respond(200, json={"items": []}) + + await stack_list_mod.stack_list( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + captured = capsys.readouterr() + assert "current-branch" in captured.out + assert "Add logout functionality" in captured.out + assert "no PR" in captured.out + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_list_mixed_pr_states( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test listing a stack with mixed PR states (open, draft, merged).""" + # Add required git config mock + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="First commit", + message="Message 1", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50", + ), + ) + git_mock.commit( + test_utils.Commit( + sha="commit2_sha", + title="Second commit", + message="Message 2", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf51", + ), + ) + git_mock.commit( + test_utils.Commit( + sha="commit3_sha", + title="Third commit", + message="Message 3", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf52", + ), + ) + + respx_mock.get("/user").respond(200, json={"login": "author"}) + respx_mock.get("/search/issues").respond( + 200, + json={ + "items": [ + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/1", + }, + }, + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/2", + }, + }, + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/3", + }, + }, + ], + }, + ) + # First PR: merged + respx_mock.get("/repos/user/repo/pulls/1").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/1", + "number": "1", + "title": "First commit", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + }, + "state": "closed", + "merged_at": "2024-01-01T00:00:00Z", + "draft": False, + "node_id": "", + }, + ) + # Second PR: draft + respx_mock.get("/repos/user/repo/pulls/2").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/2", + "number": "2", + "title": "Second commit", + "head": { + "sha": "commit2_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf51", + }, + "state": "open", + "merged_at": None, + "draft": True, + "node_id": "", + }, + ) + # Third PR: open + respx_mock.get("/repos/user/repo/pulls/3").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/3", + "number": "3", + "title": "Third commit", + "head": { + "sha": "commit3_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf52", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + }, + ) + + await stack_list_mod.stack_list( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + captured = capsys.readouterr() + assert "merged" in captured.out + assert "draft" in captured.out + assert "open" in captured.out + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_list_json_output( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test JSON output format.""" + # Add required git config mock + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="Add feature", + message="Message", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50", + ), + ) + + respx_mock.get("/user").respond(200, json={"login": "author"}) + respx_mock.get("/search/issues").respond( + 200, + json={ + "items": [ + { + "pull_request": { + "url": "https://api.github.com/repos/user/repo/pulls/42", + }, + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/42").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/42", + "number": "42", + "title": "Add feature", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + }, + ) + + await stack_list_mod.stack_list( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + output_json=True, + ) + + captured = capsys.readouterr() + output = json.loads(captured.out) + + assert output["branch"] == "current-branch" + assert output["trunk"] == "origin/main" + assert len(output["entries"]) == 1 + assert output["entries"][0]["commit_sha"] == "commit1_sha" + assert output["entries"][0]["title"] == "Add feature" + assert output["entries"][0]["status"] == "open" + assert output["entries"][0]["pull_number"] == 42 + assert output["entries"][0]["pull_url"] == "https://github.com/user/repo/pull/42" + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_list_empty_stack( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test listing an empty stack (no commits).""" + # Add required git config mock + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + # Don't add any commits - just set up the base mocks + git_mock.mock("merge-base", "--fork-point", "origin/main", output="base_commit_sha") + git_mock.mock( + "log", + "--format=%H", + "base_commit_sha..current-branch", + output="", + ) + + respx_mock.get("/user").respond(200, json={"login": "author"}) + respx_mock.get("/search/issues").respond(200, json={"items": []}) + + await stack_list_mod.stack_list( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + captured = capsys.readouterr() + assert "No commits in stack" in captured.out + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_list_on_trunk_branch_raises_error( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """Test that listing on trunk branch raises an error.""" + # Add required git config mock + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + respx_mock.get("/user").respond(200, json={"login": "author"}) + git_mock.mock("rev-parse", "--abbrev-ref", "HEAD", output="main") + git_mock.mock( + "remote", + "get-url", + "origin", + output="https://github.com/foo/bar.git", + ) + + with pytest.raises(SystemExit, match="1"): + await stack_list_mod.stack_list( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_list_on_generated_branch_raises_error( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """Test that listing on a generated stack branch raises an error.""" + # Add required git config mock - use "stack/author" prefix to match generated branch pattern + git_mock.mock( + "config", + "--get", + "mergify-cli.stack-branch-prefix", + output="stack/author", + ) + + respx_mock.get("/user").respond(200, json={"login": "author"}) + # Simulate being on a generated branch + git_mock.mock( + "rev-parse", + "--abbrev-ref", + "HEAD", + output="stack/author/my-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + ) + + with pytest.raises(SystemExit, match="1"): + await stack_list_mod.stack_list( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_list_no_fork_point_raises_error( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, +) -> None: + """Test that missing fork point raises an error.""" + # Add required git config mock + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + respx_mock.get("/user").respond(200, json={"login": "author"}) + git_mock.mock("merge-base", "--fork-point", "origin/main", output="") + + with pytest.raises(SystemExit, match="1"): + await stack_list_mod.stack_list( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + )