From b7565b81a795750559851ac6d0c91f39ba37e636 Mon Sep 17 00:00:00 2001 From: Peter Evans Date: Fri, 27 Dec 2019 14:40:08 +0900 Subject: [PATCH] Add v2 alpha --- .gitignore | 1 + action.yml | 14 +- dist/index.js | 42 +- dist/src/common.py | 31 + dist/src/create_or_update_branch.py | 147 +++++ dist/src/create_or_update_pull_request.py | 130 ++++ dist/src/create_pull_request.py | 166 +++++ dist/src/test_common.py | 39 ++ dist/src/test_create_or_update_branch.py | 748 ++++++++++++++++++++++ index.js | 42 +- src/common.py | 31 + src/create_or_update_branch.py | 147 +++++ src/create_or_update_pull_request.py | 130 ++++ src/create_pull_request.py | 166 +++++ src/test_common.py | 39 ++ src/test_create_or_update_branch.py | 748 ++++++++++++++++++++++ 16 files changed, 2564 insertions(+), 57 deletions(-) create mode 100644 dist/src/common.py create mode 100644 dist/src/create_or_update_branch.py create mode 100644 dist/src/create_or_update_pull_request.py create mode 100755 dist/src/create_pull_request.py create mode 100644 dist/src/test_common.py create mode 100644 dist/src/test_create_or_update_branch.py create mode 100644 src/common.py create mode 100644 src/create_or_update_branch.py create mode 100644 src/create_or_update_pull_request.py create mode 100755 src/create_pull_request.py create mode 100644 src/test_common.py create mode 100644 src/test_create_or_update_branch.py diff --git a/.gitignore b/.gitignore index fd4f2b0..4394a72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +__pycache__ node_modules .DS_Store diff --git a/action.yml b/action.yml index 599430d..647cef3 100644 --- a/action.yml +++ b/action.yml @@ -6,14 +6,10 @@ inputs: required: true commit-message: description: 'The message to use when committing changes.' - author-name: - description: 'The name of the commit author.' - author-email: - description: 'The email address of the commit author.' - committer-name: - description: 'The name of the committer.' - committer-email: - description: 'The email address of the committer.' + committer: + description: 'The committer name and email address.' + author: + description: 'The author name and email address.' title: description: 'The title of the pull request.' body: @@ -35,7 +31,7 @@ inputs: branch: description: 'The pull request branch name.' base: - description: 'Sets the pull request base branch.' + description: 'The pull request base branch.' branch-suffix: description: 'The branch suffix type.' outputs: diff --git a/dist/index.js b/dist/index.js index 48d46f2..2c373c8 100644 --- a/dist/index.js +++ b/dist/index.js @@ -978,10 +978,8 @@ async function run() { const inputs = { token: core.getInput("token"), commitMessage: core.getInput("commit-message"), - commitAuthorName: core.getInput("author-name"), - commitAuthorEmail: core.getInput("author-email"), - committerName: core.getInput("committer-name"), - committerEmail: core.getInput("committer-email"), + committer: core.getInput("committer"), + author: core.getInput("author"), title: core.getInput("title"), body: core.getInput("body"), labels: core.getInput("labels"), @@ -994,33 +992,29 @@ async function run() { branch: core.getInput("branch"), base: core.getInput("base"), branchSuffix: core.getInput("branch-suffix"), - debugEvent: core.getInput("debug-event") }; core.debug(`Inputs: ${inspect(inputs)}`); // Set environment variables from inputs. if (inputs.token) process.env.GITHUB_TOKEN = inputs.token; - if (inputs.commitMessage) process.env.COMMIT_MESSAGE = inputs.commitMessage; - if (inputs.commitAuthorName) process.env.COMMIT_AUTHOR_NAME = inputs.commitAuthorName; - if (inputs.commitAuthorEmail) process.env.COMMIT_AUTHOR_EMAIL = inputs.commitAuthorEmail; - if (inputs.committerName) process.env.COMMITTER_NAME = inputs.committerName; - if (inputs.committerEmail) process.env.COMMITTER_EMAIL = inputs.committerEmail; - if (inputs.title) process.env.PULL_REQUEST_TITLE = inputs.title; - if (inputs.body) process.env.PULL_REQUEST_BODY = inputs.body; - if (inputs.labels) process.env.PULL_REQUEST_LABELS = inputs.labels; - if (inputs.assignees) process.env.PULL_REQUEST_ASSIGNEES = inputs.assignees; - if (inputs.reviewers) process.env.PULL_REQUEST_REVIEWERS = inputs.reviewers; - if (inputs.teamReviewers) process.env.PULL_REQUEST_TEAM_REVIEWERS = inputs.teamReviewers; - if (inputs.milestone) process.env.PULL_REQUEST_MILESTONE = inputs.milestone; - if (inputs.project) process.env.PROJECT_NAME = inputs.project; - if (inputs.projectColumn) process.env.PROJECT_COLUMN_NAME = inputs.projectColumn; - if (inputs.branch) process.env.PULL_REQUEST_BRANCH = inputs.branch; - if (inputs.base) process.env.PULL_REQUEST_BASE = inputs.base; - if (inputs.branchSuffix) process.env.BRANCH_SUFFIX = inputs.branchSuffix; - if (inputs.debugEvent) process.env.DEBUG_EVENT = inputs.debugEvent; + if (inputs.commitMessage) process.env.CPR_COMMIT_MESSAGE = inputs.commitMessage; + if (inputs.committer) process.env.CPR_COMMITTER = inputs.committer; + if (inputs.author) process.env.CPR_AUTHOR = inputs.author; + if (inputs.title) process.env.CPR_TITLE = inputs.title; + if (inputs.body) process.env.CPR_BODY = inputs.body; + if (inputs.labels) process.env.CPR_LABELS = inputs.labels; + if (inputs.assignees) process.env.CPR_ASSIGNEES = inputs.assignees; + if (inputs.reviewers) process.env.CPR_REVIEWERS = inputs.reviewers; + if (inputs.teamReviewers) process.env.CPR_TEAM_REVIEWERS = inputs.teamReviewers; + if (inputs.milestone) process.env.CPR_MILESTONE = inputs.milestone; + if (inputs.project) process.env.CPR_PROJECT_NAME = inputs.project; + if (inputs.projectColumn) process.env.CPR_PROJECT_COLUMN_NAME = inputs.projectColumn; + if (inputs.branch) process.env.CPR_BRANCH = inputs.branch; + if (inputs.base) process.env.CPR_BASE = inputs.base; + if (inputs.branchSuffix) process.env.CPR_BRANCH_SUFFIX = inputs.branchSuffix; // Execute python script - await exec.exec("python", [`${src}/create-pull-request.py`]); + await exec.exec("python", [`${src}/create_pull_request.py`]); } catch (error) { core.setFailed(error.message); } diff --git a/dist/src/common.py b/dist/src/common.py new file mode 100644 index 0000000..950ac95 --- /dev/null +++ b/dist/src/common.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +import random +import re +import string + + +def get_random_string(length=7, chars=string.ascii_lowercase + string.digits): + return "".join(random.choice(chars) for _ in range(length)) + + +def parse_display_name_email(display_name_email): + # Parse the name and email address from a string in the following format + # Display Name + pattern = re.compile(r"^([^<]+)\s*<([^>]+)>$") + + # Check we have a match + match = pattern.match(display_name_email) + if match is None: + raise ValueError( + f"The format of '{display_name_email}' is not a valid email address with display name" + ) + + # Check that name and email are not just whitespace + name = match.group(1).strip() + email = match.group(2).strip() + if len(name) == 0 or len(email) == 0: + raise ValueError( + f"The format of '{display_name_email}' is not a valid email address with display name" + ) + + return name, email diff --git a/dist/src/create_or_update_branch.py b/dist/src/create_or_update_branch.py new file mode 100644 index 0000000..7683d1a --- /dev/null +++ b/dist/src/create_or_update_branch.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" Create or Update Branch """ +import common as cmn +from git import Repo, GitCommandError +import os + + +CHERRYPICK_EMPTY = ( + "The previous cherry-pick is now empty, possibly due to conflict resolution." +) + + +def fetch_successful(repo, repo_url, branch): + try: + repo.git.fetch(repo_url, f"{branch}:refs/remotes/origin/{branch}") + except GitCommandError: + return False + return True + + +def is_ahead(repo, branch_1, branch_2): + # Return true if branch_2 is ahead of branch_1 + return ( + int(repo.git.rev_list("--right-only", "--count", f"{branch_1}...{branch_2}")) + > 0 + ) + + +def is_behind(repo, branch_1, branch_2): + # Return true if branch_2 is behind branch_1 + return ( + int(repo.git.rev_list("--left-only", "--count", f"{branch_1}...{branch_2}")) > 0 + ) + + +def is_even(repo, branch_1, branch_2): + # Return true if branch_2 is even with branch_1 + return not is_ahead(repo, branch_1, branch_2) and not is_behind( + repo, branch_1, branch_2 + ) + + +def has_diff(repo, branch_1, branch_2): + diff = repo.git.diff(f"{branch_1}..{branch_2}") + return len(diff) > 0 + + +def create_or_update_branch(repo, repo_url, commit_message, base, branch): + # Set the default return values + action = "none" + diff = False + + # Get the working base. This may or may not be the actual base. + working_base = repo.git.symbolic_ref("HEAD", "--short") + # If the base is not specified it is assumed to be the working base + if base is None: + base = working_base + + # Save the working base changes to a temporary branch + temp_branch = cmn.get_random_string(length=20) + repo.git.checkout("HEAD", b=temp_branch) + # Commit any uncomitted changes + if repo.is_dirty(untracked_files=True): + print(f"Uncommitted changes found. Adding a commit.") + repo.git.add("-A") + repo.git.commit(m=commit_message) + + # Perform fetch and reset the working base + # Commits made during the workflow will be removed + repo.git.fetch("--force", repo_url, f"{working_base}:{working_base}") + + # If the working base is not the base, rebase the temp branch commits + if working_base != base: + print( + f"Rebasing commits made to branch '{working_base}' on to base branch '{base}'" + ) + # Checkout the actual base + repo.git.fetch("--force", repo_url, f"{base}:{base}") + repo.git.checkout(base) + # Cherrypick commits from the temporary branch starting from the working base + commits = repo.git.rev_list("--reverse", f"{working_base}..{temp_branch}", ".") + for commit in commits.splitlines(): + try: + repo.git.cherry_pick( + "--strategy", + "recursive", + "--strategy-option", + "theirs", + f"{commit}", + ) + except GitCommandError as e: + if CHERRYPICK_EMPTY not in e.stderr: + print("Unexpected error: ", e) + raise + # Reset the temp branch to the working index + repo.git.checkout("-B", temp_branch, "HEAD") + # Reset the base + repo.git.fetch("--force", repo_url, f"{base}:{base}") + + # Try to fetch the pull request branch + if not fetch_successful(repo, repo_url, branch): + # The pull request branch does not exist + print(f"Pull request branch '{branch}' does not exist yet") + # Create the pull request branch + repo.git.checkout("HEAD", b=branch) + # Check if the pull request branch is ahead of the base + diff = is_ahead(repo, base, branch) + if diff: + action = "created" + print(f"Created branch '{branch}'") + else: + print( + f"Branch '{branch}' is not ahead of base '{base}' and will not be created" + ) + else: + # The pull request branch exists + print( + f"Pull request branch '{branch}' already exists as remote branch 'origin/{branch}'" + ) + # Checkout the pull request branch + repo.git.checkout(branch) + + if has_diff(repo, branch, temp_branch): + # If the branch differs from the recreated temp version then the branch is reset + # For changes on base this action is similar to a rebase of the pull request branch + print(f"Resetting '{branch}'") + repo.git.checkout("-B", branch, temp_branch) + # repo.git.switch("-C", branch, temp_branch) + + # Check if the pull request branch has been updated + # If the branch was reset or updated it will be ahead + # It may be behind if a reset now results in no diff with the base + if not is_even(repo, f"origin/{branch}", branch): + action = "updated" + print(f"Updated branch '{branch}'") + else: + print( + f"Branch '{branch}' is even with its remote and will not be updated" + ) + + # Check if the pull request branch is ahead of the base + diff = is_ahead(repo, base, branch) + + # Delete the temporary branch + repo.git.branch("--delete", "--force", temp_branch) + + return {"action": action, "diff": diff, "base": base} diff --git a/dist/src/create_or_update_pull_request.py b/dist/src/create_or_update_pull_request.py new file mode 100644 index 0000000..52b3101 --- /dev/null +++ b/dist/src/create_or_update_pull_request.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" Create or Update Pull Request """ +from github import Github, GithubException +import os + + +def cs_string_to_list(str): + # Split the comma separated string into a list + l = [i.strip() for i in str.split(",")] + # Remove empty strings + return list(filter(None, l)) + + +def create_project_card(github_repo, project_name, project_column_name, pull_request): + # Locate the project by name + project = None + for project_item in github_repo.get_projects("all"): + if project_item.name == project_name: + project = project_item + break + + if not project: + print("::warning::Project not found. Unable to create project card.") + return + + # Locate the column by name + column = None + for column_item in project.get_columns(): + if column_item.name == project_column_name: + column = column_item + break + + if not column: + print("::warning::Project column not found. Unable to create project card.") + return + + # Create a project card for the pull request + column.create_card(content_id=pull_request.id, content_type="PullRequest") + print( + "Added pull request #%d to project '%s' under column '%s'" + % (pull_request.number, project.name, column.name) + ) + + +def create_or_update_pull_request( + github_token, + github_repository, + branch, + base, + title, + body, + labels, + assignees, + milestone, + reviewers, + team_reviewers, + project_name, + project_column_name, +): + # Create the pull request + github_repo = Github(github_token).get_repo(github_repository) + try: + pull_request = github_repo.create_pull( + title=title, body=body, base=base, head=branch + ) + print( + "Created pull request #%d (%s => %s)" % (pull_request.number, branch, base) + ) + except GithubException as e: + if e.status == 422: + # Format the branch name + head_branch = "%s:%s" % (github_repository.split("/")[0], branch) + # Get the pull request + pull_request = github_repo.get_pulls( + state="open", base=base, head=head_branch + )[0] + print( + "Updated pull request #%d (%s => %s)" + % (pull_request.number, branch, base) + ) + else: + print(str(e)) + raise + + # Set the output variables + os.system("echo ::set-env name=PULL_REQUEST_NUMBER::%d" % pull_request.number) + os.system("echo ::set-output name=pr_number::%d" % pull_request.number) + + # Set labels, assignees and milestone + if labels is not None: + print("Applying labels '%s'" % labels) + pull_request.as_issue().edit(labels=cs_string_to_list(labels)) + if assignees is not None: + print("Applying assignees '%s'" % assignees) + pull_request.as_issue().edit(assignees=cs_string_to_list(assignees)) + if milestone is not None: + print("Applying milestone '%s'" % milestone) + milestone = github_repo.get_milestone(int(milestone)) + pull_request.as_issue().edit(milestone=milestone) + + # Set pull request reviewers + if reviewers is not None: + print("Requesting reviewers '%s'" % reviewers) + try: + pull_request.create_review_request(reviewers=cs_string_to_list(reviewers)) + except GithubException as e: + # Likely caused by "Review cannot be requested from pull request + # author." + if e.status == 422: + print("Requesting reviewers failed - %s" % e.data["message"]) + + # Set pull request team reviewers + if team_reviewers is not None: + print("Requesting team reviewers '%s'" % team_reviewers) + pull_request.create_review_request( + team_reviewers=cs_string_to_list(team_reviewers) + ) + + # Create a project card for the pull request + if project_name is not None and project_column_name is not None: + try: + create_project_card( + github_repo, project_name, project_column_name, pull_request + ) + except GithubException as e: + # Likely caused by "Project already has the associated issue." + if e.status == 422: + print( + "Create project card failed - %s" % e.data["errors"][0]["message"] + ) diff --git a/dist/src/create_pull_request.py b/dist/src/create_pull_request.py new file mode 100755 index 0000000..31e602a --- /dev/null +++ b/dist/src/create_pull_request.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" Create Pull Request """ +import common as cmn +import create_or_update_branch as coub +import create_or_update_pull_request as coupr +from git import Repo +import json +import os +import sys +import time + + +# Default the committer and author to the GitHub Actions bot +DEFAULT_COMMITTER = "GitHub " +DEFAULT_AUTHOR = ( + "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" +) + + +def set_committer_author(repo, committer, author): + # When the user intends for the committer and author to be the same, + # ideally, just the committer should be supplied. When just the author + # is supplied, the same user intention is assumed. + if committer is None and author is not None: + print("Supplied author will also be used as the committer.") + committer = author + + # TODO Get committer and author from git config + # If just a committer exists, only set committer + # If just author exists also use for the committer + + # Set defaults if no committer/author has been supplied + if committer is None and author is None: + committer = DEFAULT_COMMITTER + author = DEFAULT_AUTHOR + + # Set git environment. This will not persist after the action completes. + committer_name, committer_email = cmn.parse_display_name_email(committer) + print(f"Configuring git committer as '{committer_name} <{committer_email}>'") + if author is not None: + author_name, author_email = cmn.parse_display_name_email(author) + print(f"Configuring git author as '{author_name} <{author_email}>'") + repo.git.update_environment( + GIT_COMMITTER_NAME=committer_name, + GIT_COMMITTER_EMAIL=committer_email, + GIT_AUTHOR_NAME=author_name, + GIT_AUTHOR_EMAIL=author_email, + ) + else: + repo.git.update_environment( + GIT_COMMITTER_NAME=committer_name, GIT_COMMITTER_EMAIL=committer_email, + ) + + +# Get required environment variables +github_token = os.environ["GITHUB_TOKEN"] +github_repository = os.environ["GITHUB_REPOSITORY"] +# Get environment variables with defaults +branch = os.getenv("CPR_BRANCH", "create-pull-request/patch") +commit_message = os.getenv( + "CPR_COMMIT_MESSAGE", "Changes by create-pull-request action" +) +# Get environment variables with a default of 'None' +committer = os.environ.get("CPR_COMMITTER") +author = os.environ.get("CPR_AUTHOR") +base = os.environ.get("CPR_BASE") + +# Set the repo to the working directory +repo = Repo(os.getcwd()) + +# Determine if the checked out ref is a valid base for a pull request +# The action needs the checked out HEAD ref to be a branch +# This check will fail in the following cases: +# - HEAD is detached +# - HEAD is a merge commit (pull_request events) +# - HEAD is a tag +try: + working_base = repo.git.symbolic_ref("HEAD", "--short") +except: + print(f"::debug::{working_base}") + print( + f"::error::The checked out ref is not a valid base for a pull request. " + + "Unable to continue. Exiting." + ) + sys.exit(1) + +# Exit if the working base is a PR branch created by this action. +# This may occur when using a PAT instead of GITHUB_TOKEN because +# a PAT allows workflow actions to trigger further events. +if working_base.startswith(branch): + print( + f"::error::Working base branch '{working_base}' was created by this action. " + + "Unable to continue. Exiting." + ) + sys.exit(1) + +# Fetch an optional environment variable to determine the branch suffix +branch_suffix = os.environ.get("CPR_BRANCH_SUFFIX") +if branch_suffix is not None: + if branch_suffix == "short-commit-hash": + # Suffix with the short SHA1 hash + branch = "{}-{}".format(branch, repo.git.rev_parse("--short", "HEAD")) + elif branch_suffix == "timestamp": + # Suffix with the current timestamp + branch = "{}-{}".format(branch, int(time.time())) + elif branch_suffix == "random": + # Suffix with a 7 character random string + branch = "{}-{}".format(branch, cmn.get_random_string()) + else: + print( + f"::error::Branch suffix '{branch_suffix}' is not a valid value. " + + "Unable to continue. Exiting." + ) + sys.exit(1) + +# Output head branch +print(f"Pull request branch to create or update set to '{branch}'") + +# Set the committer and author +try: + set_committer_author(repo, committer, author) +except ValueError as e: + print(f"::error::{e} " + "Unable to continue. Exiting.") + sys.exit(1) + +# Set the repository URL +repo_url = f"https://x-access-token:{github_token}@github.com/{github_repository}" + +# Create or update the pull request branch +result = coub.create_or_update_branch(repo, repo_url, commit_message, base, branch) + +if result["action"] in ["created", "updated"]: + # The branch was created or updated + print(f"Pushing pull request branch to 'origin/{branch}'") + repo.git.push("--force", repo_url, f"HEAD:refs/heads/{branch}") + + # Set the base. It would have been 'None' if not specified as an input + base = result["base"] + + # TODO Figure out what to do when there is no diff with the base anymore + # if not result["diff"]: + + # Fetch optional environment variables with default values + title = os.getenv("CPR_TITLE", "Auto-generated by create-pull-request action") + body = os.getenv( + "CPR_BODY", + "Auto-generated pull request by " + "[create-pull-request](https://github.com/peter-evans/create-pull-request) GitHub Action", + ) + + # Create or update the pull request + coupr.create_or_update_pull_request( + github_token, + github_repository, + branch, + base, + title, + body, + os.environ.get("CPR_LABELS"), + os.environ.get("CPR_ASSIGNEES"), + os.environ.get("CPR_MILESTONE"), + os.environ.get("CPR_REVIEWERS"), + os.environ.get("CPR_TEAM_REVIEWERS"), + os.environ.get("CPR_PROJECT_NAME"), + os.environ.get("CPR_PROJECT_COLUMN_NAME"), + ) diff --git a/dist/src/test_common.py b/dist/src/test_common.py new file mode 100644 index 0000000..2d7dd2b --- /dev/null +++ b/dist/src/test_common.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +""" Test Common """ +import common as cmn +import pytest + + +def test_get_random_string(): + assert len(cmn.get_random_string()) == 7 + assert len(cmn.get_random_string(length=20)) == 20 + + +def test_parse_display_name_email_success(): + name, email = cmn.parse_display_name_email("abc def ") + assert name == "abc def" + assert email == "abc@def.com" + + name, email = cmn.parse_display_name_email( + "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" + ) + assert name == "github-actions[bot]" + assert email == "41898282+github-actions[bot]@users.noreply.github.com" + + +def test_parse_display_name_email_failure(): + display_name_email = "abc@def.com" + with pytest.raises(ValueError) as e_info: + cmn.parse_display_name_email(display_name_email) + assert ( + e_info.value.args[0] + == f"The format of '{display_name_email}' is not a valid email address with display name" + ) + + display_name_email = " < >" + with pytest.raises(ValueError) as e_info: + cmn.parse_display_name_email(display_name_email) + assert ( + e_info.value.args[0] + == f"The format of '{display_name_email}' is not a valid email address with display name" + ) diff --git a/dist/src/test_create_or_update_branch.py b/dist/src/test_create_or_update_branch.py new file mode 100644 index 0000000..c6ff95b --- /dev/null +++ b/dist/src/test_create_or_update_branch.py @@ -0,0 +1,748 @@ +#!/usr/bin/env python3 +""" Test Create or Update Branch """ +import create_or_update_branch as coub +from git import Repo +import os +import pytest +import sys +import time + + +author_name = "github-actions[bot]" +author_email = "41898282+github-actions[bot]@users.noreply.github.com" +committer_name = "GitHub" +committer_email = "noreply@github.com" + +# Set git environment +repo = Repo(os.getcwd()) +repo.git.update_environment( + GIT_AUTHOR_NAME=author_name, + GIT_AUTHOR_EMAIL=author_email, + GIT_COMMITTER_NAME=committer_name, + GIT_COMMITTER_EMAIL=committer_email, +) + +REPO_URL = repo.git.config("--get", "remote.origin.url") + +TRACKED_FILE = "tracked-file.txt" +UNTRACKED_FILE = "untracked-file.txt" + +DEFAULT_BRANCH = "tests/master" +NOT_BASE_BRANCH = "tests/branch-that-is-not-the-base" +NOT_EXIST_BRANCH = "tests/branch-that-does-not-exist" + +COMMIT_MESSAGE = "Changes by create-pull-request action" +BRANCH = "tests/create-pull-request/patch" +BASE = DEFAULT_BRANCH + + +def create_tracked_change(content=None): + if content is None: + content = str(time.time()) + # Create a tracked file change + with open(TRACKED_FILE, "w") as f: + f.write(content) + return content + + +def create_untracked_change(content=None): + if content is None: + content = str(time.time()) + # Create an untracked file change + with open(UNTRACKED_FILE, "w") as f: + f.write(content) + return content + + +def get_tracked_content(): + # Read the content of the tracked file + with open(TRACKED_FILE, "r") as f: + return f.read() + + +def get_untracked_content(): + # Read the content of the untracked file + with open(UNTRACKED_FILE, "r") as f: + return f.read() + + +def create_changes(tracked_content=None, untracked_content=None): + tracked_content = create_tracked_change(tracked_content) + untracked_content = create_untracked_change(untracked_content) + return tracked_content, untracked_content + + +def create_commits(number=2, final_tracked_content=None, final_untracked_content=None): + for i in range(number): + commit_number = i + 1 + if commit_number == number: + tracked_content, untracked_content = create_changes( + final_tracked_content, final_untracked_content + ) + else: + tracked_content, untracked_content = create_changes() + repo.git.add("-A") + repo.git.commit(m=f"Commit {commit_number}") + return tracked_content, untracked_content + + +@pytest.fixture(scope="module", autouse=True) +def before_after_all(): + print("Before all tests") + # Check there are no local changes that might be + # destroyed by running these tests + assert not repo.is_dirty(untracked_files=True) + + # Create a new default branch for the test run + repo.git.checkout("master") + repo.git.checkout("HEAD", b=NOT_BASE_BRANCH) + create_tracked_change() + repo.git.add("-A") + repo.git.commit(m="This commit should not appear in pr branches") + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{NOT_BASE_BRANCH}") + # Create a new default branch for the test run + repo.git.checkout("master") + repo.git.checkout("HEAD", b=DEFAULT_BRANCH) + create_tracked_change() + repo.git.add("-A") + repo.git.commit(m="Add file to be a tracked file for tests") + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + + yield + + print("After all tests") + repo.git.checkout("master") + # Delete the "not base branch" created for the test run + repo.git.branch("--delete", "--force", NOT_BASE_BRANCH) + repo.git.push("--delete", "--force", REPO_URL, f"refs/heads/{NOT_BASE_BRANCH}") + # Delete the default branch created for the test run + repo.git.branch("--delete", "--force", DEFAULT_BRANCH) + repo.git.push("--delete", "--force", REPO_URL, f"refs/heads/{DEFAULT_BRANCH}") + + +def before_test(): + print("Before test") + # Checkout the default branch + repo.git.checkout(DEFAULT_BRANCH) + + +def after_test(delete_remote=True): + print("After test") + # Output git log + print(repo.git.log("-5", pretty="oneline")) + # Delete the pull request branch if it exists + repo.git.checkout(DEFAULT_BRANCH) + print(f"Deleting {BRANCH}") + for branch in repo.branches: + if branch.name == BRANCH: + repo.git.branch("--delete", "--force", BRANCH) + break + if delete_remote: + print(f"Deleting origin/{BRANCH}") + for ref in repo.remotes.origin.refs: + if ref.name == f"origin/{BRANCH}": + repo.git.push("--delete", "--force", REPO_URL, f"refs/heads/{BRANCH}") + repo.remotes.origin.fetch("--prune") + break + + +@pytest.fixture(autouse=True) +def before_after_tests(): + before_test() + yield + after_test() + + +# Tests if a branch exists and can be fetched +def coub_fetch_successful(): + assert coub.fetch_successful(repo, REPO_URL, NOT_BASE_BRANCH) + assert not coub.fetch_successful(repo, REPO_URL, NOT_EXIST_BRANCH) + + +# Tests no changes resulting in no new branch being created +def coub_no_changes_on_create(): + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "none" + + +# Tests create and update with a tracked file change +def coub_tracked_changes(): + # Create a tracked file change + tracked_content = create_tracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create a tracked file change + tracked_content = create_tracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + + +# Tests create and update with an untracked file change +def coub_untracked_changes(): + # Create an untracked file change + untracked_content = create_untracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create an untracked file change + untracked_content = create_untracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_untracked_content() == untracked_content + + +# Tests create and update with identical changes +# The pull request branch will not be updated +def coub_identical_changes(): + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create identical tracked and untracked file changes + create_changes(tracked_content, untracked_content) + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "none" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Tests create and update with commits on the base inbetween +def coub_commits_on_base(): + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Tests create and then an update with no changes +# This effectively reverts the branch back to match the base and results in no diff +def coub_changes_no_diff(): + # Save the default branch tracked content + default_tracked_content = get_tracked_content() + + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Running with no update effectively reverts the branch back to match the base + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] == False + assert get_tracked_content() == default_tracked_content + + +# Tests create and update with commits on the base inbetween +# The changes on base effectively revert the branch back to match the base and results in no diff +def coub_commits_on_base_no_diff(): + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + tracked_content, untracked_content = create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Create the same tracked and untracked file changes that were made to the base + create_changes(tracked_content, untracked_content) + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] == False + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Tests create and update with commits on the working base (during the workflow) +def coub_commits_on_working_base(): + # Create commits on the working base + tracked_content, untracked_content = create_commits() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the working base + tracked_content, untracked_content = create_commits() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Tests create and update with changes and commits on the working base (during the workflow) +def coub_changes_and_commits_on_working_base(): + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Tests create and update with changes and commits on the working base (during the workflow) +# with commits on the base inbetween +def coub_changes_and_commits_on_base_and_working_base(): + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests no changes resulting in no new branch being created +def coub_wbnb_no_changes_on_create(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "none" + + +# Working Base is Not Base (WBNB) +# Tests create and update with a tracked file change +def coub_wbnb_tracked_changes(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create a tracked file change + tracked_content = create_tracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create a tracked file change + tracked_content = create_tracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with an untracked file change +def coub_wbnb_untracked_changes(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create an untracked file change + untracked_content = create_untracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create an untracked file change + untracked_content = create_untracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with identical changes +# The pull request branch will not be updated +def coub_wbnb_identical_changes(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create identical tracked and untracked file changes + create_changes(tracked_content, untracked_content) + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "none" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with commits on the base inbetween +def coub_wbnb_commits_on_base(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and then an update with no changes +# This effectively reverts the branch back to match the base and results in no diff +def coub_wbnb_changes_no_diff(): + # Save the default branch tracked content + default_tracked_content = get_tracked_content() + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Running with no update effectively reverts the branch back to match the base + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] == False + assert get_tracked_content() == default_tracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with commits on the base inbetween +# The changes on base effectively revert the branch back to match the base and results in no diff +# This scenario will cause cherrypick to fail due to an empty commit. +# The commit is empty because the changes now exist on the base. +def coub_wbnb_commits_on_base_no_diff(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + tracked_content, untracked_content = create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create the same tracked and untracked file changes that were made to the base + create_changes(tracked_content, untracked_content) + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] == False + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with commits on the working base (during the workflow) +def coub_wbnb_commits_on_working_base(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + tracked_content, untracked_content = create_commits() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + tracked_content, untracked_content = create_commits() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with changes and commits on the working base (during the workflow) +def coub_wbnb_changes_and_commits_on_working_base(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with changes and commits on the working base (during the workflow) +# with commits on the base inbetween +def coub_wbnb_changes_and_commits_on_base_and_working_base(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# pytest -v -s ~/git/create-pull-request/src + +# test_coub_fetch_successful = coub_fetch_successful + +# test_coub_no_changes_on_create = coub_no_changes_on_create +# test_coub_tracked_changes = coub_tracked_changes +# test_coub_untracked_changes = coub_untracked_changes +# test_coub_identical_changes = coub_identical_changes +# test_coub_commits_on_base = coub_commits_on_base + +# test_coub_changes_no_diff = coub_changes_no_diff +# test_coub_commits_on_base_no_diff = coub_commits_on_base_no_diff + +# test_coub_commits_on_working_base = coub_commits_on_working_base +# test_coub_changes_and_commits_on_working_base = coub_changes_and_commits_on_working_base +# test_coub_changes_and_commits_on_base_and_working_base = coub_changes_and_commits_on_base_and_working_base + +# # WBNB +# test_coub_wbnb_no_changes_on_create = coub_wbnb_no_changes_on_create +# test_coub_wbnb_tracked_changes = coub_wbnb_tracked_changes +# test_coub_wbnb_untracked_changes = coub_wbnb_untracked_changes +# test_coub_wbnb_identical_changes = coub_wbnb_identical_changes +# test_coub_wbnb_commits_on_base = coub_wbnb_commits_on_base + +# test_coub_wbnb_changes_no_diff = coub_wbnb_changes_no_diff +# test_coub_wbnb_commits_on_base_no_diff = coub_wbnb_commits_on_base_no_diff + +# test_coub_wbnb_commits_on_working_base = coub_wbnb_commits_on_working_base +# test_coub_wbnb_changes_and_commits_on_working_base = coub_wbnb_changes_and_commits_on_working_base +# test_coub_wbnb_changes_and_commits_on_base_and_working_base = coub_wbnb_changes_and_commits_on_base_and_working_base diff --git a/index.js b/index.js index e95f469..d27b62b 100644 --- a/index.js +++ b/index.js @@ -23,10 +23,8 @@ async function run() { const inputs = { token: core.getInput("token"), commitMessage: core.getInput("commit-message"), - commitAuthorName: core.getInput("author-name"), - commitAuthorEmail: core.getInput("author-email"), - committerName: core.getInput("committer-name"), - committerEmail: core.getInput("committer-email"), + committer: core.getInput("committer"), + author: core.getInput("author"), title: core.getInput("title"), body: core.getInput("body"), labels: core.getInput("labels"), @@ -39,33 +37,29 @@ async function run() { branch: core.getInput("branch"), base: core.getInput("base"), branchSuffix: core.getInput("branch-suffix"), - debugEvent: core.getInput("debug-event") }; core.debug(`Inputs: ${inspect(inputs)}`); // Set environment variables from inputs. if (inputs.token) process.env.GITHUB_TOKEN = inputs.token; - if (inputs.commitMessage) process.env.COMMIT_MESSAGE = inputs.commitMessage; - if (inputs.commitAuthorName) process.env.COMMIT_AUTHOR_NAME = inputs.commitAuthorName; - if (inputs.commitAuthorEmail) process.env.COMMIT_AUTHOR_EMAIL = inputs.commitAuthorEmail; - if (inputs.committerName) process.env.COMMITTER_NAME = inputs.committerName; - if (inputs.committerEmail) process.env.COMMITTER_EMAIL = inputs.committerEmail; - if (inputs.title) process.env.PULL_REQUEST_TITLE = inputs.title; - if (inputs.body) process.env.PULL_REQUEST_BODY = inputs.body; - if (inputs.labels) process.env.PULL_REQUEST_LABELS = inputs.labels; - if (inputs.assignees) process.env.PULL_REQUEST_ASSIGNEES = inputs.assignees; - if (inputs.reviewers) process.env.PULL_REQUEST_REVIEWERS = inputs.reviewers; - if (inputs.teamReviewers) process.env.PULL_REQUEST_TEAM_REVIEWERS = inputs.teamReviewers; - if (inputs.milestone) process.env.PULL_REQUEST_MILESTONE = inputs.milestone; - if (inputs.project) process.env.PROJECT_NAME = inputs.project; - if (inputs.projectColumn) process.env.PROJECT_COLUMN_NAME = inputs.projectColumn; - if (inputs.branch) process.env.PULL_REQUEST_BRANCH = inputs.branch; - if (inputs.base) process.env.PULL_REQUEST_BASE = inputs.base; - if (inputs.branchSuffix) process.env.BRANCH_SUFFIX = inputs.branchSuffix; - if (inputs.debugEvent) process.env.DEBUG_EVENT = inputs.debugEvent; + if (inputs.commitMessage) process.env.CPR_COMMIT_MESSAGE = inputs.commitMessage; + if (inputs.committer) process.env.CPR_COMMITTER = inputs.committer; + if (inputs.author) process.env.CPR_AUTHOR = inputs.author; + if (inputs.title) process.env.CPR_TITLE = inputs.title; + if (inputs.body) process.env.CPR_BODY = inputs.body; + if (inputs.labels) process.env.CPR_LABELS = inputs.labels; + if (inputs.assignees) process.env.CPR_ASSIGNEES = inputs.assignees; + if (inputs.reviewers) process.env.CPR_REVIEWERS = inputs.reviewers; + if (inputs.teamReviewers) process.env.CPR_TEAM_REVIEWERS = inputs.teamReviewers; + if (inputs.milestone) process.env.CPR_MILESTONE = inputs.milestone; + if (inputs.project) process.env.CPR_PROJECT_NAME = inputs.project; + if (inputs.projectColumn) process.env.CPR_PROJECT_COLUMN_NAME = inputs.projectColumn; + if (inputs.branch) process.env.CPR_BRANCH = inputs.branch; + if (inputs.base) process.env.CPR_BASE = inputs.base; + if (inputs.branchSuffix) process.env.CPR_BRANCH_SUFFIX = inputs.branchSuffix; // Execute python script - await exec.exec("python", [`${src}/create-pull-request.py`]); + await exec.exec("python", [`${src}/create_pull_request.py`]); } catch (error) { core.setFailed(error.message); } diff --git a/src/common.py b/src/common.py new file mode 100644 index 0000000..950ac95 --- /dev/null +++ b/src/common.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +import random +import re +import string + + +def get_random_string(length=7, chars=string.ascii_lowercase + string.digits): + return "".join(random.choice(chars) for _ in range(length)) + + +def parse_display_name_email(display_name_email): + # Parse the name and email address from a string in the following format + # Display Name + pattern = re.compile(r"^([^<]+)\s*<([^>]+)>$") + + # Check we have a match + match = pattern.match(display_name_email) + if match is None: + raise ValueError( + f"The format of '{display_name_email}' is not a valid email address with display name" + ) + + # Check that name and email are not just whitespace + name = match.group(1).strip() + email = match.group(2).strip() + if len(name) == 0 or len(email) == 0: + raise ValueError( + f"The format of '{display_name_email}' is not a valid email address with display name" + ) + + return name, email diff --git a/src/create_or_update_branch.py b/src/create_or_update_branch.py new file mode 100644 index 0000000..7683d1a --- /dev/null +++ b/src/create_or_update_branch.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" Create or Update Branch """ +import common as cmn +from git import Repo, GitCommandError +import os + + +CHERRYPICK_EMPTY = ( + "The previous cherry-pick is now empty, possibly due to conflict resolution." +) + + +def fetch_successful(repo, repo_url, branch): + try: + repo.git.fetch(repo_url, f"{branch}:refs/remotes/origin/{branch}") + except GitCommandError: + return False + return True + + +def is_ahead(repo, branch_1, branch_2): + # Return true if branch_2 is ahead of branch_1 + return ( + int(repo.git.rev_list("--right-only", "--count", f"{branch_1}...{branch_2}")) + > 0 + ) + + +def is_behind(repo, branch_1, branch_2): + # Return true if branch_2 is behind branch_1 + return ( + int(repo.git.rev_list("--left-only", "--count", f"{branch_1}...{branch_2}")) > 0 + ) + + +def is_even(repo, branch_1, branch_2): + # Return true if branch_2 is even with branch_1 + return not is_ahead(repo, branch_1, branch_2) and not is_behind( + repo, branch_1, branch_2 + ) + + +def has_diff(repo, branch_1, branch_2): + diff = repo.git.diff(f"{branch_1}..{branch_2}") + return len(diff) > 0 + + +def create_or_update_branch(repo, repo_url, commit_message, base, branch): + # Set the default return values + action = "none" + diff = False + + # Get the working base. This may or may not be the actual base. + working_base = repo.git.symbolic_ref("HEAD", "--short") + # If the base is not specified it is assumed to be the working base + if base is None: + base = working_base + + # Save the working base changes to a temporary branch + temp_branch = cmn.get_random_string(length=20) + repo.git.checkout("HEAD", b=temp_branch) + # Commit any uncomitted changes + if repo.is_dirty(untracked_files=True): + print(f"Uncommitted changes found. Adding a commit.") + repo.git.add("-A") + repo.git.commit(m=commit_message) + + # Perform fetch and reset the working base + # Commits made during the workflow will be removed + repo.git.fetch("--force", repo_url, f"{working_base}:{working_base}") + + # If the working base is not the base, rebase the temp branch commits + if working_base != base: + print( + f"Rebasing commits made to branch '{working_base}' on to base branch '{base}'" + ) + # Checkout the actual base + repo.git.fetch("--force", repo_url, f"{base}:{base}") + repo.git.checkout(base) + # Cherrypick commits from the temporary branch starting from the working base + commits = repo.git.rev_list("--reverse", f"{working_base}..{temp_branch}", ".") + for commit in commits.splitlines(): + try: + repo.git.cherry_pick( + "--strategy", + "recursive", + "--strategy-option", + "theirs", + f"{commit}", + ) + except GitCommandError as e: + if CHERRYPICK_EMPTY not in e.stderr: + print("Unexpected error: ", e) + raise + # Reset the temp branch to the working index + repo.git.checkout("-B", temp_branch, "HEAD") + # Reset the base + repo.git.fetch("--force", repo_url, f"{base}:{base}") + + # Try to fetch the pull request branch + if not fetch_successful(repo, repo_url, branch): + # The pull request branch does not exist + print(f"Pull request branch '{branch}' does not exist yet") + # Create the pull request branch + repo.git.checkout("HEAD", b=branch) + # Check if the pull request branch is ahead of the base + diff = is_ahead(repo, base, branch) + if diff: + action = "created" + print(f"Created branch '{branch}'") + else: + print( + f"Branch '{branch}' is not ahead of base '{base}' and will not be created" + ) + else: + # The pull request branch exists + print( + f"Pull request branch '{branch}' already exists as remote branch 'origin/{branch}'" + ) + # Checkout the pull request branch + repo.git.checkout(branch) + + if has_diff(repo, branch, temp_branch): + # If the branch differs from the recreated temp version then the branch is reset + # For changes on base this action is similar to a rebase of the pull request branch + print(f"Resetting '{branch}'") + repo.git.checkout("-B", branch, temp_branch) + # repo.git.switch("-C", branch, temp_branch) + + # Check if the pull request branch has been updated + # If the branch was reset or updated it will be ahead + # It may be behind if a reset now results in no diff with the base + if not is_even(repo, f"origin/{branch}", branch): + action = "updated" + print(f"Updated branch '{branch}'") + else: + print( + f"Branch '{branch}' is even with its remote and will not be updated" + ) + + # Check if the pull request branch is ahead of the base + diff = is_ahead(repo, base, branch) + + # Delete the temporary branch + repo.git.branch("--delete", "--force", temp_branch) + + return {"action": action, "diff": diff, "base": base} diff --git a/src/create_or_update_pull_request.py b/src/create_or_update_pull_request.py new file mode 100644 index 0000000..52b3101 --- /dev/null +++ b/src/create_or_update_pull_request.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" Create or Update Pull Request """ +from github import Github, GithubException +import os + + +def cs_string_to_list(str): + # Split the comma separated string into a list + l = [i.strip() for i in str.split(",")] + # Remove empty strings + return list(filter(None, l)) + + +def create_project_card(github_repo, project_name, project_column_name, pull_request): + # Locate the project by name + project = None + for project_item in github_repo.get_projects("all"): + if project_item.name == project_name: + project = project_item + break + + if not project: + print("::warning::Project not found. Unable to create project card.") + return + + # Locate the column by name + column = None + for column_item in project.get_columns(): + if column_item.name == project_column_name: + column = column_item + break + + if not column: + print("::warning::Project column not found. Unable to create project card.") + return + + # Create a project card for the pull request + column.create_card(content_id=pull_request.id, content_type="PullRequest") + print( + "Added pull request #%d to project '%s' under column '%s'" + % (pull_request.number, project.name, column.name) + ) + + +def create_or_update_pull_request( + github_token, + github_repository, + branch, + base, + title, + body, + labels, + assignees, + milestone, + reviewers, + team_reviewers, + project_name, + project_column_name, +): + # Create the pull request + github_repo = Github(github_token).get_repo(github_repository) + try: + pull_request = github_repo.create_pull( + title=title, body=body, base=base, head=branch + ) + print( + "Created pull request #%d (%s => %s)" % (pull_request.number, branch, base) + ) + except GithubException as e: + if e.status == 422: + # Format the branch name + head_branch = "%s:%s" % (github_repository.split("/")[0], branch) + # Get the pull request + pull_request = github_repo.get_pulls( + state="open", base=base, head=head_branch + )[0] + print( + "Updated pull request #%d (%s => %s)" + % (pull_request.number, branch, base) + ) + else: + print(str(e)) + raise + + # Set the output variables + os.system("echo ::set-env name=PULL_REQUEST_NUMBER::%d" % pull_request.number) + os.system("echo ::set-output name=pr_number::%d" % pull_request.number) + + # Set labels, assignees and milestone + if labels is not None: + print("Applying labels '%s'" % labels) + pull_request.as_issue().edit(labels=cs_string_to_list(labels)) + if assignees is not None: + print("Applying assignees '%s'" % assignees) + pull_request.as_issue().edit(assignees=cs_string_to_list(assignees)) + if milestone is not None: + print("Applying milestone '%s'" % milestone) + milestone = github_repo.get_milestone(int(milestone)) + pull_request.as_issue().edit(milestone=milestone) + + # Set pull request reviewers + if reviewers is not None: + print("Requesting reviewers '%s'" % reviewers) + try: + pull_request.create_review_request(reviewers=cs_string_to_list(reviewers)) + except GithubException as e: + # Likely caused by "Review cannot be requested from pull request + # author." + if e.status == 422: + print("Requesting reviewers failed - %s" % e.data["message"]) + + # Set pull request team reviewers + if team_reviewers is not None: + print("Requesting team reviewers '%s'" % team_reviewers) + pull_request.create_review_request( + team_reviewers=cs_string_to_list(team_reviewers) + ) + + # Create a project card for the pull request + if project_name is not None and project_column_name is not None: + try: + create_project_card( + github_repo, project_name, project_column_name, pull_request + ) + except GithubException as e: + # Likely caused by "Project already has the associated issue." + if e.status == 422: + print( + "Create project card failed - %s" % e.data["errors"][0]["message"] + ) diff --git a/src/create_pull_request.py b/src/create_pull_request.py new file mode 100755 index 0000000..31e602a --- /dev/null +++ b/src/create_pull_request.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" Create Pull Request """ +import common as cmn +import create_or_update_branch as coub +import create_or_update_pull_request as coupr +from git import Repo +import json +import os +import sys +import time + + +# Default the committer and author to the GitHub Actions bot +DEFAULT_COMMITTER = "GitHub " +DEFAULT_AUTHOR = ( + "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" +) + + +def set_committer_author(repo, committer, author): + # When the user intends for the committer and author to be the same, + # ideally, just the committer should be supplied. When just the author + # is supplied, the same user intention is assumed. + if committer is None and author is not None: + print("Supplied author will also be used as the committer.") + committer = author + + # TODO Get committer and author from git config + # If just a committer exists, only set committer + # If just author exists also use for the committer + + # Set defaults if no committer/author has been supplied + if committer is None and author is None: + committer = DEFAULT_COMMITTER + author = DEFAULT_AUTHOR + + # Set git environment. This will not persist after the action completes. + committer_name, committer_email = cmn.parse_display_name_email(committer) + print(f"Configuring git committer as '{committer_name} <{committer_email}>'") + if author is not None: + author_name, author_email = cmn.parse_display_name_email(author) + print(f"Configuring git author as '{author_name} <{author_email}>'") + repo.git.update_environment( + GIT_COMMITTER_NAME=committer_name, + GIT_COMMITTER_EMAIL=committer_email, + GIT_AUTHOR_NAME=author_name, + GIT_AUTHOR_EMAIL=author_email, + ) + else: + repo.git.update_environment( + GIT_COMMITTER_NAME=committer_name, GIT_COMMITTER_EMAIL=committer_email, + ) + + +# Get required environment variables +github_token = os.environ["GITHUB_TOKEN"] +github_repository = os.environ["GITHUB_REPOSITORY"] +# Get environment variables with defaults +branch = os.getenv("CPR_BRANCH", "create-pull-request/patch") +commit_message = os.getenv( + "CPR_COMMIT_MESSAGE", "Changes by create-pull-request action" +) +# Get environment variables with a default of 'None' +committer = os.environ.get("CPR_COMMITTER") +author = os.environ.get("CPR_AUTHOR") +base = os.environ.get("CPR_BASE") + +# Set the repo to the working directory +repo = Repo(os.getcwd()) + +# Determine if the checked out ref is a valid base for a pull request +# The action needs the checked out HEAD ref to be a branch +# This check will fail in the following cases: +# - HEAD is detached +# - HEAD is a merge commit (pull_request events) +# - HEAD is a tag +try: + working_base = repo.git.symbolic_ref("HEAD", "--short") +except: + print(f"::debug::{working_base}") + print( + f"::error::The checked out ref is not a valid base for a pull request. " + + "Unable to continue. Exiting." + ) + sys.exit(1) + +# Exit if the working base is a PR branch created by this action. +# This may occur when using a PAT instead of GITHUB_TOKEN because +# a PAT allows workflow actions to trigger further events. +if working_base.startswith(branch): + print( + f"::error::Working base branch '{working_base}' was created by this action. " + + "Unable to continue. Exiting." + ) + sys.exit(1) + +# Fetch an optional environment variable to determine the branch suffix +branch_suffix = os.environ.get("CPR_BRANCH_SUFFIX") +if branch_suffix is not None: + if branch_suffix == "short-commit-hash": + # Suffix with the short SHA1 hash + branch = "{}-{}".format(branch, repo.git.rev_parse("--short", "HEAD")) + elif branch_suffix == "timestamp": + # Suffix with the current timestamp + branch = "{}-{}".format(branch, int(time.time())) + elif branch_suffix == "random": + # Suffix with a 7 character random string + branch = "{}-{}".format(branch, cmn.get_random_string()) + else: + print( + f"::error::Branch suffix '{branch_suffix}' is not a valid value. " + + "Unable to continue. Exiting." + ) + sys.exit(1) + +# Output head branch +print(f"Pull request branch to create or update set to '{branch}'") + +# Set the committer and author +try: + set_committer_author(repo, committer, author) +except ValueError as e: + print(f"::error::{e} " + "Unable to continue. Exiting.") + sys.exit(1) + +# Set the repository URL +repo_url = f"https://x-access-token:{github_token}@github.com/{github_repository}" + +# Create or update the pull request branch +result = coub.create_or_update_branch(repo, repo_url, commit_message, base, branch) + +if result["action"] in ["created", "updated"]: + # The branch was created or updated + print(f"Pushing pull request branch to 'origin/{branch}'") + repo.git.push("--force", repo_url, f"HEAD:refs/heads/{branch}") + + # Set the base. It would have been 'None' if not specified as an input + base = result["base"] + + # TODO Figure out what to do when there is no diff with the base anymore + # if not result["diff"]: + + # Fetch optional environment variables with default values + title = os.getenv("CPR_TITLE", "Auto-generated by create-pull-request action") + body = os.getenv( + "CPR_BODY", + "Auto-generated pull request by " + "[create-pull-request](https://github.com/peter-evans/create-pull-request) GitHub Action", + ) + + # Create or update the pull request + coupr.create_or_update_pull_request( + github_token, + github_repository, + branch, + base, + title, + body, + os.environ.get("CPR_LABELS"), + os.environ.get("CPR_ASSIGNEES"), + os.environ.get("CPR_MILESTONE"), + os.environ.get("CPR_REVIEWERS"), + os.environ.get("CPR_TEAM_REVIEWERS"), + os.environ.get("CPR_PROJECT_NAME"), + os.environ.get("CPR_PROJECT_COLUMN_NAME"), + ) diff --git a/src/test_common.py b/src/test_common.py new file mode 100644 index 0000000..2d7dd2b --- /dev/null +++ b/src/test_common.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +""" Test Common """ +import common as cmn +import pytest + + +def test_get_random_string(): + assert len(cmn.get_random_string()) == 7 + assert len(cmn.get_random_string(length=20)) == 20 + + +def test_parse_display_name_email_success(): + name, email = cmn.parse_display_name_email("abc def ") + assert name == "abc def" + assert email == "abc@def.com" + + name, email = cmn.parse_display_name_email( + "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" + ) + assert name == "github-actions[bot]" + assert email == "41898282+github-actions[bot]@users.noreply.github.com" + + +def test_parse_display_name_email_failure(): + display_name_email = "abc@def.com" + with pytest.raises(ValueError) as e_info: + cmn.parse_display_name_email(display_name_email) + assert ( + e_info.value.args[0] + == f"The format of '{display_name_email}' is not a valid email address with display name" + ) + + display_name_email = " < >" + with pytest.raises(ValueError) as e_info: + cmn.parse_display_name_email(display_name_email) + assert ( + e_info.value.args[0] + == f"The format of '{display_name_email}' is not a valid email address with display name" + ) diff --git a/src/test_create_or_update_branch.py b/src/test_create_or_update_branch.py new file mode 100644 index 0000000..c6ff95b --- /dev/null +++ b/src/test_create_or_update_branch.py @@ -0,0 +1,748 @@ +#!/usr/bin/env python3 +""" Test Create or Update Branch """ +import create_or_update_branch as coub +from git import Repo +import os +import pytest +import sys +import time + + +author_name = "github-actions[bot]" +author_email = "41898282+github-actions[bot]@users.noreply.github.com" +committer_name = "GitHub" +committer_email = "noreply@github.com" + +# Set git environment +repo = Repo(os.getcwd()) +repo.git.update_environment( + GIT_AUTHOR_NAME=author_name, + GIT_AUTHOR_EMAIL=author_email, + GIT_COMMITTER_NAME=committer_name, + GIT_COMMITTER_EMAIL=committer_email, +) + +REPO_URL = repo.git.config("--get", "remote.origin.url") + +TRACKED_FILE = "tracked-file.txt" +UNTRACKED_FILE = "untracked-file.txt" + +DEFAULT_BRANCH = "tests/master" +NOT_BASE_BRANCH = "tests/branch-that-is-not-the-base" +NOT_EXIST_BRANCH = "tests/branch-that-does-not-exist" + +COMMIT_MESSAGE = "Changes by create-pull-request action" +BRANCH = "tests/create-pull-request/patch" +BASE = DEFAULT_BRANCH + + +def create_tracked_change(content=None): + if content is None: + content = str(time.time()) + # Create a tracked file change + with open(TRACKED_FILE, "w") as f: + f.write(content) + return content + + +def create_untracked_change(content=None): + if content is None: + content = str(time.time()) + # Create an untracked file change + with open(UNTRACKED_FILE, "w") as f: + f.write(content) + return content + + +def get_tracked_content(): + # Read the content of the tracked file + with open(TRACKED_FILE, "r") as f: + return f.read() + + +def get_untracked_content(): + # Read the content of the untracked file + with open(UNTRACKED_FILE, "r") as f: + return f.read() + + +def create_changes(tracked_content=None, untracked_content=None): + tracked_content = create_tracked_change(tracked_content) + untracked_content = create_untracked_change(untracked_content) + return tracked_content, untracked_content + + +def create_commits(number=2, final_tracked_content=None, final_untracked_content=None): + for i in range(number): + commit_number = i + 1 + if commit_number == number: + tracked_content, untracked_content = create_changes( + final_tracked_content, final_untracked_content + ) + else: + tracked_content, untracked_content = create_changes() + repo.git.add("-A") + repo.git.commit(m=f"Commit {commit_number}") + return tracked_content, untracked_content + + +@pytest.fixture(scope="module", autouse=True) +def before_after_all(): + print("Before all tests") + # Check there are no local changes that might be + # destroyed by running these tests + assert not repo.is_dirty(untracked_files=True) + + # Create a new default branch for the test run + repo.git.checkout("master") + repo.git.checkout("HEAD", b=NOT_BASE_BRANCH) + create_tracked_change() + repo.git.add("-A") + repo.git.commit(m="This commit should not appear in pr branches") + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{NOT_BASE_BRANCH}") + # Create a new default branch for the test run + repo.git.checkout("master") + repo.git.checkout("HEAD", b=DEFAULT_BRANCH) + create_tracked_change() + repo.git.add("-A") + repo.git.commit(m="Add file to be a tracked file for tests") + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + + yield + + print("After all tests") + repo.git.checkout("master") + # Delete the "not base branch" created for the test run + repo.git.branch("--delete", "--force", NOT_BASE_BRANCH) + repo.git.push("--delete", "--force", REPO_URL, f"refs/heads/{NOT_BASE_BRANCH}") + # Delete the default branch created for the test run + repo.git.branch("--delete", "--force", DEFAULT_BRANCH) + repo.git.push("--delete", "--force", REPO_URL, f"refs/heads/{DEFAULT_BRANCH}") + + +def before_test(): + print("Before test") + # Checkout the default branch + repo.git.checkout(DEFAULT_BRANCH) + + +def after_test(delete_remote=True): + print("After test") + # Output git log + print(repo.git.log("-5", pretty="oneline")) + # Delete the pull request branch if it exists + repo.git.checkout(DEFAULT_BRANCH) + print(f"Deleting {BRANCH}") + for branch in repo.branches: + if branch.name == BRANCH: + repo.git.branch("--delete", "--force", BRANCH) + break + if delete_remote: + print(f"Deleting origin/{BRANCH}") + for ref in repo.remotes.origin.refs: + if ref.name == f"origin/{BRANCH}": + repo.git.push("--delete", "--force", REPO_URL, f"refs/heads/{BRANCH}") + repo.remotes.origin.fetch("--prune") + break + + +@pytest.fixture(autouse=True) +def before_after_tests(): + before_test() + yield + after_test() + + +# Tests if a branch exists and can be fetched +def coub_fetch_successful(): + assert coub.fetch_successful(repo, REPO_URL, NOT_BASE_BRANCH) + assert not coub.fetch_successful(repo, REPO_URL, NOT_EXIST_BRANCH) + + +# Tests no changes resulting in no new branch being created +def coub_no_changes_on_create(): + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "none" + + +# Tests create and update with a tracked file change +def coub_tracked_changes(): + # Create a tracked file change + tracked_content = create_tracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create a tracked file change + tracked_content = create_tracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + + +# Tests create and update with an untracked file change +def coub_untracked_changes(): + # Create an untracked file change + untracked_content = create_untracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create an untracked file change + untracked_content = create_untracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_untracked_content() == untracked_content + + +# Tests create and update with identical changes +# The pull request branch will not be updated +def coub_identical_changes(): + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create identical tracked and untracked file changes + create_changes(tracked_content, untracked_content) + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "none" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Tests create and update with commits on the base inbetween +def coub_commits_on_base(): + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Tests create and then an update with no changes +# This effectively reverts the branch back to match the base and results in no diff +def coub_changes_no_diff(): + # Save the default branch tracked content + default_tracked_content = get_tracked_content() + + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Running with no update effectively reverts the branch back to match the base + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] == False + assert get_tracked_content() == default_tracked_content + + +# Tests create and update with commits on the base inbetween +# The changes on base effectively revert the branch back to match the base and results in no diff +def coub_commits_on_base_no_diff(): + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + tracked_content, untracked_content = create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Create the same tracked and untracked file changes that were made to the base + create_changes(tracked_content, untracked_content) + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] == False + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Tests create and update with commits on the working base (during the workflow) +def coub_commits_on_working_base(): + # Create commits on the working base + tracked_content, untracked_content = create_commits() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the working base + tracked_content, untracked_content = create_commits() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Tests create and update with changes and commits on the working base (during the workflow) +def coub_changes_and_commits_on_working_base(): + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Tests create and update with changes and commits on the working base (during the workflow) +# with commits on the base inbetween +def coub_changes_and_commits_on_base_and_working_base(): + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, None, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests no changes resulting in no new branch being created +def coub_wbnb_no_changes_on_create(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "none" + + +# Working Base is Not Base (WBNB) +# Tests create and update with a tracked file change +def coub_wbnb_tracked_changes(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create a tracked file change + tracked_content = create_tracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create a tracked file change + tracked_content = create_tracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with an untracked file change +def coub_wbnb_untracked_changes(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create an untracked file change + untracked_content = create_untracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create an untracked file change + untracked_content = create_untracked_change() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with identical changes +# The pull request branch will not be updated +def coub_wbnb_identical_changes(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create identical tracked and untracked file changes + create_changes(tracked_content, untracked_content) + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "none" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with commits on the base inbetween +def coub_wbnb_commits_on_base(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and then an update with no changes +# This effectively reverts the branch back to match the base and results in no diff +def coub_wbnb_changes_no_diff(): + # Save the default branch tracked content + default_tracked_content = get_tracked_content() + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Running with no update effectively reverts the branch back to match the base + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] == False + assert get_tracked_content() == default_tracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with commits on the base inbetween +# The changes on base effectively revert the branch back to match the base and results in no diff +# This scenario will cause cherrypick to fail due to an empty commit. +# The commit is empty because the changes now exist on the base. +def coub_wbnb_commits_on_base_no_diff(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + tracked_content, untracked_content = create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create the same tracked and untracked file changes that were made to the base + create_changes(tracked_content, untracked_content) + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] == False + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with commits on the working base (during the workflow) +def coub_wbnb_commits_on_working_base(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + tracked_content, untracked_content = create_commits() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + tracked_content, untracked_content = create_commits() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with changes and commits on the working base (during the workflow) +def coub_wbnb_changes_and_commits_on_working_base(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# Working Base is Not Base (WBNB) +# Tests create and update with changes and commits on the working base (during the workflow) +# with commits on the base inbetween +def coub_wbnb_changes_and_commits_on_base_and_working_base(): + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "created" + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + # Push pull request branch to remote + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{BRANCH}") + repo.remotes.origin.fetch() + + after_test(delete_remote=False) + before_test() + + # Create commits on the base + create_commits() + repo.git.push("--force", REPO_URL, f"HEAD:refs/heads/{DEFAULT_BRANCH}") + repo.remotes.origin.fetch() + + # Set the working base to a branch that is not the pull request base + repo.git.checkout(NOT_BASE_BRANCH) + # Create commits on the working base + create_commits() + # Create tracked and untracked file changes + tracked_content, untracked_content = create_changes() + result = coub.create_or_update_branch(repo, REPO_URL, COMMIT_MESSAGE, BASE, BRANCH) + assert result["action"] == "updated" + assert result["diff"] + assert get_tracked_content() == tracked_content + assert get_untracked_content() == untracked_content + + +# pytest -v -s ~/git/create-pull-request/src + +# test_coub_fetch_successful = coub_fetch_successful + +# test_coub_no_changes_on_create = coub_no_changes_on_create +# test_coub_tracked_changes = coub_tracked_changes +# test_coub_untracked_changes = coub_untracked_changes +# test_coub_identical_changes = coub_identical_changes +# test_coub_commits_on_base = coub_commits_on_base + +# test_coub_changes_no_diff = coub_changes_no_diff +# test_coub_commits_on_base_no_diff = coub_commits_on_base_no_diff + +# test_coub_commits_on_working_base = coub_commits_on_working_base +# test_coub_changes_and_commits_on_working_base = coub_changes_and_commits_on_working_base +# test_coub_changes_and_commits_on_base_and_working_base = coub_changes_and_commits_on_base_and_working_base + +# # WBNB +# test_coub_wbnb_no_changes_on_create = coub_wbnb_no_changes_on_create +# test_coub_wbnb_tracked_changes = coub_wbnb_tracked_changes +# test_coub_wbnb_untracked_changes = coub_wbnb_untracked_changes +# test_coub_wbnb_identical_changes = coub_wbnb_identical_changes +# test_coub_wbnb_commits_on_base = coub_wbnb_commits_on_base + +# test_coub_wbnb_changes_no_diff = coub_wbnb_changes_no_diff +# test_coub_wbnb_commits_on_base_no_diff = coub_wbnb_commits_on_base_no_diff + +# test_coub_wbnb_commits_on_working_base = coub_wbnb_commits_on_working_base +# test_coub_wbnb_changes_and_commits_on_working_base = coub_wbnb_changes_and_commits_on_working_base +# test_coub_wbnb_changes_and_commits_on_base_and_working_base = coub_wbnb_changes_and_commits_on_base_and_working_base