diff --git a/edx_repo_tools/pull_request_creator/__init__.py b/edx_repo_tools/pull_request_creator/__init__.py new file mode 100644 index 00000000..32fc6743 --- /dev/null +++ b/edx_repo_tools/pull_request_creator/__init__.py @@ -0,0 +1,668 @@ +""" +Class helps create GitHub Pull requests +""" +# pylint: disable=missing-class-docstring,missing-function-docstring,attribute-defined-outside-init +import logging +import re +import os +import time +from ast import literal_eval + +import click +import requests +from git import Git, Repo +from github import Github, GithubObject, InputGitAuthor, InputGitTreeElement +from packaging.version import Version + + +logging.basicConfig() +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +@click.command() +@click.option( + '--repo-root', + type=click.Path(exists=True, dir_okay=True, file_okay=False), + required=True, + help="Directory containing local copy of repository" +) +@click.option( + '--base-branch-name', + required=True, + help="Base name for branch to create. Full branch name will be like repo-tools/BASENAME-1234567." +) +@click.option( + '--target-branch', + required=False, + default="master", + help="Target branch against which we have to open a PR", +) +@click.option('--commit-message', required=True) +@click.option('--pr-title', required=True) +@click.option('--pr-body', required=True) +@click.option( + '--user-reviewers', + default='', + help="Comma separated list of Github users to be tagged on pull requests" +) +@click.option( + '--team-reviewers', + default='', + help=("Comma separated list of Github teams to be tagged on pull requests. " + "NOTE: Teams must have explicit write access to the repo, or " + "Github will refuse to tag them.") +) +@click.option( + '--delete-old-pull-requests/--no-delete-old-pull-requests', + default=True, + help="If set, delete old branches with the same base branch name and close their PRs" +) +@click.option( + '--force-delete-old-prs/--no-force-delete-old-prs', + default=False, + help="If set, force delete old branches with the same base branch name and close their PRs" +) +@click.option( + '--draft', is_flag=True +) +@click.option( + '--output-pr-url-for-github-action/--no-output-pr-url-for-github-action', + default=False, + help="If set, print resultant PR in github action set output sytax" +) +@click.option( + '--untracked-files-required', + required=False, + help='If set, add Untracked files to the PR as well' +) +def main( + repo_root, base_branch_name, target_branch, + commit_message, pr_title, pr_body, + user_reviewers, team_reviewers, + delete_old_pull_requests, draft, output_pr_url_for_github_action, + untracked_files_required, force_delete_old_prs +): + """ + Create a pull request with these changes in the repo. + + Required environment variables: + + - GITHUB_TOKEN + - GITHUB_USER_EMAIL + """ + creator = PullRequestCreator( + repo_root=repo_root, + branch_name=base_branch_name, + target_branch=target_branch, + commit_message=commit_message, + pr_title=pr_title, pr_body=pr_body, + user_reviewers=user_reviewers, + team_reviewers=team_reviewers, + draft=draft, + output_pr_url_for_github_action=output_pr_url_for_github_action, + force_delete_old_prs=force_delete_old_prs + ) + creator.create(delete_old_pull_requests, untracked_files_required) + + +class GitHubHelper: # pylint: disable=missing-class-docstring + + def __init__(self): + self._set_github_token() + self._set_user_email() + self._set_github_instance() + self.AUTOMERGE_ACTION_VAR = 'AUTOMERGE_PYTHON_DEPENDENCIES_UPGRADES_PR' + + # FIXME: Does nothing, sets variable to None if env var missing + def _set_github_token(self): + try: + self.github_token = os.environ.get('GITHUB_TOKEN') + except Exception as error: + raise Exception( + "Could not find env variable GITHUB_TOKEN. " + "Please make sure the variable is set and try again." + ) from error + + # FIXME: Does nothing, sets variable to None if env var missing + def _set_user_email(self): + try: + self.github_user_email = os.environ.get('GITHUB_USER_EMAIL') + except Exception as error: + raise Exception( + "Could not find env variable GITHUB_USER_EMAIL. " + "Please make sure the variable is set and try again." + ) from error + + def _set_github_instance(self): + try: + self.github_instance = Github(self.github_token) + except Exception as error: + raise Exception( + "Failed connecting to Github. " + + "Please make sure the github token is accurate and try again." + ) from error + + def _add_reason(self, req, reason): + req['reason'] = reason + return req + + def _add_comment_about_reqs(self, pr, summary, reqs): + separator = "\n" + pr.create_issue_comment( + f"{summary}.
\n {separator.join(self.make_readable_string(req) for req in reqs)}" + ) + + def get_github_instance(self): + return self.github_instance + + def get_github_token(self): + return self.github_token + + # FIXME: Probably can end up picking repo from wrong org if two + # repos have the same name in different orgs. + # + # Use repo_from_remote instead, and delete this when no longer in use. + def connect_to_repo(self, github_instance, repo_name): + """ + Get the repository object of the desired repo. + """ + repos_list = github_instance.get_user().get_repos() + for repo in repos_list: + if repo.name == repo_name: + return repo + + raise Exception( + "Could not connect to the repository: {}. " + "Please make sure you are using the correct " + "credentials and try again.".format(repo_name) + ) + + def repo_from_remote(self, repo_root, remote_name_allow_list=None): + """ + Get the repository object for a repository with a Github remote. + + Optionally restrict the remotes under consideration by passing a list + of names as``remote_name_allow_list``, e.g. ``['origin']``. + """ + patterns = [ + r"git@github\.com:(?P[^/?#]+/[^/?#]+?).git", + # Non-greedy match for repo name so that optional .git on + # end is not included in repo name match. + r"https?://(www\.)?github\.com/(?P[^/?#]+/[^/?#]+?)(/|\.git)?" + ] + for remote in Repo(repo_root).remotes: + if remote_name_allow_list and remote.name not in remote_name_allow_list: + continue + for url in remote.urls: + for pattern in patterns: + m = re.fullmatch(pattern, url) + if m: + fullname = m.group('name') + logger.info("Discovered repo %s in remotes", fullname) + return self.github_instance.get_repo(fullname) + raise Exception("Could not find a Github URL among repo's remotes") + + def branch_exists(self, repository, branch_name): + """ + Checks to see if this branch name already exists + """ + try: + repository.get_branch(branch_name) + except: # pylint: disable=bare-except + return False + return True + + def get_current_commit(self, repo_root): + """ + Get current commit ID of repo at repo_root. + """ + return Git(repo_root).rev_parse('HEAD') + + def get_updated_files_list(self, repo_root, untracked_files_required=False): + """ + Use the Git library to run the ls-files command to find + the list of files updated. + """ + git_instance = Git(repo_root) + git_instance.init() + + if untracked_files_required: + modified_files = git_instance.ls_files("--modified") + untracked_files = git_instance.ls_files("--others", "--exclude-standard") + updated_files = modified_files + '\n' + untracked_files \ + if (len(modified_files) > 0 and len(untracked_files) > 0) \ + else modified_files + untracked_files + + else: + updated_files = git_instance.ls_files("--modified") + + if len(updated_files) > 0: + return updated_files.split("\n") + else: + return [] + + def create_branch(self, repository, branch_name, sha): + """ + Create a new branch with the given sha as its head. + """ + try: + branch_object = repository.create_git_ref(branch_name, sha) + except Exception as error: + raise Exception( + "Unable to create git branch: {}. " + "Check to make sure this branch doesn't already exist.".format(branch_name) + ) from error + return branch_object + + def close_existing_pull_requests(self, repository, user_login, user_name, target_branch='master', + branch_name_filter=None): + """ + Close any existing PR's by the bot user in this PR. This will help + reduce clutter, since any old PR's will be obsolete. + If function branch_name_filter is specified, it will be called with + branch names of PRs. The PR will only be closed when the function + returns true. + """ + pulls = repository.get_pulls(state="open") + deleted_pull_numbers = [] + for pr in pulls: + user = pr.user + if user.login == user_login and user.name == user_name and pr.base.ref == target_branch: + branch_name = pr.head.ref + if branch_name_filter and not branch_name_filter(branch_name): + continue + logger.info("Deleting PR: #%s", pr.number) + pr.create_issue_comment("Closing obsolete PR.") + pr.edit(state="closed") + deleted_pull_numbers.append(pr.number) + + self.delete_branch(repository, branch_name) + return deleted_pull_numbers + + def create_pull_request(self, repository, title, body, base, head, user_reviewers=GithubObject.NotSet, + team_reviewers=GithubObject.NotSet, verify_reviewers=True, draft=False): + """ + Create a new pull request with the changes in head. And tag a list of teams + for a review. + """ + try: + pull_request = repository.create_pull( + title=title, + body=body, + base=base, + head=head, + draft=draft + ) + except Exception as e: + raise e + + try: + any_reviewers = (user_reviewers is not GithubObject.NotSet or team_reviewers is not GithubObject.NotSet) + if any_reviewers: + logger.info("Tagging reviewers: users=%s and teams=%s", user_reviewers, team_reviewers) + pull_request.create_review_request( + reviewers=user_reviewers, + team_reviewers=team_reviewers, + ) + if verify_reviewers: + # Sometimes GitHub can't find the pull request we just made. + # Try waiting a moment before asking about it. + time.sleep(5) + self.verify_reviewers_tagged(pull_request, user_reviewers, team_reviewers) + + except Exception as e: + raise Exception( + "Some reviewers could not be tagged on new PR " + "https://github.com/{}/pull/{}".format(repository.full_name, pull_request.number) + ) from e + + # it's a discovery work that's why only enabled for repo-health-data. + if pull_request.title == 'Python Requirements Update': + self.verify_upgrade_packages(pull_request) + + return pull_request + + def verify_reviewers_tagged(self, pull_request, requested_users, requested_teams): + """ + Assert if the reviewers we requested were tagged on the PR for review. + + Considerations: + + - Teams cannot be tagged on a PR unless that team has explicit write + access to the repo. + - Github may have independently tagged additional users or teams + based on a CODEOWNERS file. + """ + tagged_for_review = pull_request.get_review_requests() + + tagged_users = [user.login for user in tagged_for_review[0]] + if not (requested_users is GithubObject.NotSet or set(requested_users) <= set(tagged_users)): + logger.info("User taggging failure: Requested %s, actually tagged %s", requested_users, tagged_users) + raise Exception('Some of the requested reviewers were not tagged on PR for review') + + tagged_teams = [team.name for team in tagged_for_review[1]] + if not (requested_teams is GithubObject.NotSet or set(requested_teams) <= set(tagged_teams)): + logger.info("Team taggging failure: Requested %s, actually tagged %s", requested_teams, tagged_teams) + raise Exception('Some of the requested teams were not tagged on PR for review') + + def verify_upgrade_packages(self, pull_request): + """ + Iterate on pull request diff and parse the packages and check the versions. + If all versions are upgrading then add a label ready for auto merge. In case of any downgrade package + add a comment on PR. + """ + location = None + location = pull_request._headers['location'] # pylint: disable=protected-access + logger.info(location) + + if not location: + return + + logger.info('Hitting pull request for difference') + headers = {"Accept": "application/vnd.github.v3.diff", "Authorization": f'Bearer {self.github_token}'} + + load_content = requests.get(location, headers=headers, timeout=5) + txt = '' + time.sleep(3) + logger.info(load_content.status_code) + + if load_content.status_code == 200: + txt = load_content.content.decode('utf-8') + valid_reqs, suspicious_reqs = self.compare_pr_differnce(txt) + + self._add_comment_about_reqs(pull_request, "List of packages in the PR without any issue", valid_reqs) + + if not suspicious_reqs and valid_reqs: + if self.check_automerge_variable_value(location): + pull_request.set_labels('Ready to Merge') + logger.info("Total valid upgrades are %s", valid_reqs) + else: + self._add_comment_about_reqs(pull_request, "These Packages need manual review.", suspicious_reqs) + + else: + logger.info("No package available for comparison.") + + def check_automerge_variable_value(self, location): + """ + Check whether repository has the `AUTOMERGE_PYTHON_DEPENDENCIES_UPGRADES_PR` variable + with `True` value exists. + """ + link = location.split('pulls') + get_repo_variable = link[0] + 'actions/variables/' + self.AUTOMERGE_ACTION_VAR + logger.info('Hitting repository to check AUTOMERGE_ACTION_VAR settings.') + + headers = {"Accept": "application/vnd.github+json", "Authorization": f'Bearer {self.github_token}'} + load_content = requests.get(get_repo_variable, headers=headers, timeout=5) + time.sleep(1) + + if load_content.status_code == 200: + val = literal_eval(load_content.json()['value']) + logger.info(f"AUTOMERGE_ACTION_VAR value is {val}") + return val + + return False + + def compare_pr_differnce(self, txt): + """ Parse the content and extract packages for comparison. """ + regex = re.compile(r"(?P[\-\+])(?P[\w][\w\-\[\]]+)==(?P\d+\.\d+(\.\d+)?(\.[\w]+)?)") + reqs = {} + if not txt: + return [], [] + + # skipping zeroth index as it will be empty + files = txt.split("diff --git")[1:] + for file in files: + lines = file.split("\n") + filename_match = re.search(r"[\w\-\_]*.txt", lines[0]) + if not filename_match: + continue + filename = filename_match[0] + reqs[filename] = {} + for line in lines: + match = re.match(regex, line) + if match: + groups = match.groupdict() + keys = ('new_version', 'old_version') if groups['change'] == '+' \ + else ('old_version', 'new_version') + if groups['name'] in reqs[filename]: + reqs[filename][groups['name']][keys[0]] = groups['version'] + else: + reqs[filename][groups['name']] = {keys[0]: groups['version'], keys[1]: None} + combined_reqs = [] + for file, lst in reqs.items(): + for name, versions in lst.items(): + combined_reqs.append( + {"name": name, 'old_version': versions['old_version'], 'new_version': versions['new_version']} + ) + + unique_reqs = [dict(s) for s in set(frozenset(d.items()) for d in combined_reqs)] + valid_reqs = [] + suspicious_reqs = [] + for req in unique_reqs: + if req['new_version'] and req['old_version']: # if both values exits then do version comparison + old_version = Version(req['old_version']) + new_version = Version(req['new_version']) + + # skip, if the package location is changed in txt file only and both versions are same + if old_version == new_version: + continue + if new_version > old_version: + if new_version.major == old_version.major: + valid_reqs.append(req) + else: + suspicious_reqs.append(self._add_reason(req, "MAJOR")) + else: + suspicious_reqs.append(self._add_reason(req, "DOWNGRADE")) + else: + if req['new_version']: + suspicious_reqs.append(self._add_reason(req, "NEW")) + else: + suspicious_reqs.append(self._add_reason(req, "REMOVED")) + + return sorted(valid_reqs, key=lambda d: d['name']), sorted(suspicious_reqs, key=lambda d: d['name']) + + def make_readable_string(self, req): + """making string for readability""" + if 'reason' in req: + if req['reason'] == 'NEW': + return f"- **[{req['reason']}]** `{req['name']}`" \ + f" (`{req['new_version']}`) added to the requirements" + if req['reason'] == 'REMOVED': + return f"- **[{req['reason']}]** `{req['name']}`" \ + f" (`{req['old_version']}`) removed from the requirements" + # either major version bump or downgraded + return f"- **[{req['reason']}]** `{req['name']}` " \ + f"changes from `{req['old_version']}` to `{req['new_version']}`" + # valid requirement + return f"- `{req['name']}` changes from `{req['old_version']}` to `{req['new_version']}`" + + def delete_branch(self, repository, branch_name): + """ + Delete a branch from a repository. + """ + logger.info("Deleting Branch: %s", branch_name) + try: + ref = "heads/{}".format(branch_name) + branch_object = repository.get_git_ref(ref) + branch_object.delete() + except Exception as error: + raise Exception( + "Failed to delete branch" + ) from error + + def get_file_contents(self, repo_root, file_path): + """ + Return contents of local file + """ + try: + full_file_path = os.path.join(repo_root, file_path) + with open(full_file_path, 'r', encoding='utf-8') as opened_file: + data = opened_file.read() + except Exception as error: + raise Exception( + "Unable to read file: {}".format(file_path) + ) from error + + return data + + # pylint: disable=missing-function-docstring + def update_list_of_files(self, repository, repo_root, file_path_list, commit_message, sha, username): + input_trees_list = [] + base_git_tree = repository.get_git_tree(sha) + for file_path in file_path_list: + content = self.get_file_contents(repo_root, file_path) + input_tree = InputGitTreeElement(file_path, "100644", "blob", content=content) + input_trees_list.append(input_tree) + if len(input_trees_list) > 0: + new_git_tree = repository.create_git_tree(input_trees_list, base_tree=base_git_tree) + parents = [repository.get_git_commit(sha)] + author = InputGitAuthor(username, self.github_user_email) + commit_sha = repository.create_git_commit( + commit_message, new_git_tree, parents, author=author, committer=author + ).sha + return commit_sha + + return None + + +class PullRequestCreator: + + def __init__(self, repo_root, branch_name, user_reviewers, team_reviewers, commit_message, pr_title, + pr_body, target_branch='master', draft=False, output_pr_url_for_github_action=False, + force_delete_old_prs=False): + self.branch_name = branch_name + self.pr_body = pr_body + self.pr_title = pr_title + self.commit_message = commit_message + self.team_reviewers = team_reviewers + self.user_reviewers = user_reviewers + self.repo_root = repo_root + self.target_branch = target_branch + self.draft = draft + self.output_pr_url_for_github_action = output_pr_url_for_github_action + self.force_delete_old_prs = force_delete_old_prs + + github_helper = GitHubHelper() + + def _get_github_instance(self): + return self.github_helper.get_github_instance() + + def _get_user(self): + return self.github_instance.get_user() + + def _set_repository(self): + self.repository = self.github_helper.repo_from_remote(self.repo_root, ['origin']) + + def _set_updated_files_list(self, untracked_files_required=False): + self.updated_files_list = self.github_helper.get_updated_files_list(self.repo_root, untracked_files_required) + + def _create_branch(self, commit_sha): + self.github_helper.create_branch(self.repository, self.branch, commit_sha) + + def _set_github_data(self, untracked_files_required=False): + logger.info("Authenticating with Github") + self.github_instance = self._get_github_instance() + self.user = self._get_user() + + logger.info("Trying to connect to repo") + self._set_repository() + logger.info("Connected to %s", self.repository) + self._set_updated_files_list(untracked_files_required) + self.base_sha = self.github_helper.get_current_commit(self.repo_root) + self.branch = "refs/heads/repo-tools/{}-{}".format(self.branch_name, self.base_sha[:7]) + + def _branch_exists(self): + return self.github_helper.branch_exists(self.repository, self.branch) + + def _create_new_branch(self): + logger.info("updated files: %s", self.updated_files_list) + commit_sha = self.github_helper.update_list_of_files( + self.repository, + self.repo_root, + self.updated_files_list, + self.commit_message, + self.base_sha, + self.user.name + ) + self._create_branch(commit_sha) + + def _create_new_pull_request(self): + # If there are reviewers to be added, split them into python lists + if isinstance(self.user_reviewers, str) and self.user_reviewers: + user_reviewers = self.user_reviewers.split(',') + else: + user_reviewers = GithubObject.NotSet + + if isinstance(self.team_reviewers, str) and self.team_reviewers: + team_reviewers = self.team_reviewers.split(',') + else: + team_reviewers = GithubObject.NotSet + + pr = self.github_helper.create_pull_request( + self.repository, + self.pr_title, + self.pr_body, + self.target_branch, + self.branch, + user_reviewers=user_reviewers, + team_reviewers=team_reviewers, + # TODO: Remove hardcoded check in favor of a new --verify-reviewers CLI option + verify_reviewers=self.branch_name != 'cleanup-python-code', + draft=self.draft + ) + logger.info( + "Created PR: https://github.com/%s/pull/%s", + self.repository.full_name, + pr.number, + ) + if self.output_pr_url_for_github_action: + # using print rather than logger to avoid the logger + # prepending anything past which github actions wouldn't parse + print(f'::set-output name=generated_pr::https://github.com/{self.repository.full_name}/pull/{pr.number}') + + def delete_old_pull_requests(self): + logger.info("Checking if there's any old pull requests to delete") + # Only delete old PRs with the same base name + # The prefix used to be 'jenkins' before we changed it to 'repo-tools' + filter_pattern = "(jenkins|repo-tools)/{}-[a-zA-Z0-9]*".format(re.escape(self.branch_name)) + deleted_pulls = self.github_helper.close_existing_pull_requests( + self.repository, self.user.login, + self.user.name, self.target_branch, + branch_name_filter=lambda name: re.fullmatch(filter_pattern, name) + ) + + for num, deleted_pull_number in enumerate(deleted_pulls): + if num == 0: + self.pr_body += "\n\nDeleted obsolete pull_requests:" + self.pr_body += "\nhttps://github.com/{}/pull/{}".format(self.repository.full_name, + deleted_pull_number) + + def _delete_old_branch(self): + logger.info("Deleting existing old branch with same name") + branch_name = self.branch.split('/', 2)[2] + self.github_helper.delete_branch(self.repository, branch_name) + + def create(self, delete_old_pull_requests, untracked_files_required=False): + self._set_github_data(untracked_files_required) + + if not self.updated_files_list: + logger.info("No changes needed") + return + + if self.force_delete_old_prs or delete_old_pull_requests: + self.delete_old_pull_requests() + if self._branch_exists(): + self._delete_old_branch() + + elif self._branch_exists(): + logger.info("Branch for this sha already exists") + return + + self._create_new_branch() + + self._create_new_pull_request() + + +if __name__ == '__main__': + main(auto_envvar_prefix="PR_CREATOR") # pylint: disable=no-value-for-parameter diff --git a/edx_repo_tools/pull_request_creator/extra.in b/edx_repo_tools/pull_request_creator/extra.in new file mode 100644 index 00000000..1f0afcd0 --- /dev/null +++ b/edx_repo_tools/pull_request_creator/extra.in @@ -0,0 +1,6 @@ +# additional dependencies needed by pull_request_creator + +-c ../../requirements/constraints.txt + +PyGithub +packaging # used in create pull request script to compare package versions diff --git a/edx_repo_tools/pull_request_creator/extra.txt b/edx_repo_tools/pull_request_creator/extra.txt new file mode 100644 index 00000000..71e0c18f --- /dev/null +++ b/edx_repo_tools/pull_request_creator/extra.txt @@ -0,0 +1,70 @@ +# +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: +# +# make upgrade +# +certifi==2024.2.2 + # via + # -c edx_repo_tools/pull_request_creator/../../requirements/base.txt + # -c edx_repo_tools/pull_request_creator/../../requirements/development.txt + # requests +cffi==1.16.0 + # via + # -c edx_repo_tools/pull_request_creator/../../requirements/base.txt + # -c edx_repo_tools/pull_request_creator/../../requirements/development.txt + # cryptography + # pynacl +charset-normalizer==3.3.2 + # via + # -c edx_repo_tools/pull_request_creator/../../requirements/base.txt + # -c edx_repo_tools/pull_request_creator/../../requirements/development.txt + # requests +cryptography==42.0.7 + # via + # -c edx_repo_tools/pull_request_creator/../../requirements/base.txt + # -c edx_repo_tools/pull_request_creator/../../requirements/development.txt + # pyjwt +deprecated==1.2.14 + # via pygithub +idna==3.7 + # via + # -c edx_repo_tools/pull_request_creator/../../requirements/base.txt + # -c edx_repo_tools/pull_request_creator/../../requirements/development.txt + # requests +packaging==24.0 + # via + # -c edx_repo_tools/pull_request_creator/../../requirements/base.txt + # -c edx_repo_tools/pull_request_creator/../../requirements/development.txt + # -r edx_repo_tools/pull_request_creator/extra.in +pycparser==2.22 + # via + # -c edx_repo_tools/pull_request_creator/../../requirements/base.txt + # -c edx_repo_tools/pull_request_creator/../../requirements/development.txt + # cffi +pygithub==2.3.0 + # via -r edx_repo_tools/pull_request_creator/extra.in +pyjwt[crypto]==2.8.0 + # via + # -c edx_repo_tools/pull_request_creator/../../requirements/base.txt + # -c edx_repo_tools/pull_request_creator/../../requirements/development.txt + # pygithub +pynacl==1.5.0 + # via pygithub +requests==2.32.2 + # via + # -c edx_repo_tools/pull_request_creator/../../requirements/base.txt + # -c edx_repo_tools/pull_request_creator/../../requirements/development.txt + # pygithub +typing-extensions==4.11.0 + # via + # -c edx_repo_tools/pull_request_creator/../../requirements/development.txt + # pygithub +urllib3==2.2.1 + # via + # -c edx_repo_tools/pull_request_creator/../../requirements/base.txt + # -c edx_repo_tools/pull_request_creator/../../requirements/development.txt + # pygithub + # requests +wrapt==1.16.0 + # via deprecated diff --git a/setup.py b/setup.py index 9821e81e..f89fc4a3 100644 --- a/setup.py +++ b/setup.py @@ -107,6 +107,7 @@ def is_requirement(line): 'modernize_travis = edx_repo_tools.codemods.django3.travis_modernizer:main', 'no_yaml = edx_repo_tools.ospr.no_yaml:no_yaml', 'oep2 = edx_repo_tools.oep2:_cli', + 'pull_request_creator = edx_repo_tools.pull_request_creator:main', 'remove_python2_unicode_compatible = edx_repo_tools.codemods.django3.remove_python2_unicode_compatible:main', 'replace_render_to_response = edx_repo_tools.codemods.django3.replace_render_to_response:main', 'replace_static = edx_repo_tools.codemods.django3.replace_static:main', diff --git a/tests/pull_request_creator_test_data/diff.txt b/tests/pull_request_creator_test_data/diff.txt new file mode 100644 index 00000000..a77487ba --- /dev/null +++ b/tests/pull_request_creator_test_data/diff.txt @@ -0,0 +1,413 @@ +diff --git a/requirements/base.txt b/requirements/base.txt +index e94bd48..132b72b 100644 +--- a/requirements/base.txt ++++ b/requirements/base.txt +@@ -1,6 +1,6 @@ + # +-# This file is autogenerated by pip-compile with python 3.8 +-# To update, run: ++# This file is autogenerated by pip-compile with Python 3.8 ++# by the following command: + # + # make upgrade + # +@@ -12,14 +12,12 @@ iniconfig==1.1.1 + # via pytest + numpy==1.23.5 + # via pandas +-packaging==21.3 ++packaging==22.0 + # via pytest + pandas==1.5.2 + # via -r requirements/base.in + pluggy==1.0.0 + # via pytest +-pyparsing==3.0.9 +- # via packaging + pytest==7.2.0 + # via + # -r requirements/base.in +diff --git a/requirements/dev.txt b/requirements/dev.txt +index 2259a89..7e171fa 100644 +--- a/requirements/dev.txt ++++ b/requirements/dev.txt +@@ -1,6 +1,6 @@ + # +-# This file is autogenerated by pip-compile with python 3.8 +-# To update, run: ++# This file is autogenerated by pip-compile with Python 3.8 ++# by the following command: + # + # make upgrade + # +@@ -19,12 +19,19 @@ build==0.9.0 + # via + # -r requirements/pip-tools.txt + # pip-tools +-certifi==2022.9.24 ++cachetools==5.2.0 ++ # via ++ # -r requirements/travis.txt ++ # tox ++certifi==2022.12.7 + # via + # -r requirements/travis.txt + # requests +-chardet==5.0.0 +- # via diff-cover ++chardet==5.1.0 ++ # via ++ # -r requirements/travis.txt ++ # diff-cover ++ # tox + charset-normalizer==2.1.1 + # via + # -r requirements/travis.txt +@@ -47,6 +54,10 @@ code-annotations==1.3.0 + # edx-lint + codecov==2.1.12 + # via -r requirements/travis.txt ++colorama==0.4.6 ++ # via ++ # -r requirements/travis.txt ++ # tox + coverage[toml]==6.5.0 + # via + # -r requirements/quality.txt +@@ -75,7 +86,7 @@ exceptiongroup==1.0.4 + # via + # -r requirements/quality.txt + # pytest +-filelock==3.8.0 ++filelock==3.8.2 + # via + # -r requirements/travis.txt + # tox +@@ -113,12 +124,13 @@ numpy==1.23.5 + # via + # -r requirements/quality.txt + # pandas +-packaging==21.3 ++packaging==22.0 + # via + # -r requirements/pip-tools.txt + # -r requirements/quality.txt + # -r requirements/travis.txt + # build ++ # pyproject-api + # pytest + # tox + pandas==1.5.2 +@@ -133,13 +145,14 @@ pep517==0.13.0 + # via + # -r requirements/pip-tools.txt + # build +-pip-tools==6.10.0 ++pip-tools==6.11.0 + # via -r requirements/pip-tools.txt +-platformdirs==2.5.4 ++platformdirs==2.6.0 + # via + # -r requirements/quality.txt + # -r requirements/travis.txt + # pylint ++ # tox + # virtualenv + pluggy==1.0.0 + # via +@@ -150,17 +163,13 @@ pluggy==1.0.0 + # tox + polib==1.1.1 + # via edx-i18n-tools +-py==1.11.0 +- # via +- # -r requirements/travis.txt +- # tox + pycodestyle==2.10.0 + # via -r requirements/quality.txt + pydocstyle==6.1.1 + # via -r requirements/quality.txt + pygments==2.13.0 + # via diff-cover +-pylint==2.15.7 ++pylint==2.15.8 + # via + # -r requirements/quality.txt + # edx-lint +@@ -180,12 +189,10 @@ pylint-plugin-utils==0.7 + # -r requirements/quality.txt + # pylint-celery + # pylint-django +-pyparsing==3.0.9 ++pyproject-api==1.2.1 + # via +- # -r requirements/pip-tools.txt +- # -r requirements/quality.txt + # -r requirements/travis.txt +- # packaging ++ # tox + pytest==7.2.0 + # via + # -r requirements/quality.txt +@@ -228,10 +235,8 @@ requests==2.28.1 + six==1.16.0 + # via + # -r requirements/quality.txt +- # -r requirements/travis.txt + # edx-lint + # python-dateutil +- # tox + snowballstemmer==2.2.0 + # via + # -r requirements/quality.txt +@@ -255,13 +260,14 @@ tomli==2.0.1 + # coverage + # pep517 + # pylint ++ # pyproject-api + # pytest + # tox + tomlkit==0.11.6 + # via + # -r requirements/quality.txt + # pylint +-tox==3.27.1 ++tox==4.0.2 + # via + # -r requirements/travis.txt + # tox-battery +@@ -276,7 +282,7 @@ urllib3==1.26.13 + # via + # -r requirements/travis.txt + # requests +-virtualenv==20.17.0 ++virtualenv==20.17.1 + # via + # -r requirements/travis.txt + # tox +diff --git a/requirements/doc.txt b/requirements/doc.txt +index ae644b7..e91647e 100644 +--- a/requirements/doc.txt ++++ b/requirements/doc.txt +@@ -1,6 +1,6 @@ + # +-# This file is autogenerated by pip-compile with python 3.8 +-# To update, run: ++# This file is autogenerated by pip-compile with Python 3.8 ++# by the following command: + # + # make upgrade + # +@@ -14,7 +14,7 @@ babel==2.11.0 + # via sphinx + bleach==5.0.1 + # via readme-renderer +-certifi==2022.9.24 ++certifi==2022.12.7 + # via requests + charset-normalizer==2.1.1 + # via requests +@@ -65,7 +65,7 @@ numpy==1.23.5 + # via + # -r requirements/test.txt + # pandas +-packaging==21.3 ++packaging==22.0 + # via + # -r requirements/test.txt + # pytest +@@ -85,10 +85,6 @@ pygments==2.13.0 + # doc8 + # readme-renderer + # sphinx +-pyparsing==3.0.9 +- # via +- # -r requirements/test.txt +- # packaging + pytest==7.2.0 + # via + # -r requirements/test.txt +diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt +index 33e5fff..6a926f1 100644 +--- a/requirements/pip-tools.txt ++++ b/requirements/pip-tools.txt +@@ -8,14 +8,12 @@ build==0.9.0 + # via pip-tools + click==8.1.3 + # via pip-tools +-packaging==21.3 ++packaging==22.0 + # via build + pep517==0.13.0 + # via build +-pip-tools==6.10.0 ++pip-tools==6.11.0 + # via -r requirements/pip-tools.in +-pyparsing==3.0.9 +- # via packaging + tomli==2.0.1 + # via + # build +diff --git a/requirements/quality.txt b/requirements/quality.txt +index 496785e..70f135b 100644 +--- a/requirements/quality.txt ++++ b/requirements/quality.txt +@@ -1,6 +1,6 @@ + # +-# This file is autogenerated by pip-compile with python 3.8 +-# To update, run: ++# This file is autogenerated by pip-compile with Python 3.8 ++# by the following command: + # + # make upgrade + # +@@ -60,7 +60,7 @@ numpy==1.23.5 + # via + # -r requirements/test.txt + # pandas +-packaging==21.3 ++packaging==22.0 + # via + # -r requirements/test.txt + # pytest +@@ -70,7 +70,7 @@ pbr==5.11.0 + # via + # -r requirements/test.txt + # stevedore +-platformdirs==2.5.4 ++platformdirs==2.6.0 + # via pylint + pluggy==1.0.0 + # via +@@ -80,7 +80,7 @@ pycodestyle==2.10.0 + # via -r requirements/quality.in + pydocstyle==6.1.1 + # via -r requirements/quality.in +-pylint==2.15.7 ++pylint==2.15.8 + # via + # edx-lint + # pylint-celery +@@ -94,10 +94,6 @@ pylint-plugin-utils==0.7 + # via + # pylint-celery + # pylint-django +-pyparsing==3.0.9 +- # via +- # -r requirements/test.txt +- # packaging + pytest==7.2.0 + # via + # -r requirements/test.txt +diff --git a/requirements/test.txt b/requirements/test.txt +index adee822..407a915 100644 +--- a/requirements/test.txt ++++ b/requirements/test.txt +@@ -1,6 +1,6 @@ + # +-# This file is autogenerated by pip-compile with python 3.8 +-# To update, run: ++# This file is autogenerated by pip-compile with Python 3.8 ++# by the following command: + # + # make upgrade + # +@@ -30,7 +30,7 @@ numpy==1.23.5 + # via + # -r requirements/base.txt + # pandas +-packaging==21.3 ++packaging==22.0 + # via + # -r requirements/base.txt + # pytest +@@ -42,10 +42,6 @@ pluggy==1.0.0 + # via + # -r requirements/base.txt + # pytest +-pyparsing==3.0.9 +- # via +- # -r requirements/base.txt +- # packaging + pytest==7.2.0 + # via + # -r requirements/base.txt +diff --git a/requirements/travis.txt b/requirements/travis.txt +index 284c0b0..191d25a 100644 +--- a/requirements/travis.txt ++++ b/requirements/travis.txt +@@ -1,42 +1,50 @@ + # +-# This file is autogenerated by pip-compile with python 3.8 +-# To update, run: ++# This file is autogenerated by pip-compile with Python 3.8 ++# by the following command: + # + # make upgrade + # +-certifi==2022.9.24 ++cachetools==5.2.0 ++ # via tox ++certifi==2022.12.7 + # via requests ++codecov==2.1.12 ++ # via -r requirements/travis.in ++chardet==5.1.0 ++ # via tox + charset-normalizer==2.1.1 + # via requests +-codecov==2.1.12 +- # via -r requirements/travis.in ++colorama==0.4.6 ++ # via tox + coverage==6.5.0 + # via codecov + distlib==0.3.6 + # via virtualenv +-filelock==3.8.0 ++filelock==3.8.2 + # via + # tox + # virtualenv + idna==3.4 + # via requests +-packaging==21.3 +- # via tox +-platformdirs==2.5.4 +- # via virtualenv ++packaging==22.0 ++ # via ++ # pyproject-api ++ # tox ++platformdirs==2.6.0 ++ # via ++ # tox ++ # virtualenv + pluggy==1.0.0 + # via tox +-py==1.11.0 ++pyproject-api==1.2.1 + # via tox +-pyparsing==3.0.9 +- # via packaging + requests==2.28.1 + # via codecov +-six==1.16.0 +- # via tox + tomli==2.0.1 +- # via tox +-tox==3.27.1 ++ # via ++ # pyproject-api ++ # tox ++tox==4.0.2 + # via + # -r requirements/travis.in + # tox-battery +@@ -44,5 +52,5 @@ tox-battery==0.6.1 + # via -r requirements/travis.in + urllib3==1.26.13 + # via requests +-virtualenv==20.17.0 ++virtualenv==20.17.1 + # via tox diff --git a/tests/pull_request_creator_test_data/minor_diff.txt b/tests/pull_request_creator_test_data/minor_diff.txt new file mode 100644 index 00000000..905159ad --- /dev/null +++ b/tests/pull_request_creator_test_data/minor_diff.txt @@ -0,0 +1,17 @@ +diff --git a/requirements/base.txt b/requirements/base.txt +index e94bd48..132b72b 100644 +--- a/requirements/base.txt ++++ b/requirements/base.txt +@@ -1,6 +1,6 @@ + # +-# This file is autogenerated by pip-compile with python 3.8 +-# To update, run: ++# This file is autogenerated by pip-compile with Python 3.8 ++# by the following command: + # + # make upgrade + # +@@ -12,14 +12,12 @@ iniconfig==1.1.1 +-packaging==21.3 ++packaging==21.6 + diff --git a/tests/test_pull_request_creator.py b/tests/test_pull_request_creator.py new file mode 100644 index 00000000..f2856511 --- /dev/null +++ b/tests/test_pull_request_creator.py @@ -0,0 +1,432 @@ +# pylint: disable=missing-module-docstring,missing-class-docstring + +from os import path +from unittest import TestCase +from unittest.mock import MagicMock, Mock, mock_open, patch + +from edx_repo_tools.pull_request_creator import GitHubHelper, PullRequestCreator + + +class HelpersTestCase(TestCase): + + def test_close_existing_pull_requests(self): + """ + Make sure we close only PR's by the correct author. + """ + + incorrect_pr_one = Mock() + incorrect_pr_one.user.name = "Not John" + incorrect_pr_one.user.login = "notJohn" + incorrect_pr_one.number = 1 + incorrect_pr_one.head.ref = "incorrect-branch-name" + incorrect_pr_one.base.ref = "master" + + incorrect_pr_two = Mock() + incorrect_pr_two.user.name = "John Smith" + incorrect_pr_two.user.login = "johnsmithiscool100" + incorrect_pr_two.number = 2 + incorrect_pr_two.head.ref = "incorrect-branch-name-2" + incorrect_pr_two.base.ref = "master" + + incorrect_pr_three = Mock() + incorrect_pr_three.user.name = "John Smith" + incorrect_pr_three.user.login = "fakeuser100" + incorrect_pr_three.number = 5 + incorrect_pr_three.head.ref = "jenkins/upgrade-python-requirements-ce0515e" + incorrect_pr_three.base.ref = "some-other-branch" + + correct_pr_one = Mock() + correct_pr_one.user.name = "John Smith" + correct_pr_one.user.login = "fakeuser100" + correct_pr_one.number = 3 + correct_pr_one.head.ref = "repo-tools/upgrade-python-requirements-ce0515e" + correct_pr_one.base.ref = "master" + + correct_pr_two = Mock() + correct_pr_two.user.name = "John Smith" + correct_pr_two.user.login = "fakeuser100" + correct_pr_two.number = 4 + correct_pr_two.head.ref = "repo-tools/upgrade-python-requirements-0c51f37" + correct_pr_two.base.ref = "master" + + mock_repo = Mock() + mock_repo.get_pulls = MagicMock(return_value=[ + incorrect_pr_one, + incorrect_pr_two, + incorrect_pr_three, + correct_pr_one, + correct_pr_two + ]) + + deleted_pulls = GitHubHelper().close_existing_pull_requests(mock_repo, "fakeuser100", "John Smith") + assert deleted_pulls == [3, 4] + assert not incorrect_pr_one.edit.called + assert not incorrect_pr_two.edit.called + assert not incorrect_pr_three.edit.called + assert correct_pr_one.edit.called + assert correct_pr_two.edit.called + + def test_get_updated_files_list_no_change(self): + git_instance = Mock() + git_instance.ls_files = MagicMock(return_value="") + with patch('edx_repo_tools.pull_request_creator.Git', return_value=git_instance): + result = GitHubHelper().get_updated_files_list("edx-platform") + assert result == [] + + def test_get_updated_files_list_with_changes(self): + git_instance = Mock() + git_instance.ls_files = MagicMock(return_value="file1\nfile2") + with patch('edx_repo_tools.pull_request_creator.Git', return_value=git_instance): + result = GitHubHelper().get_updated_files_list("edx-platform") + assert result == ["file1", "file2"] + + def test_update_list_of_files_no_change(self): + repo_mock = Mock() + repo_root = "../../edx-platform" + file_path_list = [] + commit_message = "commit" + sha = "abc123" + username = "fakeusername100" + + return_sha = GitHubHelper().update_list_of_files(repo_mock, repo_root, file_path_list, commit_message, sha, + username) + assert return_sha is None + assert not repo_mock.create_git_tree.called + assert not repo_mock.create_git_commit.called + + @patch('edx_repo_tools.pull_request_creator.GitHubHelper.get_file_contents', + return_value=None) + @patch('edx_repo_tools.pull_request_creator.InputGitAuthor', + return_value=Mock()) + @patch('edx_repo_tools.pull_request_creator.InputGitTreeElement', + return_value=Mock()) + # pylint: disable=unused-argument + def test_update_list_of_files_with_changes(self, get_file_contents_mock, author_mock, git_tree_mock): + repo_mock = Mock() + repo_root = "../../edx-platform" + file_path_list = ["path/to/file1", "path/to/file2"] + commit_message = "commit" + sha = "abc123" + username = "fakeusername100" + + return_sha = GitHubHelper().update_list_of_files(repo_mock, repo_root, file_path_list, commit_message, sha, + username) + assert repo_mock.create_git_tree.called + assert repo_mock.create_git_commit.called + assert return_sha is not None + # pylint: enable=unused-argument + + def test_get_file_contents(self): + with patch("builtins.open", mock_open(read_data="data")) as mock_file: + contents = GitHubHelper().get_file_contents("../../edx-platform", "path/to/file") + mock_file.assert_called_with("../../edx-platform/path/to/file", "r", encoding='utf-8') + assert contents == "data" + + +class UpgradePythonRequirementsPullRequestTestCase(TestCase): + """ + Test Case class for PR creator. + """ + + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.close_existing_pull_requests', + return_value=[]) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_github_instance', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.repo_from_remote', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_updated_files_list', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_current_commit', return_value='1234567') + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.branch_exists', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.update_list_of_files', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_pull_request') + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_branch', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator._get_user', + return_value=Mock(name="fake name", login="fake login")) + @patch('edx_repo_tools.pull_request_creator.GitHubHelper.delete_branch', return_value=None) + def test_no_changes(self, delete_branch_mock, get_user_mock, create_branch_mock, create_pr_mock, + update_files_mock, branch_exists_mock, current_commit_mock, + modified_list_mock, repo_mock, authenticate_mock, + close_existing_prs_mock): + """ + Ensure a merge with no changes to db files will not result in any updates. + """ + pull_request_creator = PullRequestCreator('--repo_root=../../edx-platform', 'upgrade-branch', [], + [], 'Upgrade python requirements', 'Update python requirements', + 'make upgrade PR') + pull_request_creator.create(True) + + assert authenticate_mock.called + assert repo_mock.called + assert modified_list_mock.called + assert not branch_exists_mock.called + assert not create_branch_mock.called + assert not update_files_mock.called + assert not create_pr_mock.called + + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.close_existing_pull_requests', + return_value=[]) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_github_instance', + return_value=Mock()) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.repo_from_remote', return_value=Mock()) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_updated_files_list', + return_value=["requirements/edx/base.txt", "requirements/edx/coverage.txt"]) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_current_commit', return_value='1234567') + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.branch_exists', return_value=False) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.update_list_of_files', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_pull_request') + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_branch', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator._get_user', + return_value=Mock(name="fake name", login="fake login")) + @patch('edx_repo_tools.pull_request_creator.GitHubHelper.delete_branch', return_value=None) + def test_changes(self, delete_branch_mock, get_user_mock, create_branch_mock, create_pr_mock, + update_files_mock, branch_exists_mock, current_commit_mock, + modified_list_mock, repo_mock, authenticate_mock, + close_existing_prs_mock): + """ + Ensure a merge with no changes to db files will not result in any updates. + """ + pull_request_creator = PullRequestCreator('--repo_root=../../edx-platform', 'upgrade-branch', [], + [], 'Upgrade python requirements', 'Update python requirements', + 'make upgrade PR') + pull_request_creator.create(True) + + assert branch_exists_mock.called + assert create_branch_mock.called + self.assertEqual(create_branch_mock.call_count, 1) + assert update_files_mock.called + self.assertEqual(update_files_mock.call_count, 1) + assert create_pr_mock.called + + create_pr_mock.title = "Python Requirements Update" + create_pr_mock.diff_url = "/" + create_pr_mock.repository.name = 'repo-health-data' + + basepath = path.dirname(__file__) + + filepath = path.abspath(path.join(basepath, "pull_request_creator_test_data", "diff.txt")) + with open(filepath, "r") as f: + content = f.read().encode('utf-8') + with patch('requests.get') as mock_request: + mock_request.return_value.content = content + mock_request.return_value.status_code = 200 + GitHubHelper().verify_upgrade_packages(create_pr_mock) + assert create_pr_mock.create_issue_comment.called + assert not delete_branch_mock.called + + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator._get_user', + return_value=Mock(name="fake name", login="fake login")) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_github_instance', + return_value=Mock()) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.repo_from_remote', return_value=Mock()) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_updated_files_list', + return_value=["requirements/edx/base.txt", "requirements/edx/coverage.txt"]) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_current_commit', return_value='1234567') + # all above this unused params, no need to interact with those mocks + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.branch_exists', return_value=False) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.update_list_of_files', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_pull_request') + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_branch', return_value=None) + @patch('builtins.print') + def test_outputs_url_on_success(self, print_mock, create_branch_mock, create_pr_mock, + update_files_mock, branch_exists_mock, *args): + """ + Ensure that a successful run outputs the URL consumable by github actions + """ + pull_request_creator = PullRequestCreator('--repo_root=../../edx-platform', 'upgrade-branch', [], + [], 'Upgrade python requirements', 'Update python requirements', + 'make upgrade PR', output_pr_url_for_github_action=True) + pull_request_creator.create(False) + + assert branch_exists_mock.called + assert create_branch_mock.called + self.assertEqual(create_branch_mock.call_count, 1) + assert update_files_mock.called + self.assertEqual(update_files_mock.call_count, 1) + assert create_pr_mock.called + assert print_mock.call_count == 1 + found_matching_call = False + for call in print_mock.call_args_list: + if 'set-output' in call.args[0]: + found_matching_call = True + assert found_matching_call + + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.close_existing_pull_requests', + return_value=[]) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_github_instance', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.repo_from_remote', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_updated_files_list', + return_value=["requirements/edx/base.txt", "requirements/edx/coverage.txt"]) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_current_commit', return_value='1234567') + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.branch_exists', return_value=True) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.update_list_of_files', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_pull_request') + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_branch', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator._get_user', + return_value=Mock(name="fake name", login="fake login")) + @patch('edx_repo_tools.pull_request_creator.GitHubHelper.delete_branch', return_value=None) + def test_branch_exists(self, delete_branch_mock, get_user_mock, create_branch_mock, create_pr_mock, + update_files_mock, branch_exists_mock, current_commit_mock, + modified_list_mock, repo_mock, authenticate_mock, + close_existing_prs_mock): + """ + Ensure if a branch exists and delete_old_pull_requests is set to False, then there are no updates. + """ + pull_request_creator = PullRequestCreator('--repo_root=../../edx-platform', 'upgrade-branch', [], + [], 'Upgrade python requirements', 'Update python requirements', + 'make upgrade PR') + pull_request_creator.create(False) + + assert branch_exists_mock.called + assert not create_branch_mock.called + assert not create_pr_mock.called + assert not delete_branch_mock.called + + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.close_existing_pull_requests', + return_value=[]) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator._get_user', + return_value=Mock(name="fake name", login="fake login")) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_github_instance', + return_value=Mock()) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.repo_from_remote', return_value=Mock()) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_updated_files_list', + return_value=["requirements/edx/base.txt", "requirements/edx/coverage.txt"]) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_current_commit', return_value='1234567') + # all above this unused params, no need to interact with those mocks + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.branch_exists', return_value=True) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.update_list_of_files', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_pull_request') + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_branch', return_value=None) + @patch('edx_repo_tools.pull_request_creator.GitHubHelper.delete_branch', return_value=None) + @patch('builtins.print') + def test_branch_deletion(self, create_branch_mock, create_pr_mock, + update_files_mock, branch_exists_mock, delete_branch_mock, *args): + """ + Ensure if a branch exists and delete_old_pull_requests is set, then branch is deleted + before creating new PR. + """ + pull_request_creator = PullRequestCreator('--repo_root=../../edx-platform', 'upgrade-branch', [], + [], 'Upgrade python requirements', 'Update python requirements', + 'make upgrade PR', output_pr_url_for_github_action=True) + pull_request_creator.create(True) + + assert branch_exists_mock.called + assert delete_branch_mock.called + assert create_branch_mock.called + assert update_files_mock.called + assert create_pr_mock.called + + def test_compare_upgrade_difference_with_major_changes(self): + basepath = path.dirname(__file__) + filepath = path.abspath(path.join(basepath, "pull_request_creator_test_data", "diff.txt")) + with open(filepath, "r") as f: + valid, suspicious = GitHubHelper().compare_pr_differnce(f.read()) + assert sorted( + ['certifi', 'chardet', 'filelock', 'pip-tools', 'platformdirs', 'pylint', 'virtualenv'] + ) == [g['name'] for g in valid] + + assert sorted( + ['cachetools', 'six', 'tox', 'pyproject-api', 'colorama', 'py', 'chardet', 'pyparsing', 'packaging'] + ) == [g['name'] for g in suspicious] + + def test_compare_upgrade_difference_with_minor_changes(self): + basepath = path.dirname(__file__) + filepath = path.abspath(path.join(basepath, "pull_request_creator_test_data", "minor_diff.txt")) + with open(filepath, "r") as f: + valid, suspicious = GitHubHelper().compare_pr_differnce(f.read()) + assert sorted( + ['packaging'] + ) == [g['name'] for g in valid] + + assert sorted( + [] + ) == [g['name'] for g in suspicious] + + def test_check_automerge_variable_value(self): + with patch('requests.get') as mock_request: + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = { + 'name': 'ENABLE_AUTOMERGE_FOR_DEPENDENCIES_PRS', 'value': 'True', + 'created_at': '2023-03-17T12:58:50Z', 'updated_at': '2023-03-17T13:01:12Z' + } + self.assertTrue( + GitHubHelper().check_automerge_variable_value( + 'https://foo/bar/testrepo/pulls/1' + ) + ) + + # in case of false value of variable. + mock_request.return_value.json.return_value = { + 'name': 'ENABLE_AUTOMERGE_FOR_DEPENDENCIES_PRS', 'value': 'False', + 'created_at': '2023-03-17T12:58:50Z', 'updated_at': '2023-03-17T13:01:12Z' + } + self.assertFalse( + GitHubHelper().check_automerge_variable_value( + 'https://foo/bar/testrepo/pulls/1' + ) + ) + # in case of no variable exists. + mock_request.return_value.status_code = 404 + mock_request.return_value.json.return_value = { + 'name': 'ENABLE_AUTOMERGE_FOR_DEPENDENCIES_PRS', 'value': 'False', + 'created_at': '2023-03-17T12:58:50Z', 'updated_at': '2023-03-17T13:01:12Z' + } + self.assertFalse( + GitHubHelper().check_automerge_variable_value( + 'https://foo/bar/testrepo/pulls/1' + ) + ) + + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.close_existing_pull_requests', + return_value=[]) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_github_instance', + return_value=Mock()) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.repo_from_remote', return_value=Mock()) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_updated_files_list', + return_value=["requirements/edx/base.txt", "requirements/edx/coverage.txt"]) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.get_current_commit', return_value='1234567') + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.branch_exists', return_value=False) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.update_list_of_files', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_pull_request') + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator.github_helper.create_branch', return_value=None) + @patch('edx_repo_tools.pull_request_creator.PullRequestCreator._get_user', + return_value=Mock(name="fake name", login="fake login")) + @patch('edx_repo_tools.pull_request_creator.GitHubHelper.delete_branch', return_value=None) + def test_changes_with_minor_versions_and_variable( + self, delete_branch_mock, get_user_mock, create_branch_mock, create_pr_mock, + update_files_mock, branch_exists_mock, current_commit_mock, + modified_list_mock, repo_mock, authenticate_mock, + close_existing_prs_mock + ): + """ + Ensure a merge with no changes to db files will not result in any updates. + """ + pull_request_creator = PullRequestCreator('--repo_root=../../edx-platform', 'upgrade-branch', [], + [], 'Upgrade python requirements', 'Update python requirements', + 'make upgrade PR') + pull_request_creator.create(True) + + assert branch_exists_mock.called + assert create_branch_mock.called + self.assertEqual(create_branch_mock.call_count, 1) + assert update_files_mock.called + self.assertEqual(update_files_mock.call_count, 1) + assert create_pr_mock.called + + create_pr_mock.title = "chore: Upgrade Python requirements" + create_pr_mock.diff_url = "/" + create_pr_mock.repository.name = 'xblock-lti-consumer' + + basepath = path.dirname(__file__) + + filepath = path.abspath(path.join(basepath, "pull_request_creator_test_data", "minor_diff.txt")) + with open(filepath, "r") as f: + content = f.read().encode('utf-8') + with patch('requests.get') as mock_request: + mock_request.return_value.content = content + mock_request.return_value.status_code = 200 + + # in case of `check_automerge_variable_value` false value label will not added. + with patch( + 'edx_repo_tools.pull_request_creator.GitHubHelper.check_automerge_variable_value' + ) as check_automerge_variable_value: + check_automerge_variable_value.return_value = False + GitHubHelper().verify_upgrade_packages(create_pr_mock) + assert not create_pr_mock.set_labels.called