Public Access
889 lines
40 KiB
Python
889 lines
40 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_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
|