Files
brave-core/script/uplift.py
T

501 lines
20 KiB
Python
Executable File

#!/usr/bin/env python
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at http://mozilla.org/MPL/2.0/.
import argparse
import errno
import hashlib
import os
import requests
import re
import shutil
import subprocess
import sys
import tempfile
import json
from io import StringIO
from lib.config import get_env_var, SOURCE_ROOT, BRAVE_CORE_ROOT, get_raw_version
from lib.util import execute, scoped_cwd
from lib.helpers import *
from lib.github import (GitHub, get_authenticated_user_login, parse_user_logins,
parse_labels, get_file_contents, add_reviewers_to_pull_request,
get_milestones, create_pull_request, set_issue_details,
fetch_origin_check_staged, get_local_branch_name,
get_title_from_first_commit, push_branches_to_remote)
class PrConfig:
channel_names = channels()
channels_to_process = channels()
is_verbose = False
is_dryrun = False
branches_to_push = []
master_pr_number = -1
github_token = None
team_reviewers = ['uplift-approvers']
parsed_owners = []
milestones = None
title = None
labels = []
def initialize(self, args):
try:
self.is_verbose = args.verbose
self.is_dryrun = args.dry_run
self.title = args.title
# validate channel names
validate_channel(args.uplift_to)
validate_channel(args.start_from)
# read github token FIRST from CLI, then from .npmrc
self.github_token = get_env_var('GITHUB_TOKEN')
if len(self.github_token) == 0:
try:
npm_cmd = 'npm'
if sys.platform.startswith('win'):
npm_cmd = 'npm.cmd'
result = execute(
[npm_cmd, 'config', 'get', 'BRAVE_GITHUB_TOKEN']).strip()
if result == 'undefined':
raise Exception(
'`BRAVE_GITHUB_TOKEN` value not found!')
self.github_token = result
except Exception as e:
print('[ERROR] no valid GitHub token was found either in npmrc or ' +
'via environment variables (BRAVE_GITHUB_TOKEN)')
return 1
# if `--owners` is not provided, fall back to user owning token
self.parsed_owners = parse_user_logins(
self.github_token, args.owners, verbose=self.is_verbose)
if len(self.parsed_owners) == 0:
self.parsed_owners = [
get_authenticated_user_login(self.github_token)]
self.labels = parse_labels(self.github_token, BRAVE_CORE_REPO,
args.labels, verbose=self.is_verbose)
if self.is_verbose:
print('[INFO] config: ' + str(vars(self)))
return 0
except Exception as e:
print(
'[ERROR] error returned from GitHub API while initializing config: ' + str(e))
return 1
config = PrConfig()
def is_nightly(channel):
global config
return config.channel_names[0] == channel
# given something like "1.5.2", return branch version ("1.5.x")
def get_current_version_branch(version):
version = str(version)
if version[0] == 'v':
version = version[1:]
parts = version.split('.', 3)
parts[2] = 'x'
return '.'.join(parts)
# given something like "1.6.x", get previous version ("1.5.x")
def get_previous_version_branch(version):
version = str(version)
if version[0] == 'v':
version = version[1:]
parts = version.split('.', 3)
# TODO(bsclifton): hack used when deprecating dev channel
# remove me when 1.7 hits release channel
if int(parts[1]) == 7:
parts[1] = "5"
else:
parts[1] = str(int(parts[1]) - 1)
parts[2] = 'x'
return '.'.join(parts)
def get_remote_channel_branches(raw_nightly_version):
global config
nightly_version = get_current_version_branch(raw_nightly_version)
beta_version = get_previous_version_branch(nightly_version)
release_version = get_previous_version_branch(beta_version)
return {
config.channel_names[0]: nightly_version,
config.channel_names[1]: beta_version,
config.channel_names[2]: release_version
}
def validate_channel(channel):
global config
try:
config.channel_names.index(channel)
except Exception as e:
raise Exception('Channel name "' + channel + '" is not valid!')
def parse_args():
parser = argparse.ArgumentParser(
description='create PRs for all branches given branch against master')
parser.add_argument('-g', '--gpgsign',
help='GPG sign GitHub commit', action='store_true')
parser.add_argument('--owners',
help='comma seperated list of GitHub logins to mark as assignee',
default=None)
parser.add_argument('--uplift-to',
help='starting at nightly (master), how far back to uplift the changes',
default='beta')
parser.add_argument('--uplift-using-pr',
help='link to already existing pull request (number) to use as a reference for uplifting',
required=True)
parser.add_argument('--start-from',
help='instead of starting from nightly (default), start from beta/release',
default='beta')
parser.add_argument('-v', '--verbose', action='store_true',
help='prints the output of the GitHub API calls')
parser.add_argument('-n', '--dry-run', action='store_true',
help='don\'t actually create pull requests; just show a call would be made')
parser.add_argument('--labels',
help='comma seperated list of labels to apply to each pull request',
default=None)
parser.add_argument('--title',
help='title to use (instead of inferring one from the first commit)',
default=None)
return parser.parse_args()
def get_remote_version(branch_to_compare):
global config
decoded_file = get_file_contents(config.github_token, BRAVE_CORE_REPO,
'package.json', branch_to_compare)
json_file = json.loads(decoded_file)
return json_file['version']
def fancy_print(text):
print('#' * len(text))
print(text)
print('#' * len(text))
def parse_issues_fixed(body):
try:
regex = r'((Resolves|Fixes|Fix|Closes|Close|resolves|fixes|fix|closes|close) https:\/\/github\.com\/brave\/brave-browser\/issues\/(\d*))' # nopep8
return re.findall(regex, body)
except Exception as e:
print str(e)
return []
def main():
args = parse_args()
if args.verbose:
print('[INFO] args: ' + str(args))
global config
result = config.initialize(args)
if result != 0:
return result
result = fetch_origin_check_staged(BRAVE_CORE_ROOT)
if result != 0:
return result
# get all channel branches (starting at master)
brave_core_version = get_remote_version('master')
remote_branches = get_remote_channel_branches(brave_core_version)
top_level_base = 'master'
issues_fixed = []
# if starting point is NOT nightly, remove options which aren't desired
# also, find the branch which should be used for diffs (for cherry-picking)
if not is_nightly(args.start_from):
top_level_base = remote_branches[args.start_from]
try:
start_index = config.channel_names.index(args.start_from)
config.channels_to_process = config.channel_names[start_index:]
except Exception as e:
print('[ERROR] specified `start-from` value "' +
args.start_from + '" not found in channel list')
return 1
# optionally (instead of having a local branch), allow uplifting a specific PR
# this pulls down the pr locally (in a special branch)
if args.uplift_using_pr:
try:
pr_number = int(args.uplift_using_pr)
repo = GitHub(config.github_token).repos(BRAVE_CORE_REPO)
# get enough details from PR to check out locally
response = repo.pulls(pr_number).get()
head = response['head']
local_branch = 'pr' + str(pr_number) + '_' + head['ref']
head_sha = head['sha']
top_level_base = response['base']['ref']
top_level_sha = response['base']['sha']
merged_at = str(response['merged_at']).strip()
config.title = str(response['title']).strip()
issues_fixed = parse_issues_fixed(response['body'])
except Exception as e:
print('[ERROR] Error parsing or error returned from API when looking up pull request "' +
str(args.uplift_using_pr) + '":\n' + str(e))
return 1
# set starting point AHEAD of the PR provided
config.master_pr_number = pr_number
if top_level_base == 'master':
config.channels_to_process = config.channel_names[1:]
elif len(config.channels_to_process) == 0:
branch_index = remote_branches.index(top_level_base)
config.channels_to_process = config.channel_names[branch_index:]
# if PR was already merged, use the SHA it was PRed against
if merged_at != 'None' and len(merged_at) > 0:
print('pr was already merged at ' + merged_at + '; using "' + top_level_sha +
'" instead of "' + top_level_base + '"')
top_level_base = top_level_sha
else:
# don't allow uplift of PRs which are not merged
print('[ERROR] Pull request ' + str(pr_number) + ' has not been merged yet. ' +
'Only merged requests can be uplifted.')
return 1
# create local branch which matches the contents of the PR
with scoped_cwd(BRAVE_CORE_ROOT):
# check if branch exists already
try:
branch_sha = execute(
['git', 'rev-parse', '-q', '--verify', local_branch])
except Exception as e:
branch_sha = ''
if len(branch_sha) > 0:
# branch exists; reset it
print('branch "' + local_branch + '" exists; resetting to origin/' + head['ref'] +
' (' + head_sha + ')')
execute(['git', 'checkout', local_branch])
execute(['git', 'reset', '--hard', head_sha])
else:
# create the branch
print('creating branch "' + local_branch +
'" using origin/' + head['ref'] + ' (' + head_sha + ')')
execute(['git', 'checkout', '-b', local_branch, head_sha])
# If title isn't set already, generate one from first commit
local_branch = get_local_branch_name(BRAVE_CORE_ROOT)
if not config.title and not args.uplift_using_pr:
config.title = get_title_from_first_commit(
BRAVE_CORE_ROOT, top_level_base)
# Create a branch for each channel
print('\nCreating branches...')
fancy_print('NOTE: Commits are being detected by diffing "' +
local_branch + '" against "' + top_level_base + '"')
local_branches = {}
branch = ''
try:
for channel in config.channels_to_process:
branch = create_branch(channel, top_level_base,
remote_branches[channel], local_branch, args)
local_branches[channel] = branch
if channel == args.uplift_to:
break
except Exception as e:
print('[ERROR] cherry-pick failed for branch "' +
branch + '". Please resolve manually:\n' + str(e))
return 1
print('\nPushing local branches to remote...')
push_branches_to_remote(BRAVE_CORE_ROOT, config.branches_to_push,
dryrun=config.is_dryrun, token=config.github_token)
try:
print('\nCreating the pull requests...')
for channel in config.channels_to_process:
submit_pr(
channel,
top_level_base,
remote_branches[channel],
local_branches[channel],
issues_fixed)
if channel == args.uplift_to:
break
print('\nDone!')
except Exception as e:
print('\n[ERROR] Unhandled error while creating pull request; ' + str(e))
return 1
return 0
def is_sha(ref):
global config
repo = GitHub(config.github_token).repos(BRAVE_CORE_REPO)
try:
repo.git.commits(str(ref)).get()
except Exception as e:
response = str(e)
try:
# Unsure of original intention, but github.py (on purpose) throws
# an exception if the response body contains `message`.
# This catch has the response (in JSON) in exception
json_response = json.loads(response)
if json_response['sha'] == ref:
return True
except Exception as e2:
return False
return False
def create_branch(channel, top_level_base, remote_base, local_branch, args):
global config
if is_nightly(channel):
return local_branch
channel_branch = local_branch + '_' + remote_base
if is_sha(top_level_base):
compare_from = top_level_base
else:
compare_from = 'origin/' + top_level_base
with scoped_cwd(BRAVE_CORE_ROOT):
# get SHA for all commits (in order)
sha_list = execute(['git', 'log', compare_from + '..HEAD',
'--pretty=format:%h', '--reverse'])
sha_list = sha_list.split('\n')
if len(sha_list) == 0:
raise Exception('No changes detected!')
try:
# check if branch exists already
try:
branch_sha = execute(
['git', 'rev-parse', '-q', '--verify', channel_branch])
except Exception as e:
branch_sha = ''
if len(branch_sha) > 0:
# branch exists; reset it
print('(' + channel + ') branch "' + channel_branch +
'" exists; resetting to origin/' + remote_base)
execute(['git', 'checkout', channel_branch])
execute(['git', 'reset', '--hard', 'origin/' + remote_base])
else:
# create the branch
print('(' + channel + ') creating "' +
channel_branch + '" from ' + remote_base)
execute(['git', 'checkout', remote_base])
execute(['git', 'pull', 'origin', remote_base])
execute(['git', 'checkout', '-b', channel_branch])
# TODO: handle errors thrown by cherry-pick
for sha in sha_list:
output = execute(['git', 'cherry-pick', sha]).split('\n')
print('- picked ' + sha + ' (' + output[0] + ')')
# squash all commits into one
# NOTE: master is not squashed. This only runs for uplifts.
execute(['git', 'reset', '--soft', remote_base])
squash_message = 'Squash of commits from branch "' + \
str(local_branch) + '" to ' + channel
if int(config.master_pr_number) > 0:
squash_message = 'Uplift of #' + \
str(config.master_pr_number) + ' (squashed) to ' + channel
if args.gpgsign:
cmdline = ['git', 'commit', '-S', '-m', squash_message]
else:
cmdline = ['git', 'commit', '-m', squash_message]
execute(cmdline)
squash_hash = execute(['git', 'log', '--pretty="%h"', '-n1'])
print('- squashed all commits into ' + squash_hash +
' with message: "' + squash_message + '"')
finally:
# switch back to original branch
execute(['git', 'checkout', local_branch])
execute(['git', 'reset', '--hard', sha_list[-1]])
config.branches_to_push.append(channel_branch)
return channel_branch
def get_milestone_for_branch(channel_branch):
global config
if not config.milestones:
config.milestones = get_milestones(
config.github_token, BRAVE_CORE_REPO)
for milestone in config.milestones:
if (milestone['title'].startswith(channel_branch + ' - ') or
milestone['title'].startswith('Android ' + channel_branch + ' - ')):
return milestone['number']
return None
def submit_pr(channel, top_level_base, remote_base, local_branch, issues_fixed):
global config
try:
milestone_number = get_milestone_for_branch(remote_base)
if milestone_number is None:
print('milestone for "' + remote_base + '" was not found!')
return 0
print('(' + channel + ') creating pull request')
pr_title = config.title or ''
pr_dst = remote_base
if is_nightly(channel):
pr_dst = 'master'
# add uplift specific details (if needed)
if is_nightly(channel) or local_branch.startswith(top_level_base):
pr_body = 'TODO: fill me in\n(created using `npm run pr`)'
else:
pr_title += ' (uplift to ' + remote_base + ')'
pr_body = 'Uplift of #' + str(config.master_pr_number) + '\n'
if len(issues_fixed) > 0:
for fixed in issues_fixed:
pr_body += (fixed[0] + '\n')
pr_body += '\nPre-approval checklist: \n'
pr_body += '- [ ] You have tested your change on Nightly. \n'
pr_body += '- [ ] This contains text which needs to be translated. \n'
pr_body += ' - [ ] There are more than 7 days before the release. \n'
pr_body += ' - [ ] I\'ve notified folks in #l10n on Slack that translations are needed. \n'
pr_body += '- [ ] The PR milestones match the branch they are landing to. \n\n'
pr_body += '\nPre-merge checklist: \n'
pr_body += '- [ ] You have checked CI and the builds, lint, and tests all ' \
'pass or are not related to your PR. \n\n'
pr_body += 'Post-merge checklist: \n'
pr_body += '- [ ] The associated issue milestone is set to the smallest version ' \
'that the changes is landed on.'
number = create_pull_request(config.github_token, BRAVE_CORE_REPO, pr_title, pr_body,
branch_src=local_branch, branch_dst=pr_dst,
open_in_browser=True, verbose=config.is_verbose, dryrun=config.is_dryrun)
# store the original PR number so that it can be referenced in uplifts
if is_nightly(channel) or local_branch.startswith(top_level_base):
config.master_pr_number = number
# assign milestone / reviewer(s) / owner(s)
add_reviewers_to_pull_request(config.github_token, BRAVE_CORE_REPO, number,
team_reviewers=config.team_reviewers,
verbose=config.is_verbose, dryrun=config.is_dryrun)
set_issue_details(config.github_token, BRAVE_CORE_REPO, number, milestone_number,
config.parsed_owners, config.labels,
verbose=config.is_verbose, dryrun=config.is_dryrun)
except Exception as e:
print('[ERROR] unhandled error occurred:', str(e))
if __name__ == '__main__':
import sys
sys.exit(main())