From ec7abc0384fd6da05c238a429ecd8c8111caeda8 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Mon, 6 Nov 2023 20:34:36 -0500 Subject: [PATCH] refactor(bot): refactor bot.py logic into TrestleBot class Signed-off-by: Jennifer Power --- tests/trestlebot/test_bot.py | 135 +++++---- trestlebot/bot.py | 351 ++++++++++++---------- trestlebot/entrypoints/entrypoint_base.py | 11 +- 3 files changed, 284 insertions(+), 213 deletions(-) diff --git a/tests/trestlebot/test_bot.py b/tests/trestlebot/test_bot.py index c2876648..c0489064 100644 --- a/tests/trestlebot/test_bot.py +++ b/tests/trestlebot/test_bot.py @@ -24,7 +24,7 @@ from git import GitCommandError from git.repo import Repo -import trestlebot.bot as bot +from trestlebot.bot import RepoException, TrestleBot from trestlebot.provider import GitProvider, GitProviderException from trestlebot.tasks.base_task import TaskBase, TaskException @@ -60,6 +60,12 @@ def test_stage_files( f.write("test,") # Stage the files + bot = TrestleBot( + working_dir=repo_path, + branch="main", + commit_name="Test User", + commit_email="test@example.com", + ) bot._stage_files(repo, file_patterns) # Verify that files are staged @@ -80,10 +86,14 @@ def test_local_commit(tmp_repo: Tuple[str, Repo]) -> None: 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", + ) commit_sha = bot._local_commit( repo, - commit_user="Test User", - commit_email="test@example.com", commit_message="Test commit message", ) assert commit_sha != "" @@ -110,10 +120,16 @@ def test_local_commit_with_committer(tmp_repo: Tuple[str, Repo]) -> None: 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="Test Commit User", + author_email="test-committer@example.com", + ) commit_sha = bot._local_commit( repo, - commit_user="Test Commit User", - commit_email="test-committer@example.com", commit_message="Test commit message", ) @@ -141,14 +157,18 @@ def test_local_commit_with_author(tmp_repo: Tuple[str, Repo]) -> None: repo.index.add(test_file_path) # Commit the test file - commit_sha = bot._local_commit( - repo, - commit_user="Test User", + bot = TrestleBot( + working_dir=repo_path, + branch="main", + commit_name="Test User", commit_email="test@example.com", - commit_message="Test commit message", author_name="The Author", author_email="author@test.com", ) + commit_sha = bot._local_commit( + repo, + commit_message="Test commit message", + ) assert commit_sha != "" # Verify that the commit is made @@ -176,14 +196,16 @@ def test_run(tmp_repo: Tuple[str, Repo]) -> None: mock_push.return_value = "Mocked result" # Test running the bot - commit_sha, pr_number = bot.run( + bot = TrestleBot( working_dir=repo_path, branch="main", commit_name="Test User", commit_email="test@example.com", - commit_message="Test commit message", author_name="The Author", author_email="author@test.com", + ) + commit_sha, pr_number = bot.run( + commit_message="Test commit message", patterns=["*.txt"], dry_run=False, ) @@ -214,14 +236,14 @@ def test_run_dry_run(tmp_repo: Tuple[str, Repo]) -> None: mock_push.return_value = "Mocked result" # Test running the bot - commit_sha, pr_number = bot.run( + bot = TrestleBot( working_dir=repo_path, branch="main", commit_name="Test User", commit_email="test@example.com", + ) + commit_sha, pr_number = bot.run( commit_message="Test commit message", - author_name="The Author", - author_email="author@test.com", patterns=["*.txt"], dry_run=True, ) @@ -236,14 +258,14 @@ def test_empty_commit(tmp_repo: Tuple[str, Repo]) -> None: repo_path, repo = tmp_repo # Test running the bot - commit_sha, pr_number = bot.run( + bot = TrestleBot( working_dir=repo_path, branch="main", commit_name="Test User", commit_email="test@example.com", + ) + commit_sha, pr_number = bot.run( commit_message="Test commit message", - author_name="The Author", - author_email="author@test.com", patterns=["*.txt"], dry_run=True, ) @@ -260,18 +282,19 @@ def test_run_check_only(tmp_repo: Tuple[str, Repo]) -> None: 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( - bot.RepoException, + RepoException, match="Check only mode is enabled and diff detected. Manual intervention on main is required.", ): _, _ = bot.run( - working_dir=repo_path, - branch="main", - commit_name="Test User", - commit_email="test@example.com", commit_message="Test commit message", - author_name="The Author", - author_email="author@test.com", patterns=["*.txt"], dry_run=True, check_only=True, @@ -306,18 +329,19 @@ def test_run_with_exception( repo.create_remote("origin", url="git.test.com/test/repo.git") + bot = TrestleBot( + working_dir=repo_path, + branch="main", + commit_name="Test User", + commit_email="test@example.com", + ) + with patch("git.remote.Remote.push") as mock_push: mock_push.side_effect = side_effect - with pytest.raises(bot.RepoException, match=msg): + with pytest.raises(RepoException, match=msg): _ = bot.run( - working_dir=repo_path, - branch="main", - commit_name="Test User", - commit_email="test@example.com", commit_message="Test commit message", - author_name="The Author", - author_email="author@test.com", patterns=["*.txt"], dry_run=False, ) @@ -337,15 +361,16 @@ def test_run_with_failed_pre_task(tmp_repo: Tuple[str, Repo]) -> None: repo.create_remote("origin", url="git.test.com/test/repo.git") - with pytest.raises(bot.RepoException, match="Bot pre-tasks failed: example"): + bot = TrestleBot( + working_dir=repo_path, + branch="main", + commit_name="Test User", + commit_email="test@example.com", + ) + + with pytest.raises(RepoException, match="Bot pre-tasks failed: example"): _ = bot.run( - working_dir=repo_path, - branch="main", - commit_name="Test User", - commit_email="test@example.com", commit_message="Test commit message", - author_name="The Author", - author_email="author@test.com", patterns=["*.txt"], dry_run=True, pre_tasks=[mock], @@ -367,21 +392,24 @@ def test_run_with_provider(tmp_repo: Tuple[str, Repo]) -> None: repo.create_remote("origin", url="git.test.com/test/repo.git") + bot = TrestleBot( + working_dir=repo_path, + branch="test", + commit_name="Test User", + commit_email="test@example.com", + author_name="The Author", + author_email="author@test.com", + target_branch="main", + ) + with patch("git.remote.Remote.push") as mock_push: mock_push.return_value = "Mocked result" # Test running the bot commit_sha, pr_number = bot.run( - working_dir=repo_path, - branch="test", - commit_name="Test User", - commit_email="test@example.com", commit_message="Test commit message", - author_name="The Author", - author_email="author@test.com", patterns=["*.txt"], git_provider=mock, - target_branch="main", dry_run=False, ) assert commit_sha != "" @@ -423,21 +451,24 @@ def test_run_with_provider_with_custom_pr_title(tmp_repo: Tuple[str, Repo]) -> N repo.create_remote("origin", url="git.test.com/test/repo.git") + bot = TrestleBot( + working_dir=repo_path, + branch="test", + commit_name="Test User", + commit_email="test@example.com", + author_name="The Author", + author_email="author@test.com", + target_branch="main", + ) + 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="test@example.com", commit_message="Test commit message", - author_name="The Author", - author_email="author@test.com", patterns=["*.txt"], git_provider=mock, - target_branch="main", pull_request_title="Test", dry_run=False, ) diff --git a/trestlebot/bot.py b/trestlebot/bot.py index 8d9d8bab..c7a4285b 100644 --- a/trestlebot/bot.py +++ b/trestlebot/bot.py @@ -36,170 +36,207 @@ class RepoException(Exception): """ -def _stage_files(gitwd: Repo, patterns: List[str]) -> None: - """Stages files in git based on file patterns""" - for pattern in patterns: - if pattern == ".": - logger.info("Staging all repository changes") - # Using check to avoid adding git directory - # https://github.com/gitpython-developers/GitPython/issues/292 - gitwd.git.add(all=True) - return - else: - logger.info(f"Adding file for pattern {pattern}") - gitwd.git.add(pattern) - - -def _local_commit( - gitwd: Repo, - commit_user: str, - commit_email: str, - commit_message: str, - author_name: str = "", - author_email: str = "", -) -> str: - """Creates a local commit in git working directory""" - try: - # Set the user and email for the commit - gitwd.config_writer().set_value("user", "name", commit_user).release() - gitwd.config_writer().set_value("user", "email", commit_email).release() - - author: Optional[Actor] = None - - if author_name and author_email: - author = Actor(name=author_name, email=author_email) - - # Commit the changes - commit = gitwd.index.commit(commit_message, author=author) - - # Return commit sha - return commit.hexsha - except GitCommandError as e: - raise RepoException(f"Git commit failed: {e}") from e - - -def run( - working_dir: str, - branch: str, - commit_name: str, - commit_email: str, - commit_message: str, - author_name: str, - author_email: str, - patterns: List[str], - git_provider: Optional[GitProvider] = None, - pre_tasks: Optional[List[TaskBase]] = None, - target_branch: str = "", - pull_request_title: str = "Automatic updates from bot", - check_only: bool = False, - dry_run: bool = False, -) -> Tuple[str, int]: - """Run Trestle Bot and returns commit and pull request information. - - Args: - working_dir: Location of the git repo - branch: Branch to put updates to - commit_name: Name of the user for commit creation - commit_email: Email of the user for commit creation - commit_message: Customized commit message - author_name: Name of the commit author - author_email: Email of the commit author - patterns: List of file patterns for `git add` - git_provider: Optional configured git provider for interacting with the API - pre_tasks: Optional task list to executing before updating the workspace - target_branch: Optional target or base branch for submitted pull request - pull_request_title: Optional customized pull request title - check_only: Optional heck if the repo is dirty. Fail if true. - dry_run: Only complete local work. Do not push. - - Returns: - A tuple with 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. - """ - commit_sha: str = "" - pr_number: int = 0 - - # Create Git Repo - repo = Repo(working_dir) - - branch_names: List[str] = [b.name for b in repo.branches] # type: ignore - if branch in branch_names: - logger.debug(f"Local branch {branch} found") - repo.git.checkout(branch) - else: - logger.debug(f"Local branch {branch} created") - repo.git.checkout("-b", branch) - - # Execute bot pre-tasks before committing repository updates - if pre_tasks is not None: - for task in pre_tasks: +class TrestleBot: + """Trestle Bot class for managing git repositories.""" + + def __init__( + self, + working_dir: str, + branch: str, + commit_name: str, + commit_email: str, + author_name: str = "", + author_email: str = "", + target_branch: str = "", + ) -> None: + """Initialize Trestle Bot. + + Args: + working_dir: Location of the git repository + branch: Branch to push updates to + commit_name: Name of the user for commit creation + commit_email: Email of the user for commit creation + author_name: Optional name of the commit author + author_email: Optional email of the commit author + target_branch: Optional target or base branch for submitted pull request + """ + self.working_dir = working_dir + self.branch = branch + self.target_branch = target_branch + self.commit_name = commit_name + self.commit_email = commit_email + self.author_name = author_name + self.author_email = author_email + + @staticmethod + def _stage_files(gitwd: Repo, patterns: List[str]) -> None: + """Stages files in git based on file patterns""" + for pattern in patterns: + if pattern == ".": + logger.info("Staging all repository changes") + # Using check to avoid adding git directory + # https://github.com/gitpython-developers/GitPython/issues/292 + gitwd.git.add(all=True) + return + else: + logger.info(f"Adding file for pattern {pattern}") + gitwd.git.add(pattern) + + def _local_commit( + self, + gitwd: Repo, + commit_message: str, + ) -> str: + """Creates a local commit in git working directory""" + try: + committer: Actor = Actor(name=self.commit_name, email=self.commit_email) + + author: Optional[Actor] = None + if self.author_name and self.author_email: + author = Actor(name=self.author_name, email=self.author_email) + commit = gitwd.index.commit( + commit_message, author=author, committer=committer + ) + + return commit.hexsha + + except GitCommandError as e: + raise RepoException(f"Git commit failed: {e}") from e + + def _push_to_remote(self, gitwd: Repo) -> str: + """Pushes the local branch to the remote repository""" + remote = gitwd.remote() + + # Push changes to the remote repository + remote.push(refspec=f"HEAD:{self.branch}") + + logger.info(f"Changes pushed to {self.branch} successfully.") + return remote.url + + def _create_pull_request( + self, + git_provider: GitProvider, + remote_url: str, + pull_request_title: str, + ) -> int: + """Creates a pull request in the remote repository""" + + # Parse remote url to get repository information for pull request + namespace, repo_name = git_provider.parse_repository(remote_url) + logger.debug(f"Detected namespace {namespace} and {repo_name}") + + pr_number = git_provider.create_pull_request( + ns=namespace, + repo_name=repo_name, + head_branch=self.branch, + base_branch=self.target_branch, + title=pull_request_title, + body="", + ) + return pr_number + + def _checkout_branch(self, gitwd: Repo) -> None: + """Checkout the branch""" + try: + branch_names: List[str] = [b.name for b in gitwd.branches] # type: ignore + if self.branch in branch_names: + logger.debug(f"Local branch {self.branch} found") + gitwd.git.checkout(self.branch) + else: + logger.debug(f"Local branch {self.branch} created") + gitwd.git.checkout("-b", self.branch) + except GitCommandError as e: + raise RepoException(f"Git checkout failed: {e}") from e + + def _run_tasks(self, tasks: List[TaskBase]) -> None: + """Run tasks""" + for task in tasks: try: task.execute() except TaskException as e: raise RepoException(f"Bot pre-tasks failed: {e}") - # 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 {branch} is required." - ) - - _stage_files(repo, patterns) - - if repo.is_dirty(): - commit_sha = _local_commit( - repo, - commit_name, - commit_email, - commit_message, - author_name, - author_email, - ) - - if dry_run: - logger.info("Dry run mode is enabled. Do not push to remote.") - return commit_sha, pr_number - - try: - # Get the remote repository by name - remote = repo.remote() - - # Push changes to the remote repository - remote.push(refspec=f"HEAD:{branch}") - - logger.info(f"Changes pushed to {branch} successfully.") - - # Only create a pull request if a GitProvider is configured and - # a target branch is set. - if git_provider is not None and target_branch: - logger.info( - f"Git provider detected, submitting pull request to {target_branch}" - ) - # Parse remote url to get repository information for pull request - namespace, repo_name = git_provider.parse_repository(remote.url) - logger.debug(f"Detected namespace {namespace} and {repo_name}") - - pr_number = git_provider.create_pull_request( - ns=namespace, - repo_name=repo_name, - head_branch=branch, - base_branch=target_branch, - title=pull_request_title, - body="", + def run( + self, + patterns: List[str], + git_provider: Optional[GitProvider] = None, + 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]: + """ + Run Trestle Bot and returns commit and pull request information. + + Args: + patterns: List of file patterns for `git add` + git_provider: Optional configured git provider for interacting with the API + pre_tasks: Optional task list to executing before updating the workspace + commit_message: Optional commit message for local commit + pull_request_title: Optional customized pull request title + check_only: Optional heck if the repo is dirty. Fail if true. + dry_run: Only complete local work. Do not push. + + Returns: + A tuple with 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. + """ + commit_sha: str = "" + pr_number: int = 0 + + # Create Git Repo + repo = Repo(self.working_dir) + self._checkout_branch(repo) + + # Execute bot pre-tasks before committing repository updates + if pre_tasks: + self._run_tasks(pre_tasks) + + # 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( + repo, + commit_message, + ) + + if dry_run: + logger.info("Dry run mode is enabled. Do not push to remote.") + return commit_sha, pr_number + + try: + remote_url = self._push_to_remote(repo) + + # Only create a pull request if a GitProvider is configured and + # a target branch is set. + if git_provider and self.target_branch: + logger.info( + f"Git provider detected, submitting pull request to {self.target_branch}" + ) + pr_number = self._create_pull_request( + git_provider, remote_url, pull_request_title + ) + return commit_sha, pr_number + + except GitCommandError as e: + raise RepoException(f"Git push to {self.branch} failed: {e}") + except GitProviderException as e: + raise RepoException( + f"Git pull request to {self.target_branch} failed: {e}" ) - + else: + logger.info("Nothing to commit") return commit_sha, pr_number - - except GitCommandError as e: - raise RepoException(f"Git push to {branch} failed: {e}") - except GitProviderException as e: - raise RepoException(f"Git pull request to {target_branch} failed: {e}") else: logger.info("Nothing to commit") return commit_sha, pr_number - else: - logger.info("Nothing to commit") - return commit_sha, pr_number diff --git a/trestlebot/entrypoints/entrypoint_base.py b/trestlebot/entrypoints/entrypoint_base.py index 24434997..7ceee02c 100644 --- a/trestlebot/entrypoints/entrypoint_base.py +++ b/trestlebot/entrypoints/entrypoint_base.py @@ -30,7 +30,8 @@ import sys from typing import List, Optional -from trestlebot import bot, const +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.provider import GitProvider @@ -169,18 +170,20 @@ def run_base(args: argparse.Namespace, pre_tasks: List[TaskBase]) -> None: # Assume it is a successful run, if the bot # throws an exception update the exit code accordingly try: - commit_sha, pr_number = bot.run( + bot = TrestleBot( 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, + target_branch=args.target_branch, + ) + commit_sha, pr_number = bot.run( + commit_message=args.commit_message, 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, )