diff --git a/mergify_cli/ci/scopes/cli.py b/mergify_cli/ci/scopes/cli.py index c29ac870..99f47203 100644 --- a/mergify_cli/ci/scopes/cli.py +++ b/mergify_cli/ci/scopes/cli.py @@ -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 @@ -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} @@ -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] @@ -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: diff --git a/mergify_cli/ci/scopes/config/__init__.py b/mergify_cli/ci/scopes/config/__init__.py new file mode 100644 index 00000000..9dec2f24 --- /dev/null +++ b/mergify_cli/ci/scopes/config/__init__.py @@ -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", +] diff --git a/mergify_cli/ci/scopes/config/root.py b/mergify_cli/ci/scopes/config/root.py new file mode 100644 index 00000000..2e160788 --- /dev/null +++ b/mergify_cli/ci/scopes/config/root.py @@ -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) diff --git a/mergify_cli/ci/scopes/config/scopes.py b/mergify_cli/ci/scopes/config/scopes.py new file mode 100644 index 00000000..e749115e --- /dev/null +++ b/mergify_cli/ci/scopes/config/scopes.py @@ -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." + ), + ) diff --git a/mergify_cli/tests/ci/scopes/test_cli.py b/mergify_cli/tests/ci/scopes/test_cli.py index 38fad453..befcbce8 100644 --- a/mergify_cli/tests/ci/scopes/test_cli.py +++ b/mergify_cli/tests/ci/scopes/test_cli.py @@ -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: @@ -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": { @@ -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": { @@ -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": { @@ -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 == { @@ -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": { @@ -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": { @@ -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 == { @@ -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": { @@ -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 == { @@ -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": { @@ -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 == { @@ -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": { @@ -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 == {