diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bea5d43f..c984df44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,7 +165,7 @@ cat envfile GITHUB_OUTPUT= INPUT_SKIP_ITEMS= -INPUT_CHECK_ONLY=true +INPUT_DRY_RUN=true INPUT_SKIP_ASSEMBLE=false INPUT_SKIP_REGENERATE=false INPUT_REPOSITORY=. diff --git a/actions/autosync/README.md b/actions/autosync/README.md index 1fecb987..61b56239 100644 --- a/actions/autosync/README.md +++ b/actions/autosync/README.md @@ -25,7 +25,7 @@ name: Example Workflow | --- | --- | --- | --- | | markdown_path | Path relative to the repository path where the Trestle markdown files are located. See action README.md for more information. | None | True | | oscal_model | OSCAL Model type to assemble. Values can be catalog, profile, compdef, or ssp. | None | True | -| check_only | Runs tasks and exits with an error if there is a diff. Defaults to false | false | False | +| dry_run | Runs tasks without pushing changes to the repository. | false | False | | github_token | GitHub token used to make authenticated API requests | None | False | | version | Version of the OSCAL model to set during assembly into JSON. | None | False | | skip_assemble | Skip assembly task. Defaults to false | false | False | @@ -106,18 +106,28 @@ The purpose of this action is to sync JSON and Markdown data with `compliance-tr github_token: ${{ secret.GITHUB_TOKEN }} ``` -- When `check_only` is set, the trestle `assemble` and `regenerate` tasks are run and the repository is checked for changes. If changes exists, the action with exit with an error. This can be useful if you only want to check that the content is in sync without making any changes to the remote repository. +- When `dry_run` is set, the trestle `assemble` and `regenerate` tasks are run and changes are not pushed to the remote repository. The files that would be changed are logged and the output `changes` is set to true. + +This can be helpful if you want to enforce that the content is in sync before it is merged into the repository with out making changes to the remote repository (e.g. helpful for changes from forks). If assembly and regeneratation are triggered by pushes to main, it can validate that the changes will be successful before merging to main to avoid unexpected errors. ```yaml steps: - uses: actions/checkout@v3 - name: Run trestlebot - id: trestlebot + id: check uses: RedHatProductSecurity/trestle-bot/actions/autosync@main with: markdown_path: "markdown/profiles" oscal_model: "profile" - check_only: true + dry_run: true + # Optional - Set the action to failed if changes are detected. + - name: Fail for changes + if: ${{ steps.check.outputs.changes == 'true' }} + uses: actions/github-script@v7 + with: + script: | + core.setFailed('Changes detected. Manual intervention required.') + ``` > Note: Trestle `assemble` or `regenerate` tasks may be skipped if desired using `skip_assemble: true` or `skip_regenerate: true`, respectively. \ No newline at end of file diff --git a/actions/autosync/action.yml b/actions/autosync/action.yml index dcdb318f..be9590fe 100644 --- a/actions/autosync/action.yml +++ b/actions/autosync/action.yml @@ -9,8 +9,8 @@ inputs: oscal_model: description: OSCAL Model type to assemble. Values can be catalog, profile, compdef, or ssp. required: true - check_only: - description: "Runs tasks and exits with an error if there is a diff. Defaults to false" + dry_run: + description: "Runs tasks without pushing changes to the repository." required: false default: "false" github_token: diff --git a/actions/autosync/auto-sync-entrypoint.sh b/actions/autosync/auto-sync-entrypoint.sh index f4731450..35a4e713 100644 --- a/actions/autosync/auto-sync-entrypoint.sh +++ b/actions/autosync/auto-sync-entrypoint.sh @@ -34,8 +34,8 @@ if [[ ${INPUT_SKIP_REGENERATE} == true ]]; then command+=" --skip-regenerate" fi -if [[ ${INPUT_CHECK_ONLY} == true ]]; then - command+=" --check-only" +if [[ ${INPUT_DRY_RUN} == true ]]; then + command+=" --dry-run" fi if [[ ${INPUT_VERBOSE} == true ]]; then @@ -52,4 +52,4 @@ if [[ -n ${INPUT_TARGET_BRANCH} ]]; then command+=" --with-token - <<<\"${GITHUB_TOKEN}\"" fi -execute_command "${command}" \ No newline at end of file +eval "${command}" \ No newline at end of file diff --git a/actions/common.sh b/actions/common.sh index 4d78f1e2..f4b68953 100644 --- a/actions/common.sh +++ b/actions/common.sh @@ -15,24 +15,3 @@ function set_git_safe_directory() { fi } -# Execute the command and set the output variables for GitHub Actions -function execute_command() { - local command=$1 - exec 3>&1 - output=$(eval "$command" > >(tee /dev/fd/3) 2>&1) - - commit=$(echo "$output" | grep "Commit Hash:" | sed 's/.*: //') - - if [ -n "$commit" ]; then - echo "changes=true" >> "$GITHUB_OUTPUT" - echo "commit=$commit" >> "$GITHUB_OUTPUT" - else - echo "changes=false" >> "$GITHUB_OUTPUT" - fi - - pr_number=$(echo "$output" | grep "Pull Request Number:" | sed 's/.*: //') - - if [ -n "$pr_number" ]; then - echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" - fi -} diff --git a/actions/create-cd/README.md b/actions/create-cd/README.md index a15d1a8b..b3929e68 100644 --- a/actions/create-cd/README.md +++ b/actions/create-cd/README.md @@ -32,6 +32,7 @@ name: Example Workflow | component_type | Type of the component to create. Values can be interconnection, software, hardware, service, policy, physical, process-procedure, plan, guidance, standard, or validation | service | False | | component_description | Description of the component to create | None | True | | filter_by_profile | Name of the profile in the workspace to filter controls by | None | False | +| dry_run | Runs tasks without pushing changes to the repository. | false | False | | github_token | GitHub token used to make authenticated API requests | None | False | | commit_message | Commit message | Sync automatic updates | False | | pull_request_title | Custom pull request title | Automatic updates from trestlebot | False | diff --git a/actions/create-cd/action.yml b/actions/create-cd/action.yml index da2034dd..06f14cb7 100644 --- a/actions/create-cd/action.yml +++ b/actions/create-cd/action.yml @@ -25,6 +25,10 @@ inputs: filter_by_profile: description: Name of the profile in the workspace to filter controls by required: false + dry_run: + description: "Runs tasks without pushing changes to the repository." + required: false + default: "false" github_token: description: "GitHub token used to make authenticated API requests" required: false diff --git a/actions/create-cd/create-cd-entrypoint.sh b/actions/create-cd/create-cd-entrypoint.sh index 5fd93b27..5c62ec68 100644 --- a/actions/create-cd/create-cd-entrypoint.sh +++ b/actions/create-cd/create-cd-entrypoint.sh @@ -32,6 +32,10 @@ if [[ ${INPUT_VERBOSE} == true ]]; then command+=" --verbose" fi +if [[ ${INPUT_DRY_RUN} == true ]]; then + command+=" --dry-run" +fi + # Only set the token value when is a target branch so pull requests can be created if [[ -n ${INPUT_TARGET_BRANCH} ]]; then if [[ -z ${GITHUB_TOKEN} ]]; then @@ -42,4 +46,4 @@ if [[ -n ${INPUT_TARGET_BRANCH} ]]; then command+=" --with-token - <<<\"${GITHUB_TOKEN}\"" fi -execute_command "${command}" \ No newline at end of file +eval "${command}" \ No newline at end of file diff --git a/actions/rules-transform/README.md b/actions/rules-transform/README.md index 6e376864..729399ff 100644 --- a/actions/rules-transform/README.md +++ b/actions/rules-transform/README.md @@ -33,6 +33,7 @@ With custom rules directory: | Name | Description | Default | Required | | --- | --- | --- | --- | | rules_view_path | Path relative to the repository path where the Trestle rules view files are located. Defaults to `rules/`. | rules/ | False | +| dry_run | Runs tasks without pushing changes to the repository. | false | False | | github_token | GitHub token used to make authenticated API requests | None | False | | skip_items | Comma-separated glob patterns list of content by Trestle name to skip during task execution. For example `compdef_x,compdef_y*,`. | None | False | | commit_message | Commit message | Sync automatic updates | False | diff --git a/actions/rules-transform/action.yml b/actions/rules-transform/action.yml index 123270b9..832cb4f2 100644 --- a/actions/rules-transform/action.yml +++ b/actions/rules-transform/action.yml @@ -7,6 +7,10 @@ inputs: description: Path relative to the repository path where the Trestle rules view files are located. Defaults to `rules/`. required: false default: "rules/" + dry_run: + description: "Runs tasks without pushing changes to the repository." + required: false + default: "false" github_token: description: "GitHub token used to make authenticated API requests" required: false diff --git a/actions/rules-transform/rules-transform-entrypoint.sh b/actions/rules-transform/rules-transform-entrypoint.sh index 8fb9d814..681c8193 100644 --- a/actions/rules-transform/rules-transform-entrypoint.sh +++ b/actions/rules-transform/rules-transform-entrypoint.sh @@ -27,6 +27,11 @@ if [[ ${INPUT_VERBOSE} == true ]]; then command+=" --verbose" fi +if [[ ${INPUT_DRY_RUN} == true ]]; then + command+=" --dry-run" +fi + + # Only set the token value when is a target branch so pull requests can be created if [[ -n ${INPUT_TARGET_BRANCH} ]]; then if [[ -z ${GITHUB_TOKEN} ]]; then @@ -37,4 +42,4 @@ if [[ -n ${INPUT_TARGET_BRANCH} ]]; then command+=" --with-token - <<<\"${GITHUB_TOKEN}\"" fi -execute_command "${command}" \ No newline at end of file +eval "${command}" \ No newline at end of file diff --git a/actions/sync-upstreams/README.md b/actions/sync-upstreams/README.md index 5dd5c298..65854f25 100644 --- a/actions/sync-upstreams/README.md +++ b/actions/sync-upstreams/README.md @@ -23,6 +23,7 @@ name: Example Workflow | Name | Description | Default | Required | | --- | --- | --- | --- | | sources | A newline separated list of upstream sources to sync with a repo@branch format. For example, `https://github.com/myorg/myprofiles@main` | None | True | +| dry_run | Runs tasks without pushing changes to the repository. | false | False | | github_token | GitHub token used to make authenticated API requests | None | False | | include_model_names | Comma-separated glob pattern list of model names (i.e. trestle directory name) to include in the sync. For example, `*framework-v2`. Defaults to include all model names. | None | False | | exclude_model_names | Comma-separated glob pattern of model names (i.e. trestle directory name) to exclude from the sync. For example, `*framework-v1`. Defaults to skip no model names. | None | False | diff --git a/actions/sync-upstreams/action.yml b/actions/sync-upstreams/action.yml index b579b97a..ceb4dbfa 100644 --- a/actions/sync-upstreams/action.yml +++ b/actions/sync-upstreams/action.yml @@ -6,6 +6,10 @@ inputs: sources: description: "A newline separated list of upstream sources to sync with a repo@branch format. For example, `https://github.com/myorg/myprofiles@main`" required: true + dry_run: + description: "Runs tasks without pushing changes to the repository." + required: false + default: "false" github_token: description: "GitHub token used to make authenticated API requests" required: false diff --git a/actions/sync-upstreams/sync-upstreams-entrypoint.sh b/actions/sync-upstreams/sync-upstreams-entrypoint.sh index 60a07d42..5422ee73 100644 --- a/actions/sync-upstreams/sync-upstreams-entrypoint.sh +++ b/actions/sync-upstreams/sync-upstreams-entrypoint.sh @@ -31,6 +31,10 @@ if [[ ${INPUT_VERBOSE} == true ]]; then command+=" --verbose" fi +if [[ ${INPUT_DRY_RUN} == true ]]; then + command+=" --dry-run" +fi + if [[ ${INPUT_SKIP_VALIDATION} == true ]]; then command+=" --skip-validation" fi @@ -45,4 +49,4 @@ if [[ -n ${INPUT_TARGET_BRANCH} ]]; then command+=" --with-token - <<<\"${GITHUB_TOKEN}\"" fi -execute_command "${command}" \ No newline at end of file +eval "${command}" \ No newline at end of file diff --git a/tests/trestlebot/test_bot.py b/tests/trestlebot/test_bot.py index 6a221de7..7a2c678a 100644 --- a/tests/trestlebot/test_bot.py +++ b/tests/trestlebot/test_bot.py @@ -96,8 +96,8 @@ def test_local_commit(tmp_repo: Tuple[str, Repo]) -> None: assert os.path.basename(test_file_path) in commit.stats.files -def test_local_commit_with_committer(tmp_repo: Tuple[str, Repo]) -> None: - """Test setting committer information for commits""" +def test_local_commit_with_author_information(tmp_repo: Tuple[str, Repo]) -> None: + """Test setting author information for commits""" repo_path, repo = tmp_repo # Create a test file @@ -113,45 +113,8 @@ def test_local_commit_with_committer(tmp_repo: Tuple[str, Repo]) -> None: branch="main", commit_name="Test User", commit_email="test@example.com", - author_name="Test Commit User", - author_email="test-committer@example.com", - ) - commit_sha = bot._local_commit( - repo, - commit_message="Test commit message", - ) - - assert commit_sha != "" - - # Verify that the commit is made - commit = next(repo.iter_commits()) - assert commit.message.strip() == "Test commit message" - assert commit.author.name == "Test Commit User" - assert commit.author.email == "test-committer@example.com" - - # Verify that the file is tracked by the commit - assert os.path.basename(test_file_path) in commit.stats.files - - -def test_local_commit_with_author(tmp_repo: Tuple[str, Repo]) -> None: - """Test setting author for commits""" - repo_path, repo = tmp_repo - - # Create a test file - test_file_path = os.path.join(repo_path, "test.txt") - with open(test_file_path, "w") as f: - f.write("Test content") - - repo.index.add(test_file_path) - - # Commit the test file - bot = TrestleBot( - working_dir=repo_path, - branch="main", - commit_name="Test User", - commit_email="test@example.com", - author_name="The Author", - author_email="author@test.com", + author_name="Test Author User", + author_email="test-author@example.com", ) commit_sha = bot._local_commit( repo, @@ -162,8 +125,8 @@ def test_local_commit_with_author(tmp_repo: Tuple[str, Repo]) -> None: # Verify that the commit is made commit = next(repo.iter_commits()) assert commit.message.strip() == "Test commit message" - assert commit.author.name == "The Author" - assert commit.author.email == "author@test.com" + assert commit.author.name == "Test Author User" + assert commit.author.email == "test-author@example.com" # Verify that the file is tracked by the commit assert os.path.basename(test_file_path) in commit.stats.files @@ -190,13 +153,13 @@ def test_run(tmp_repo: Tuple[str, Repo]) -> None: author_name="The Author", author_email="author@test.com", ) - commit_sha, pr_number = bot.run( + results = bot.run( commit_message="Test commit message", patterns=["*.txt"], - dry_run=False, ) - assert commit_sha != "" - assert pr_number == 0 + assert not results.changes + assert results.commit_sha != "" + assert results.pr_number == 0 # Verify that the commit is made commit = next(repo.iter_commits()) @@ -211,7 +174,7 @@ def test_run(tmp_repo: Tuple[str, Repo]) -> None: def test_run_dry_run(tmp_repo: Tuple[str, Repo]) -> None: """Test bot run with dry run""" - repo_path, repo = tmp_repo + repo_path, _ = tmp_repo # Create a test file test_file_path = os.path.join(repo_path, "test.txt") @@ -228,20 +191,21 @@ def test_run_dry_run(tmp_repo: Tuple[str, Repo]) -> None: commit_name="Test User", commit_email="test@example.com", ) - commit_sha, pr_number = bot.run( + results = bot.run( commit_message="Test commit message", patterns=["*.txt"], dry_run=True, ) - assert commit_sha != "" - assert pr_number == 0 + assert results.changes == ["test.txt [added]"] + assert results.commit_sha == "" + assert results.pr_number == 0 mock_push.assert_not_called() def test_empty_commit(tmp_repo: Tuple[str, Repo]) -> None: """Test running bot with no file updates""" - repo_path, repo = tmp_repo + repo_path, _ = tmp_repo # Test running the bot bot = TrestleBot( @@ -250,41 +214,13 @@ def test_empty_commit(tmp_repo: Tuple[str, Repo]) -> None: commit_name="Test User", commit_email="test@example.com", ) - commit_sha, pr_number = bot.run( + results = bot.run( commit_message="Test commit message", patterns=["*.txt"], - dry_run=True, ) - assert commit_sha == "" - assert pr_number == 0 - - -def test_run_check_only(tmp_repo: Tuple[str, Repo]) -> None: - """Test bot run with check_only""" - repo_path, repo = tmp_repo - - # Create a test file - test_file_path = os.path.join(repo_path, "test.txt") - with open(test_file_path, "w") as f: - f.write("Test content") - - bot = TrestleBot( - working_dir=repo_path, - branch="main", - commit_name="Test User", - commit_email="test@example.com", - ) - - with pytest.raises( - RepoException, - match="Check only mode is enabled and diff detected. Manual intervention on main is required.", - ): - _, _ = bot.run( - commit_message="Test commit message", - patterns=["*.txt"], - dry_run=True, - check_only=True, - ) + assert not results.changes + assert results.commit_sha == "" + assert results.pr_number == 0 def push_side_effect(refspec: str) -> None: @@ -306,7 +242,7 @@ def test_run_with_exception( tmp_repo: Tuple[str, Repo], side_effect: Callable[[str], None], msg: str ) -> None: """Test bot run with mocked push with side effects that throw exceptions""" - repo_path, repo = tmp_repo + repo_path, _ = tmp_repo # Create a test file test_file_path = os.path.join(repo_path, "test.txt") @@ -333,7 +269,7 @@ def test_run_with_exception( def test_run_with_failed_pre_task(tmp_repo: Tuple[str, Repo]) -> None: """Test bot run with mocked task that fails""" - repo_path, repo = tmp_repo + repo_path, _ = tmp_repo # Create a test file test_file_path = os.path.join(repo_path, "test.txt") @@ -386,14 +322,14 @@ def test_run_with_provider(tmp_repo: Tuple[str, Repo]) -> None: mock_push.return_value = "Mocked result" # Test running the bot - commit_sha, pr_number = bot.run( + results = bot.run( commit_message="Test commit message", patterns=["*.txt"], git_provider=mock, - dry_run=False, ) - assert commit_sha != "" - assert pr_number == 10 + assert not results.changes + assert results.commit_sha != "" + assert results.pr_number == 10 # Verify that the commit is made commit = next(repo.iter_commits()) @@ -418,7 +354,7 @@ def test_run_with_provider(tmp_repo: Tuple[str, Repo]) -> None: def test_run_with_provider_with_custom_pr_title(tmp_repo: Tuple[str, Repo]) -> None: """Test bot run with customer pull request title""" - repo_path, repo = tmp_repo + repo_path, _ = tmp_repo # Create a test file test_file_path = os.path.join(repo_path, "test.txt") @@ -426,7 +362,7 @@ def test_run_with_provider_with_custom_pr_title(tmp_repo: Tuple[str, Repo]) -> N f.write("Test content") mock = Mock(spec=GitProvider) - mock.create_pull_request.return_value = "10" + mock.create_pull_request.return_value = 10 mock.parse_repository.return_value = ("ns", "repo") bot = TrestleBot( @@ -443,23 +379,15 @@ def test_run_with_provider_with_custom_pr_title(tmp_repo: Tuple[str, Repo]) -> N mock_push.return_value = "Mocked result" # Test running the bot - commit_sha = bot.run( + results = bot.run( commit_message="Test commit message", patterns=["*.txt"], git_provider=mock, pull_request_title="Test", - dry_run=False, ) - assert commit_sha != "" - - # Verify that the commit is made - commit = next(repo.iter_commits()) - assert commit.message.strip() == "Test commit message" - assert commit.author.name == "The Author" - assert commit.author.email == "author@test.com" - - # Verify that the file is tracked by the commit - assert os.path.basename(test_file_path) in commit.stats.files + assert not results.changes + assert results.commit_sha != "" + assert results.pr_number == 10 # Verify that the method was called with the expected arguments mock.create_pull_request.assert_called_once_with( diff --git a/tests/trestlebot/test_github.py b/tests/trestlebot/test_github.py index 481af258..73b09845 100644 --- a/tests/trestlebot/test_github.py +++ b/tests/trestlebot/test_github.py @@ -5,6 +5,7 @@ """Test for GitHub provider logic""" import json +import tempfile from typing import Generator, Tuple from unittest.mock import patch @@ -13,8 +14,9 @@ from responses import GET, POST, RequestsMock from tests.testutils import JSON_TEST_DATA_PATH, clean -from trestlebot.github import GitHub +from trestlebot.github import GitHub, GitHubActionsResultsReporter, set_output from trestlebot.provider import GitProviderException +from trestlebot.reporter import BotResults @pytest.mark.parametrize( @@ -111,3 +113,62 @@ def test_create_pull_request_invalid_repo() -> None: "owner", "repo", "main", "test", "My PR", "Has Changes" ) mock_pull.assert_called_once() + + +def test_set_output(monkeypatch: pytest.MonkeyPatch) -> None: + """Test set output""" + tmpdir = tempfile.TemporaryDirectory() + tmpfile_path = f"{tmpdir.name}/output.txt" + with open(tmpfile_path, "w") as tmpfile: + tmpfile.write("") + + monkeypatch.setenv("GITHUB_OUTPUT", tmpfile_path) + + set_output("name", "value") + + with open(tmpfile_path, "r") as tmpfile: + content = tmpfile.read() + assert "name=value" in content + + +def test_github_actions_results_reporter() -> None: + """Test results reporter""" + results = BotResults(changes=[], commit_sha="123456", pr_number=2) + + expected_output = "::group::Commit\n123456\n::endgroup::\n::group::Pull Request\n2\n::endgroup::\n" + + # Mock set output + def mock_set_output(name: str, value: str) -> None: + print(f"{name}={value}") # noqa: T201 + + with patch("builtins.print") as mock_print: + with patch( + "trestlebot.github.set_output", side_effect=mock_set_output + ) as mock_set_output: + GitHubActionsResultsReporter().report_results(results) + mock_print.assert_any_call(expected_output) + mock_print.assert_any_call("changes=true") + mock_print.assert_any_call("commit=123456") + mock_print.assert_any_call("pr_number=2") + + results = BotResults(changes=["file1"], commit_sha="", pr_number=0) + + expected_output = "::group::Changes\nfile1\n::endgroup::\n" + with patch("builtins.print") as mock_print: + with patch( + "trestlebot.github.set_output", side_effect=mock_set_output + ) as mock_set_output: + GitHubActionsResultsReporter().report_results(results) + mock_print.assert_any_call(expected_output) + mock_print.assert_any_call("changes=true") + + results = BotResults(changes=[], commit_sha="", pr_number=0) + + expected_output = "No changes detected" + with patch("builtins.print") as mock_print: + with patch( + "trestlebot.github.set_output", side_effect=mock_set_output + ) as mock_set_output: + GitHubActionsResultsReporter().report_results(results) + mock_print.assert_any_call(expected_output) + mock_print.assert_any_call("changes=false") diff --git a/tests/trestlebot/test_gitlab.py b/tests/trestlebot/test_gitlab.py index 73c067ce..669dca31 100644 --- a/tests/trestlebot/test_gitlab.py +++ b/tests/trestlebot/test_gitlab.py @@ -13,8 +13,9 @@ from responses import GET, POST, RequestsMock from tests.testutils import clean -from trestlebot.gitlab import GitLab +from trestlebot.gitlab import GitLab, GitLabCIResultsReporter from trestlebot.provider import GitProviderException +from trestlebot.reporter import BotResults @pytest.mark.parametrize( @@ -176,3 +177,37 @@ def test_create_pull_request_with_exceptions( "owner", "repo", "main", "test", "My PR", "Has Changes" ) mock_get.assert_called_once() + + +def test_gitlab_ci_results_reporter() -> None: + """Test results reporter""" + + results = BotResults(changes=[], commit_sha="", pr_number=0) + + expected_output = "No changes detected" + with patch("builtins.print") as mock_print: + GitLabCIResultsReporter().report_results(results) + mock_print.assert_any_call(expected_output) + + results = BotResults(changes=[], commit_sha="123456", pr_number=2) + expected_output = ( + "\x1b[0Ksection_start:1234567890:commit_sha" + "[collapsed=true]\r\x1b[0KCommit Information\n123456\n" + "\x1b[0Ksection_end:1234567890:commit_sha\r\x1b[0K\n" + "\x1b[0Ksection_start:1234567890:merge_request_number[collapsed=true]\r\x1b[0K" + "Merge Request Number\n2\n\x1b[0Ksection_end:1234567890:merge_request_number\r\x1b[0K\n" + ) + with patch("builtins.print") as mock_print: + with patch("time.time_ns", return_value=1234567890): + GitLabCIResultsReporter().report_results(results) + mock_print.assert_called_once_with(expected_output) + + results = BotResults(changes=["file2"], commit_sha="", pr_number=0) + expected_output = ( + "\x1b[0Ksection_start:1234567890:changes[collapsed=true]\r\x1b" + "[0KChanges detected\nfile2\n\x1b[0Ksection_end:1234567890:changes\r\x1b[0K\n" + ) + with patch("builtins.print") as mock_print: + with patch("time.time_ns", return_value=1234567890): + GitLabCIResultsReporter().report_results(results) + mock_print.assert_called_once_with(expected_output) diff --git a/tests/trestlebot/test_reporter.py b/tests/trestlebot/test_reporter.py new file mode 100644 index 00000000..2e82d62b --- /dev/null +++ b/tests/trestlebot/test_reporter.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2024 Red Hat, Inc. + +"""Test for general reporting logic""" + +from unittest.mock import patch + +from trestlebot.reporter import BotResults, ResultsReporter + + +def test_results_reporter_with_commit() -> None: + """Test results reporter""" + results = BotResults(changes=[], commit_sha="123456", pr_number=2) + + with patch("builtins.print") as mock_print: + ResultsReporter().report_results(results) + mock_print.assert_called_once_with( + "\nCommit Hash: 123456\nPull Request Number: 2" + ) + + +def test_results_reporter_no_commit() -> None: + """Test results reporter with no commit""" + results = BotResults(changes=[], commit_sha="", pr_number=0) + + with patch("builtins.print") as mock_print: + ResultsReporter().report_results(results) + mock_print.assert_called_once_with("No changes detected") + + +def test_results_reporter_with_changes() -> None: + """Test results reporter with changes""" + results = BotResults(changes=["file1"], commit_sha="", pr_number=0) + + with patch("builtins.print") as mock_print: + ResultsReporter().report_results(results) + mock_print.assert_called_once_with("\nChanges:\nfile1") diff --git a/trestlebot/bot.py b/trestlebot/bot.py index 0ea6a84c..23aeaddb 100644 --- a/trestlebot/bot.py +++ b/trestlebot/bot.py @@ -5,13 +5,15 @@ """This module implements functions for the Trestle Bot.""" import logging -from typing import List, Optional, Tuple +from typing import List, Optional from git import GitCommandError +from git.objects.commit import Commit from git.repo import Repo from git.util import Actor from trestlebot.provider import GitProvider, GitProviderException +from trestlebot.reporter import BotResults from trestlebot.tasks.base_task import TaskBase, TaskException @@ -74,7 +76,7 @@ def _local_commit( self, gitwd: Repo, commit_message: str, - ) -> str: + ) -> Commit: """Creates a local commit in git working directory""" try: committer: Actor = Actor(name=self.commit_name, email=self.commit_email) @@ -85,8 +87,7 @@ def _local_commit( commit = gitwd.index.commit( commit_message, author=author, committer=committer ) - - return commit.hexsha + return commit except GitCommandError as e: raise RepoException(f"Git commit failed: {e}") from e @@ -144,6 +145,23 @@ def _run_tasks(self, tasks: List[TaskBase]) -> None: except TaskException as e: raise RepoException(f"Bot pre-tasks failed: {e}") + def _get_committed_files(self, commit: Commit) -> List[str]: + """Get the list of committed files in the commit.""" + changes: List[str] = [] + diffs = {diff.a_path: diff for diff in commit.parents[0].diff(commit)} + for path in commit.stats.files.keys(): + diff = diffs.get(path, None) + if diff: + if diff.change_type == "A": + changes.append(f"{path} [added]") + elif diff.change_type == "M": + changes.append(f"{path} [modified]") + elif diff.change_type == "D": + changes.append(f"{path} [deleted]") + elif diff.change_type == "R": + changes.append(f"{path} [renamed]") + return changes + def run( self, patterns: List[str], @@ -151,9 +169,8 @@ def run( pre_tasks: Optional[List[TaskBase]] = None, commit_message: str = "Automatic updates from bot", pull_request_title: str = "Automatic updates from bot", - check_only: bool = False, dry_run: bool = False, - ) -> Tuple[str, int]: + ) -> BotResults: """ Runs Trestle Bot logic and returns commit and pull request information. @@ -163,16 +180,15 @@ def run( pre_tasks: Optional workspace task list to execute before staging files commit_message: Optional commit message for local commit pull_request_title: Optional customized pull request title - check_only: Optional check if the repo is dirty. Fail if true. - dry_run: Only complete local work. Do not push. + dry_run: Only complete pre-tasks and return changes without pushing Returns: - A tuple with commit_sha and pull request number. + BotResults with changes, commit_sha, and pull request number. The commit_sha defaults to "" if there was no updates and the - pull request number default to 0 if not submitted. + pull request number default to 0 if not submitted. The changes list is + only populated if dry_run is enabled. """ - commit_sha: str = "" - pr_number: int = 0 + results: BotResults = BotResults([], "", 0) # Create Git Repo repo = Repo(self.working_dir) @@ -184,23 +200,21 @@ def run( # Check if there are any unstaged files if repo.is_dirty(untracked_files=True): - if check_only: - raise RepoException( - "Check only mode is enabled and diff detected. " - f"Manual intervention on {self.branch} is required." - ) self._stage_files(repo, patterns) if repo.is_dirty(): - commit_sha = self._local_commit( + + commit: Commit = self._local_commit( repo, commit_message, ) + results.commit_sha = commit.hexsha + # Do not return the commit sha if dry run is enabled if dry_run: - logger.info("Dry run mode is enabled. Do not push to remote.") - return commit_sha, pr_number + logger.info("Dry run mode is enabled, no changes will be pushed") + return BotResults(self._get_committed_files(commit), "", 0) try: remote_url = self._push_to_remote(repo) @@ -211,10 +225,10 @@ def run( logger.info( f"Git provider detected, submitting pull request to {self.target_branch}" ) - pr_number = self._create_pull_request( + results.pr_number = self._create_pull_request( git_provider, remote_url, pull_request_title ) - return commit_sha, pr_number + return results except GitCommandError as e: raise RepoException(f"Git push to {self.branch} failed: {e}") @@ -224,7 +238,7 @@ def run( ) else: logger.info("Nothing to commit") - return commit_sha, pr_number + return results else: logger.info("Nothing to commit") - return commit_sha, pr_number + return results diff --git a/trestlebot/const.py b/trestlebot/const.py index d6b70b6d..ac9143d4 100644 --- a/trestlebot/const.py +++ b/trestlebot/const.py @@ -38,3 +38,9 @@ RULES_VIEW_DIR = "rules" RULE_PREFIX = "rule-" + + +# GitHub Actions Outputs +COMMIT = "commit" +PR_NUMBER = "pr_number" +CHANGES = "changes" diff --git a/trestlebot/entrypoints/entrypoint_base.py b/trestlebot/entrypoints/entrypoint_base.py index 1a54be83..5c73da03 100644 --- a/trestlebot/entrypoints/entrypoint_base.py +++ b/trestlebot/entrypoints/entrypoint_base.py @@ -19,9 +19,15 @@ from trestlebot import const from trestlebot.bot import TrestleBot -from trestlebot.github import GitHub, is_github_actions -from trestlebot.gitlab import GitLab, get_gitlab_root_url, is_gitlab_ci +from trestlebot.github import GitHub, GitHubActionsResultsReporter, is_github_actions +from trestlebot.gitlab import ( + GitLab, + GitLabCIResultsReporter, + get_gitlab_root_url, + is_gitlab_ci, +) from trestlebot.provider import GitProvider +from trestlebot.reporter import BotResults, ResultsReporter from trestlebot.tasks.base_task import TaskBase @@ -103,10 +109,10 @@ def setup_common_arguments(self) -> None: help="Email for commit author if differs from committer", ) self.parser.add_argument( - "--check-only", + "--dry-run", required=False, action="store_true", - help="Runs tasks and exits with an error if there is a diff", + help="Run tasks, but do not push to the repository", ) self.parser.add_argument( "--target-branch", @@ -153,10 +159,21 @@ def set_git_provider(args: argparse.Namespace) -> Optional[GitProvider]: ) return git_provider + @staticmethod + def set_reporter() -> ResultsReporter: + """Get the reporter based on the environment and args.""" + if is_github_actions(): + return GitHubActionsResultsReporter() + elif is_gitlab_ci(): + return GitLabCIResultsReporter() + else: + return ResultsReporter() + def run_base(self, args: argparse.Namespace, pre_tasks: List[TaskBase]) -> None: """Reusable logic for all entrypoints.""" git_provider: Optional[GitProvider] = self.set_git_provider(args) + results_reporter: ResultsReporter = self.set_reporter() # Configure and run the bot bot = TrestleBot( @@ -168,22 +185,17 @@ def run_base(self, args: argparse.Namespace, pre_tasks: List[TaskBase]) -> None: author_email=args.author_email, target_branch=args.target_branch, ) - commit_sha, pr_number = bot.run( + results: BotResults = bot.run( commit_message=args.commit_message, pre_tasks=pre_tasks, patterns=comma_sep_to_list(args.file_patterns), git_provider=git_provider, pull_request_title=args.pull_request_title, - check_only=args.check_only, + dry_run=args.dry_run, ) - # Print the full commit sha - if commit_sha: - print(f"Commit Hash: {commit_sha}") # noqa: T201 - - # Print the pr number - if pr_number: - print(f"Pull Request Number: {pr_number}") # noqa: T201 + # Report the results + results_reporter.report_results(results) def comma_sep_to_list(string: str) -> List[str]: diff --git a/trestlebot/github.py b/trestlebot/github.py index 66c760d5..5fa7ffe3 100644 --- a/trestlebot/github.py +++ b/trestlebot/github.py @@ -12,7 +12,9 @@ from github3.exceptions import AuthenticationFailed from github3.repos.repo import Repository +from trestlebot import const from trestlebot.provider import GitProvider, GitProviderException +from trestlebot.reporter import BotResults, ResultsReporter class GitHub(GitProvider): @@ -102,6 +104,46 @@ def create_pull_request( ) +class GitHubActionsResultsReporter(ResultsReporter): + """Report bot results to the console in GitHub Actions""" + + def report_results(self, results: BotResults) -> None: + """ + Report the results of the Trestle Bot in GitHub Actions + + Args: + results: BotResults object + """ + results_str = "" + if results.commit_sha: + set_output("changes", "true") + set_output(const.COMMIT, results.commit_sha) + + commit_str = self._create_group("Commit", results.commit_sha) + results_str += commit_str + + if results.pr_number: + set_output(const.PR_NUMBER, str(results.pr_number)) + pr_str = self._create_group("Pull Request", str(results.pr_number)) + results_str += pr_str + elif results.changes: + set_output(const.CHANGES, "true") + changes_str = self._create_group( + "Changes", self.get_changes_str(results.changes) + ) + results_str += changes_str + else: + set_output(const.CHANGES, "false") + results_str += "No changes detected" + + print(results_str) # noqa: T201 + + @staticmethod + def _create_group(section: str, content: str) -> str: + """Create a group of text in GitHub Actions""" + return f"::group::{section}\n{content}\n::endgroup::\n" + + # GitHub ref: # https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables def is_github_actions() -> bool: @@ -110,3 +152,9 @@ def is_github_actions() -> bool: if var_value and var_value.lower() in ["true", "1"]: return True return False + + +def set_output(name: str, value: str) -> None: + """Set the output during the GitHub Actions workflow.""" + with open(os.environ["GITHUB_OUTPUT"], "a") as fh: + print(f"{name}={value}", file=fh) # noqa: T201 diff --git a/trestlebot/gitlab.py b/trestlebot/gitlab.py index 65a8c326..5d8c58b9 100644 --- a/trestlebot/gitlab.py +++ b/trestlebot/gitlab.py @@ -6,11 +6,13 @@ import os import re +import time from typing import Tuple import gitlab from trestlebot.provider import GitProvider, GitProviderException +from trestlebot.reporter import BotResults, ResultsReporter class GitLab(GitProvider): @@ -94,9 +96,58 @@ def create_pull_request( ) -# GitLab ref: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html +class GitLabCIResultsReporter(ResultsReporter): + """Report bot results to the console in GitLabCI""" + + start_sequence = "\x1b[0K" + end_sequence = "\r\x1b[0K" + + def report_results(self, results: BotResults) -> None: + """ + Report the results of the Trestle Bot in GitLab CI + """ + results_str = "" + if results.commit_sha: + commit_str = self._create_group( + "commit_sha", + "Commit Information", + results.commit_sha, + ) + results_str += commit_str + + if results.pr_number: + pr_str = self._create_group( + "merge_request_number", + "Merge Request Number", + str(results.pr_number), + ) + results_str += pr_str + elif results.changes: + changes_str = self._create_group( + "changes", "Changes detected", self.get_changes_str(results.changes) + ) + results_str += changes_str + else: + results_str += "No changes detected" + print(results_str) # noqa: T201 + + @staticmethod + def _create_group( + section_title: str, section_description: str, content: str + ) -> str: + """Create a group for the GitLab CI output""" + group_str = GitLabCIResultsReporter.start_sequence + group_str += f"section_start:{time.time_ns()}:{section_title}[collapsed=true]" + group_str += GitLabCIResultsReporter.end_sequence + group_str += f"{section_description}\n{content}\n" + group_str += GitLabCIResultsReporter.start_sequence + group_str += f"section_end:{time.time_ns()}:{section_title}" + group_str += GitLabCIResultsReporter.end_sequence + group_str += "\n" + return group_str +# GitLab ref: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html def is_gitlab_ci() -> bool: """Determine if the environment is GitLab CI""" var_value = os.getenv("GITLAB_CI") diff --git a/trestlebot/reporter.py b/trestlebot/reporter.py new file mode 100644 index 00000000..2940a936 --- /dev/null +++ b/trestlebot/reporter.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2024 Red Hat, Inc. + +"""Results reporting for the Trestle Bot.""" + +from dataclasses import dataclass +from typing import List + + +@dataclass +class BotResults: + """A dataclass to hold the results of the bot run""" + + changes: List[str] + commit_sha: str + pr_number: int + + +class ResultsReporter: + """ + Base class for reporting the results of the Trestle Bot. + """ + + def report_results(self, results: BotResults) -> None: + """ + Report the results of the Trestle Bot. + + Args: + results: BotResults object + """ + results_str = "" + if results.commit_sha: + results_str += f"\nCommit Hash: {results.commit_sha}" + + if results.pr_number: + results_str += f"\nPull Request Number: {results.pr_number}" + elif results.changes: + results_str += "\nChanges:\n" + results_str += ResultsReporter.get_changes_str(results.changes) + else: + results_str = "No changes detected" + + print(results_str) # noqa: T201 + + @staticmethod + def get_changes_str(changes: List[str]) -> str: + """ + Return a string representation of the changes. + + Notes: This method is starting off as a simple join of the changes list, + but is intended to be expanded to provide more detailed information about the changes. + The goal is consistent representation. + """ + return "\n".join(changes)