diff --git a/mergify_cli/config/cli.py b/mergify_cli/config/cli.py index 818e2c3..0bbac14 100644 --- a/mergify_cli/config/cli.py +++ b/mergify_cli/config/cli.py @@ -1,13 +1,17 @@ from __future__ import annotations +import asyncio import pathlib +import re import click import httpx +from rich.markdown import Markdown from rich.markup import escape import yaml from mergify_cli import console +from mergify_cli import utils from mergify_cli.ci.detector import MERGIFY_CONFIG_PATHS from mergify_cli.ci.detector import get_mergify_config_path from mergify_cli.config import validate as config_validate @@ -84,3 +88,61 @@ def validate(ctx: click.Context) -> None: ) raise SystemExit(1) + + +_PR_URL_RE = re.compile( + r"https?://[^/]+/(?P[^/]+)/(?P[^/]+)/pull/(?P\d+)$", +) + + +def _parse_pr_url(url: str) -> tuple[str, int]: + m = _PR_URL_RE.match(url) + if not m: + msg = f"Invalid pull request URL: {url}" + raise click.ClickException(msg) + return f"{m.group('owner')}/{m.group('repo')}", int(m.group("number")) + + +@config.command( + help="Simulate Mergify actions on a pull request using the local configuration", +) +@click.argument("pull_request_url") +@click.option( + "--token", + "-t", + help="Mergify or GitHub token", + envvar=["MERGIFY_TOKEN", "GITHUB_TOKEN"], + required=True, + default=lambda: asyncio.run(utils.get_default_token()), +) +@click.option( + "--api-url", + "-u", + help="URL of the Mergify API", + envvar="MERGIFY_API_URL", + default="https://api.mergify.com", + show_default=True, +) +@click.pass_context +@utils.run_with_asyncio +async def simulate( + ctx: click.Context, + *, + pull_request_url: str, + token: str, + api_url: str, +) -> None: + repository, pull_number = _parse_pr_url(pull_request_url) + config_path = _resolve_config_path(ctx.obj["config_file"]) + mergify_yml = config_validate.read_raw(config_path) + + async with utils.get_mergify_http_client(api_url, token) as client: + result = await config_validate.simulate_pr( + client, + repository, + pull_number, + mergify_yml, + ) + + console.print(f"[bold]{escape(result.title)}[/]") + console.print(Markdown(result.summary)) diff --git a/mergify_cli/config/validate.py b/mergify_cli/config/validate.py index 4caca18..b41fcdd 100644 --- a/mergify_cli/config/validate.py +++ b/mergify_cli/config/validate.py @@ -66,3 +66,23 @@ def validate_config( path = ".".join(str(p) for p in error.absolute_path) or "(root)" errors.append(ValidationError(path=path, message=error.message)) return ValidationResult(errors=errors) + + +@dataclasses.dataclass +class SimulatorResult: + title: str + summary: str + + +async def simulate_pr( + client: httpx.AsyncClient, + repository: str, + pull_number: int, + mergify_yml: str, +) -> SimulatorResult: + response = await client.post( + f"/v1/repos/{repository}/pulls/{pull_number}/simulator", + json={"mergify_yml": mergify_yml}, + ) + data = response.json() + return SimulatorResult(title=data["title"], summary=data["summary"]) diff --git a/mergify_cli/tests/config/test_validate.py b/mergify_cli/tests/config/test_validate.py index 56eee2d..56da980 100644 --- a/mergify_cli/tests/config/test_validate.py +++ b/mergify_cli/tests/config/test_validate.py @@ -26,6 +26,7 @@ } _SCHEMA_URL = "https://docs.mergify.com/mergify-configuration-schema.json" +_PR_URL = "https://github.com/owner/repo/pull/42" def _write_config(tmp_path: pathlib.Path, content: str) -> str: @@ -157,3 +158,81 @@ def test_empty_config(tmp_path: pathlib.Path) -> None: ) assert result.exit_code == 0, result.output assert "is valid" in result.output + + +def test_simulate_pr(tmp_path: pathlib.Path) -> None: + config_path = _write_config(tmp_path, "pull_request_rules: []\n") + + with respx.mock(base_url="https://api.mergify.com") as rsp: + rsp.post("/v1/repos/owner/repo/pulls/42/simulator").mock( + return_value=Response( + 200, + json={ + "title": "The configuration is valid", + "summary": "No actions will be triggered", + }, + ), + ) + + result = CliRunner().invoke( + config, + [ + "--config-file", + config_path, + "simulate", + _PR_URL, + "--token", + "test-token", + ], + ) + assert result.exit_code == 0, result.output + assert "The configuration is valid" in result.output + assert "No actions will be triggered" in result.output + + +def test_simulate_invalid_pr_url() -> None: + result = CliRunner().invoke( + config, + ["simulate", "not-a-url", "--token", "test-token"], + ) + assert result.exit_code != 0 + assert "Invalid pull request URL" in result.output + + +def test_simulate_api_failure(tmp_path: pathlib.Path) -> None: + config_path = _write_config(tmp_path, "pull_request_rules: []\n") + + with respx.mock(base_url="https://api.mergify.com") as rsp: + rsp.post("/v1/repos/owner/repo/pulls/42/simulator").mock( + return_value=Response(500), + ) + + result = CliRunner().invoke( + config, + [ + "--config-file", + config_path, + "simulate", + _PR_URL, + "--token", + "test-token", + ], + ) + assert result.exit_code != 0 + assert "Traceback" not in result.output + + +def test_simulate_config_not_found() -> None: + result = CliRunner().invoke( + config, + [ + "--config-file", + "/nonexistent/.mergify.yml", + "simulate", + _PR_URL, + "--token", + "test-token", + ], + ) + assert result.exit_code != 0 + assert "not found" in result.output.lower()