diff --git a/mergify_cli/ci/scopes/cli.py b/mergify_cli/ci/scopes/cli.py index d5db3356..3f63ba30 100644 --- a/mergify_cli/ci/scopes/cli.py +++ b/mergify_cli/ci/scopes/cli.py @@ -26,21 +26,38 @@ class ConfigInvalidError(exceptions.ScopesError): pass -class ScopeConfig(pydantic.BaseModel): - include: tuple[str, ...] = pydantic.Field(default_factory=tuple) - exclude: tuple[str, ...] = pydantic.Field(default_factory=tuple) - - ScopeName = typing.Annotated[ str, pydantic.StringConstraints(pattern=SCOPE_NAME_RE, min_length=1), ] -class Config(pydantic.BaseModel): - scopes: dict[ScopeName, ScopeConfig] +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 SourceOther(pydantic.BaseModel): + other: None + + +class ScopesConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + mode: typing.Literal["serial", "parallel"] = "serial" + source: SourceFiles | SourceOther | 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: @@ -60,15 +77,15 @@ def from_yaml(cls, path: str) -> Config: def match_scopes( - config: Config, files: abc.Iterable[str], + filters: dict[ScopeName, FileFilters], ) -> tuple[set[str], dict[str, list[str]]]: scopes_hit: set[str] = set() - per_scope: dict[str, list[str]] = {s: [] for s in config.scopes} + per_scope: dict[str, list[str]] = {s: [] for s in filters} for f in files: # NOTE(sileht): we use pathlib.full_match to support **, as fnmatch does not p = pathlib.PurePosixPath(f) - for scope, scope_config in config.scopes.items(): + for scope, scope_config in filters.items(): if not scope_config.include and not scope_config.exclude: continue @@ -128,14 +145,32 @@ def load_from_file(cls, filename: str) -> DetectedScope: def detect(config_path: str) -> DetectedScope: cfg = Config.from_yaml(config_path) base = base_detector.detect() - changed = changed_files.git_changed_files(base.ref) - scopes_hit, per_scope = match_scopes(cfg, changed) - all_scopes = set(cfg.scopes.keys()) - if cfg.merge_queue_scope is not None: - all_scopes.add(cfg.merge_queue_scope) + scopes_hit: set[str] + per_scope: dict[str, list[str]] + + source = cfg.scopes.source + if source is None: + all_scopes = set() + scopes_hit = set() + per_scope = {} + elif isinstance(source, 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, SourceOther): + msg = ( + "source `other` has been set, scopes must be send with `scopes-send` or API" + ) + raise exceptions.ScopesError(msg) + else: + msg = "Unsupported source type" # type:ignore[unreachable] + raise RuntimeError(msg) + + if cfg.scopes.merge_queue_scope is not None: + all_scopes.add(cfg.scopes.merge_queue_scope) if base.is_merge_queue: - scopes_hit.add(cfg.merge_queue_scope) + scopes_hit.add(cfg.scopes.merge_queue_scope) click.echo(f"Base: {base.ref}") if scopes_hit: diff --git a/mergify_cli/tests/ci/scopes/test_cli.py b/mergify_cli/tests/ci/scopes/test_cli.py index 4cb13ed4..8decc758 100644 --- a/mergify_cli/tests/ci/scopes/test_cli.py +++ b/mergify_cli/tests/ci/scopes/test_cli.py @@ -10,15 +10,63 @@ from mergify_cli.ci.scopes import cli +def test_from_yaml_with_extras_ignored(tmp_path: pathlib.Path) -> None: + config_file = tmp_path / "config.yml" + config_file.write_text( + yaml.dump( + { + "defaults": {}, + "queue_rules": [], + "pull_request_rules": [], + "partitions_rules": [], + "scopes": { + "source": { + "files": { + "backend": {"include": ["api/**/*.py", "backend/**/*.py"]}, + "frontend": {"include": ["ui/**/*.js", "ui/**/*.tsx"]}, + "docs": {"include": ["*.md", "docs/**/*"]}, + }, + }, + }, + }, + ), + ) + + config = cli.Config.from_yaml(str(config_file)) + assert config.model_dump() == { + "scopes": { + "mode": "serial", + "source": { + "files": { + "backend": { + "include": ("api/**/*.py", "backend/**/*.py"), + "exclude": (), + }, + "frontend": { + "include": ("ui/**/*.js", "ui/**/*.tsx"), + "exclude": (), + }, + "docs": {"include": ("*.md", "docs/**/*"), "exclude": ()}, + }, + }, + "merge_queue_scope": "merge-queue", + }, + } + + def test_from_yaml_valid(tmp_path: pathlib.Path) -> None: config_file = tmp_path / "config.yml" config_file.write_text( yaml.dump( { "scopes": { - "backend": {"include": ["api/**/*.py", "backend/**/*.py"]}, - "frontend": {"include": ["ui/**/*.js", "ui/**/*.tsx"]}, - "docs": {"include": ["*.md", "docs/**/*"]}, + "source": { + "files": { + "backend": {"include": ["api/**/*.py", "backend/**/*.py"]}, + "frontend": {"include": ["ui/**/*.js", "ui/**/*.tsx"]}, + "docs": {"include": ["*.md", "docs/**/*"]}, + }, + }, }, }, ), @@ -26,21 +74,34 @@ def test_from_yaml_valid(tmp_path: pathlib.Path) -> None: config = cli.Config.from_yaml(str(config_file)) assert config.model_dump() == { - "merge_queue_scope": "merge-queue", "scopes": { - "backend": {"include": ("api/**/*.py", "backend/**/*.py"), "exclude": ()}, - "frontend": {"include": ("ui/**/*.js", "ui/**/*.tsx"), "exclude": ()}, - "docs": {"include": ("*.md", "docs/**/*"), "exclude": ()}, + "merge_queue_scope": "merge-queue", + "mode": "serial", + "source": { + "files": { + "backend": { + "include": ("api/**/*.py", "backend/**/*.py"), + "exclude": (), + }, + "frontend": { + "include": ("ui/**/*.js", "ui/**/*.tsx"), + "exclude": (), + }, + "docs": {"include": ("*.md", "docs/**/*"), "exclude": ()}, + }, + }, }, } def test_from_yaml_invalid_config(tmp_path: pathlib.Path) -> None: config_file = tmp_path / "config.yml" - config_file.write_text(yaml.dump({"scopes": {"Back#end-API": ["api/**/*.py"]}})) + config_file.write_text( + yaml.dump({"scopes": {"source": {"files": {"Back#end-API": ["api/**/*.py"]}}}}), + ) # Bad name and missing dict - with pytest.raises(cli.ConfigInvalidError, match="2 validation errors"): + with pytest.raises(cli.ConfigInvalidError, match="3 validation errors"): cli.Config.from_yaml(str(config_file)) @@ -48,15 +109,21 @@ def test_match_scopes_basic() -> None: config = cli.Config.from_dict( { "scopes": { - "backend": {"include": ("api/**/*.py", "backend/**/*.py")}, - "frontend": {"include": ("ui/**/*.js", "ui/**/*.tsx")}, - "docs": {"include": ("*.md", "docs/**/*")}, + "source": { + "files": { + "backend": {"include": ("api/**/*.py", "backend/**/*.py")}, + "frontend": {"include": ("ui/**/*.js", "ui/**/*.tsx")}, + "docs": {"include": ("*.md", "docs/**/*")}, + }, + }, }, }, ) files = ["api/models.py", "ui/components/Button.tsx", "README.md", "other.txt"] - scopes_hit, per_scope = cli.match_scopes(config, files) + 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 scopes_hit == {"backend", "frontend", "docs"} assert per_scope == { @@ -70,14 +137,20 @@ def test_match_scopes_no_matches() -> None: config = cli.Config.from_dict( { "scopes": { - "backend": {"include": ("api/**/*.py",)}, - "frontend": {"include": ("ui/**/*.js",)}, + "source": { + "files": { + "backend": {"include": ("api/**/*.py",)}, + "frontend": {"include": ("ui/**/*.js",)}, + }, + }, }, }, ) files = ["other.txt", "unrelated.cpp"] - scopes_hit, per_scope = cli.match_scopes(config, files) + 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 scopes_hit == set() assert per_scope == {} @@ -87,13 +160,19 @@ def test_match_scopes_multiple_include_single_scope() -> None: config = cli.Config.from_dict( { "scopes": { - "backend": {"include": ("api/**/*.py", "backend/**/*.py")}, + "source": { + "files": { + "backend": {"include": ("api/**/*.py", "backend/**/*.py")}, + }, + }, }, }, ) files = ["api/models.py", "backend/services.py"] - scopes_hit, per_scope = cli.match_scopes(config, files) + 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 scopes_hit == {"backend"} assert per_scope == { @@ -105,13 +184,17 @@ def test_match_scopes_with_negation_include() -> None: config = cli.Config.from_dict( { "scopes": { - "backend": { - "include": ("api/**/*.py",), - "exclude": ("api/**/test_*.py",), - }, - "frontend": { - "include": ("ui/**/*.js",), - "exclude": ("ui/**/*.spec.js",), + "source": { + "files": { + "backend": { + "include": ("api/**/*.py",), + "exclude": ("api/**/test_*.py",), + }, + "frontend": { + "include": ("ui/**/*.js",), + "exclude": ("ui/**/*.spec.js",), + }, + }, }, }, }, @@ -123,7 +206,9 @@ def test_match_scopes_with_negation_include() -> None: "ui/components.spec.js", ] - scopes_hit, per_scope = cli.match_scopes(config, files) + 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 scopes_hit == {"backend", "frontend"} assert per_scope == { @@ -136,16 +221,22 @@ def test_match_scopes_negation_only() -> None: config = cli.Config.from_dict( { "scopes": { - "exclude_images": { - "include": ("**/*",), - "exclude": ("**/*.jpeg", "**/*.png"), + "source": { + "files": { + "exclude_images": { + "include": ("**/*",), + "exclude": ("**/*.jpeg", "**/*.png"), + }, + }, }, }, }, ) files = ["image.jpeg", "document.txt", "photo.png", "readme.md"] - scopes_hit, per_scope = cli.match_scopes(config, files) + 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 scopes_hit == {"exclude_images"} assert per_scope == { @@ -157,9 +248,13 @@ def test_match_scopes_mixed_with_complex_negation() -> None: config = cli.Config.from_dict( { "scopes": { - "backend": { - "include": ("**/*.py",), - "exclude": ("**/test_*.py", "**/*_test.py"), + "source": { + "files": { + "backend": { + "include": ("**/*.py",), + "exclude": ("**/test_*.py", "**/*_test.py"), + }, + }, }, }, }, @@ -172,7 +267,9 @@ def test_match_scopes_mixed_with_complex_negation() -> None: "main.py", ] - scopes_hit, per_scope = cli.match_scopes(config, files) + 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 scopes_hit == {"backend"} assert per_scope == { @@ -219,8 +316,12 @@ def test_detect_with_matches( # Setup config file config_data = { "scopes": { - "backend": {"include": ["api/**/*.py"]}, - "frontend": {"include": ["api/**/*.js"]}, + "source": { + "files": { + "backend": {"include": ["api/**/*.py"]}, + "frontend": {"include": ["api/**/*.js"]}, + }, + }, }, } config_file = tmp_path / "mergify-ci.yml" @@ -260,7 +361,9 @@ def test_detect_no_matches( tmp_path: pathlib.Path, ) -> None: # Setup config file - config_data = {"scopes": {"backend": {"include": ["api/**/*.py"]}}} + config_data = { + "scopes": {"source": {"files": {"backend": {"include": ["api/**/*.py"]}}}}, + } config_file = tmp_path / ".mergify-ci.yml" config_file.write_text(yaml.dump(config_data)) @@ -292,7 +395,9 @@ def test_detect_debug_output( monkeypatch.setenv("ACTIONS_STEP_DEBUG", "true") # Setup config file - config_data = {"scopes": {"backend": {"include": ["api/**/*.py"]}}} + config_data = { + "scopes": {"source": {"files": {"backend": {"include": ["api/**/*.py"]}}}}, + } config_file = tmp_path / ".mergify-ci.yml" config_file.write_text(yaml.dump(config_data))