Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions mergify_cli/ci/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
25 changes: 17 additions & 8 deletions mergify_cli/ci/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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")
19 changes: 7 additions & 12 deletions mergify_cli/ci/scopes/base_detector.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down
39 changes: 38 additions & 1 deletion mergify_cli/ci/scopes/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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},
)
62 changes: 59 additions & 3 deletions mergify_cli/tests/ci/scopes/test_cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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
Loading