diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml index 916c14f..510cbaf 100644 --- a/.github/workflows/pull-request-checks.yml +++ b/.github/workflows/pull-request-checks.yml @@ -5,12 +5,11 @@ on: branches: [ main ] jobs: - pr-test: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.12' ] + python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ] steps: - uses: actions/checkout@v6 @@ -51,3 +50,277 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} flake8_args: --config=.flake8 fail_level: "error" + + integrationtest-test-action-usage: + runs-on: ubuntu-latest + needs: [ pr-test, pr-lint ] + name: Integrationtest | Test Action - Check Changed Files + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create test changes in src directory + run: | + echo "# Test change" >> src/test_file.py + echo "print('test')" >> src/another_test.py + + - name: Run check-changed-files-action for src directory + id: check-src + uses: ./ + with: + checked_location: 'src/' + git_location: './' + + - name: Verify src changes detected + run: | + git status + echo "Files changed in src: ${{ steps.check-src.outputs.files_changed }}" + if [ "${{ steps.check-src.outputs.files_changed }}" != "true" ]; then + echo "ERROR: Expected files_changed=true for src/ changes" + exit 1 + fi + + - name: Create test changes in docs directory + run: | + mkdir -p docs + echo "# Documentation" >> docs/test.md + + - name: Run check-changed-files-action for docs directory + id: check-docs + uses: ./ + with: + checked_location: 'docs/' + git_location: './' + + - name: Verify docs changes detected + run: | + echo "Files changed in docs: ${{ steps.check-docs.outputs.files_changed }}" + if [ "${{ steps.check-docs.outputs.files_changed }}" != "true" ]; then + echo "ERROR: Expected files_changed=true for docs/ changes" + exit 1 + fi + + - name: Verify the GH if condition format works for docs + if: steps.check-docs.outputs.files_changed == 'true' + run: | + echo "Files changed: ${{ steps.check-docs.outputs.files_changed }}" + + - name: Run check-changed-files-action for non-existent directory + id: check-nonexistent + uses: ./ + with: + checked_location: 'nonexistent/' + git_location: './' + check_all_files: 'false' + + - name: Verify non-existent directory returns false + run: | + echo "Files changed in nonexistent: ${{ steps.check-nonexistent.outputs.files_changed }}" + # This should be false since we didn't change files in nonexistent/ + # (we have changes in src/ and docs/, but not in nonexistent/) + + - name: Verify the GH if condition format works for nonexistent + if: steps.check-nonexistent.outputs.files_changed == 'false' + run: | + echo "Files changed: ${{ steps.check-nonexistent.outputs.files_changed }}" + + integrationtest-test-action-check-all-files: + runs-on: ubuntu-latest + needs: [ pr-test, pr-lint ] + name: Integrationtest | Test Action - Check All Files Mode + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create test changes in allowed directories + run: | + echo "# Test change in src" >> src/test_src.py + echo "# Test change in tests" >> tests/test_integration_new.py + + - name: Run check-changed-files-action with check_all_files=true + id: check-all-allowed + uses: ./ + with: + checked_location: 'src/;tests/' + git_location: './' + check_all_files: 'true' + + - name: Verify all files in allowed locations + run: | + echo "All files allowed: ${{ steps.check-all-allowed.outputs.files_changed }}" + if [ "${{ steps.check-all-allowed.outputs.files_changed }}" != "true" ]; then + echo "ERROR: Expected files_changed=true when all files are in allowed locations" + exit 1 + fi + + - name: Create change in non-allowed directory + run: | + mkdir -p config + echo "key: value" >> config/settings.yml + + - name: Run check-changed-files-action with mixed locations + id: check-all-mixed + uses: ./ + with: + checked_location: 'src/;tests/' + git_location: './' + check_all_files: 'true' + + - name: Verify mixed locations fail with check_all_files=true + run: | + echo "Mixed locations result: ${{ steps.check-all-mixed.outputs.files_changed }}" + if [ "${{ steps.check-all-mixed.outputs.files_changed }}" != "false" ]; then + echo "ERROR: Expected files_changed=false when not all files are in allowed locations" + exit 1 + fi + + integrationtest-test-action-multiple-locations: + runs-on: ubuntu-latest + needs: [ pr-test, pr-lint ] + name: Integrationtest | Test Action - Multiple Locations + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create changes in multiple directories + run: | + echo "# Change 1" >> src/file1.py + echo "# Change 2" >> tests/file2.py + mkdir -p docs + echo "# Change 3" >> docs/file3.md + + - name: Run check-changed-files-action with multiple locations + id: check-multiple + uses: ./ + with: + checked_location: 'src/;tests/;docs/' + git_location: './' + check_all_files: 'true' + + - name: Verify multiple locations work + run: | + echo "Multiple locations result: ${{ steps.check-multiple.outputs.files_changed }}" + if [ "${{ steps.check-multiple.outputs.files_changed }}" != "true" ]; then + echo "ERROR: Expected files_changed=true for multiple allowed locations" + exit 1 + fi + + integrationtest-test-action-specific-files: + runs-on: ubuntu-latest + needs: [ pr-test, pr-lint ] + name: Integrationtest | Test Action - Specific File Patterns + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Modify README.md + run: | + echo "# Updated README" >> README.md + + - name: Run check-changed-files-action for specific file + id: check-readme + uses: ./ + with: + checked_location: 'README.md' + git_location: './' + check_all_files: 'false' + + - name: Verify specific file detection + run: | + echo "README.md changed: ${{ steps.check-readme.outputs.files_changed }}" + if [ "${{ steps.check-readme.outputs.files_changed }}" != "true" ]; then + echo "ERROR: Expected files_changed=true for README.md" + exit 1 + fi + + - name: Modify LICENSE + run: | + echo "# Comment" >> LICENSE + + - name: Run check-changed-files-action for LICENSE + id: check-action + uses: ./ + with: + checked_location: 'LICENSE' + git_location: './' + check_all_files: 'false' + + - name: Verify LICENSE detection + run: | + echo "LICENSE changed: ${{ steps.check-action.outputs.files_changed }}" + if [ "${{ steps.check-action.outputs.files_changed }}" != "true" ]; then + echo "ERROR: Expected files_changed=true for LICENSE" + exit 1 + fi + + integrationtest-test-action-with-token: + runs-on: ubuntu-latest + needs: [ pr-test, pr-lint ] + name: Integrationtest | Test Action - With GitHub Token + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create test change + run: | + echo "# Test with token" >> src/token_test.py + + - name: Run check-changed-files-action with token + id: check-with-token + uses: ./ + with: + checked_location: 'src/' + git_location: './' + check_all_files: 'false' + github_user_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify token scenario works + run: | + echo "With token result: ${{ steps.check-with-token.outputs.files_changed }}" + if [ "${{ steps.check-with-token.outputs.files_changed }}" != "true" ]; then + echo "ERROR: Expected files_changed=true with token" + exit 1 + fi + + integrationtest-run-python-integration-tests: + runs-on: ubuntu-latest + name: Integrationtest | Run Python Integration Tests + needs: [ pr-test, pr-lint ] + strategy: + matrix: + python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run integration tests + run: | + PYTHONPATH=src python -m unittest tests.test_integration -v diff --git a/action.yml b/action.yml index d8e5e56..94be92b 100644 --- a/action.yml +++ b/action.yml @@ -74,7 +74,10 @@ runs: REF_PATH="main" fi - curl -s -L -o /tmp/check_changed_files.py "https://${AUTH_PREFIX}raw.githubusercontent.com/ZPascal/check-changed-files-action/$REF_PATH/src/check_changed_files.py" + if ! curl -f -s -L -o /tmp/check_changed_files.py "https://${AUTH_PREFIX}raw.githubusercontent.com/ZPascal/check-changed-files-action/$REF_PATH/src/check_changed_files.py"; then + echo "Error: Failed to download check_changed_files.py script" + exit 1 + fi optional_flags="" if [ -n "${{ inputs.git_location }}" ]; then @@ -83,5 +86,5 @@ runs: if [ "${{ inputs.check_all_files }}" == "true" ]; then optional_flags+=" -caf" fi - echo "changed-files='$(python3 /tmp/check_changed_files.py -cl '${{ inputs.checked_location }}' $optional_flags)'" >> "$GITHUB_OUTPUT" + echo "changed-files=$(python3 /tmp/check_changed_files.py -cl '${{ inputs.checked_location }}' $optional_flags)" >> "$GITHUB_OUTPUT" shell: bash diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..f6c6e29 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python +""" +Integration tests for the Check Changed Files GitHub Action. + +These tests create real Git repositories and verify the action's behavior +in realistic scenarios. +""" + +import os +import shutil +import tempfile +import unittest +from unittest.mock import patch +import argparse + +from pygit2 import Repository, init_repository, Signature + +from check_changed_files import CheckedChangedFiles + + +class TestCheckChangedFilesIntegration(unittest.TestCase): + """Integration tests for CheckedChangedFiles with real Git repositories.""" + + def setUp(self): + """Create a temporary directory for test repositories.""" + self.test_dir = tempfile.mkdtemp() + self.original_cwd = os.getcwd() + + def tearDown(self): + """Clean up temporary directory after tests.""" + os.chdir(self.original_cwd) + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _create_git_repo(self, repo_path: str) -> Repository: + """ + Create a new Git repository with an initial commit. + + Args: + repo_path: Path where the repository should be created + + Returns: + Repository: The initialized repository + """ + repo = init_repository(repo_path) + + # Create an initial commit + signature = Signature("Test User", "test@example.com") + + # Create initial file + initial_file = os.path.join(repo_path, "README.md") + with open(initial_file, "w") as f: + f.write("# Test Repository\n") + + # Add and commit + repo.index.add("README.md") + repo.index.write() + tree = repo.index.write_tree() + repo.create_commit("HEAD", signature, signature, "Initial commit", tree, []) + + return repo + + def _modify_file(self, repo_path: str, file_path: str, content: str): + """ + Modify or create a file in the repository. + + Args: + repo_path: Path to the repository + file_path: Relative path to the file + content: Content to write to the file + """ + full_path = os.path.join(repo_path, file_path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + + def test_integration_single_file_changed_in_allowed_location(self): + """Test that a single file change in an allowed location is detected.""" + repo_path = os.path.join(self.test_dir, "test_repo_1") + self._create_git_repo(repo_path) + + # Modify a file in the src directory + self._modify_file(repo_path, "src/main.py", 'print("Hello World")\n') + + # Test the action + with patch("argparse.ArgumentParser.parse_args") as mock_args: + mock_args.return_value = argparse.Namespace( + checked_location="src/", git_location=repo_path, check_all_files=False + ) + + checker = CheckedChangedFiles() + result = checker.files_changed() + + self.assertTrue(result) + + def test_integration_single_file_changed_in_disallowed_location(self): + """Test that a single file change in a disallowed location is detected.""" + repo_path = os.path.join(self.test_dir, "test_repo_2") + self._create_git_repo(repo_path) + + # Modify a file in the docs directory + self._modify_file(repo_path, "docs/guide.md", "# User Guide\n") + + # Test the action with only src/ allowed + with patch("argparse.ArgumentParser.parse_args") as mock_args: + mock_args.return_value = argparse.Namespace( + checked_location="src/", git_location=repo_path, check_all_files=False + ) + + checker = CheckedChangedFiles() + result = checker.files_changed() + + self.assertFalse(result) + + def test_integration_multiple_files_all_allowed(self): + """Test multiple file changes where all are in allowed locations.""" + repo_path = os.path.join(self.test_dir, "test_repo_3") + self._create_git_repo(repo_path) + + # Modify multiple files in allowed directories + self._modify_file(repo_path, "src/main.py", 'print("Hello")\n') + self._modify_file(repo_path, "src/utils.py", "def helper(): pass\n") + self._modify_file(repo_path, "tests/test_main.py", "def test(): pass\n") + + # Test with check_all_files=True + with patch("argparse.ArgumentParser.parse_args") as mock_args: + mock_args.return_value = argparse.Namespace( + checked_location="src/;tests/", + git_location=repo_path, + check_all_files=True, + ) + + checker = CheckedChangedFiles() + result = checker.files_changed() + + self.assertTrue(result) + + def test_integration_multiple_files_mixed_locations_check_all_files(self): + """Test multiple file changes with mixed allowed/disallowed locations and check_all_files=True.""" + repo_path = os.path.join(self.test_dir, "test_repo_4") + self._create_git_repo(repo_path) + + # Modify files in both allowed and disallowed directories + self._modify_file(repo_path, "src/main.py", 'print("Hello")\n') + self._modify_file(repo_path, "config/settings.yml", "key: value\n") + + # Test with check_all_files=True - should fail because config/ is not allowed + with patch("argparse.ArgumentParser.parse_args") as mock_args: + mock_args.return_value = argparse.Namespace( + checked_location="src/", git_location=repo_path, check_all_files=True + ) + + checker = CheckedChangedFiles() + result = checker.files_changed() + + self.assertFalse(result) + + def test_integration_multiple_files_mixed_locations_check_any_file(self): + """Test multiple file changes with mixed allowed/disallowed locations and check_all_files=False.""" + repo_path = os.path.join(self.test_dir, "test_repo_5") + self._create_git_repo(repo_path) + + # Modify files in both allowed and disallowed directories + self._modify_file(repo_path, "src/main.py", 'print("Hello")\n') + self._modify_file(repo_path, "config/settings.yml", "key: value\n") + + # Test with check_all_files=False - should succeed because at least one file is allowed + with patch("argparse.ArgumentParser.parse_args") as mock_args: + mock_args.return_value = argparse.Namespace( + checked_location="src/", git_location=repo_path, check_all_files=False + ) + + checker = CheckedChangedFiles() + result = checker.files_changed() + + self.assertTrue(result) + + def test_integration_no_changes(self): + """Test repository with no changes.""" + repo_path = os.path.join(self.test_dir, "test_repo_6") + self._create_git_repo(repo_path) + + # Don't modify any files + + # Test the action + with patch("argparse.ArgumentParser.parse_args") as mock_args: + mock_args.return_value = argparse.Namespace( + checked_location="src/", git_location=repo_path, check_all_files=False + ) + + checker = CheckedChangedFiles() + result = checker.files_changed() + + self.assertFalse(result) + + def test_integration_nested_directory_structure(self): + """Test with deeply nested directory structures.""" + repo_path = os.path.join(self.test_dir, "test_repo_7") + self._create_git_repo(repo_path) + + # Create deeply nested structure + self._modify_file( + repo_path, "src/components/ui/button/Button.py", "class Button: pass\n" + ) + self._modify_file( + repo_path, "src/utils/helpers/string_helpers.py", "def trim(): pass\n" + ) + + # Test the action + with patch("argparse.ArgumentParser.parse_args") as mock_args: + mock_args.return_value = argparse.Namespace( + checked_location="src/", git_location=repo_path, check_all_files=True + ) + + checker = CheckedChangedFiles() + result = checker.files_changed() + + self.assertTrue(result) + + def test_integration_specific_file_pattern(self): + """Test with specific file patterns in checked_location.""" + repo_path = os.path.join(self.test_dir, "test_repo_8") + self._create_git_repo(repo_path) + + # Modify specific files + self._modify_file(repo_path, "docs/README.md", "# Updated README\n") + self._modify_file(repo_path, "docs/guide.md", "# Guide\n") + + # Test the action with specific file in checked_location + with patch("argparse.ArgumentParser.parse_args") as mock_args: + mock_args.return_value = argparse.Namespace( + checked_location="docs/README.md", + git_location=repo_path, + check_all_files=False, + ) + + checker = CheckedChangedFiles() + result = checker.files_changed() + + self.assertTrue(result) + + def test_integration_multiple_semicolon_separated_locations(self): + """Test with multiple semicolon-separated locations.""" + repo_path = os.path.join(self.test_dir, "test_repo_9") + self._create_git_repo(repo_path) + + # Modify files in different directories + self._modify_file(repo_path, "src/main.py", 'print("Hello")\n') + self._modify_file(repo_path, "docs/api.md", "# API\n") + self._modify_file(repo_path, "tests/test_api.py", "def test(): pass\n") + + # Test the action with multiple locations + with patch("argparse.ArgumentParser.parse_args") as mock_args: + mock_args.return_value = argparse.Namespace( + checked_location="src/;docs/;tests/", + git_location=repo_path, + check_all_files=True, + ) + + checker = CheckedChangedFiles() + result = checker.files_changed() + + self.assertTrue(result) + + def test_integration_file_deletion(self): + """Test detection of deleted files.""" + repo_path = os.path.join(self.test_dir, "test_repo_10") + repo = self._create_git_repo(repo_path) + + # Create and commit a file first + signature = Signature("Test User", "test@example.com") + self._modify_file(repo_path, "src/old_file.py", 'print("Old")\n') + + repo.index.add("src/old_file.py") + repo.index.write() + tree = repo.index.write_tree() + parent = repo.head.target + repo.create_commit("HEAD", signature, signature, "Add old file", tree, [parent]) + + # Now delete the file + os.remove(os.path.join(repo_path, "src/old_file.py")) + + # Test the action + with patch("argparse.ArgumentParser.parse_args") as mock_args: + mock_args.return_value = argparse.Namespace( + checked_location="src/", git_location=repo_path, check_all_files=False + ) + + checker = CheckedChangedFiles() + result = checker.files_changed() + + self.assertTrue(result) + + def test_integration_new_file_creation(self): + """Test detection of newly created files.""" + repo_path = os.path.join(self.test_dir, "test_repo_11") + self._create_git_repo(repo_path) + + # Create a new file (not committed) + self._modify_file(repo_path, "src/new_feature.py", "def new_feature(): pass\n") + + # Test the action + with patch("argparse.ArgumentParser.parse_args") as mock_args: + mock_args.return_value = argparse.Namespace( + checked_location="src/", git_location=repo_path, check_all_files=False + ) + + checker = CheckedChangedFiles() + result = checker.files_changed() + + self.assertTrue(result) + + +if __name__ == "__main__": + unittest.main()