diff --git a/mergify_cli/ci/cli.py b/mergify_cli/ci/cli.py index 95847c8a..80caa08d 100644 --- a/mergify_cli/ci/cli.py +++ b/mergify_cli/ci/cli.py @@ -199,13 +199,88 @@ async def junit_process( # noqa: PLR0913 "config_path", required=True, type=click.Path(exists=True), - default=".mergify-ci.yml", + default=".mergify.yml", help="Path to YAML config file.", ) +@click.option( + "--write", + "-w", + type=click.Path(), + help="Write the detected scopes to a file (json).", +) def scopes( config_path: str, + write: str | None = None, ) -> None: try: - scopes_cli.detect(config_path=config_path) + scopes = scopes_cli.detect(config_path=config_path) except scopes_cli.ScopesError as e: raise click.ClickException(str(e)) from e + + if write is not None: + scopes.save_to_file(write) + + +@ci.command(help="Send scopes tied to a pull request to Mergify") +@click.option( + "--api-url", + "-u", + help="URL of the Mergify API", + required=True, + envvar="MERGIFY_API_URL", + default="https://api.mergify.com", + show_default=True, +) +@click.option( + "--token", + "-t", + help="Mergify Key", + envvar="MERGIFY_TOKEN", + required=True, +) +@click.option( + "--repository", + "-r", + help="Repository full name (owner/repo)", + default=detector.get_github_repository, + required=True, +) +@click.option( + "--pull-request", + "-p", + help="pull_request number", + type=int, + default=detector.get_github_pull_request_number, + required=True, +) +@click.option("--scope", "-s", multiple=True, help="Scope to upload") +@click.option( + "--file", + "-f", + help="File containing scopes to upload", + type=click.Path(exists=True), +) +@utils.run_with_asyncio +async def scopes_send( # noqa: PLR0913, PLR0917 + api_url: str, + token: str, + repository: str, + pull_request: int, + scope: tuple[str, ...], + file: str | None, +) -> None: + scopes = list(scope) + if file is not None: + try: + dump = scopes_cli.DetectedScope.load_from_file(file) + except scopes_cli.ScopesError as e: + raise click.ClickException(str(e)) from e + scopes.extend(dump.scopes) + + await scopes_cli.send_scopes( + api_url, + token, + repository, + pull_request, + scopes, + ) diff --git a/mergify_cli/ci/detector.py b/mergify_cli/ci/detector.py index 7d2adc85..cea90202 100644 --- a/mergify_cli/ci/detector.py +++ b/mergify_cli/ci/detector.py @@ -174,6 +174,22 @@ def _get_github_repository_from_env(env: str) -> str | None: return None +def get_github_pull_request_number() -> int | None: + match get_ci_provider(): + case "github_actions": + try: + event = utils.get_github_event() + except utils.GitHubEventNotFoundError: + return None + pr = event.get("pull_request") + if not isinstance(pr, dict): + return None + return typing.cast("int", pr["number"]) + + case _: + return None + + def get_github_repository() -> str | None: match get_ci_provider(): case "github_actions": @@ -187,11 +203,4 @@ def get_github_repository() -> str | None: def is_flaky_test_detection_enabled() -> bool: - return os.getenv("MERGIFY_TEST_FLAKY_DETECTION", default="").lower() in { - "y", - "yes", - "t", - "true", - "on", - "1", - } + return utils.get_boolean_env("MERGIFY_TEST_FLAKY_DETECTION") diff --git a/mergify_cli/ci/scopes/base_detector.py b/mergify_cli/ci/scopes/base_detector.py index a48a8a9f..d8068d8b 100644 --- a/mergify_cli/ci/scopes/base_detector.py +++ b/mergify_cli/ci/scopes/base_detector.py @@ -1,14 +1,14 @@ from __future__ import annotations import dataclasses -import json import os -import pathlib import typing import click import yaml +from mergify_cli import utils + class MergeQueuePullRequest(typing.TypedDict): number: int @@ -72,16 +72,11 @@ class Base: def detect() -> Base: - event_path = os.environ.get("GITHUB_EVENT_PATH") - event: dict[str, typing.Any] | None = None - if event_path and pathlib.Path(event_path).is_file(): - try: - with pathlib.Path(event_path).open("r", encoding="utf-8") as f: - event = json.load(f) - except FileNotFoundError: - event = None - - if event is not None: + try: + event = utils.get_github_event() + except utils.GitHubEventNotFoundError: + pass + else: # 0) merge-queue PR override mq_sha = _detect_base_from_merge_queue_payload(event) if mq_sha: diff --git a/mergify_cli/ci/scopes/cli.py b/mergify_cli/ci/scopes/cli.py index 59eacbbe..ea3c1c04 100644 --- a/mergify_cli/ci/scopes/cli.py +++ b/mergify_cli/ci/scopes/cli.py @@ -8,6 +8,7 @@ import pydantic import yaml +from mergify_cli import utils from mergify_cli.ci.scopes import base_detector from mergify_cli.ci.scopes import changed_files @@ -110,7 +111,28 @@ def maybe_write_github_outputs( fh.write(f"{key}={val}\n") -def detect(config_path: str) -> None: +class InvalidDetectedScopeError(ScopesError): + pass + + +class DetectedScope(pydantic.BaseModel): + base_ref: str + scopes: set[str] + + def save_to_file(self, file: str) -> None: + with pathlib.Path(file).open("w", encoding="utf-8") as f: + f.write(self.model_dump_json()) + + @classmethod + def load_from_file(cls, filename: str) -> DetectedScope: + with pathlib.Path(filename).open("r", encoding="utf-8") as f: + try: + return cls.model_validate_json(f.read()) + except pydantic.ValidationError as e: + raise InvalidDetectedScopeError(str(e)) + + +def detect(config_path: str) -> DetectedScope: cfg = Config.from_yaml(config_path) base = base_detector.detect() try: @@ -137,3 +159,18 @@ def detect(config_path: str) -> None: click.echo("No scopes matched.") maybe_write_github_outputs(all_scopes, scopes_hit) + return DetectedScope(base_ref=base.ref, scopes=scopes_hit) + + +async def send_scopes( + api_url: str, + token: str, + repository: str, + pull_request: int, + scopes: list[str], +) -> None: + client = utils.get_mergify_http_client(api_url, token) + await client.post( + f"/v1/repos/{repository}/pulls/{pull_request}/scopes", + json={"scopes": scopes}, + ) diff --git a/mergify_cli/tests/ci/scopes/test_cli.py b/mergify_cli/tests/ci/scopes/test_cli.py index 21db571b..4cb13ed4 100644 --- a/mergify_cli/tests/ci/scopes/test_cli.py +++ b/mergify_cli/tests/ci/scopes/test_cli.py @@ -1,7 +1,9 @@ +import json import pathlib from unittest import mock import pytest +import respx import yaml from mergify_cli.ci.scopes import base_detector @@ -230,7 +232,7 @@ def test_detect_with_matches( # Capture output with mock.patch("click.echo") as mock_echo: - cli.detect(str(config_file)) + result = cli.detect(str(config_file)) # Verify calls mock_detect_base.assert_called_once() @@ -244,6 +246,9 @@ def test_detect_with_matches( assert "- backend" in calls assert "- merge-queue" in calls + assert result.base_ref == "main" + assert result.scopes == {"backend", "merge-queue"} + @mock.patch("mergify_cli.ci.scopes.cli.base_detector.detect") @mock.patch("mergify_cli.ci.scopes.changed_files.git_changed_files") @@ -265,12 +270,14 @@ def test_detect_no_matches( # Capture output with mock.patch("click.echo") as mock_echo: - cli.detect(str(config_file)) + result = cli.detect(str(config_file)) # Verify output calls = [call.args[0] for call in mock_echo.call_args_list] assert "Base: main" in calls assert "No scopes matched." in calls + assert result.scopes == set() + assert result.base_ref == "main" @mock.patch("mergify_cli.ci.scopes.cli.base_detector.detect") @@ -295,9 +302,58 @@ def test_detect_debug_output( # Capture output with mock.patch("click.echo") as mock_echo: - cli.detect(str(config_file)) + result = cli.detect(str(config_file)) # Verify debug output includes file details calls = [call.args[0] for call in mock_echo.call_args_list] assert any(" api/models.py" in call for call in calls) assert any(" api/views.py" in call for call in calls) + + assert result.base_ref == "main" + assert result.scopes == {"backend"} + + +async def test_upload_scopes(respx_mock: respx.MockRouter) -> None: + api_url = "https://api.mergify.test" + token = "test-token" # noqa: S105 + repository = "owner/repo" + pull_request = 123 + + # Mock the HTTP request + route = respx_mock.post( + f"{api_url}/v1/repos/{repository}/pulls/{pull_request}/scopes", + ).mock( + return_value=respx.MockResponse(200, json={"status": "ok"}), + ) + + # Call the upload function + await cli.send_scopes( + api_url, + token, + repository, + pull_request, + ["backend", "frontend"], + ) + + # Verify the request was made + assert route.called + assert route.call_count == 1 + + # Verify the request body + request = route.calls[0].request + assert request.headers["Authorization"] == "Bearer test-token" + assert request.headers["Accept"] == "application/json" + + # Verify the JSON payload + payload = json.loads(request.content) + assert payload == {"scopes": ["backend", "frontend"]} + + +def test_dump(tmp_path: pathlib.Path) -> None: + config_file = tmp_path / "scopes.json" + saved = cli.DetectedScope(base_ref="main", scopes={"backend", "merge-queue"}) + saved.save_to_file(str(config_file)) + + loaded = cli.DetectedScope.load_from_file(str(config_file)) + assert loaded.scopes == saved.scopes + assert loaded.base_ref == saved.base_ref diff --git a/mergify_cli/tests/ci/test_cli.py b/mergify_cli/tests/ci/test_cli.py index da608873..8f045e35 100644 --- a/mergify_cli/tests/ci/test_cli.py +++ b/mergify_cli/tests/ci/test_cli.py @@ -1,3 +1,4 @@ +import json import pathlib from unittest import mock @@ -5,8 +6,9 @@ import click from click import testing import pytest +import respx -from mergify_cli.ci import cli as cli_junit_upload +from mergify_cli.ci import cli as ci_cli from mergify_cli.ci.junit_processing import cli as junit_processing_cli from mergify_cli.ci.junit_processing import quarantine from mergify_cli.ci.junit_processing import upload @@ -65,11 +67,11 @@ def test_cli(env: dict[str, str], monkeypatch: pytest.MonkeyPatch) -> None: ), ): result_process = runner.invoke( - cli_junit_upload.junit_process, + ci_cli.junit_process, [str(REPORT_XML)], ) result_upload = runner.invoke( - cli_junit_upload.junit_upload, + ci_cli.junit_upload, [str(REPORT_XML)], ) assert result_process.exit_code == 0, result_process.stdout @@ -169,7 +171,7 @@ def test_tests_target_branch_environment_variable_processing( mock.AsyncMock(), ) as mocked_process_junit_files: result = runner.invoke( - cli_junit_upload.junit_process, + ci_cli.junit_process, [str(REPORT_XML)], ) @@ -209,7 +211,7 @@ def test_quarantine_unhandled_error(monkeypatch: pytest.MonkeyPatch) -> None: ) as mocked_quarantine, ): result = runner.invoke( - cli_junit_upload.junit_process, + ci_cli.junit_process, [str(REPORT_XML)], ) @@ -258,7 +260,7 @@ def test_quarantine_handled_error(monkeypatch: pytest.MonkeyPatch) -> None: ) as mocked_quarantine, ): result = runner.invoke( - cli_junit_upload.junit_process, + ci_cli.junit_process, [str(REPORT_XML)], ) assert result.exit_code == 1, (result.stdout, result.stderr) @@ -307,7 +309,7 @@ def test_upload_error(monkeypatch: pytest.MonkeyPatch) -> None: ): mocked_upload.side_effect = Exception("Upload failed") result = runner.invoke( - cli_junit_upload.junit_process, + ci_cli.junit_process, [str(REPORT_XML)], ) assert result.exit_code == 0, (result.stdout, result.stderr) @@ -329,7 +331,7 @@ def test_process_tests_target_branch_callback() -> None: # Test stripping refs/heads/ prefix assert ( - cli_junit_upload._process_tests_target_branch( + ci_cli._process_tests_target_branch( context_mock, param_mock, "refs/heads/main", @@ -337,7 +339,7 @@ def test_process_tests_target_branch_callback() -> None: == "main" ) assert ( - cli_junit_upload._process_tests_target_branch( + ci_cli._process_tests_target_branch( context_mock, param_mock, "refs/heads/feature-branch", @@ -347,7 +349,7 @@ def test_process_tests_target_branch_callback() -> None: # Test not stripping other prefixes assert ( - cli_junit_upload._process_tests_target_branch( + ci_cli._process_tests_target_branch( context_mock, param_mock, "refs/tags/v1.0.0", @@ -355,7 +357,7 @@ def test_process_tests_target_branch_callback() -> None: == "refs/tags/v1.0.0" ) assert ( - cli_junit_upload._process_tests_target_branch( + ci_cli._process_tests_target_branch( context_mock, param_mock, "main", @@ -365,7 +367,7 @@ def test_process_tests_target_branch_callback() -> None: # Test None value assert ( - cli_junit_upload._process_tests_target_branch( + ci_cli._process_tests_target_branch( context_mock, param_mock, None, @@ -374,7 +376,7 @@ def test_process_tests_target_branch_callback() -> None: ) # Test empty string - assert not cli_junit_upload._process_tests_target_branch( + assert not ci_cli._process_tests_target_branch( context_mock, param_mock, "", @@ -396,7 +398,7 @@ def test_junit_file_not_found_error_message() -> None: with runner.isolated_filesystem(): # Try to run junit-process with a non-existent file result = runner.invoke( - cli_junit_upload.junit_process, + ci_cli.junit_process, ["non_existent_junit.xml"], env=env, ) @@ -408,3 +410,43 @@ def test_junit_file_not_found_error_message() -> None: assert ( "check if your test execution step completed successfully" in result.output ) + + +@pytest.mark.respx(base_url="https://api.github.com/") +def test_scopes_send( + respx_mock: respx.MockRouter, + tmp_path: pathlib.Path, +) -> None: + """Test scopes command with all required parameters.""" + + # Create config file + scopes_file = tmp_path / "scopes.json" + scopes_file.write_text( + json.dumps({"base_ref": "main", "scopes": ["backend", "frontend"]}), + ) + + runner = testing.CliRunner() + + post_mock = respx_mock.post( + "https://api.mergify.com/v1/repos/owner/repository/pulls/123/scopes", + headers={"Authorization": "Bearer test-token"}, + ).respond(200) + result = runner.invoke( + ci_cli.scopes_send, + [ + "--pull-request", + "123", + "--repository", + "owner/repository", + "--token", + "test-token", + "--scope", + "foobar", + "--file", + str(scopes_file), + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(post_mock.calls[0].request.content) + assert sorted(payload["scopes"]) == ["backend", "foobar", "frontend"] diff --git a/mergify_cli/tests/ci/test_detector.py b/mergify_cli/tests/ci/test_detector.py index 4b2a787c..5cc27c8c 100644 --- a/mergify_cli/tests/ci/test_detector.py +++ b/mergify_cli/tests/ci/test_detector.py @@ -1,3 +1,4 @@ +import json import pathlib import pytest @@ -110,3 +111,73 @@ def test_get_repository_name_from_url_invalid( monkeypatch.setenv("MY_URL", url) result = detector._get_github_repository_from_env("MY_URL") assert result is None + + +def test_get_github_pull_request_number_github_actions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(PULL_REQUEST_EVENT)) + + result = detector.get_github_pull_request_number() + assert result == 2 + + +def test_get_github_pull_request_number_no_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False) + + result = detector.get_github_pull_request_number() + assert result is None + + +def test_get_github_pull_request_number_non_pr_event( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + event_data = {"push": {"ref": "refs/heads/main"}} + event_file = tmp_path / "push_event.json" + event_file.write_text(json.dumps(event_data)) + + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) + + result = detector.get_github_pull_request_number() + assert result is None + + +def test_get_github_pull_request_number_unsupported_ci( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + monkeypatch.delenv("CIRCLECI", raising=False) + + result = detector.get_github_pull_request_number() + assert result is None + + +@pytest.mark.parametrize( + ("env_value", "expected"), + [ + ("true", True), + ("1", True), + ("yes", True), + ("", False), + ("false", False), + ], +) +def test_is_flaky_test_detection_enabled( + monkeypatch: pytest.MonkeyPatch, + env_value: str, + expected: bool, +) -> None: + if env_value: + monkeypatch.setenv("MERGIFY_TEST_FLAKY_DETECTION", env_value) + else: + monkeypatch.delenv("MERGIFY_TEST_FLAKY_DETECTION", raising=False) + + result = detector.is_flaky_test_detection_enabled() + assert result == expected diff --git a/mergify_cli/tests/conftest.py b/mergify_cli/tests/conftest.py index a0cfe161..01b0490e 100644 --- a/mergify_cli/tests/conftest.py +++ b/mergify_cli/tests/conftest.py @@ -23,6 +23,13 @@ from mergify_cli.tests import utils as test_utils +@pytest.fixture(autouse=True) +def _unset_ci( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CI", raising=False) + + @pytest.fixture(autouse=True) def _unset_github_token( monkeypatch: pytest.MonkeyPatch, diff --git a/mergify_cli/tests/test_utils.py b/mergify_cli/tests/test_utils.py index a0cb61a0..3f33a2b5 100644 --- a/mergify_cli/tests/test_utils.py +++ b/mergify_cli/tests/test_utils.py @@ -15,6 +15,8 @@ import collections +import json +import pathlib from unittest import mock import pytest @@ -64,3 +66,85 @@ 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( + ("env_value", "expected"), + [ + ("true", True), + ("True", True), + ("TRUE", True), + ("yes", True), + ("YES", True), + ("y", True), + ("Y", True), + ("1", True), + ("on", True), + ("ON", True), + ("false", False), + ("no", False), + ("0", False), + ("", False), + ("random", False), + (" true ", True), # Test with whitespace + (" false ", False), + ], +) +def test_get_boolean_env( + monkeypatch: pytest.MonkeyPatch, + env_value: str, + expected: bool, +) -> None: + monkeypatch.setenv("TEST_VAR", env_value) + assert utils.get_boolean_env("TEST_VAR") == expected + + +def test_get_boolean_env_default_false(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TEST_VAR", raising=False) + assert utils.get_boolean_env("TEST_VAR") is False + + +def test_get_boolean_env_default_true(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TEST_VAR", raising=False) + assert utils.get_boolean_env("TEST_VAR", default=True) is True + + +def test_get_github_event_success( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + event_data = {"pull_request": {"number": 123}} + event_file = tmp_path / "event.json" + event_file.write_text(json.dumps(event_data)) + + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file)) + result = utils.get_github_event() + assert result == event_data + + +def test_get_github_event_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False) + + with pytest.raises(utils.GitHubEventNotFoundError): + utils.get_github_event() + + +def test_get_github_event_file_not_exists( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + event_path = tmp_path / "nonexistent.json" + monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_path)) + + with pytest.raises(utils.GitHubEventNotFoundError): + utils.get_github_event() + + +def test_get_mergify_http_client() -> None: + client = utils.get_mergify_http_client( + "https://api.mergify.com", + "test-token", + ) + assert client.headers["Authorization"] == "Bearer test-token" + assert client.headers["Accept"] == "application/json" + assert client.base_url == "https://api.mergify.com" diff --git a/mergify_cli/utils.py b/mergify_cli/utils.py index d0acddc5..55a207f5 100644 --- a/mergify_cli/utils.py +++ b/mergify_cli/utils.py @@ -18,6 +18,9 @@ import asyncio import dataclasses import functools +import json +import os +import pathlib import sys import typing from urllib import parse @@ -237,6 +240,26 @@ def get_http_client( ) +def get_mergify_http_client(api_url: str, token: str) -> httpx.AsyncClient: + event_hooks: Mapping[str, list[Callable[..., typing.Any]]] = { + "request": [], + "response": [check_for_status], + } + if is_debug(): + event_hooks["request"].insert(0, log_httpx_request) + event_hooks["response"].insert(0, log_httpx_response) + + return get_http_client( + api_url, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + }, + event_hooks=event_hooks, + follow_redirects=True, + ) + + def get_github_http_client(github_server: str, token: str) -> httpx.AsyncClient: event_hooks: Mapping[str, list[Callable[..., typing.Any]]] = { "request": [], @@ -257,6 +280,20 @@ def get_github_http_client(github_server: str, token: str) -> httpx.AsyncClient: ) +def get_boolean_env(name: str, default: bool = False) -> bool: + v = os.getenv(name) + if v is None: + return default + return v.strip().lower() in { + "y", + "yes", + "t", + "true", + "on", + "1", + } + + P = typing.ParamSpec("P") R = typing.TypeVar("R") @@ -278,3 +315,18 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return asyncio.run(result) return wrapper + + +class GitHubEventNotFoundError(Exception): + pass + + +def get_github_event() -> typing.Any: # noqa: ANN401 + event_path = os.environ.get("GITHUB_EVENT_PATH") + if event_path and pathlib.Path(event_path).is_file(): + try: + with pathlib.Path(event_path).open("r", encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + pass + raise GitHubEventNotFoundError