diff --git a/.github/update-release-branch.py b/.github/update-release-branch.py new file mode 100644 index 000000000..2297ee596 --- /dev/null +++ b/.github/update-release-branch.py @@ -0,0 +1,178 @@ +import datetime +from github import Github +import random +import requests +import subprocess +import sys + +# The branch being merged from. +# This is the one that contains day-to-day development work. +MASTER_BRANCH = 'master' +# The branch being merged into. +# This is the release branch that users reference. +LATEST_RELEASE_BRANCH = 'v1' +# Name of the remote +ORIGIN = 'origin' + +# Runs git with the given args and returns the stdout. +# Raises an error if git does not exit successfully. +def run_git(*args): + cmd = ['git', *args] + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if (p.returncode != 0): + raise Exception('Call to ' + ' '.join(cmd) + ' exited with code ' + str(p.returncode) + ' stderr:' + p.stderr.decode('ascii')) + return p.stdout.decode('ascii') + +# Returns true if the given branch exists on the origin remote +def branch_exists_on_remote(branch_name): + return run_git('ls-remote', '--heads', ORIGIN, branch_name).strip() != '' + +# Opens a PR from the given branch to the release branch +def open_pr(repo, all_commits, short_master_sha, branch_name): + # Sort the commits into the pull requests that introduced them, + # and any commits that don't have a pull request + pull_requests = [] + commits_without_pull_requests = [] + for commit in all_commits: + pr = get_pr_for_commit(repo, commit) + + if pr is None: + commits_without_pull_requests.append(commit) + elif not any(p for p in pull_requests if p.number == pr.number): + pull_requests.append(pr) + + print('Found ' + str(len(pull_requests)) + ' pull requests') + print('Found ' + str(len(commits_without_pull_requests)) + ' commits not in a pull request') + + # Sort PRs and commits by age + sorted(pull_requests, key=lambda pr: pr.number) + sorted(commits_without_pull_requests, key=lambda c: c.commit.author.date) + + # Start constructing the body text + body = 'Merging ' + short_master_sha + ' into ' + LATEST_RELEASE_BRANCH + + conductor = get_conductor(repo, pull_requests, commits_without_pull_requests) + body += '\n\nConductor for this PR is @' + conductor + + # List all PRs merged + if len(pull_requests) > 0: + body += '\n\nContains the following pull requests:' + for pr in pull_requests: + merger = get_merger_of_pr(repo, pr) + body += '\n- #' + str(pr.number) + body += ' - ' + pr.title + body += ' (@' + merger + ')' + + # List all commits not part of a PR + if len(commits_without_pull_requests) > 0: + body += '\n\nContains the following commits not from a pull request:' + for commit in commits_without_pull_requests: + body += '\n- ' + commit.sha + body += ' - ' + get_truncated_commit_message(commit) + body += ' (@' + commit.author.login + ')' + + title = 'Merge ' + MASTER_BRANCH + ' into ' + LATEST_RELEASE_BRANCH + + # Create the pull request + pr = repo.create_pull(title=title, body=body, head=branch_name, base=LATEST_RELEASE_BRANCH) + print('Created PR #' + str(pr.number)) + + # Assign the conductor + pr.add_to_assignees(conductor) + print('Assigned PR to ' + conductor) + +# Gets the person who should be in charge of the mergeback PR +def get_conductor(repo, pull_requests, other_commits): + # If there are any PRs then use whoever merged the last one + if len(pull_requests) > 0: + return get_merger_of_pr(repo, pull_requests[-1]) + + # Otherwise take the author of the latest commit + return other_commits[-1].author.login + +# Gets a list of the SHAs of all commits that have happened on master +# since the release branched off. +# This will not include any commits that exist on the release branch +# that aren't on master. +def get_commit_difference(repo): + commits = run_git('log', '--pretty=format:%H', ORIGIN + '/' + LATEST_RELEASE_BRANCH + '...' + MASTER_BRANCH).strip().split('\n') + + # Convert to full-fledged commit objects + commits = [repo.get_commit(c) for c in commits] + + # Filter out merge commits for PRs + return list(filter(lambda c: not is_pr_merge_commit(c), commits)) + +# Is the given commit the automatic merge commit from when merging a PR +def is_pr_merge_commit(commit): + return commit.committer.login == 'web-flow' and len(commit.parents) > 1 + +# Gets a copy of the commit message that should display nicely +def get_truncated_commit_message(commit): + message = commit.commit.message.split('\n')[0] + if len(message) > 60: + return message[:57] + '...' + else: + return message + +# Converts a commit into the PR that introduced it to the master branch. +# Returns the PR object, or None if no PR could be found. +def get_pr_for_commit(repo, commit): + prs = commit.get_pulls() + + if prs.totalCount > 0: + # In the case that there are multiple PRs, return the earliest one + prs = list(prs) + sorted(prs, key=lambda pr: int(pr.number)) + return prs[0] + else: + return None + +# Get the person who merged the pull request. +# For most cases this will be the same as the author, but for PRs opened +# by external contributors getting the merger will get us the GitHub +# employee who reviewed and merged the PR. +def get_merger_of_pr(repo, pr): + return repo.get_commit(pr.merge_commit_sha).author.login + +def main(): + if len(sys.argv) != 3: + raise Exception('Usage: update-release.branch.py ') + github_token = sys.argv[1] + repository_nwo = sys.argv[2] + + repo = Github(github_token).get_repo(repository_nwo) + + # Print what we intend to go + print('Considering difference between ' + MASTER_BRANCH + ' and ' + LATEST_RELEASE_BRANCH) + short_master_sha = run_git('rev-parse', '--short', MASTER_BRANCH).strip() + print('Current head of ' + MASTER_BRANCH + ' is ' + short_master_sha) + + # See if there are any commits to merge in + commits = get_commit_difference(repo) + if len(commits) == 0: + print('No commits to merge from ' + MASTER_BRANCH + ' to ' + LATEST_RELEASE_BRANCH) + return + + # The branch name is based off of the name of branch being merged into + # and the SHA of the branch being merged from. Thus if the branch already + # exists we can assume we don't need to recreate it. + new_branch_name = 'update-' + LATEST_RELEASE_BRANCH + '-' + short_master_sha + print('Branch name is ' + new_branch_name) + + # Check if the branch already exists. If so we can abort as this script + # has already run on this combination of branches. + if branch_exists_on_remote(new_branch_name): + print('Branch ' + new_branch_name + ' already exists. Nothing to do.') + return + + # Create the new branch and push it to the remote + print('Creating branch ' + new_branch_name) + run_git('checkout', '-b', new_branch_name, MASTER_BRANCH) + run_git('push', ORIGIN, new_branch_name) + + # Open a PR to update the branch + open_pr(repo, commits, short_master_sha, new_branch_name) + +if __name__ == '__main__': + main() diff --git a/.github/workflows/update-release-branch.yml b/.github/workflows/update-release-branch.yml new file mode 100644 index 000000000..3cd98d649 --- /dev/null +++ b/.github/workflows/update-release-branch.yml @@ -0,0 +1,28 @@ +name: Update release branch +on: + schedule: + - cron: 0 9 * * 1 + repository_dispatch: + types: [update-release-branch] + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + # Need full history so we calculate diffs + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.5 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install PyGithub==1.51 requests + + - name: Update release branch + run: python .github/update-release-branch.py ${{ secrets.GITHUB_TOKEN }} ${{ github.repository }}