"""Unit tests for devx.ci.classify_changes. Tests cover: - Glob matching (``_glob_to_regex``, ``_matches_glob``) - Classifier config loading from pyproject.toml - ChangeClassifier with layered rules (overrides, patterns, default) - Tag system (orthogonal categories) - Backward-compatible API (is_user_facing, is_workflow_only, classify_changes) - Git helpers (get_changed_files, get_latest_tag, run_git) - CLI (main with --quiet, --check, --github-output) """ from __future__ import annotations from pathlib import Path from unittest.mock import MagicMock, patch import click import pytest from click.testing import CliRunner import devx.ci.classify_changes as classify_changes_mod from devx.ci.classify_changes import ( DEFAULT_INFRASTRUCTURE, ChangeClassifier, ClassificationResult, ClassifierConfig, FileClassification, _glob_to_regex, _matches_glob, classify_changes, get_changed_files, get_latest_tag, has_user_facing_changes, is_user_facing, is_workflow_only, main, run_git, ) # --------------------------------------------------------------------------- # Glob matching tests # --------------------------------------------------------------------------- class TestGlobToRegex: def test_double_star_matches_anything(self) -> None: regex = _glob_to_regex(".gitea/**") assert regex.match(".gitea/workflows/ci.yml") assert regex.match(".gitea/actionlint.yaml") assert regex.match(".gitea/a/b/c/d.yml") assert not regex.match("tests/test_foo.py") def test_double_star_in_middle(self) -> None: """** in the middle of a pattern matches any number of segments.""" regex = _glob_to_regex("src/**/test_*.py") assert regex.match("src/test_foo.py") assert regex.match("src/devx/test_cli.py") assert regex.match("src/a/b/c/test_bar.py") assert not regex.match("src/cli.py") def test_single_star_matches_within_segment(self) -> None: regex = _glob_to_regex("src/*/cli.py") assert regex.match("src/devx/cli.py") assert regex.match("src/pkg/cli.py") assert not regex.match("src/devx/sub/cli.py") def test_question_mark_matches_single_char(self) -> None: regex = _glob_to_regex("file?.py") assert regex.match("file1.py") assert regex.match("fileA.py") assert not regex.match("file12.py") def test_literal_match(self) -> None: regex = _glob_to_regex("Makefile") assert regex.match("Makefile") assert not regex.match("makefile") def test_special_chars_escaped(self) -> None: regex = _glob_to_regex("file.test.py") assert regex.match("file.test.py") assert not regex.match("fileXtest.py") class TestMatchesGlob: def test_double_star(self) -> None: assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/**") assert _matches_glob("tests/unit/test_cli.py", "tests/**") assert not _matches_glob("src/devx/cli.py", "tests/**") def test_exact_match(self) -> None: assert _matches_glob("Makefile", "Makefile") assert _matches_glob("src/devx/__init__.py", "src/devx/__init__.py") assert not _matches_glob("src/devx/cli.py", "src/devx/__init__.py") def test_prefix_matching(self) -> None: assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/") assert _matches_glob("scripts/ci/release.py", "scripts/") assert not _matches_glob("tests/test_foo.py", "scripts/") def test_single_star(self) -> None: assert _matches_glob("src/devx/cli.py", "src/devx/*.py") assert not _matches_glob("src/devx/sub/cli.py", "src/devx/*.py") # --------------------------------------------------------------------------- # ClassifierConfig tests # --------------------------------------------------------------------------- class TestClassifierConfig: def test_from_pyproject_merges_with_defaults(self, tmp_path: Path) -> None: pyproject = tmp_path / "pyproject.toml" pyproject.write_text( "[tool.devx.classify]\n" 'infrastructure = ["scripts/**"]\n' 'infrastructure_overrides = ["src/pkg/__init__.py"]\n' 'user_facing_overrides = ["docs/important.py"]\n' "\n" "[tool.devx.classify.tags]\n" 'ansible = ["ansible/**"]\n' ) config = ClassifierConfig.from_pyproject(str(pyproject)) # Project-specific path is merged with defaults assert "scripts/**" in config.infrastructure assert ".gitea/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE assert "tests/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE assert config.use_defaults is True assert config.infrastructure_overrides == ["src/pkg/__init__.py"] assert config.user_facing_overrides == ["docs/important.py"] assert config.tags == {"ansible": ["ansible/**"]} def test_from_pyproject_use_defaults_false(self, tmp_path: Path) -> None: pyproject = tmp_path / "pyproject.toml" pyproject.write_text('[tool.devx.classify]\nuse_defaults = false\ninfrastructure = [".gitea/**"]\n') config = ClassifierConfig.from_pyproject(str(pyproject)) assert config.infrastructure == [".gitea/**"] assert "tests/**" not in config.infrastructure # no defaults assert config.use_defaults is False def test_from_pyproject_missing_file_returns_defaults(self) -> None: config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml") assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE) assert config.infrastructure_overrides == [] assert config.user_facing_overrides == [] assert config.tags == {} assert config.use_defaults is True def test_from_pyproject_missing_section_returns_defaults(self, tmp_path: Path) -> None: pyproject = tmp_path / "pyproject.toml" pyproject.write_text('[project]\nname = "test"\n') config = ClassifierConfig.from_pyproject(str(pyproject)) assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE) def test_from_pyproject_partial_config(self, tmp_path: Path) -> None: pyproject = tmp_path / "pyproject.toml" pyproject.write_text('[tool.devx.classify]\ninfrastructure = ["scripts/**"]\n') config = ClassifierConfig.from_pyproject(str(pyproject)) assert "scripts/**" in config.infrastructure assert ".gitea/**" in config.infrastructure # merged with defaults assert config.infrastructure_overrides == [] assert config.user_facing_overrides == [] assert config.tags == {} def test_from_pyproject_dedupes_existing_default(self, tmp_path: Path) -> None: """Project infrastructure patterns already in defaults are not duplicated.""" pyproject = tmp_path / "pyproject.toml" pyproject.write_text('[tool.devx.classify]\ninfrastructure = [".gitea/**", "scripts/**"]\n') config = ClassifierConfig.from_pyproject(str(pyproject)) # .gitea/** should appear only once (deduplicated with defaults) assert config.infrastructure.count(".gitea/**") == 1 assert "scripts/**" in config.infrastructure def test_defaults_are_empty_for_bare_constructor(self) -> None: """ClassifierConfig() without from_pyproject has empty lists.""" config = ClassifierConfig() assert config.infrastructure == [] assert config.infrastructure_overrides == [] assert config.user_facing_overrides == [] assert config.tags == {} assert config.use_defaults is True def test_default_infrastructure_is_non_empty(self) -> None: """The framework ships with a curated default infrastructure list.""" assert len(DEFAULT_INFRASTRUCTURE) > 0 assert ".gitea/**" in DEFAULT_INFRASTRUCTURE assert "tests/**" in DEFAULT_INFRASTRUCTURE assert "docs/**" in DEFAULT_INFRASTRUCTURE def test_default_infrastructure_covers_common_project_files(self) -> None: """DEFAULT_INFRASTRUCTURE must cover common project-level files that are not part of the installed package. This test prevents regression of the root cause of GRM-64 misclassification: 28 files (scripts/**, REVIEW_CHECKLIST.md) were classified as user-facing because these patterns were missing from the defaults. """ # Project documentation assert "AGENTS.md" in DEFAULT_INFRASTRUCTURE assert "README.md" in DEFAULT_INFRASTRUCTURE assert "CHANGELOG.md" in DEFAULT_INFRASTRUCTURE assert "TROUBLESHOOTING.md" in DEFAULT_INFRASTRUCTURE assert "CONTRIBUTING.md" in DEFAULT_INFRASTRUCTURE assert "CODE_OF_CONDUCT.md" in DEFAULT_INFRASTRUCTURE assert "REVIEW_CHECKLIST.md" in DEFAULT_INFRASTRUCTURE # Build tooling assert "Makefile" in DEFAULT_INFRASTRUCTURE assert "cliff.toml" in DEFAULT_INFRASTRUCTURE assert "uv.lock" in DEFAULT_INFRASTRUCTURE # Lint config assert ".pre-commit-config.yaml" in DEFAULT_INFRASTRUCTURE assert ".ruff.toml" in DEFAULT_INFRASTRUCTURE assert ".ansible-lint" in DEFAULT_INFRASTRUCTURE assert ".checkmake.ini" in DEFAULT_INFRASTRUCTURE assert ".editorconfig" in DEFAULT_INFRASTRUCTURE # Git config assert ".gitignore" in DEFAULT_INFRASTRUCTURE assert ".gitattributes" in DEFAULT_INFRASTRUCTURE # Agent config assert ".devin/**" in DEFAULT_INFRASTRUCTURE # --------------------------------------------------------------------------- # ChangeClassifier tests # --------------------------------------------------------------------------- class TestChangeClassifier: def _make_classifier(self, **kwargs: object) -> ChangeClassifier: """Create a classifier with explicit config (no pyproject.toml needed).""" config = ClassifierConfig(**kwargs) # type: ignore[arg-type] return ChangeClassifier(config) def test_infrastructure_pattern_matches(self) -> None: classifier = self._make_classifier(infrastructure=[".gitea/**", "tests/**"]) fc = classifier.classify_file(".gitea/workflows/ci.yml") assert not fc.is_user_facing assert "infrastructure" in fc.matched_rule def test_unknown_file_defaults_to_user_facing(self) -> None: classifier = self._make_classifier(infrastructure=[".gitea/**"]) fc = classifier.classify_file("src/devx/cli.py") assert fc.is_user_facing assert fc.matched_rule is None assert "default" in fc.reason.lower() def test_infrastructure_override(self) -> None: classifier = self._make_classifier( infrastructure=[".gitea/**"], infrastructure_overrides=["src/devx/__init__.py"], ) fc = classifier.classify_file("src/devx/__init__.py") assert not fc.is_user_facing assert fc.matched_rule == "infrastructure_overrides" def test_user_facing_override_beats_infrastructure(self) -> None: """User-facing overrides have highest priority (safety).""" classifier = self._make_classifier( infrastructure=["tests/**"], user_facing_overrides=["tests/test_public_api.py"], ) fc = classifier.classify_file("tests/test_public_api.py") assert fc.is_user_facing assert fc.matched_rule == "user_facing_overrides" def test_user_facing_override_glob_matches_nested(self) -> None: """User-facing overrides support glob patterns like infrastructure.""" classifier = self._make_classifier( infrastructure=[".gitea/**"], user_facing_overrides=[".gitea/**"], ) fc = classifier.classify_file(".gitea/workflows/ci.yml") assert fc.is_user_facing assert fc.matched_rule == "user_facing_overrides" def test_user_facing_override_beats_infrastructure_override(self) -> None: """User-facing overrides beat infrastructure overrides (safety first).""" classifier = self._make_classifier( infrastructure=[".gitea/**"], infrastructure_overrides=["src/devx/__init__.py"], user_facing_overrides=["src/devx/__init__.py"], ) fc = classifier.classify_file("src/devx/__init__.py") assert fc.is_user_facing def test_tags_are_computed(self) -> None: classifier = self._make_classifier( infrastructure=[".gitea/**"], tags={"ansible": ["ansible/**", ".ansible-lint"], "docs": ["docs/**"]}, ) fc = classifier.classify_file("ansible/tasks/main.yml") assert "ansible" in fc.tags assert "docs" not in fc.tags def test_tags_orthogonal_to_classification(self) -> None: """A file can be infrastructure AND tagged.""" classifier = self._make_classifier( infrastructure=[".gitea/**", "docs/**"], tags={"docs": ["docs/**"]}, ) fc = classifier.classify_file("docs/index.md") assert not fc.is_user_facing # infrastructure assert "docs" in fc.tags # also tagged def test_classify_multiple_files(self) -> None: classifier = self._make_classifier( infrastructure=[".gitea/**", "tests/**"], infrastructure_overrides=["src/devx/__init__.py"], tags={"ansible": ["ansible/**"]}, ) files = [ "src/devx/cli.py", ".gitea/workflows/ci.yml", "src/devx/__init__.py", "ansible/tasks/main.yml", "tests/test_foo.py", ] result = classifier.classify(files) assert "src/devx/cli.py" in result.user_facing assert "ansible/tasks/main.yml" in result.user_facing assert ".gitea/workflows/ci.yml" in result.infrastructure assert "src/devx/__init__.py" in result.infrastructure assert "tests/test_foo.py" in result.infrastructure assert result.has_user_facing assert result.has_tag("ansible") assert "ansible/tasks/main.yml" in result.tags["ansible"] def test_classify_empty(self) -> None: classifier = self._make_classifier(infrastructure=[".gitea/**"]) result = classifier.classify([]) assert not result.has_user_facing assert result.user_facing == [] assert result.infrastructure == [] def test_reason_is_human_readable(self) -> None: classifier = self._make_classifier(infrastructure=[".gitea/**"]) fc = classifier.classify_file(".gitea/workflows/ci.yml") assert ".gitea/**" in fc.reason fc2 = classifier.classify_file("src/devx/cli.py") assert "default" in fc2.reason.lower() or "user-facing" in fc2.reason.lower() class TestClassificationResult: def test_has_user_facing(self) -> None: result = ClassificationResult(user_facing=["src/cli.py"]) assert result.has_user_facing def test_has_user_facing_empty(self) -> None: result = ClassificationResult() assert not result.has_user_facing def test_has_tag(self) -> None: result = ClassificationResult(tags={"ansible": ["ansible/tasks/main.yml"]}) assert result.has_tag("ansible") assert not result.has_tag("docs") # --------------------------------------------------------------------------- # Backward-compatible API tests # --------------------------------------------------------------------------- class TestBackwardCompatibleAPI: def test_is_workflow_only_with_config(self) -> None: """is_workflow_only uses the config-driven classifier by default.""" with patch.object(classify_changes_mod, "_get_classifier") as mock: classifier = MagicMock() classifier.classify_file.return_value = FileClassification( path=".gitea/workflows/ci.yml", is_user_facing=False, reason="test", matched_rule="infrastructure: .gitea/**", ) mock.return_value = classifier assert is_workflow_only(".gitea/workflows/ci.yml") is True def test_is_user_facing_with_config(self) -> None: with patch.object(classify_changes_mod, "_get_classifier") as mock: classifier = MagicMock() classifier.classify_file.return_value = FileClassification( path="src/devx/cli.py", is_user_facing=True, reason="test", matched_rule=None, ) mock.return_value = classifier assert is_user_facing("src/devx/cli.py") is True def test_legacy_patterns_mode(self) -> None: """is_workflow_only with explicit patterns uses legacy prefix matching.""" patterns = frozenset([".gitea/", "tests/"]) assert is_workflow_only(".gitea/workflows/ci.yml", patterns) is True assert is_workflow_only("tests/test_foo.py", patterns) is True assert is_workflow_only("src/devx/cli.py", patterns) is False def test_classify_changes_with_config(self) -> None: with patch.object(classify_changes_mod, "_get_classifier") as mock: classifier = MagicMock() classifier.classify.return_value = ClassificationResult( user_facing=["src/devx/cli.py"], infrastructure=[".gitea/workflows/ci.yml"], ) mock.return_value = classifier result = classify_changes(["src/devx/cli.py", ".gitea/workflows/ci.yml"]) assert "src/devx/cli.py" in result["user_facing"] assert ".gitea/workflows/ci.yml" in result["workflow_only"] def test_classify_changes_legacy_mode(self) -> None: patterns = frozenset([".gitea/", "tests/"]) result = classify_changes([".gitea/ci.yml", "src/cli.py"], patterns) assert ".gitea/ci.yml" in result["workflow_only"] assert "src/cli.py" in result["user_facing"] def test_has_user_facing_changes_with_config(self) -> None: with ( patch.object(classify_changes_mod, "get_changed_files", return_value=["src/devx/cli.py"]), patch.object(classify_changes_mod, "_get_classifier") as mock, ): classifier = MagicMock() classifier.classify.return_value = ClassificationResult( user_facing=["src/devx/cli.py"], ) mock.return_value = classifier assert has_user_facing_changes("v0.1.0", "HEAD") is True def test_has_user_facing_changes_legacy(self) -> None: with patch.object(classify_changes_mod, "get_changed_files", return_value=[".gitea/ci.yml"]): patterns = frozenset([".gitea/"]) assert has_user_facing_changes("v0.1.0", "HEAD", patterns) is False # --------------------------------------------------------------------------- # Git helper tests # --------------------------------------------------------------------------- class TestGetChangedFiles: @patch("devx.ci.classify_changes.run_git") def test_returns_file_list(self, mock_run_git: MagicMock) -> None: mock_run_git.return_value = "file1.py\nfile2.py\nfile3.md" result = get_changed_files("v0.1.0", "HEAD") assert result == ["file1.py", "file2.py", "file3.md"] @patch("devx.ci.classify_changes.run_git") def test_empty_when_no_changes(self, mock_run_git: MagicMock) -> None: mock_run_git.return_value = "" result = get_changed_files("v0.1.0", "HEAD") assert result == [] class TestGetLatestTag: @patch("subprocess.run") def test_returns_tag(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout="v0.3.0\n", stderr="") assert get_latest_tag() == "v0.3.0" @patch("subprocess.run") def test_returns_empty_when_no_tags(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error") assert get_latest_tag() == "" class TestRunGit: @patch("devx.ci.classify_changes.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout="file1.py\n", stderr="") result = run_git(["git", "diff", "--name-only", "v0.1.0", "HEAD"]) assert result == "file1.py" @patch("devx.ci.classify_changes.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="git error") with pytest.raises(click.ClickException): run_git(["git", "bad-command"]) # --------------------------------------------------------------------------- # CLI tests # --------------------------------------------------------------------------- class TestMain: @patch("devx.ci.classify_changes.get_changed_files", return_value=[]) @patch("devx.ci.classify_changes.get_latest_tag", return_value="") def test_no_tags_outputs_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["--quiet"]) assert result.exit_code == 0 assert "true" in result.output @patch("devx.ci.classify_changes.get_changed_files", return_value=[]) @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_no_changes_outputs_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["--quiet"]) assert result.exit_code == 0 assert "false" in result.output @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: mock_changes.return_value = ["docs/index.md", "README.md"] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 2 assert "no release needed" in result.output @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_user_facing_exits_0(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: mock_changes.return_value = ["src/devx/cli.py", "docs/index.md"] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "release needed" in result.output @patch("devx.ci.classify_changes._get_classifier") @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_default_mode_displays_tags( self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock ) -> None: """Default mode shows tag files when tags are configured.""" mock_changes.return_value = ["src/devx/cli.py", "ansible/tasks/main.yml"] mock_clf.return_value = ChangeClassifier( ClassifierConfig( infrastructure=[".gitea/**"], tags={"ansible": ["ansible/**"]}, ) ) runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "Ansible files" in result.output assert "ansible/tasks/main.yml" in result.output @patch("devx.ci.classify_changes._get_classifier") @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_default_mode_skips_empty_tag( self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock ) -> None: """Tags with no matching files are skipped in default mode output.""" mock_changes.return_value = ["ansible/tasks/main.yml"] mock_clf.return_value = ChangeClassifier( ClassifierConfig( infrastructure=[".gitea/**"], tags={"ansible": ["ansible/**"], "docs": ["docs/**"]}, ) ) runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "Ansible files" in result.output # docs tag has no matching files — should not appear assert "Docs files" not in result.output @patch("devx.ci.classify_changes.get_changed_files", return_value=[]) @patch("devx.ci.classify_changes.get_latest_tag", return_value="") def test_no_tags_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "No tags found" in result.output @patch("devx.ci.classify_changes.get_changed_files", return_value=[]) @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_no_changes_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "No changes" in result.output @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: mock_changes.return_value = ["src/devx/cli.py"] runner = CliRunner() result = runner.invoke(main, ["--quiet"]) assert result.exit_code == 0 assert "true" in result.output @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: mock_changes.return_value = ["docs/index.md"] runner = CliRunner() result = runner.invoke(main, ["--quiet"]) assert result.exit_code == 0 assert "false" in result.output @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: mock_changes.return_value = ["src/devx/cli.py"] runner = CliRunner() result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"]) assert result.exit_code == 0 assert "release needed" in result.output @patch("devx.ci.classify_changes._get_classifier") @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None: mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"] mock_clf.return_value = ChangeClassifier( ClassifierConfig( infrastructure=[".gitea/**"], tags={"ansible": ["ansible/**"]}, ) ) runner = CliRunner() result = runner.invoke(main, ["--check", "ansible", "--quiet"]) assert result.exit_code == 0 assert "true" in result.output @patch("devx.ci.classify_changes._get_classifier") @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None: mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"] mock_clf.return_value = ChangeClassifier( ClassifierConfig( infrastructure=[".gitea/**"], tags={"ansible": ["ansible/**"]}, ) ) runner = CliRunner() result = runner.invoke(main, ["--check", "ansible", "--quiet"]) assert result.exit_code == 0 assert "false" in result.output @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_user_facing_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"] runner = CliRunner() result = runner.invoke(main, ["--check", "user-facing", "--quiet"]) assert result.exit_code == 0 assert "true" in result.output @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: mock_changes.return_value = ["docs/index.md", "tests/test_foo.py"] runner = CliRunner() result = runner.invoke(main, ["--check", "user-facing", "--quiet"]) assert result.exit_code == 0 assert "false" in result.output @patch("devx.ci.classify_changes._get_classifier") @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None: mock_changes.return_value = ["ansible/tasks/main.yml"] mock_clf.return_value = ChangeClassifier( ClassifierConfig( infrastructure=[".gitea/**"], tags={"ansible": ["ansible/**"]}, ) ) runner = CliRunner() result = runner.invoke(main, ["--check", "ansible"]) assert result.exit_code == 0 assert "Ansible changes detected" in result.output @patch("devx.ci.classify_changes._get_classifier") @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_unknown_tag_raises(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None: mock_changes.return_value = ["src/devx/cli.py"] mock_clf.return_value = ChangeClassifier( ClassifierConfig( infrastructure=[".gitea/**"], tags={"ansible": ["ansible/**"]}, ) ) runner = CliRunner() result = runner.invoke(main, ["--check", "nonexistent"]) assert result.exit_code != 0 assert "Unknown check category" in result.output @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: mock_changes.return_value = ["src/devx/cli.py"] runner = CliRunner() result = runner.invoke(main, ["--check", "user-facing"]) assert result.exit_code == 0 assert "User-facing changes detected" in result.output class TestGithubOutput: def _make_classifier_with_ansible(self) -> ChangeClassifier: return ChangeClassifier( ClassifierConfig( infrastructure=[".gitea/**", "AGENTS.md"], tags={"ansible": ["ansible/**"]}, ) ) @patch("devx.ci.classify_changes._get_classifier") def test_writes_outputs(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) with patch.object( classify_changes_mod, "get_changed_files", return_value=["src/cli.py", "ansible/tasks/main.yml"] ): runner = CliRunner() result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"]) assert result.exit_code == 0 content = gh_file.read_text() assert "ansible-changed=true" in content assert "user-facing-changed=true" in content @patch("devx.ci.classify_changes._get_classifier") def test_no_changes(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) with patch.object(classify_changes_mod, "get_changed_files", return_value=[]): runner = CliRunner() result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"]) assert result.exit_code == 0 content = gh_file.read_text() assert "ansible-changed=false" in content assert "user-facing-changed=false" in content @patch("devx.ci.classify_changes._get_classifier") def test_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) with ( patch.object(classify_changes_mod, "get_latest_tag", return_value=""), patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]), ): runner = CliRunner() result = runner.invoke(main, ["--github-output"]) assert result.exit_code == 0 content = gh_file.read_text() assert "ansible-changed=true" in content assert "user-facing-changed=true" in content def test_no_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("GITHUB_OUTPUT", raising=False) with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]): runner = CliRunner() result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"]) assert result.exit_code != 0 @patch("devx.ci.classify_changes._get_classifier") def test_workflow_only(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) with patch.object( classify_changes_mod, "get_changed_files", return_value=[".gitea/workflows/ci.yml", "AGENTS.md"] ): runner = CliRunner() result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"]) assert result.exit_code == 0 content = gh_file.read_text() assert "ansible-changed=false" in content assert "user-facing-changed=false" in content @patch("devx.ci.classify_changes._get_classifier") def test_no_tags_outputs_all_tags_true( self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """When no tags exist, only user-facing-changed is written.""" mock_clf.return_value = ChangeClassifier( ClassifierConfig( infrastructure=[".gitea/**"], tags={}, ) ) gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) with ( patch.object(classify_changes_mod, "get_latest_tag", return_value=""), patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]), ): runner = CliRunner() result = runner.invoke(main, ["--github-output"]) assert result.exit_code == 0 content = gh_file.read_text() assert "user-facing-changed=true" in content # No tag outputs since no tags are configured assert "ansible-changed" not in content @patch("devx.ci.classify_changes._get_classifier") def test_force_outputs_true(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """--force with --github-output writes user-facing-changed=true and all tags true.""" mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]): runner = CliRunner() result = runner.invoke(main, ["--github-output", "--force"]) assert result.exit_code == 0 content = gh_file.read_text() assert "user-facing-changed=true" in content assert "ansible-changed=true" in content assert "Forced user-facing-changed=true" in result.output @patch("devx.ci.classify_changes._get_classifier") def test_force_without_github_output_does_nothing( self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """--force without --github-output falls through to normal classification.""" mock_clf.return_value = self._make_classifier_with_ansible() monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "output.txt")) with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"): with patch.object(classify_changes_mod, "get_changed_files", return_value=[]): runner = CliRunner() result = runner.invoke(main, ["--force", "--quiet"]) assert result.exit_code == 0 assert result.output.strip() == "false" @patch("devx.ci.classify_changes._get_classifier") def test_force_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """--force with --github-output and no tags writes only user-facing-changed=true.""" mock_clf.return_value = ChangeClassifier( ClassifierConfig( infrastructure=[".gitea/**"], tags={}, ) ) gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]): runner = CliRunner() result = runner.invoke(main, ["--github-output", "--force"]) assert result.exit_code == 0 content = gh_file.read_text() assert "user-facing-changed=true" in content assert "ansible-changed" not in content @patch("devx.ci.classify_changes._get_classifier") def test_force_deploy_env_var(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """FORCE_DEPLOY=true env var activates force mode without --force flag.""" mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) monkeypatch.setenv("FORCE_DEPLOY", "true") with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]): runner = CliRunner() result = runner.invoke(main, ["--github-output"]) assert result.exit_code == 0 content = gh_file.read_text() assert "user-facing-changed=true" in content assert "ansible-changed=true" in content @patch("devx.ci.classify_changes._get_classifier") def test_force_deploy_env_var_false( self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """FORCE_DEPLOY=false does not activate force mode.""" mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) monkeypatch.setenv("FORCE_DEPLOY", "false") with patch.object(classify_changes_mod, "get_latest_tag", return_value="v1.0"): with patch.object(classify_changes_mod, "get_changed_files", return_value=[]): runner = CliRunner() result = runner.invoke(main, ["--github-output"]) assert result.exit_code == 0 content = gh_file.read_text() assert "user-facing-changed=false" in content @patch("devx.ci.classify_changes._get_classifier") def test_force_flag_overrides_env_var( self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """--force flag works even when FORCE_DEPLOY=false.""" mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) monkeypatch.setenv("FORCE_DEPLOY", "false") with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]): runner = CliRunner() result = runner.invoke(main, ["--github-output", "--force"]) assert result.exit_code == 0 content = gh_file.read_text() assert "user-facing-changed=true" in content