diff --git a/.github/actions/checkout_dependency/README.md b/.github/actions/checkout_dependency/README.md new file mode 100644 index 00000000..87057ba8 --- /dev/null +++ b/.github/actions/checkout_dependency/README.md @@ -0,0 +1,56 @@ +# checkout_dependency + +This action checks-out your repository under the specified destination directory using the action actions/checkout. Use the `depends-On: repository/pull/xx` to override the reference to checkout. + +# Usage + + + +```yaml +- uses: ansible-network/github_actions/.github/actions/checkout_dependency@main + with: + # Repository name with owner. For example, ansible-collections/kubernetes.core + repository: "" + + # The branch, tag, or SHA to checkout when the pull request body does not + # contain any override for this repository. + ref: "" + + # Relative path under $GITHUB_WORKSPACE to place the repository + path: "" + + # Number of commits to fetch. 0 indicates all history for all branches and tags. + fetch-depth: "1" +``` + + + +# Depending on others PRs + +The pull request body should contain the following sequence: + +``` +Depends-On: repository/pull/xx +``` + +# Scenarios + +- [checkout pull request 12345 from repository my_org/my_collection](#Checkout-depending-pull-request) + +## Checkout depending pull request + +Github action step: + +```yaml +- uses: ansible-network/github_actions/.github/actions/checkout_dependency@main + with: + repository: my_org/my_collection + ref: main + path: /path/to/checkout/repository +``` + +Pull request body: + +```text +Depends-On: https://github.com/my_org/my_collection/pull/12345 +``` diff --git a/.github/actions/checkout_dependency/action.yml b/.github/actions/checkout_dependency/action.yml new file mode 100644 index 00000000..4982f3d4 --- /dev/null +++ b/.github/actions/checkout_dependency/action.yml @@ -0,0 +1,48 @@ +name: "Checkout Dependency" +description: | + checkout repository and override commit based on keyword 'depends-on' from pull request message +inputs: + repository: + description: "Repository name with owner. For example, ansible-collections/cloud.common" + default: ${{ github.repository }} + ref: + description: "The default branch, tag or SHA to checkout if no reference (using depends-on) is found" + path: + description: "Relative path under $GITHUB_WORKSPACE to place the repository" + fetch-depth: + description: "Number of commits to fetch. 0 indicates all history for all branches and tags." + default: "1" +runs: + using: composite + steps: + - name: Set up Python '3.9' + uses: actions/setup-python@v4 + with: + python-version: "3.9" + + - name: install PyGithub + run: | + pip install -U PyGithub + shell: bash + + - id: resolve-dependency + shell: bash + run: | + python ${{ github.action_path }}/resolve_dependency.py + env: + RESOLVE_REF_PR_BODY: ${{ github.event.pull_request.body }} + RESOLVE_REF_REPOSITORY: ${{ inputs.repository }} + + - name: Display reference to checkout + run: echo "Resolved reference -> '${RESOLVED_REF}'" + shell: bash + env: + RESOLVED_REF: ${{ steps.resolve-dependency.outputs.merge_commit_sha }} + + - name: checkout repository + uses: actions/checkout@v3 + with: + repository: ${{ inputs.repository }} + path: ${{ inputs.path }} + ref: ${{ steps.resolve-dependency.outputs.merge_commit_sha || inputs.ref }} + fetch-depth: ${{ inputs.fetch-depth }} diff --git a/.github/actions/checkout_dependency/resolve_dependency.py b/.github/actions/checkout_dependency/resolve_dependency.py new file mode 100644 index 00000000..e84e3ba6 --- /dev/null +++ b/.github/actions/checkout_dependency/resolve_dependency.py @@ -0,0 +1,78 @@ +#!/usr/bin/python +"""Script to check if a depends-on pull request has been defined into pull request body.""" + +import logging +import os +import re +import sys + +from github import Github + + +FORMAT = "[%(asctime)s] - %(message)s" +logging.basicConfig(format=FORMAT) +logger = logging.getLogger("resolve_dependency") +logger.setLevel(logging.DEBUG) + + +def get_pr_merge_commit_sha(repository: str, pr_number: int) -> str: + """Retrieve pull request merge commit sha. + + :param repository: The repository name + :param pr_number: The pull request number + :returns: The pull request merge commit sha if it exists + :raises ValueError: if the pull request is not mergeable + """ + access_token = os.environ.get("GITHUB_TOKEN") + gh_obj = Github(access_token) + repo = gh_obj.get_repo(repository) + + pr_obj = repo.get_pull(pr_number) + if not pr_obj.mergeable: + # raise an error when the pull request is not mergeable + sys.tracebacklimit = -1 + raise ValueError(f"Pull request {pr_number} from {repository} is not mergeable") + + return pr_obj.merge_commit_sha + + +def resolve_ref(pr_body: str, repository: str) -> int: + """Get pull request reference number defined with Depends-On. + + :param pr_body: the pull request body + :param repository: The repository name + :returns: pull request number if it is defined else 0 + """ + pr_regx = re.compile( + rf"^Depends-On:[ ]*https://github.com/{repository}/pull/(\d+)\s*$", + re.MULTILINE | re.IGNORECASE, + ) + # Search for expression starting with depends-on not case-sensitive + match = pr_regx.search(pr_body) + return int(match.group(1)) if match else 0 + + +def main() -> None: + """Run the script.""" + pr_body = os.environ.get("RESOLVE_REF_PR_BODY") or "" + repository = os.environ.get("RESOLVE_REF_REPOSITORY") or "" + + if not repository: + return + + pr_number = resolve_ref(pr_body, repository) + if not pr_number: + return + logger.info("Override checkout with pr number: %d", pr_number) + + # get pull request merge commit sha + merge_commit_sha = get_pr_merge_commit_sha(repository, pr_number) + logger.info("merge commit sha for pull request %d => '%s'", pr_number, merge_commit_sha) + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(str(github_output), "a", encoding="utf-8") as file_handler: + file_handler.write(f"merge_commit_sha={merge_commit_sha}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/actions/checkout_dependency/test_resolve_dependency.py b/.github/actions/checkout_dependency/test_resolve_dependency.py new file mode 100644 index 00000000..716c156b --- /dev/null +++ b/.github/actions/checkout_dependency/test_resolve_dependency.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Module used to test resolve_dependency.py script.""" + +import os +import string + +from pathlib import PosixPath +from random import choice +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from resolve_dependency import get_pr_merge_commit_sha +from resolve_dependency import main +from resolve_dependency import resolve_ref + + +@pytest.mark.parametrize( + "pr_body,match", + [ + ("Depends-On: https://github.com/my_org/my_collection/pull/12345", True), + ( + "Depends-On: https://github.com/my_org/my_collection/pull/12345\n" + "Depends-On: https://github.com/my_org/my_collection/pull/67890", + True, + ), + ( + "Depends-On: https://github.com/another_org/my_collection/pull/4000\n" + "Depends-On: https://github.com/my_org/my_collection/pull/12345", + True, + ), + ( + "Depends-On: https://github.com/my_org/my_collection/pull/12345\n" + "Depends-On: https://github.com/my_org/my_collection/pull/67890", + True, + ), + ("Depends-On: https://github.com/another_org/my_collection/pull/12345", False), + ("Depends-On: https://github.com/my_org/my_collection2/pull/12345", False), + ("Depends-On: https://github.com/my_org/my_collection/pull", False), + ], +) +def test_resolve_ref(pr_body: str, match: bool) -> None: + """Test resolve_ref function. + + :param pr_body: pull request body + :param match: whether a depends-on should be found or not + """ + expected = 12345 if match else 0 + assert resolve_ref(pr_body, "my_org/my_collection") == expected + + +class FakePullRequest: + # pylint: disable=too-few-public-methods + """Class to simulate PullRequest Object.""" + + def __init__(self, mergeable: bool) -> None: + """Class constructor. + + :param mergeable: whether the pull request is mergeable or not + """ + self.mergeable = mergeable + self.merge_commit_sha = self.generate_commit_sha() + + @staticmethod + def generate_commit_sha(length: int = 16) -> str: + """Generate random commit sha. + + :param length: The length of the generated string + :returns: The generated commit sha + """ + data = string.ascii_letters + string.digits + return "".join([choice(data) for _ in range(length)]) + + +@pytest.mark.parametrize("mergeable", [True, False]) +@patch("resolve_dependency.Github") +def test_get_pr_merge_commit_sha(m_github: MagicMock, mergeable: bool) -> None: + """Test get_pr_merge_commit_sha function. + + :param m_github: The github module + :param mergeable: whether the pull request is mergeable or not + """ + github_obj = MagicMock() + m_github.return_value = github_obj + + os.environ["GITHUB_TOKEN"] = "unittest_github_token" + + m_github_repo = MagicMock() + github_obj.get_repo = MagicMock() + github_obj.get_repo.return_value = m_github_repo + + local_pr = FakePullRequest(mergeable=mergeable) + m_github_repo.get_pull = MagicMock() + m_github_repo.get_pull.return_value = local_pr + + repository = "my_testing_repository" + pr_number = 12345 + + if mergeable: + assert get_pr_merge_commit_sha(repository, pr_number) == local_pr.merge_commit_sha + else: + with pytest.raises(ValueError): + get_pr_merge_commit_sha(repository, pr_number) + + m_github.assert_called_once_with("unittest_github_token") + github_obj.get_repo.assert_called_once_with(repository) + m_github_repo.get_pull.assert_called_once_with(pr_number) + + +@pytest.mark.parametrize("repository", [True, False]) +@pytest.mark.parametrize("resolve_ref_pr", [0, 1]) +@patch("resolve_dependency.get_pr_merge_commit_sha") +@patch("resolve_dependency.resolve_ref") +def test_main( + m_resolve_ref: MagicMock, + m_get_pr_merge_commit_sha: MagicMock, + repository: bool, + resolve_ref_pr: int, + tmp_path: PosixPath, +) -> None: + """Test main function. + + :param m_resolve_ref: The resolve_ref mock function + :param m_get_pr_merge_commit_sha: The get_pr_merge_commit_sha mock function + :param repository: whether the repository is defined on environment variable or not + :param resolve_ref_pr: The pull request number + :param tmp_path: The temporary path for file to create for test + """ + pr_body = "My pull request body - this is a sample for unit tests" + repository_name = "my_test_repository" + os.environ["RESOLVE_REF_PR_BODY"] = pr_body + + gh_output_file = tmp_path / "github_output.txt" + env_update = {"GITHUB_OUTPUT": str(gh_output_file)} + if repository: + env_update.update({"RESOLVE_REF_REPOSITORY": repository_name}) + + m_resolve_ref.return_value = resolve_ref_pr + merge_commit_sha = FakePullRequest.generate_commit_sha() + m_get_pr_merge_commit_sha.return_value = merge_commit_sha + + with patch.dict(os.environ, env_update): + main() + + if not repository: + m_resolve_ref.assert_not_called() + m_get_pr_merge_commit_sha.assert_not_called() + assert not gh_output_file.exists() + elif not resolve_ref_pr: + m_resolve_ref.assert_called_once_with(pr_body, repository_name) + m_get_pr_merge_commit_sha.assert_not_called() + assert not gh_output_file.exists() + else: + m_resolve_ref.assert_called_once_with(pr_body, repository_name) + m_get_pr_merge_commit_sha.assert_called_once_with(repository_name, resolve_ref_pr) + assert gh_output_file.exists() + # gh_output_file.read_text() == f"merge_commit_sha={merge_commit_sha}\n" diff --git a/.github/workflows/ansible-lint.yml b/.github/workflows/ansible-lint.yml index 407b868a..db1db7df 100644 --- a/.github/workflows/ansible-lint.yml +++ b/.github/workflows/ansible-lint.yml @@ -6,6 +6,6 @@ jobs: ansible-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: ansible-network/github_actions/.github/actions/checkout_dependency@main - name: Run ansible-lint uses: ansible-community/ansible-lint-action@main diff --git a/.github/workflows/coverage_network_devices.yml b/.github/workflows/coverage_network_devices.yml index 435a99ae..6b8bbff8 100644 --- a/.github/workflows/coverage_network_devices.yml +++ b/.github/workflows/coverage_network_devices.yml @@ -18,7 +18,7 @@ jobs: name: "Code Coverage | Python 3.10" steps: - name: Checkout the collection repository - uses: actions/checkout@v3 + uses: ansible-network/github_actions/.github/actions/checkout_dependency@main with: path: ${{ env.source_directory }} ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index cb874b33..e43fd27d 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -54,7 +54,7 @@ jobs: name: "py${{ matrix.python-version }} / ${{ matrix.ansible-version }} / ${{ matrix.dependency-source }}" steps: - name: Checkout the collection repository - uses: actions/checkout@v3 + uses: ansible-network/github_actions/.github/actions/checkout_dependency@main with: path: ${{ env.source_directory }} ref: ${{ github.event.pull_request.head.sha }} @@ -79,7 +79,7 @@ jobs: - name: Checkout ${{ steps.identify.outputs.dependency }} if: steps.identify.outputs.dependency != '' && matrix.dependency-source == 'github' - uses: actions/checkout@v3 + uses: ansible-network/github_actions/.github/actions/checkout_dependency@main with: repository: ansible-collections/${{ steps.identify.outputs.dependency }} path: ${{ env.dependency_directory }} diff --git a/.github/workflows/integration_simple.yml b/.github/workflows/integration_simple.yml index 57e5113e..279072b0 100644 --- a/.github/workflows/integration_simple.yml +++ b/.github/workflows/integration_simple.yml @@ -87,7 +87,7 @@ jobs: name: "py${{ matrix.python-version }} / ${{ matrix.ansible-version }}" steps: - name: Checkout the collection repository - uses: actions/checkout@v3 + uses: ansible-network/github_actions/.github/actions/checkout_dependency@main with: path: ${{ env.source_directory }} ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/sanity.yml b/.github/workflows/sanity.yml index 13b610ee..7efaa97c 100644 --- a/.github/workflows/sanity.yml +++ b/.github/workflows/sanity.yml @@ -118,7 +118,7 @@ jobs: name: "py${{ matrix.python-version }} / ${{ matrix.os }} / ${{ matrix.ansible-version }}" steps: - name: Checkout the collection repository - uses: actions/checkout@v3 + uses: ansible-network/github_actions/.github/actions/checkout_dependency@main with: path: ${{ env.source_directory }} ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/unit_galaxy.yml b/.github/workflows/unit_galaxy.yml index 1367a805..b00f5ec5 100644 --- a/.github/workflows/unit_galaxy.yml +++ b/.github/workflows/unit_galaxy.yml @@ -118,7 +118,7 @@ jobs: name: "py${{ matrix.python-version }} / ${{ matrix.os }} / ${{ matrix.ansible-version }}" steps: - name: Checkout the collection repository - uses: actions/checkout@v3 + uses: ansible-network/github_actions/.github/actions/checkout_dependency@main with: path: ${{ env.source_directory }} ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/unit_source.yml b/.github/workflows/unit_source.yml index d02e9a08..6d3935eb 100644 --- a/.github/workflows/unit_source.yml +++ b/.github/workflows/unit_source.yml @@ -64,7 +64,7 @@ jobs: name: "py${{ matrix.python-version }} / ${{ matrix.ansible-version }}" steps: - name: Checkout the collection repository - uses: actions/checkout@v3 + uses: ansible-network/github_actions/.github/actions/checkout_dependency@main with: path: ${{ env.source_directory }} ref: ${{ github.event.pull_request.head.sha }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 60fd5df1..a42fa22d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,6 +62,8 @@ repos: - id: mypy additional_dependencies: - types-PyYAML + - pygithub + - pytest - repo: https://github.com/pycqa/pylint rev: v3.0.0a6 @@ -69,3 +71,18 @@ repos: - id: pylint additional_dependencies: - PyYAML + - pygithub + - pytest + + - repo: local + hooks: + - id: pytest-check + name: pytest-check + entry: pytest .github scripts -vvvv + types: [python] + language: python + pass_filenames: false + always_run: true + additional_dependencies: + - pytest + - pygithub