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
64 changes: 5 additions & 59 deletions mergify_cli/ci/scopes/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

import click
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
from mergify_cli.ci.scopes import config
from mergify_cli.ci.scopes import exceptions


Expand All @@ -19,65 +19,11 @@


SCOPE_PREFIX = "scope_"
SCOPE_NAME_RE = r"^[A-Za-z0-9_-]+$"


class ConfigInvalidError(exceptions.ScopesError):
pass


ScopeName = typing.Annotated[
str,
pydantic.StringConstraints(pattern=SCOPE_NAME_RE, min_length=1),
]


class FileFilters(pydantic.BaseModel):
include: tuple[str, ...] = pydantic.Field(default_factory=lambda: ("**/*",))
exclude: tuple[str, ...] = pydantic.Field(default_factory=tuple)


class SourceFiles(pydantic.BaseModel):
files: dict[ScopeName, FileFilters]


class SourceManual(pydantic.BaseModel):
manual: None


class ScopesConfig(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra="forbid")

source: SourceFiles | SourceManual | None = None
merge_queue_scope: str | None = "merge-queue"


class Config(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra="ignore")

scopes: ScopesConfig

@classmethod
def from_dict(cls, data: dict[str, typing.Any] | typing.Any) -> Config: # noqa: ANN401
try:
return cls.model_validate(data)
except pydantic.ValidationError as e:
raise ConfigInvalidError(e)

@classmethod
def from_yaml(cls, path: str) -> Config:
with pathlib.Path(path).open(encoding="utf-8") as f:
try:
data = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
raise ConfigInvalidError(e)

return cls.from_dict(data)


def match_scopes(
files: abc.Iterable[str],
filters: dict[ScopeName, FileFilters],
filters: dict[config.ScopeName, config.FileFilters],
) -> tuple[set[str], dict[str, list[str]]]:
scopes_hit: set[str] = set()
per_scope: dict[str, list[str]] = {s: [] for s in filters}
Expand Down Expand Up @@ -142,7 +88,7 @@ def load_from_file(cls, filename: str) -> DetectedScope:


def detect(config_path: str) -> DetectedScope:
cfg = Config.from_yaml(config_path)
cfg = config.Config.from_yaml(config_path)
base = base_detector.detect()

scopes_hit: set[str]
Expand All @@ -153,11 +99,11 @@ def detect(config_path: str) -> DetectedScope:
all_scopes = set()
scopes_hit = set()
per_scope = {}
elif isinstance(source, SourceFiles):
elif isinstance(source, config.SourceFiles):
changed = changed_files.git_changed_files(base.ref)
all_scopes = set(source.files.keys())
scopes_hit, per_scope = match_scopes(changed, source.files)
elif isinstance(source, SourceManual):
elif isinstance(source, config.SourceManual):
msg = "source `manual` has been set, scopes must be send with `scopes-send` or API"
raise exceptions.ScopesError(msg)
else:
Expand Down
18 changes: 18 additions & 0 deletions mergify_cli/ci/scopes/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from mergify_cli.ci.scopes.config.root import Config
from mergify_cli.ci.scopes.config.root import ConfigInvalidError
from mergify_cli.ci.scopes.config.scopes import FileFilters
from mergify_cli.ci.scopes.config.scopes import ScopeName
from mergify_cli.ci.scopes.config.scopes import Scopes
from mergify_cli.ci.scopes.config.scopes import SourceFiles
from mergify_cli.ci.scopes.config.scopes import SourceManual


__all__ = [
"Config",
"ConfigInvalidError",
"FileFilters",
"ScopeName",
"Scopes",
"SourceFiles",
"SourceManual",
]
38 changes: 38 additions & 0 deletions mergify_cli/ci/scopes/config/root.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import pathlib
import typing

import pydantic
import yaml

from mergify_cli.ci.scopes import exceptions
from mergify_cli.ci.scopes.config.scopes import Scopes


class ConfigInvalidError(exceptions.ScopesError):
pass


class Config(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra="ignore")

scopes: Scopes

@classmethod
def from_dict(
cls,
data: dict[str, typing.Any] | typing.Any, # noqa: ANN401
) -> typing.Self:
try:
return cls.model_validate(data)
except pydantic.ValidationError as e:
raise ConfigInvalidError(e)

@classmethod
def from_yaml(cls, path: str) -> typing.Self:
with pathlib.Path(path).open(encoding="utf-8") as f:
try:
data = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
raise ConfigInvalidError(e)

return cls.from_dict(data)
70 changes: 70 additions & 0 deletions mergify_cli/ci/scopes/config/scopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from __future__ import annotations

import typing

import pydantic


SCOPE_NAME_RE = r"^[A-Za-z0-9_-]+$"


ScopeName = typing.Annotated[
str,
pydantic.StringConstraints(pattern=SCOPE_NAME_RE, min_length=1),
]


class FileFilters(pydantic.BaseModel):
include: tuple[str, ...] = pydantic.Field(
default_factory=lambda: ("**/*",),
description=(
"Glob patterns of files to include for this scope. "
"Empty means 'include everything' before exclusions. "
"Examples: ('src/**/*.py', 'Makefile')"
),
)
exclude: tuple[str, ...] = pydantic.Field(
default_factory=tuple,
description=(
"Glob patterns of files to exclude from this scope. "
"Evaluated after `include` and takes precedence. "
"Examples: ('**/tests/**', '*.md')"
),
)


class SourceFiles(pydantic.BaseModel):
files: dict[ScopeName, FileFilters] = pydantic.Field(
description=(
"Mapping of scope name to its file filters. "
"A file belongs to a scope if it matches the scope's `include` "
"patterns and not its `exclude` patterns."
),
)


class SourceManual(pydantic.BaseModel):
manual: None = pydantic.Field(
description="Scopes are manually sent via API or `mergify scopes-send`",
)


class Scopes(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra="forbid")

source: SourceFiles | SourceManual | None = pydantic.Field(
default=None,
description=(
"Where scopes come from. "
"`files` uses file-pattern rules (`gha-mergify-ci-scopes` must have been setup on your pull request); "
"`manual` uses scopes sent via API or `mergify scopes-send`; "
"`None` disables scoping."
),
)
merge_queue_scope: str | None = pydantic.Field(
default="merge-queue",
description=(
"Optional scope name automatically applied to merge queue PRs. "
"Set to `None` to disable."
),
)
61 changes: 31 additions & 30 deletions mergify_cli/tests/ci/scopes/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from mergify_cli.ci.scopes import base_detector
from mergify_cli.ci.scopes import cli
from mergify_cli.ci.scopes import config


def test_from_yaml_with_extras_ignored(tmp_path: pathlib.Path) -> None:
Expand All @@ -32,8 +33,8 @@ def test_from_yaml_with_extras_ignored(tmp_path: pathlib.Path) -> None:
),
)

config = cli.Config.from_yaml(str(config_file))
assert config.model_dump() == {
cfg = config.Config.from_yaml(str(config_file))
assert cfg.model_dump() == {
"scopes": {
"source": {
"files": {
Expand Down Expand Up @@ -71,8 +72,8 @@ def test_from_yaml_valid(tmp_path: pathlib.Path) -> None:
),
)

config = cli.Config.from_yaml(str(config_file))
assert config.model_dump() == {
cfg = config.Config.from_yaml(str(config_file))
assert cfg.model_dump() == {
"scopes": {
"merge_queue_scope": "merge-queue",
"source": {
Expand All @@ -99,12 +100,12 @@ def test_from_yaml_invalid_config(tmp_path: pathlib.Path) -> None:
)

# Bad name and missing dict
with pytest.raises(cli.ConfigInvalidError, match="3 validation errors"):
cli.Config.from_yaml(str(config_file))
with pytest.raises(config.ConfigInvalidError, match="3 validation errors"):
config.Config.from_yaml(str(config_file))


def test_match_scopes_basic() -> None:
config = cli.Config.from_dict(
cfg = config.Config.from_dict(
{
"scopes": {
"source": {
Expand All @@ -119,9 +120,9 @@ def test_match_scopes_basic() -> None:
)
files = ["api/models.py", "ui/components/Button.tsx", "README.md", "other.txt"]

assert config.scopes.source is not None
assert isinstance(config.scopes.source, cli.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
assert cfg.scopes.source is not None
assert isinstance(cfg.scopes.source, config.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)

assert scopes_hit == {"backend", "frontend", "docs"}
assert per_scope == {
Expand All @@ -132,7 +133,7 @@ def test_match_scopes_basic() -> None:


def test_match_scopes_no_matches() -> None:
config = cli.Config.from_dict(
cfg = config.Config.from_dict(
{
"scopes": {
"source": {
Expand All @@ -146,16 +147,16 @@ def test_match_scopes_no_matches() -> None:
)
files = ["other.txt", "unrelated.cpp"]

assert config.scopes.source is not None
assert isinstance(config.scopes.source, cli.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
assert cfg.scopes.source is not None
assert isinstance(cfg.scopes.source, config.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)

assert scopes_hit == set()
assert per_scope == {}


def test_match_scopes_multiple_include_single_scope() -> None:
config = cli.Config.from_dict(
cfg = config.Config.from_dict(
{
"scopes": {
"source": {
Expand All @@ -168,9 +169,9 @@ def test_match_scopes_multiple_include_single_scope() -> None:
)
files = ["api/models.py", "backend/services.py"]

assert config.scopes.source is not None
assert isinstance(config.scopes.source, cli.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
assert cfg.scopes.source is not None
assert isinstance(cfg.scopes.source, config.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)

assert scopes_hit == {"backend"}
assert per_scope == {
Expand All @@ -179,7 +180,7 @@ def test_match_scopes_multiple_include_single_scope() -> None:


def test_match_scopes_with_negation_include() -> None:
config = cli.Config.from_dict(
cfg = config.Config.from_dict(
{
"scopes": {
"source": {
Expand All @@ -204,9 +205,9 @@ def test_match_scopes_with_negation_include() -> None:
"ui/components.spec.js",
]

assert config.scopes.source is not None
assert isinstance(config.scopes.source, cli.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
assert cfg.scopes.source is not None
assert isinstance(cfg.scopes.source, config.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)

assert scopes_hit == {"backend", "frontend"}
assert per_scope == {
Expand All @@ -216,7 +217,7 @@ def test_match_scopes_with_negation_include() -> None:


def test_match_scopes_negation_only() -> None:
config = cli.Config.from_dict(
cfg = config.Config.from_dict(
{
"scopes": {
"source": {
Expand All @@ -232,9 +233,9 @@ def test_match_scopes_negation_only() -> None:
)
files = ["image.jpeg", "document.txt", "photo.png", "readme.md"]

assert config.scopes.source is not None
assert isinstance(config.scopes.source, cli.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
assert cfg.scopes.source is not None
assert isinstance(cfg.scopes.source, config.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)

assert scopes_hit == {"exclude_images"}
assert per_scope == {
Expand All @@ -243,7 +244,7 @@ def test_match_scopes_negation_only() -> None:


def test_match_scopes_mixed_with_complex_negation() -> None:
config = cli.Config.from_dict(
cfg = config.Config.from_dict(
{
"scopes": {
"source": {
Expand All @@ -265,9 +266,9 @@ def test_match_scopes_mixed_with_complex_negation() -> None:
"main.py",
]

assert config.scopes.source is not None
assert isinstance(config.scopes.source, cli.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, config.scopes.source.files)
assert cfg.scopes.source is not None
assert isinstance(cfg.scopes.source, config.SourceFiles)
scopes_hit, per_scope = cli.match_scopes(files, cfg.scopes.source.files)

assert scopes_hit == {"backend"}
assert per_scope == {
Expand Down