From 533ce2887b0e3cb68e6ee355230e963e2090311a Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 9 Oct 2023 16:04:20 -0400 Subject: [PATCH] =?UTF-8?q?PSCE-251:=20refactor(cli):=20removes=20reusable?= =?UTF-8?q?=20CLI=20code=20to=20cli=5Fbase=20to=20enable=20multip=E2=80=A6?= =?UTF-8?q?=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(cli): removes reusable CLI code to cli_base to enable multiple entrypoints Signed-off-by: Jennifer Power * chore: adds comment fixes in cli_base Signed-off-by: Jennifer Power --------- Signed-off-by: Jennifer Power --- README.md | 2 +- pyproject.toml | 2 +- tests/trestlebot/test_cli.py | 4 +- trestlebot/__main__.py | 2 +- trestlebot/cli.py | 355 +++++++++++------------------------ trestlebot/cli_base.py | 201 ++++++++++++++++++++ 6 files changed, 319 insertions(+), 247 deletions(-) create mode 100644 trestlebot/cli_base.py diff --git a/README.md b/README.md index 8a8c09cb..8f3febb5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # trestle-bot -trestle-bot assists users in leveraging [Compliance-Trestle](https://github.com/IBM/compliance-trestle) in automated workflows or [OSCAL](https://github.com/usnistgov/OSCAL) formatted compliance content management. +trestle-bot assists users in leveraging [Compliance-Trestle](https://github.com/IBM/compliance-trestle) in automated workflows for [OSCAL](https://github.com/usnistgov/OSCAL) formatted compliance content management. > WARNING: This project is currently under initial development. APIs may be changed incompatibly from one commit to another. diff --git a/pyproject.toml b/pyproject.toml index a28c9c94..60061995 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ readme = 'README.md' repository = 'https://github.com/RedHatProductSecurity/trestle-bot' [tool.poetry.scripts] -trestle-bot = "trestlebot.cli:run" +trestle-bot = "trestlebot.cli:main" [tool.poetry.dependencies] python = '^3.8.1' diff --git a/tests/trestlebot/test_cli.py b/tests/trestlebot/test_cli.py index 0706ccea..f883891b 100644 --- a/tests/trestlebot/test_cli.py +++ b/tests/trestlebot/test_cli.py @@ -23,7 +23,7 @@ import pytest -from trestlebot.cli import run as cli_main +from trestlebot.cli import main as cli_main @pytest.fixture @@ -114,7 +114,7 @@ def test_with_target_branch( # Patch is_github_actions since these tests will be running in # GitHub Actions - with patch("trestlebot.cli.is_github_actions") as mock_check: + with patch("trestlebot.cli_base.is_github_actions") as mock_check: mock_check.return_value = False with pytest.raises(SystemExit): diff --git a/trestlebot/__main__.py b/trestlebot/__main__.py index 109a686d..9c84b787 100644 --- a/trestlebot/__main__.py +++ b/trestlebot/__main__.py @@ -20,7 +20,7 @@ def init() -> None: """Initialize trestlebot""" if __name__ == "__main__": - trestlebot.cli.run() + trestlebot.cli.main() init() diff --git a/trestlebot/cli.py b/trestlebot/cli.py index 2204acbe..e5d8b194 100644 --- a/trestlebot/cli.py +++ b/trestlebot/cli.py @@ -20,14 +20,12 @@ import argparse import logging import sys -from typing import List, Optional +from typing import List import trestle.common.log as log -from trestlebot import bot, const -from trestlebot.github import GitHub, is_github_actions -from trestlebot.gitlab import GitLab, get_gitlab_root_url, is_gitlab_ci -from trestlebot.provider import GitProvider +from trestlebot import const +from trestlebot.cli_base import EntrypointBase, comma_sep_to_list from trestlebot.tasks.assemble_task import AssembleTask from trestlebot.tasks.authored import types from trestlebot.tasks.base_task import TaskBase @@ -37,256 +35,129 @@ logger = logging.getLogger(__name__) -def _parse_cli_arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Automation git actions for compliance-trestle" - ) - parser.add_argument( - "--branch", - type=str, - required=True, - help="Branch name to push changes to", - ) - parser.add_argument( - "--markdown-path", - required=True, - type=str, - help="Path to Trestle markdown files", - ) - parser.add_argument( - "--oscal-model", - required=True, - type=str, - help="OSCAL model type to run tasks on. Values can be catalog, profile, compdef, or ssp", - ) - parser.add_argument( - "--file-patterns", - required=True, - type=str, - help="Comma-separated list of file patterns to be used with `git add` in repository updates", - ) - parser.add_argument( - "--skip-items", - type=str, - required=False, - help="Comma-separated list of glob patterns of the chosen model type to skip when running tasks", - ) - parser.add_argument( - "--skip-assemble", - required=False, - action="store_true", - help="Skip assembly task. Defaults to false", - ) - parser.add_argument( - "--skip-regenerate", - required=False, - action="store_true", - help="Skip regenerate task. Defaults to false.", - ) - parser.add_argument( - "--check-only", - required=False, - action="store_true", - help="Runs tasks and exits with an error if there is a diff", - ) - parser.add_argument( - "--working-dir", - type=str, - required=False, - default=".", - help="Working directory wit git repository", - ) - parser.add_argument( - "--commit-message", - type=str, - required=False, - default="chore: automatic updates", - help="Commit message for automated updates", - ) - parser.add_argument( - "--pull-request-title", - type=str, - required=False, - default="Automatic updates from trestlebot", - help="Customized title for submitted pull requests", - ) - parser.add_argument( - "--committer-name", - type=str, - required=True, - help="Name of committer", - ) - parser.add_argument( - "--committer-email", - type=str, - required=True, - help="Email for committer", - ) - parser.add_argument( - "--author-name", - required=False, - type=str, - help="Name for commit author if differs from committer", - ) - parser.add_argument( - "--author-email", - required=False, - type=str, - help="Email for commit author if differs from committer", - ) - parser.add_argument( - "--ssp-index-path", - required=False, - type=str, - default="ssp-index.json", - help="Path to ssp index file", - ) - parser.add_argument( - "--verbose", - required=False, - action="store_true", - help="Run in verbose mode", - ) - parser.add_argument( - "--target-branch", - type=str, - required=False, - help="Target branch or base branch to create a pull request against. \ - No pull request is created if unset", - ) - parser.add_argument( - "--with-token", - nargs="?", - type=argparse.FileType("r"), - required=False, - default=sys.stdin, - help="Read token from standard input for authenticated requests with \ - Git provider (e.g. create pull requests)", - ) - return parser.parse_args() +class AutoSyncEntrypoint(EntrypointBase): + """Entrypoint for the autosync operation.""" + def __init__(self, parser: argparse.ArgumentParser) -> None: + """Initialize.""" + # Setup base arguments + super().__init__(parser) + self.setup_autosync_arguments() -def handle_exception( - exception: Exception, msg: str = "Exception occurred during execution" -) -> int: - """Log the exception and return the exit code""" - logger.error(msg + f": {exception}", exc_info=True) - - return const.ERROR_EXIT_CODE - - -def run() -> None: - """Trestle Bot entry point function.""" - - args = _parse_cli_arguments() - log.set_log_level_from_args(args=args) - - pre_tasks: List[TaskBase] = [] - git_provider: Optional[GitProvider] = None - - authored_list: List[str] = [model.value for model in types.AuthoredType] - - # Pre-process flags - - if args.oscal_model: - if args.oscal_model not in authored_list: - logger.error( - f"Invalid value {args.oscal_model} for oscal model. " - f"Please use catalog, profile, compdef, or ssp." - ) - sys.exit(const.ERROR_EXIT_CODE) + def setup_autosync_arguments(self) -> None: + """Setup arguments for the autosync entrypoint.""" + self.parser.add_argument( + "--markdown-path", + required=True, + type=str, + help="Path to Trestle markdown files", + ) + self.parser.add_argument( + "--oscal-model", + required=True, + type=str, + help="OSCAL model type to run tasks on. Values can be catalog, profile, compdef, or ssp", + ) + self.parser.add_argument( + "--skip-items", + type=str, + required=False, + help="Comma-separated list of glob patterns of the chosen model type to skip when running \ + tasks", + ) + self.parser.add_argument( + "--skip-assemble", + required=False, + action="store_true", + help="Skip assembly task. Defaults to false", + ) + self.parser.add_argument( + "--skip-regenerate", + required=False, + action="store_true", + help="Skip regenerate task. Defaults to false.", + ) + self.parser.add_argument( + "--check-only", + required=False, + action="store_true", + help="Runs tasks and exits with an error if there is a diff", + ) + self.parser.add_argument( + "--ssp-index-path", + required=False, + type=str, + default="ssp-index.json", + help="Path to ssp index file", + ) - if not args.markdown_path: - logger.error("Must set markdown path with oscal model.") - sys.exit(const.ERROR_EXIT_CODE) + def run(self, args: argparse.Namespace) -> None: + """Run the autosync entrypoint.""" - if args.oscal_model == "ssp" and args.ssp_index_path == "": - logger.error("Must set ssp_index_path when using SSP as oscal model.") - sys.exit(const.ERROR_EXIT_CODE) + log.set_log_level_from_args(args=args) - # Assuming an edit has occurred assemble would be run before regenerate. - # Adding this to the list first - if not args.skip_assemble: - assemble_task = AssembleTask( - args.working_dir, - args.oscal_model, - args.markdown_path, - args.ssp_index_path, - comma_sep_to_list(args.skip_items), - ) - pre_tasks.append(assemble_task) - else: - logger.info("Assemble task skipped") + pre_tasks: List[TaskBase] = [] - if not args.skip_regenerate: - regenerate_task = RegenerateTask( - args.working_dir, - args.oscal_model, - args.markdown_path, - args.ssp_index_path, - comma_sep_to_list(args.skip_items), - ) - pre_tasks.append(regenerate_task) - else: - logger.info("Regeneration task skipped") + authored_list: List[str] = [model.value for model in types.AuthoredType] - if args.target_branch: - if not args.with_token: - logger.error("with-token value cannot be empty") - sys.exit(const.ERROR_EXIT_CODE) + # Pre-process flags - if is_github_actions(): - git_provider = GitHub(access_token=args.with_token.read().strip()) - elif is_gitlab_ci(): - server_api_url = get_gitlab_root_url() - git_provider = GitLab( - api_token=args.with_token.read().strip(), server_url=server_api_url - ) - else: - logger.error( - ( - "target-branch flag is set with an unset git provider. " - "To test locally, set the GITHUB_ACTIONS or GITLAB_CI environment variable." + if args.oscal_model: + if args.oscal_model not in authored_list: + logger.error( + f"Invalid value {args.oscal_model} for oscal model. " + f"Please use catalog, profile, compdef, or ssp." ) - ) - sys.exit(const.ERROR_EXIT_CODE) - - exit_code: int = const.SUCCESS_EXIT_CODE + sys.exit(const.ERROR_EXIT_CODE) + + if not args.markdown_path: + logger.error("Must set markdown path with oscal model.") + sys.exit(const.ERROR_EXIT_CODE) + + if args.oscal_model == "ssp" and args.ssp_index_path == "": + logger.error("Must set ssp_index_path when using SSP as oscal model.") + sys.exit(const.ERROR_EXIT_CODE) + + # Assuming an edit has occurred assemble would be run before regenerate. + # Adding this to the list first + if not args.skip_assemble: + assemble_task = AssembleTask( + args.working_dir, + args.oscal_model, + args.markdown_path, + args.ssp_index_path, + comma_sep_to_list(args.skip_items), + ) + pre_tasks.append(assemble_task) + else: + logger.info("Assemble task skipped") + + if not args.skip_regenerate: + regenerate_task = RegenerateTask( + args.working_dir, + args.oscal_model, + args.markdown_path, + args.ssp_index_path, + comma_sep_to_list(args.skip_items), + ) + pre_tasks.append(regenerate_task) + else: + logger.info("Regeneration task skipped") - # Assume it is a successful run, if the bot - # throws an exception update the exit code accordingly - try: - commit_sha, pr_number = bot.run( - working_dir=args.working_dir, - branch=args.branch, - commit_name=args.committer_name, - commit_email=args.committer_email, - commit_message=args.commit_message, - author_name=args.author_name, - author_email=args.author_email, - pre_tasks=pre_tasks, - patterns=comma_sep_to_list(args.file_patterns), - git_provider=git_provider, - target_branch=args.target_branch, - pull_request_title=args.pull_request_title, - check_only=args.check_only, - ) + super().run_base(args, pre_tasks) - # 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 +def main() -> None: + """Run the CLI.""" + parser = argparse.ArgumentParser( + description="Workflow automation bot for compliance-trestle" + ) + auto_sync = AutoSyncEntrypoint(parser=parser) - except Exception as e: - exit_code = handle_exception(e) + args = parser.parse_args() - sys.exit(exit_code) + auto_sync.run(args) -def comma_sep_to_list(string: str) -> List[str]: - """Convert comma-sep string to list of strings and strip.""" - string = string.strip() if string else "" - return list(map(str.strip, string.split(","))) if string else [] +if __name__ == "__main__": + main() diff --git a/trestlebot/cli_base.py b/trestlebot/cli_base.py new file mode 100644 index 00000000..1bf3c0bc --- /dev/null +++ b/trestlebot/cli_base.py @@ -0,0 +1,201 @@ +#!/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. + + +""" +This module creates reusable common options for Trestle Bot entrypoints. + +All entrypoints should inherit from this class and use reusable_logic for interaction with the top-level +trestle bot logic located in trestlebot/bot.py. + +The inheriting class should add required arguments for pre-task setup and +call the run_base method with the pre_tasks argument. +""" + +import argparse +import logging +import sys +from typing import List, Optional + +from trestlebot import bot, const +from trestlebot.github import GitHub, is_github_actions +from trestlebot.gitlab import GitLab, get_gitlab_root_url, is_gitlab_ci +from trestlebot.provider import GitProvider +from trestlebot.tasks.base_task import TaskBase + + +logger = logging.getLogger(__name__) + + +class EntrypointBase: + """Base class for all entrypoints.""" + + def __init__(self, parser: argparse.ArgumentParser) -> None: + self.parser: argparse.ArgumentParser = parser + self.setup_common_arguments() + + def setup_common_arguments(self) -> None: + """Setup arguments for the entrypoint.""" + self.parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose mode" + ) + self.parser.add_argument( + "--branch", + type=str, + required=True, + help="Branch name to push changes to", + ) + self.parser.add_argument( + "--working-dir", + type=str, + required=False, + default=".", + help="Working directory wit git repository", + ) + self.parser.add_argument( + "--file-patterns", + required=True, + type=str, + help="Comma-separated list of file patterns to be used with `git add` in repository updates", + ) + self.parser.add_argument( + "--commit-message", + type=str, + required=False, + default="chore: automatic updates", + help="Commit message for automated updates", + ) + self.parser.add_argument( + "--pull-request-title", + type=str, + required=False, + default="Automatic updates from trestlebot", + help="Customized title for submitted pull requests", + ) + self.parser.add_argument( + "--committer-name", + type=str, + required=True, + help="Name of committer", + ) + self.parser.add_argument( + "--committer-email", + type=str, + required=True, + help="Email for committer", + ) + self.parser.add_argument( + "--author-name", + required=False, + type=str, + help="Name for commit author if differs from committer", + ) + self.parser.add_argument( + "--author-email", + required=False, + type=str, + help="Email for commit author if differs from committer", + ) + self.parser.add_argument( + "--target-branch", + type=str, + required=False, + help="Target branch or base branch to create a pull request against. \ + No pull request is created if unset", + ) + self.parser.add_argument( + "--with-token", + nargs="?", + type=argparse.FileType("r"), + required=False, + default=sys.stdin, + help="Read token from standard input for authenticated requests with \ + Git provider (e.g. create pull requests)", + ) + + @staticmethod + def run_base(args: argparse.Namespace, pre_tasks: List[TaskBase]) -> None: + """Reusable logic for all entrypoints.""" + git_provider: Optional[GitProvider] = None + if args.target_branch: + if not args.with_token: + logger.error("with-token value cannot be empty") + sys.exit(const.ERROR_EXIT_CODE) + + if is_github_actions(): + git_provider = GitHub(access_token=args.with_token.read().strip()) + elif is_gitlab_ci(): + server_api_url = get_gitlab_root_url() + git_provider = GitLab( + api_token=args.with_token.read().strip(), server_url=server_api_url + ) + else: + logger.error( + ( + "target-branch flag is set with an unset git provider. " + "To test locally, set the GITHUB_ACTIONS or GITLAB_CI environment variable." + ) + ) + sys.exit(const.ERROR_EXIT_CODE) + + exit_code: int = const.SUCCESS_EXIT_CODE + + # Assume it is a successful run, if the bot + # throws an exception update the exit code accordingly + try: + commit_sha, pr_number = bot.run( + working_dir=args.working_dir, + branch=args.branch, + commit_name=args.committer_name, + commit_email=args.committer_email, + commit_message=args.commit_message, + author_name=args.author_name, + author_email=args.author_email, + pre_tasks=pre_tasks, + patterns=comma_sep_to_list(args.file_patterns), + git_provider=git_provider, + target_branch=args.target_branch, + pull_request_title=args.pull_request_title, + check_only=args.check_only, + ) + + # 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 + + except Exception as e: + exit_code = handle_exception(e) + + sys.exit(exit_code) + + +def comma_sep_to_list(string: str) -> List[str]: + """Convert comma-sep string to list of strings and strip.""" + string = string.strip() if string else "" + return list(map(str.strip, string.split(","))) if string else [] + + +def handle_exception( + exception: Exception, msg: str = "Exception occurred during execution" +) -> int: + """Log the exception and return the exit code""" + logger.error(msg + f": {exception}", exc_info=True) + + return const.ERROR_EXIT_CODE