Files
devx/tests/unit/test_classify_changes.py
T
emil 9060cd7b1e
Post-merge / detect-type (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / release (push) Successful in 58s
Post-merge / vikunja (push) Successful in 19s
Post-merge / badges (push) Successful in 55s
Post-merge / sync-wiki (push) Successful in 1m18s
DEVX-57: feat: add FORCE_DEPLOY env var, --git flag, --from-tag flag
2026-06-25 23:22:41 +00:00

837 lines
38 KiB
Python

"""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_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_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_latest_tag", return_value="")
def test_no_tags_outputs_true(self, mock_tag: 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 = [".gitea/workflows/ci.yml", "docs/index.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_latest_tag", return_value="")
def test_no_tags_non_quiet(self, mock_tag: 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 = [".gitea/workflows/ci.yml"]
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 = [".gitea/workflows/ci.yml", "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=""):
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=""):
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))
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))
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")
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")
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