Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @ZPascal
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
2 changes: 1 addition & 1 deletion .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:

steps:
- name: Checkout the repository and the branch
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Setup the release version and overwrite the existing major version tag
run: |
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/pull-request-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
python-version: [ '3.12' ]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Set up Python
uses: actions/setup-python@v5
Expand All @@ -23,10 +23,10 @@ jobs:
cache: 'pip'

- name: Install the requirements
run: pip install pygit2
run: pip install -r requirements.txt

- name: Execute the unittests
run: python3 -m unittest discover tests
run: PYTHONPATH=src python3 -m unittest discover tests

pr-lint:
runs-on: ubuntu-latest
Expand All @@ -36,7 +36,7 @@ jobs:
python-version: [ '3.12' ]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Set up Python
uses: actions/setup-python@v5
Expand All @@ -50,4 +50,4 @@ jobs:
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
flake8_args: --config=.flake8
fail_on_error: true
fail_level: "error"
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ This GitHub Action checks for changed files in a Git repository and validates th
- Define a custom location of the Git repository
- Supports checking all files and folders in the directory
- The functionality is implemented in Python using the `pygit2` library and fully tested by unit tests
- The action can be used in GitHub Enterprise environments

---

## Inputs

| Name | Description | Required | Default |
|-------------------|----------------------------------------------------------------------------------------------|----------|-----------------------------|
| checked_location | Enter the location of the files, separated by `;`. Example: `src/;docs/test.txt;tests/test*` | true | |
| git_location | Path to the Git repository. | false | (current working directory) |
| check_all_files | Enables the check of all defined files and folders in the directory. | false | false |
| github_user_token | Define the used GitHub server user token for Github.com. | false | |
| Name | Description | Required | Default |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------|----------|-----------------------------|
| checked_location | Enter the location of the files, separated by `;`. Example: `src/;docs/test.txt;tests/test*` | true | |
| git_location | Path to the Git repository. | false | (current working directory) |
| check_all_files | Enables the check of all defined files and folders in the directory. | false | false |
| github_user_token | Define the used GitHub server user token for Github.com. | false | ${{ github.token }} |
| action_version | Define the used version of the Github action. The parameter should only be used, if you want force overwrite the default version. | false | ${{ github.action_ref }} |

---

Expand Down Expand Up @@ -54,19 +56,31 @@ jobs:
uses: actions/checkout@v4

- name: Check changed files
id: test-changed-files
uses: ZPascal/check-changed-files-action@v1
with:
checked_location: 'src/;docs/README.md'
git_location: './'
check_all_files: 'true'

- name: Test
if: steps.test-changed-files.outputs.files_changed == 'true'
run: |
echo "Files changed: ${{ steps.test-changed-files.outputs.files_changed }}"
```

---

## Output

- Logs info about changed files that are allowed or not allowed.
- The workflow fails if changed files are not within the allowed locations.
- Returns true/ false and reports if changed files are detected.

### Output table

| Name | Description |
|---------------|------------------------------------------------------------------|
| files_changed | Returns true if changed files are detected and false by default. |

---

Expand All @@ -79,4 +93,4 @@ jobs:

## License

The gcp-bucket-upload-action is licensed under the [Apache 2.0](LICENSE).
The check-changed-files-action is licensed under the [Apache 2.0](LICENSE).
20 changes: 15 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,33 @@ inputs:
required: false
github_user_token:
description: "Define the used GitHub server user token for Github.com"
default: "GITHUB_TOKEN"
default: ${{ github.token }}
required: false
action_version:
description: "Define the used version of the Github action. The parameter should only be used, if you want force overwrite the default version"
default: ${{ github.action_ref }}
required: false
outputs:
files_changed:
description: "Returns true if changed files are detected and false by default"
value: ${{ steps.check-changed-files.outputs.changed-files }}
runs:
using: "composite"
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: ZPascal/check-changed-files-action
ref: v1
github-server-url: https://oauth2:${{ secrets[inputs.github_user_token] }}@github.com
ref: ${{ inputs.action_version }}
github-server-url: https://github.com
token: ${{ inputs.github_user_token }}

- name: Install Python Dependencies
run: python3 -m pip install pygit2
shell: bash

- name: Check the changed files (wrapper)
id: check-changed-files
run: |
optional_flags=""
if [ -n "${{ inputs.git_location }}" ]; then
Expand All @@ -44,5 +54,5 @@ runs:
if [ "${{ inputs.check_all_files }}" == "true" ]; then
optional_flags+=" -caf"
fi
python3 src/check_changed_files.py -cl "${{ inputs.checked_location }}" $optional_flags
echo "changed-files=$(python3 src/check_changed_files.py -cl '${{ inputs.checked_location }}' $optional_flags)" >> "$GITHUB_OUTPUT"
shell: bash
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pygit2
37 changes: 24 additions & 13 deletions src/check_changed_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

class CheckedChangedFiles:
"""
A class to check for changed files in a Git repository and validates them against a list of allowed files/folders list.
A class to check for changed files in a Git repository and validates them against a list of allowed files/folders.

Attributes:
_logger (logging.Logger): Logger instance for tracking operations
Expand Down Expand Up @@ -37,7 +37,7 @@ def _parse_args() -> argparse.Namespace:

Returns:
_checked_location (str): Files and folder to check (semicolon-separated)
_git_location (str): Git location folder as a relative or absolute path (default is current working directory)
_git_location (str): Git location folder as a relative or absolute path (default is the current working directory)
_check_all_files (bool): Whether to check all files in the repository (default is False)
"""

Expand Down Expand Up @@ -113,12 +113,9 @@ def _get_changed_files(self) -> list[str]:
changed_files.append(filepath)
return changed_files

def validate_changed_files(self):
def files_changed(self) -> bool:
"""
Validates that all changed files are within the allowed checked locations.

Raises:
ValueError: If no changed files are found.
"""

changed_files: list[str] = self._get_changed_files()
Expand All @@ -134,19 +131,33 @@ def validate_changed_files(self):

if len(changed_files) == checked_files_and_folders_counter:
self._logger.info(
f"All changed files are allowed in checked location {checked_files_and_folder}."
f"All changed files are allowed in checked location {checked_files_and_folders}."
)
return
return True
else:
self._logger.info(
f"Changed file {changed_file} is allowed in checked location "
f"{checked_files_and_folder}."
f"{checked_files_and_folders}."
)
break
return True
else:
file_found = False
for other_checked_location in checked_files_and_folders:
if other_checked_location in changed_file:
file_found = True
break
if not file_found and checked_files_and_folder == checked_files_and_folders[-1]:
self._logger.info(
f"Changed file {changed_file} is not a part of the checked location "
f"{checked_files_and_folders}."
)
return False
return False
else:
raise ValueError("No changed files found.")

self._logger.info("No changed files found.")
return False

if __name__ == "__main__":
checked_changed_files: CheckedChangedFiles = CheckedChangedFiles()
checked_changed_files.validate_changed_files()
files_changed: bool = checked_changed_files.files_changed()
print(str(files_changed).lower())
104 changes: 94 additions & 10 deletions tests/test_check_changed_files.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import sys
import io
import runpy
from contextlib import redirect_stdout

from unittest import TestCase
from unittest.mock import patch, MagicMock, Mock

Expand Down Expand Up @@ -131,38 +136,117 @@ def test_get_changed_files_key_error(self, mock_repo, mock_validate):
)

@patch("check_changed_files.CheckedChangedFiles._get_changed_files")
def test_validate_changed_files_with_changes(self, get_changed_files_mock):
def test_files_changed_with_changes(self, get_changed_files_mock):
get_changed_files_mock.return_value = ["src/test.py"]

self.check_changed_files._checked_location = "src/"
self.check_changed_files._check_all_files = False
self.check_changed_files._logger = MagicMock()
self.check_changed_files.validate_changed_files()
self.assertEqual(True, self.check_changed_files.files_changed())

self.check_changed_files._logger.info.assert_called_with(
"Changed file src/test.py is allowed in checked location src/."
"Changed file src/test.py is allowed in checked location ['src/']."
)

@patch("check_changed_files.CheckedChangedFiles._get_changed_files")
def test_validate_changed_files_no_changes(self, get_changed_files_mock):
def test_files_changed_no_changes(self, get_changed_files_mock):
get_changed_files_mock.return_value = []

self.check_changed_files._checked_location = "src/"
self.check_changed_files._check_all_files = False
self.check_changed_files._logger = MagicMock()

with self.assertRaises(ValueError):
self.check_changed_files.validate_changed_files()
self.assertEqual(False, self.check_changed_files.files_changed())

@patch("check_changed_files.CheckedChangedFiles._get_changed_files")
def test_validate_changed_files_with_changes_all_changes(self, get_changed_files_mock):
def test_files_changed_with_changes_all_changes(self, get_changed_files_mock):
get_changed_files_mock.return_value = ["src/test.py"]

self.check_changed_files._checked_location = "src/"
self.check_changed_files._check_all_files = True
self.check_changed_files._logger = MagicMock()
self.check_changed_files.validate_changed_files()
self.assertEqual(True, self.check_changed_files.files_changed())

self.check_changed_files._logger.info.assert_called_with(
"All changed files are allowed in checked location ['src/']."
)

@patch("check_changed_files.CheckedChangedFiles._get_changed_files")
def test_files_changed_multiple_locations(self, get_changed_files_mock):
get_changed_files_mock.return_value = ["src/test.py", "docs/README.md"]

self.check_changed_files._checked_location = "src;docs"
self.check_changed_files._check_all_files = True
self.check_changed_files._logger = MagicMock()

self.assertEqual(True, self.check_changed_files.files_changed())

self.check_changed_files._logger.info.assert_called_with(
"All changed files are allowed in checked location ['src', 'docs']."
)

@patch("check_changed_files.CheckedChangedFiles._get_changed_files")
def test_files_changed_with_not_allowed_changes(self, get_changed_files_mock):
get_changed_files_mock.return_value = ["temp/test.py"]

self.check_changed_files._checked_location = "src/"
self.check_changed_files._check_all_files = False
self.check_changed_files._logger = MagicMock()

self.assertEqual(False, self.check_changed_files.files_changed())

self.check_changed_files._logger.info.assert_called_with(
"Changed file temp/test.py is not a part of the checked location ['src/']."
)

@patch("check_changed_files.CheckedChangedFiles._get_changed_files")
def test_files_changed_partial_matches_all_files(self, get_changed_files_mock):
get_changed_files_mock.return_value = ["src/test.py", "temp/test.py"]

self.check_changed_files._checked_location = "src/"
self.check_changed_files._check_all_files = True
self.check_changed_files._logger = MagicMock()

self.assertEqual(False, self.check_changed_files.files_changed())

self.check_changed_files._logger.info.assert_called_with(
"Changed file temp/test.py is not a part of the checked location ['src/']."
)

@patch("check_changed_files.CheckedChangedFiles._get_changed_files")
def test_files_changed_multiple_checks(self, get_changed_files_mock):
get_changed_files_mock.return_value = ["src/subfolder/test.py"]

self.check_changed_files._checked_location = "docs/;src/;test/"
self.check_changed_files._check_all_files = False
self.check_changed_files._logger = MagicMock()

self.assertEqual(True, self.check_changed_files.files_changed())

self.check_changed_files._logger.info.assert_called_with(
"All changed files are allowed in checked location src/."
)
"Changed file src/subfolder/test.py is allowed in checked location ['docs/', 'src/', 'test/']."
)

@patch("check_changed_files.CheckedChangedFiles._get_changed_files")
def test_files_changed_returns_false_when_checked_location_is_empty(self, get_changed_files_mock):
get_changed_files_mock.return_value = ["some/path/file.txt"]

class EmptySplit:
def split(self, sep):
return []

self.check_changed_files._checked_location = EmptySplit()
self.check_changed_files._check_all_files = True
self.check_changed_files._logger = MagicMock()

self.assertFalse(self.check_changed_files.files_changed())

@patch("os.path.exists", retun_value=True)
@patch("os.path.isdir", return_value=True)
def test_main_prints_false(self, isdir_mock, exists_mock):
argv = ["check_changed_files.py", "-cl", "src", "-gl", "."]
with patch.object(sys, "argv", argv):
buf = io.StringIO()
with redirect_stdout(buf):
runpy.run_module("check_changed_files", run_name="__main__")
self.assertEqual("false\n", buf.getvalue())