From fe9b76f9d4da6df7db5cc10183c25384748cd86f Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Fri, 10 Apr 2026 10:53:46 +0200 Subject: [PATCH] feat: add inline CI, review, and merge status to `mergify stack list` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhances `mergify stack list` to show CI status, review status, and merge conflict state for each PR in the stack. - Default: summary line (CI: ✓ passing | Review: ✓ approved) - --verbose: detailed check names and reviewer names - --json: full structured data (ci_checks, reviews, mergeable) - Conflict indicator (✗ conflicting) when PR has merge conflicts Fetches check-runs and reviews in parallel with bounded concurrency. Callers like `stack open` skip the extra fetches via include_status=False. Change-Id: I546622141e7fec536c9fa26c2f0a8a50cc59fa60 Claude-Session-Id: 2d9f4a52-4a1d-4a50-a459-3b57847dec9f --- mergify_cli/github_types.py | 1 + mergify_cli/stack/cli.py | 8 + mergify_cli/stack/list.py | 269 +++++++++++++++++++++--- mergify_cli/stack/open.py | 1 + mergify_cli/tests/stack/test_list.py | 295 +++++++++++++++++++++++++++ mergify_cli/tests/stack/test_open.py | 5 + skills/mergify-stack/SKILL.md | 2 +- 7 files changed, 554 insertions(+), 27 deletions(-) diff --git a/mergify_cli/github_types.py b/mergify_cli/github_types.py index 42d93d89..98acde09 100644 --- a/mergify_cli/github_types.py +++ b/mergify_cli/github_types.py @@ -20,6 +20,7 @@ class PullRequest(typing.TypedDict): node_id: str merged_at: str | None merge_commit_sha: str | None + mergeable: bool | None class Comment(typing.TypedDict): diff --git a/mergify_cli/stack/cli.py b/mergify_cli/stack/cli.py index e2dcdeb7..1ea28bac 100644 --- a/mergify_cli/stack/cli.py +++ b/mergify_cli/stack/cli.py @@ -517,18 +517,26 @@ async def sync( is_flag=True, help="Output in JSON format for scripting", ) +@click.option( + "--verbose", + "-v", + is_flag=True, + help="Show detailed CI check names and reviewer names", +) @utils.run_with_asyncio async def list_cmd( ctx: click.Context, *, trunk: tuple[str, str], output_json: bool, + verbose: bool, ) -> None: await stack_list_mod.stack_list( github_server=ctx.obj["github_server"], token=ctx.obj["token"], trunk=trunk, output_json=output_json, + verbose=verbose, ) diff --git a/mergify_cli/stack/list.py b/mergify_cli/stack/list.py index 818ec7bc..743e69a7 100644 --- a/mergify_cli/stack/list.py +++ b/mergify_cli/stack/list.py @@ -15,6 +15,7 @@ from __future__ import annotations +import asyncio import dataclasses import json import sys @@ -28,18 +29,54 @@ StackEntryStatusT = typing.Literal["merged", "draft", "open", "no_pr"] +CIStatusT = typing.Literal["passing", "failing", "pending", "unknown"] +ReviewStatusT = typing.Literal["approved", "changes_requested", "pending", "unknown"] _STATUS_DISPLAY: dict[StackEntryStatusT, tuple[str, str]] = { - "merged": ("merged", "purple"), - "draft": ("draft", "yellow"), - "open": ("open", "green"), - "no_pr": ("no PR", "dim"), + "merged": ("✓ merged", "purple"), + "draft": ("● draft", "yellow"), + "open": ("● open", "green"), + "no_pr": ("○ no PR", "dim"), +} + +_CI_STATUS_DISPLAY: dict[CIStatusT, tuple[str, str]] = { + "passing": ("✓ passing", "green"), + "failing": ("✗ failing", "red"), + "pending": ("● pending", "yellow"), + "unknown": ("—", "dim"), +} + +_REVIEW_STATUS_DISPLAY: dict[ReviewStatusT, tuple[str, str]] = { + "approved": ("✓ approved", "green"), + "changes_requested": ("✗ changes requested", "red"), + "pending": ("● pending", "yellow"), + "unknown": ("—", "dim"), } if typing.TYPE_CHECKING: + import httpx + from mergify_cli import github_types +@dataclasses.dataclass +class CICheck: + name: str + status: str + + def to_dict(self) -> dict[str, str]: + return {"name": self.name, "status": self.status} + + +@dataclasses.dataclass +class Review: + user: str + state: str + + def to_dict(self) -> dict[str, str]: + return {"user": self.user, "state": self.state} + + @dataclasses.dataclass class StackListEntry: """A single entry in the stack list.""" @@ -50,6 +87,11 @@ class StackListEntry: status: StackEntryStatusT pull_number: int | None = None pull_url: str | None = None + ci_status: CIStatusT = "unknown" + ci_checks: list[CICheck] = dataclasses.field(default_factory=list) + review_status: ReviewStatusT = "unknown" + reviews: list[Review] = dataclasses.field(default_factory=list) + mergeable: bool | None = None def to_dict(self) -> dict[str, typing.Any]: return { @@ -59,6 +101,11 @@ def to_dict(self) -> dict[str, typing.Any]: "status": self.status, "pull_number": self.pull_number, "pull_url": self.pull_url, + "ci_status": self.ci_status, + "ci_checks": [c.to_dict() for c in self.ci_checks], + "review_status": self.review_status, + "reviews": [r.to_dict() for r in self.reviews], + "mergeable": self.mergeable, } @@ -78,6 +125,40 @@ def to_dict(self) -> dict[str, typing.Any]: } +def _format_ci_display(entry: StackListEntry, *, verbose: bool) -> str: + if entry.ci_status == "unknown": + return "" + if verbose and entry.ci_checks: + checks = [] + for check in entry.ci_checks: + if check.status == "success": + checks.append(f"[green]✓ {check.name}[/]") + elif check.status == "failure": + checks.append(f"[red]✗ {check.name}[/]") + else: + checks.append(f"[yellow]● {check.name}[/]") + return f"CI: {', '.join(checks)}" + text, color = _CI_STATUS_DISPLAY[entry.ci_status] + return f"CI: [{color}]{text}[/]" + + +def _format_review_display(entry: StackListEntry, *, verbose: bool) -> str: + if entry.review_status == "unknown": + return "" + if verbose and entry.reviews: + reviewers = [] + for review in entry.reviews: + if review.state == "APPROVED": + reviewers.append(f"[green]✓ {review.user}[/]") + elif review.state == "CHANGES_REQUESTED": + reviewers.append(f"[red]✗ {review.user}[/]") + else: + reviewers.append(f"[dim]{review.user}[/]") + return f"Review: {', '.join(reviewers)}" + text, color = _REVIEW_STATUS_DISPLAY[entry.review_status] + return f"Review: [{color}]{text}[/]" + + def _get_entry_status( pull: github_types.PullRequest | None, ) -> StackEntryStatusT: @@ -96,30 +177,151 @@ def _get_status_display(status: StackEntryStatusT) -> tuple[str, str]: return _STATUS_DISPLAY[status] -def display_stack_list(output: StackListOutput) -> None: +def _compute_ci_status( + check_runs: list[dict[str, typing.Any]], +) -> tuple[CIStatusT, list[CICheck]]: + """Compute CI status from GitHub check run data.""" + if not check_runs: + return ("unknown", []) + + checks: list[CICheck] = [] + has_pending = False + has_failure = False + + for run in check_runs: + name = run.get("name", "") + if run.get("status") != "completed": + checks.append(CICheck(name=name, status="pending")) + has_pending = True + elif run.get("conclusion") in {"success", "skipped"}: + checks.append(CICheck(name=name, status="success")) + else: + checks.append(CICheck(name=name, status="failure")) + has_failure = True + + if has_failure: + status: CIStatusT = "failing" + elif has_pending: + status = "pending" + else: + status = "passing" + + return (status, checks) + + +def _compute_review_status( + reviews_data: list[dict[str, typing.Any]], +) -> tuple[ReviewStatusT, list[Review]]: + """Compute review status from GitHub review data.""" + if not reviews_data: + return ("unknown", []) + + # Keep latest review per user (APPROVED/CHANGES_REQUESTED/DISMISSED + # take precedence over COMMENTED) + latest_by_user: dict[str, str] = {} + for review in reviews_data: + user = review.get("user", {}).get("login", "") + state = review.get("state", "") + if not user: + continue + if ( + state in {"APPROVED", "CHANGES_REQUESTED", "DISMISSED"} + or user not in latest_by_user + ): + latest_by_user[user] = state + + reviews = [Review(user=u, state=s) for u, s in latest_by_user.items()] + + has_changes_requested = any(r.state == "CHANGES_REQUESTED" for r in reviews) + has_approved = any(r.state == "APPROVED" for r in reviews) + + if has_changes_requested: + status: ReviewStatusT = "changes_requested" + elif has_approved: + status = "approved" + else: + status = "pending" + + return (status, reviews) + + +_MAX_CONCURRENT_API_CALLS = 5 + + +async def _fetch_pr_details( + client: httpx.AsyncClient, + user: str, + repo: str, + entries: list[StackListEntry], + pulls: dict[int, github_types.PullRequest], +) -> None: + """Fetch CI checks and reviews for each PR and update entries in place.""" + sem = asyncio.Semaphore(_MAX_CONCURRENT_API_CALLS) + + async def _fetch_for_entry(entry: StackListEntry) -> None: + if entry.pull_number is None: + return + + pull = pulls.get(entry.pull_number) + if pull is None: + return + + head_sha = pull["head"]["sha"] + + async with sem: + r_checks, r_reviews = await asyncio.gather( + client.get( + f"/repos/{user}/{repo}/commits/{head_sha}/check-runs", + ), + client.get( + f"/repos/{user}/{repo}/pulls/{entry.pull_number}/reviews", + ), + ) + + check_runs = r_checks.json().get("check_runs", []) + entry.ci_status, entry.ci_checks = _compute_ci_status(check_runs) + + reviews_data = r_reviews.json() + entry.review_status, entry.reviews = _compute_review_status(reviews_data) + + await asyncio.gather(*[_fetch_for_entry(e) for e in entries]) + + +def display_stack_list(output: StackListOutput, *, verbose: bool = False) -> 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", + f"\nStack on [cyan]{output.branch}[/] → [cyan]{output.trunk}[/]:\n", ) if not output.entries: - console.print("No commits in stack", style="dim") + 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: + conflict = " [red]✗ conflicting[/]" if entry.mergeable is False else "" + console.print( - f"* [{status_color}]\\[{status_text}][/] " - f"[bold]#{entry.pull_number}[/] {entry.title} ({short_sha})", + f" [{status_color}]{status_text}[/] " + f"[bold]#{entry.pull_number}[/] {entry.title} " + f"[dim]({short_sha})[/]{conflict}", ) - console.print(f" {entry.pull_url}\n") + + # Status line (CI + review) + ci_display = _format_ci_display(entry, verbose=verbose) + review_display = _format_review_display(entry, verbose=verbose) + parts = [p for p in [ci_display, review_display] if p] + if parts: + console.print(f" {' | '.join(parts)}") + + console.print(f" [dim]{entry.pull_url}[/]\n") else: console.print( - f"* [{status_color}]\\[{status_text}][/] {entry.title} ({short_sha})\n", + f" [{status_color}]{status_text}[/] {entry.title} " + f"[dim]({short_sha})[/]\n", ) @@ -130,6 +332,7 @@ async def get_stack_list( trunk: tuple[str, str], branch_prefix: str | None = None, author: str | None = None, + include_status: bool = True, ) -> StackListOutput: """Get the current stack's commits and their associated PRs. @@ -212,19 +415,31 @@ async def get_stack_list( 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) + # Build output structure + entries: list[StackListEntry] = [] + pulls_by_number: dict[int, github_types.PullRequest] = {} + for local_change in stack_changes.locals: + status = _get_entry_status(local_change.pull) + pull_number = ( + int(local_change.pull["number"]) if local_change.pull else None + ) + entry = StackListEntry( + commit_sha=local_change.commit_sha, + title=local_change.title, + change_id=local_change.id, + status=status, + pull_number=pull_number, + pull_url=local_change.pull["html_url"] if local_change.pull else None, + mergeable=local_change.pull.get("mergeable") + if local_change.pull + else None, + ) + entries.append(entry) + if pull_number is not None and local_change.pull is not None: + pulls_by_number[pull_number] = local_change.pull + + if include_status: + await _fetch_pr_details(client, user, repo, entries, pulls_by_number) return StackListOutput( branch=dest_branch, @@ -241,6 +456,7 @@ async def stack_list( branch_prefix: str | None = None, author: str | None = None, output_json: bool = False, + verbose: bool = False, ) -> None: """List the current stack's commits and their associated PRs. @@ -251,6 +467,7 @@ async def stack_list( 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 + verbose: If True, show detailed CI check names and reviewer names """ output = await get_stack_list( github_server=github_server, @@ -263,4 +480,4 @@ async def stack_list( if output_json: console.print(json.dumps(output.to_dict(), indent=2)) else: - display_stack_list(output) + display_stack_list(output, verbose=verbose) diff --git a/mergify_cli/stack/open.py b/mergify_cli/stack/open.py index 61d3f608..a029ebc6 100644 --- a/mergify_cli/stack/open.py +++ b/mergify_cli/stack/open.py @@ -66,6 +66,7 @@ async def stack_open( github_server=github_server, token=token, trunk=trunk, + include_status=False, ) if not output.entries: diff --git a/mergify_cli/tests/stack/test_list.py b/mergify_cli/tests/stack/test_list.py index 1db15918..5d3d0e6a 100644 --- a/mergify_cli/tests/stack/test_list.py +++ b/mergify_cli/tests/stack/test_list.py @@ -89,6 +89,7 @@ async def test_stack_list_with_prs( "merged_at": None, "draft": False, "node_id": "", + "mergeable": True, }, ) respx_mock.get("/repos/user/repo/pulls/124").respond( @@ -105,8 +106,14 @@ async def test_stack_list_with_prs( "merged_at": None, "draft": True, "node_id": "", + "mergeable": None, }, ) + respx_mock.get(url__regex=r".*/commits/.*/check-runs$").respond( + 200, + json={"check_runs": []}, + ) + respx_mock.get(url__regex=r".*/pulls/\d+/reviews$").respond(200, json=[]) await stack_list_mod.stack_list( github_server="https://api.github.com/", @@ -233,6 +240,7 @@ async def test_stack_list_mixed_pr_states( "merged_at": "2024-01-01T00:00:00Z", "draft": False, "node_id": "", + "mergeable": None, }, ) # Second PR: draft @@ -250,6 +258,7 @@ async def test_stack_list_mixed_pr_states( "merged_at": None, "draft": True, "node_id": "", + "mergeable": None, }, ) # Third PR: open @@ -267,8 +276,14 @@ async def test_stack_list_mixed_pr_states( "merged_at": None, "draft": False, "node_id": "", + "mergeable": True, }, ) + respx_mock.get(url__regex=r".*/commits/.*/check-runs$").respond( + 200, + json={"check_runs": []}, + ) + respx_mock.get(url__regex=r".*/pulls/\d+/reviews$").respond(200, json=[]) await stack_list_mod.stack_list( github_server="https://api.github.com/", @@ -329,8 +344,14 @@ async def test_stack_list_json_output( "merged_at": None, "draft": False, "node_id": "", + "mergeable": True, }, ) + respx_mock.get(url__regex=r".*/commits/.*/check-runs$").respond( + 200, + json={"check_runs": []}, + ) + respx_mock.get(url__regex=r".*/pulls/\d+/reviews$").respond(200, json=[]) await stack_list_mod.stack_list( github_server="https://api.github.com/", @@ -350,6 +371,11 @@ async def test_stack_list_json_output( 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" + assert output["entries"][0]["ci_status"] == "unknown" + assert output["entries"][0]["ci_checks"] == [] + assert output["entries"][0]["review_status"] == "unknown" + assert output["entries"][0]["reviews"] == [] + assert output["entries"][0]["mergeable"] is True @pytest.mark.respx(base_url="https://api.github.com/") @@ -460,3 +486,272 @@ async def test_stack_list_no_fork_point_raises_error( token="", trunk=("origin", "main"), ) + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_list_json_includes_ci_and_review_status( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test that JSON output includes CI status, reviews, and mergeable fields.""" + git_mock.mock("config", "--get", "mergify-cli.stack-branch-prefix", output="") + + git_mock.commit( + test_utils.Commit( + sha="commit1_sha", + title="Add CI feature", + message="Message", + change_id="I29617d37762fd69809c255d7e7073cb11f8fbf50", + ), + ) + git_mock.finalize() + + 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/99", + }, + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/99").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/99", + "number": "99", + "title": "Add CI feature", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + "mergeable": True, + }, + ) + respx_mock.get("/repos/user/repo/commits/commit1_sha/check-runs").respond( + 200, + json={ + "check_runs": [ + { + "name": "tests", + "status": "completed", + "conclusion": "success", + }, + { + "name": "lint", + "status": "completed", + "conclusion": "success", + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/99/reviews").respond( + 200, + json=[ + { + "user": {"login": "reviewer1"}, + "state": "APPROVED", + }, + ], + ) + + 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) + + entry = output["entries"][0] + assert entry["ci_status"] == "passing" + assert len(entry["ci_checks"]) == 2 + assert entry["ci_checks"][0] == {"name": "tests", "status": "success"} + assert entry["ci_checks"][1] == {"name": "lint", "status": "success"} + assert entry["review_status"] == "approved" + assert len(entry["reviews"]) == 1 + assert entry["reviews"][0] == {"user": "reviewer1", "state": "APPROVED"} + assert entry["mergeable"] is True + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_list_shows_ci_and_review_summary( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test that display output includes CI status, review status, and conflict indicator.""" + 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", + ), + ) + git_mock.finalize() + + 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/77", + }, + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/77").respond( + 200, + json={ + "html_url": "https://github.com/user/repo/pull/77", + "number": "77", + "title": "Add feature", + "head": { + "sha": "commit1_sha", + "ref": "current-branch/I29617d37762fd69809c255d7e7073cb11f8fbf50", + }, + "state": "open", + "merged_at": None, + "draft": False, + "node_id": "", + "mergeable": False, + }, + ) + respx_mock.get("/repos/user/repo/commits/commit1_sha/check-runs").respond( + 200, + json={ + "check_runs": [ + { + "name": "linters", + "status": "completed", + "conclusion": "success", + }, + { + "name": "tests", + "status": "completed", + "conclusion": "failure", + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/77/reviews").respond( + 200, + json=[ + { + "user": {"login": "alice"}, + "state": "CHANGES_REQUESTED", + }, + ], + ) + + await stack_list_mod.stack_list( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + ) + + captured = capsys.readouterr() + assert "failing" in captured.out + assert "changes requested" in captured.out + assert "conflicting" in captured.out + + +@pytest.mark.respx(base_url="https://api.github.com/") +async def test_stack_list_verbose_shows_check_names_and_reviewers( + git_mock: test_utils.GitMock, + respx_mock: respx.MockRouter, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test that verbose mode shows individual check names and reviewer names.""" + 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", + ), + ) + git_mock.finalize() + + 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": "", + "mergeable": True, + }, + ) + respx_mock.get("/repos/user/repo/commits/commit1_sha/check-runs").respond( + 200, + json={ + "check_runs": [ + {"name": "linters", "status": "completed", "conclusion": "success"}, + { + "name": "tests (ubuntu)", + "status": "completed", + "conclusion": "failure", + }, + ], + }, + ) + respx_mock.get("/repos/user/repo/pulls/42/reviews").respond( + 200, + json=[ + {"user": {"login": "alice"}, "state": "APPROVED"}, + {"user": {"login": "bob"}, "state": "CHANGES_REQUESTED"}, + ], + ) + + await stack_list_mod.stack_list( + github_server="https://api.github.com/", + token="", + trunk=("origin", "main"), + verbose=True, + ) + + captured = capsys.readouterr() + assert "linters" in captured.out + assert "tests (ubuntu)" in captured.out + assert "alice" in captured.out + assert "bob" in captured.out diff --git a/mergify_cli/tests/stack/test_open.py b/mergify_cli/tests/stack/test_open.py index 4afdecb9..ad746d17 100644 --- a/mergify_cli/tests/stack/test_open.py +++ b/mergify_cli/tests/stack/test_open.py @@ -80,6 +80,7 @@ async def test_stack_open_head( "merged_at": None, "draft": False, "node_id": "", + "mergeable": True, }, ) @@ -158,6 +159,7 @@ async def test_stack_open_specific_commit( "merged_at": None, "draft": False, "node_id": "", + "mergeable": True, }, ) respx_mock.get("/repos/user/repo/pulls/124").respond( @@ -174,6 +176,7 @@ async def test_stack_open_specific_commit( "merged_at": None, "draft": False, "node_id": "", + "mergeable": True, }, ) @@ -393,6 +396,7 @@ async def test_stack_open_interactive_selection( "merged_at": None, "draft": False, "node_id": "", + "mergeable": True, }, ) respx_mock.get("/repos/user/repo/pulls/124").respond( @@ -409,6 +413,7 @@ async def test_stack_open_interactive_selection( "merged_at": None, "draft": False, "node_id": "", + "mergeable": True, }, ) diff --git a/skills/mergify-stack/SKILL.md b/skills/mergify-stack/SKILL.md index b7ad116f..c21edaf5 100644 --- a/skills/mergify-stack/SKILL.md +++ b/skills/mergify-stack/SKILL.md @@ -57,7 +57,7 @@ mergify stack move X after Y # Move commit X after commit Y Use `mergify stack sync` to bring your stack up to date. It fetches the latest trunk, detects which PRs have been merged, removes those commits from your local branch, and rebases the remaining commits. Run this before starting new work on an existing stack. -Use `mergify stack list` to see which commits have been pushed, which PRs they map to, and whether the stack is up to date with the remote. This is the go-to command to understand the current state of a stack. Use `--json` when you need to parse the output programmatically. +Use `mergify stack list` to see which commits have been pushed, which PRs they map to, and whether the stack is up to date with the remote. It also shows CI status, review status, and merge conflicts for each PR. Use `--verbose` for detailed check names and reviewer names. Use `--json` when you need to parse the output programmatically — it includes full CI check details and review data. ## CRITICAL: Check Branch Before ANY Commit