"""Unit tests for scripts/ci/classify_changes.py.""" from unittest.mock import MagicMock, patch import click import pytest from click.testing import CliRunner from scripts.ci.classify_changes import ( classify_changes, get_changed_files, get_latest_tag, has_user_facing_changes, is_user_facing, is_workflow_only, main, run_git, ) class TestIsUserFacing: def test_src_is_user_facing(self) -> None: assert is_user_facing("src/gitea_runner_manager/cli.py") is True def test_ansible_is_user_facing(self) -> None: assert is_user_facing("ansible/roles/gitea-runner/tasks/main.yml") is True def test_pyproject_is_user_facing(self) -> None: assert is_user_facing("pyproject.toml") is True def test_workflow_is_not_user_facing(self) -> None: assert is_user_facing(".gitea/workflows/ci.yml") is False def test_ci_scripts_are_not_user_facing(self) -> None: assert is_user_facing("scripts/ci/release.py") is False def test_dev_scripts_are_user_facing(self) -> None: """Dev scripts (check_test_speed, configure_repo) are NOT in the workflow-only allowlist, so they default to user-facing.""" assert is_user_facing("scripts/check_test_speed.py") is True assert is_user_facing("scripts/configure_repo.py") is True assert is_user_facing("scripts/install_checkmake.py") is True def test_shell_scripts_are_not_user_facing(self) -> None: assert is_user_facing("scripts/setup.sh") is False assert is_user_facing("scripts/molecule_all.sh") is False def test_scripts_init_is_not_user_facing(self) -> None: assert is_user_facing("scripts/__init__.py") is False def test_docs_are_not_user_facing(self) -> None: assert is_user_facing("docs/user/getting-started.md") is False def test_tests_are_not_user_facing(self) -> None: assert is_user_facing("tests/unit/test_cli.py") is False def test_agents_md_is_not_user_facing(self) -> None: assert is_user_facing("AGENTS.md") is False def test_makefile_is_not_user_facing(self) -> None: assert is_user_facing("Makefile") is False def test_unknown_file_defaults_to_user_facing(self) -> None: """Safe default: unknown files are user-facing (require release).""" assert is_user_facing("some/new/file.type") is True assert is_user_facing("new_root_file.txt") is True def test_is_workflow_only_inverse(self) -> None: assert is_workflow_only(".gitea/workflows/ci.yml") is True assert is_workflow_only("src/gitea_runner_manager/cli.py") is False assert is_workflow_only("pyproject.toml") is False class TestClassifyChanges: def test_all_user_facing(self) -> None: files = ["src/gitea_runner_manager/cli.py", "ansible/roles/gitea-runner/tasks/main.yml"] result = classify_changes(files) assert result["user_facing"] == files assert result["workflow_only"] == [] def test_all_workflow_only(self) -> None: files = [".gitea/workflows/ci.yml", "docs/index.md", "AGENTS.md"] result = classify_changes(files) assert result["user_facing"] == [] assert result["workflow_only"] == files def test_mixed(self) -> None: files = [ "src/gitea_runner_manager/cli.py", ".gitea/workflows/ci.yml", "pyproject.toml", "docs/index.md", ] result = classify_changes(files) assert "src/gitea_runner_manager/cli.py" in result["user_facing"] assert "pyproject.toml" in result["user_facing"] assert ".gitea/workflows/ci.yml" in result["workflow_only"] assert "docs/index.md" in result["workflow_only"] def test_empty(self) -> None: result = classify_changes([]) assert result == {"user_facing": [], "workflow_only": []} class TestGetChangedFiles: @patch("scripts.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("scripts.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 TestHasUserFacingChanges: @patch("scripts.ci.classify_changes.get_changed_files") def test_true_when_user_facing(self, mock_get: MagicMock) -> None: mock_get.return_value = ["src/gitea_runner_manager/cli.py", "docs/index.md"] assert has_user_facing_changes("v0.1.0", "HEAD") is True @patch("scripts.ci.classify_changes.get_changed_files") def test_false_when_workflow_only(self, mock_get: MagicMock) -> None: mock_get.return_value = [".gitea/workflows/ci.yml", "docs/index.md"] assert has_user_facing_changes("v0.1.0", "HEAD") is False @patch("scripts.ci.classify_changes.get_changed_files") def test_false_when_no_changes(self, mock_get: MagicMock) -> None: mock_get.return_value = [] assert has_user_facing_changes("v0.1.0", "HEAD") is False 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("scripts.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("scripts.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"]) class TestMain: @patch("scripts.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("scripts.ci.classify_changes.get_changed_files", return_value=[]) @patch("scripts.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("scripts.ci.classify_changes.get_changed_files") @patch("scripts.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("scripts.ci.classify_changes.get_changed_files") @patch("scripts.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/gitea_runner_manager/cli.py", "docs/index.md"] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "release needed" in result.output @patch("scripts.ci.classify_changes.get_latest_tag", return_value="") def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None: """Non-quiet mode with no tags prints user-facing message.""" runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "No tags found" in result.output @patch("scripts.ci.classify_changes.get_changed_files", return_value=[]) @patch("scripts.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: """Non-quiet mode with no changes prints message.""" runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "No changes" in result.output @patch("scripts.ci.classify_changes.get_changed_files") @patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: """Quiet mode with user-facing changes outputs true.""" mock_changes.return_value = ["src/gitea_runner_manager/cli.py"] runner = CliRunner() result = runner.invoke(main, ["--quiet"]) assert result.exit_code == 0 assert "true" in result.output @patch("scripts.ci.classify_changes.get_changed_files") @patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: """Quiet mode with workflow-only changes outputs false.""" 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("scripts.ci.classify_changes.get_changed_files") @patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: """Explicit --base overrides latest tag.""" mock_changes.return_value = ["src/gitea_runner_manager/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