Skip to content

Commit

Permalink
feat: adds automated GH pull request creation to trestlebot
Browse files Browse the repository at this point in the history
Signed-off-by: Jennifer Power <[email protected]>
  • Loading branch information
jpower432 committed Jul 10, 2023
1 parent aebe98e commit 3dcc280
Show file tree
Hide file tree
Showing 11 changed files with 670 additions and 109 deletions.
11 changes: 10 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ inputs:
description: "Runs tasks and exits with an error if there is a diff. Defaults to false"
required: false
default: false
github_token:
description: "GitHub token used to make authenticated API requests"
required: false
skip_assemble:
description: "Skip assembly task. Defaults to false"
required: false
Expand All @@ -34,9 +37,13 @@ inputs:
required: false
default: "Sync automatic updates"
branch:
description: Git branch name, where changes should be pushed too. Required if Action is used on the `pull_request` event
description: Name of the Git branch to which modifications should be pushed. Required if Action is used on the `pull_request` event.
required: false
default: ${{ github.ref_name }}
target_branch:
description: Target branch (or base branch) to create a pull request against. If unset, no pull request will be created. If set, a pull request will be created using the `branch` field as the head branch.
required: false
default: ""
file_pattern:
description: Comma separated file pattern list used for `git add`. For example `component-definitions/*,*json`. Defaults to (`.`)
required: false
Expand Down Expand Up @@ -72,6 +79,8 @@ runs:
using: "docker"
image: "Dockerfile"
entrypoint: "/entrypoint.sh"
env:
GITHUB_TOKEN: ${{ inputs.github_token }}

branding:
icon: "check"
Expand Down
11 changes: 9 additions & 2 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ exec 3>&1

trap exec 3>&- EXIT


# Initialize the command variable
command="python3.8 -m trestlebot \
--markdown-path=\"${INPUT_MARKDOWN_PATH}\" \
Expand All @@ -37,6 +36,7 @@ command="python3.8 -m trestlebot \
--author-name=\"${INPUT_COMMIT_AUTHOR_NAME}\" \
--author-email=\"${INPUT_COMMIT_AUTHOR_EMAIL}\" \
--working-dir=\"${INPUT_REPOSITORY}\" \
--target-branch=\"${INPUT_TARGET_BRANCH}\" \
--skip-items=\"${INPUT_SKIP_ITEMS}\""

# Conditionally include flags
Expand All @@ -50,11 +50,18 @@ fi

if [[ ${INPUT_CHECK_ONLY} == true ]]; then
command+=" --check-only"
else

if [[ -z ${GITHUB_TOKEN} ]]; then
echo "Set the GITHUB_TOKEN env variable."
exit 1
fi

command+=" --with-token <<<\"${GITHUB_TOKEN}\""
fi

output=$(eval "$command" 2>&1 > >(tee /dev/fd/3))


commit=$(echo "$output" | grep "Commit Hash:" | sed 's/.*: //')

if [ -n "$commit" ]; then
Expand Down
270 changes: 168 additions & 102 deletions poetry.lock

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ trestle-bot = "trestlebot.cli:run"
python = '^3.8.1'
gitpython = "^3.1.31"
compliance-trestle = "^2.1.1"
github3-py = "^4.0.1"

[tool.poetry.group.dev.dependencies]
flake8 = "^6.0.0"
Expand Down Expand Up @@ -53,3 +54,13 @@ addopts = """
testpaths = [
'tests',
]

[tool.mypy]

[[tool.mypy.overrides]]
module = "github3.*"
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "ruamel"
ignore_missing_imports = true
145 changes: 145 additions & 0 deletions tests/trestlebot/test_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@

"""Test for top-level Trestle Bot logic."""

import json
import os
from typing import Tuple
from unittest.mock import Mock, patch

import pytest
from git.repo import Repo

import trestlebot.bot as bot
from tests.testutils import clean
from trestlebot.provider import GitProvider


def test_stage_files(tmp_repo: Tuple[str, Repo]) -> None:
Expand Down Expand Up @@ -148,6 +151,47 @@ def test_local_commit_with_author(tmp_repo: Tuple[str, Repo]) -> None:
clean(repo_path, repo)


def test_run(tmp_repo: Tuple[str, Repo]) -> None:
"""Test bot run with mocked push"""
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.create_remote("origin", url="git.test.com/test/repo.git")

with patch("git.remote.Remote.push") as mock_push:
mock_push.return_value = "Mocked result"

# Test running the bot
commit_sha = bot.run(
working_dir=repo_path,
branch="main",
commit_name="Test User",
commit_email="[email protected]",
commit_message="Test commit message",
author_name="The Author",
author_email="[email protected]",
patterns=["*.txt"],
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 == "[email protected]"
mock_push.assert_called_once_with(refspec="HEAD:main")

# Verify that the file is tracked by the commit
assert os.path.basename(test_file_path) in commit.stats.files

clean(repo_path, repo)


def test_run_dry_run(tmp_repo: Tuple[str, Repo]) -> None:
"""Test bot run with dry run"""
repo_path, repo = tmp_repo
Expand Down Expand Up @@ -204,6 +248,48 @@ def test_empty_commit(tmp_repo: Tuple[str, Repo]) -> None:
clean(repo_path, repo)


def test_non_matching_files(tmp_repo: Tuple[str, Repo]) -> None:
"""Test that non-matching files are ignored"""
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")

# Create a test file
data = {"test": "file"}
test_json_path = os.path.join(repo_path, "test.json")
with open(test_json_path, "w") as f:
json.dump(data, f, indent=4)

# Test running the bot
commit_sha = bot.run(
working_dir=repo_path,
branch="main",
commit_name="Test User",
commit_email="[email protected]",
commit_message="Test commit message",
author_name="The Author",
author_email="[email protected]",
patterns=["*.json"],
dry_run=True,
)
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 == "[email protected]"

# Verify that only the JSON file is tracked in the commits
assert os.path.basename(test_file_path) not in commit.stats.files
assert os.path.basename(test_json_path) in commit.stats.files

clean(repo_path, repo)


def test_run_check_only(tmp_repo: Tuple[str, Repo]) -> None:
"""Test bot run with check_only"""
repo_path, repo = tmp_repo
Expand All @@ -229,3 +315,62 @@ def test_run_check_only(tmp_repo: Tuple[str, Repo]) -> None:
dry_run=True,
check_only=True,
)

clean(repo_path, repo)


def test_run_with_provider(tmp_repo: Tuple[str, Repo]) -> None:
"""Test bot run with mock git provider"""
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")

mock = Mock(spec=GitProvider)
mock.create_pull_request.return_value = "10"
mock.parse_repository.return_value = ("ns", "repo")

repo.create_remote("origin", url="git.test.com/test/repo.git")

with patch("git.remote.Remote.push") as mock_push:
mock_push.return_value = "Mocked result"

# Test running the bot
commit_sha = bot.run(
working_dir=repo_path,
branch="test",
commit_name="Test User",
commit_email="[email protected]",
commit_message="Test commit message",
author_name="The Author",
author_email="[email protected]",
patterns=["*.txt"],
git_provider=mock,
target_branch="main",
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 == "[email protected]"

# Verify that the file is tracked by the commit
assert os.path.basename(test_file_path) in commit.stats.files

# Verify that the method was called with the expected arguments
mock.create_pull_request.assert_called_once_with(
ns="ns",
repo_name="repo",
head_branch="test",
base_branch="main",
title="Automatic updates from trestlebot",
body="",
)
mock_push.assert_called_once_with(refspec="HEAD:test")

clean(repo_path, repo)
38 changes: 38 additions & 0 deletions tests/trestlebot/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import sys
from typing import List
from unittest.mock import patch

import pytest

Expand Down Expand Up @@ -75,3 +76,40 @@ def test_no_ssp_index(monkeypatch, valid_args_dict, capsys):
captured = capsys.readouterr()

assert "Must set ssp_index_path when using SSP as oscal model." in captured.err


def test_no_markdown_path(monkeypatch, valid_args_dict, capsys):
"""Test without a markdown file passed as a flag"""
args_dict = valid_args_dict
args_dict["markdown-path"] = ""
monkeypatch.setattr(sys, "argv", ["trestlebot", *args_dict_to_list(args_dict)])

with pytest.raises(SystemExit):
cli_main()

captured = capsys.readouterr()

assert "Must set markdown path with oscal model." in captured.err


def test_with_target_branch(monkeypatch, valid_args_dict, capsys):
"""Test with target branch set an an unsupported Git provider"""
args_dict = valid_args_dict
args_dict["target-branch"] = "main"
monkeypatch.setattr(sys, "argv", ["trestlebot", *args_dict_to_list(args_dict)])

with patch("trestlebot.cli.is_github_actions") as mock_check:
mock_check.return_value = False

with pytest.raises(SystemExit):
cli_main()

captured = capsys.readouterr()

expected_string = (
"target-branch flag is set with an unsupported git provider. "
"If testing locally with the GitHub API, "
"set the GITHUB_ACTIONS environment variable to true."
)

assert expected_string in captured.err
72 changes: 72 additions & 0 deletions tests/trestlebot/test_github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/python

# Copyright 2023 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

"""Test for GitHub provider logic"""

from typing import Tuple

import pytest
from git.repo import Repo

from tests.testutils import clean
from trestlebot.github import GitHub
from trestlebot.provider import GitProviderException


@pytest.mark.parametrize(
"repo_url",
[
"https://github.com/owner/repo",
"https://github.com/owner/repo.git",
"github.com/owner/repo.git",
],
)
def test_parse_repository(repo_url: str) -> None:
"""Tests parsing valid GitHub repo urls"""
gh = GitHub("fake")

owner, repo_name = gh.parse_repository(repo_url)

assert owner == "owner"
assert repo_name == "repo"


def test_parse_repository_integration(tmp_repo: Tuple[str, Repo]) -> None:
"""Tests integration with git remote get-url"""
repo_path, repo = tmp_repo

repo.create_remote("origin", url="github.com/test/repo.git")

remote = repo.remote()

gh = GitHub("fake")

owner, repo_name = gh.parse_repository(remote.url)

assert owner == "test"
assert repo_name == "repo"

clean(repo_path, repo)


def test_parse_repository_with_incorrect_name() -> None:
"""Test an invalid url input"""
gh = GitHub("fake")
with pytest.raises(
GitProviderException,
match="https://notgithub.com/owner/repo.git is an invalid GitHub repo URL",
):
gh.parse_repository("https://notgithub.com/owner/repo.git")
Loading

0 comments on commit 3dcc280

Please sign in to comment.