458 lines
15 KiB
Python
Executable File
458 lines
15 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 math
|
|
import os
|
|
import platform
|
|
import re
|
|
import sys
|
|
|
|
from io import StringIO
|
|
from lib.config import get_env_var
|
|
from lib.github import GitHub
|
|
from lib.helpers import *
|
|
from lib.util import download, execute, tempdir, extract_zip
|
|
|
|
|
|
tag_names = []
|
|
releases = {}
|
|
is_mac_os = True
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(
|
|
description='create PRs for all branches given branch against master')
|
|
parser.add_argument('--bad',
|
|
help='optional version which is known to be bad',
|
|
default=None)
|
|
parser.add_argument('--good',
|
|
help='optional version which is known to be good',
|
|
default=None)
|
|
parser.add_argument('--branch',
|
|
help='optional branch where the problem is occurring',
|
|
default=None)
|
|
parser.add_argument('-v', '--verbose', action='store_true',
|
|
help='extra logging')
|
|
parser.add_argument('--real-profile', action='store_true',
|
|
help='if true, use your real profile (instead of a fresh one). \
|
|
can\'t be combined with `--use-profile`.')
|
|
parser.add_argument('--use-profile',
|
|
help='url of a zipped profile to unzip/use for each install',
|
|
default=None)
|
|
parser.add_argument('--channel',
|
|
help='narrow down to a specific release channel. nightly/dev/beta/release',
|
|
default=None)
|
|
parser.add_argument('--demo-mode', action='store_true',
|
|
help='if true, don\'t actually perform download/install')
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
def get_releases(repo):
|
|
global releases
|
|
global tag_names
|
|
|
|
releases = {}
|
|
tag_names = []
|
|
page = 1
|
|
done = False
|
|
draft_count = 0
|
|
|
|
print('fetching releases from GitHub...')
|
|
|
|
while not done:
|
|
# for more info, see: https://developer.github.com/v3/guides/traversing-with-pagination/
|
|
get_data = {
|
|
'page': page,
|
|
'per_page': 100
|
|
}
|
|
# get all the releases and index them
|
|
response = repo.releases.get(params=get_data)
|
|
if len(response) == 0:
|
|
done = True
|
|
break
|
|
|
|
for release in response:
|
|
# skip releases in draft status
|
|
if release['draft']:
|
|
draft_count = draft_count + 1
|
|
continue
|
|
tag_name = str(release['tag_name'].strip().replace('v', ''))
|
|
# skip "android" releases and others not matching version format
|
|
if not tag_name.replace('.', '').isdigit():
|
|
continue
|
|
tag_names.append(tag_name)
|
|
releases[tag_name] = release
|
|
page = page + 1
|
|
|
|
print('fetch complete; ' + str(len(tag_names)) +
|
|
' versions found (excluding ' + str(draft_count) + ' drafts)')
|
|
|
|
|
|
def filter_releases(args):
|
|
global tag_names
|
|
|
|
filtered_tag_names = []
|
|
|
|
print('filtering out versions which are not relevant...')
|
|
|
|
# find all unique MINOR versions
|
|
for tag in tag_names:
|
|
version = tag.split('.')
|
|
if len(version) != 3:
|
|
continue
|
|
|
|
major_version = str(version[0])
|
|
minor_version = str(version[1])
|
|
branch_version = major_version + '.' + minor_version + '.x'
|
|
|
|
# remove entries which don't match optional branch (if present)
|
|
if args.branch is not None and branch_version != args.branch:
|
|
print(' - skipping "' + tag +
|
|
'" (' + branch_version + ' != ' + args.branch + ')')
|
|
continue
|
|
|
|
# remove entries which don't have installer binary
|
|
if not get_release_asset(tag, False):
|
|
print(' - skipping "' + tag + '" (installer not found)')
|
|
continue
|
|
|
|
if args.channel:
|
|
channel = get_release_channel(tag)
|
|
if args.channel != channel:
|
|
print(' - skipping "' + tag +
|
|
'" (not in channel "' + args.channel + '")')
|
|
continue
|
|
|
|
filtered_tag_names.append(tag)
|
|
|
|
print('filtering complete (' + str(len(tag_names) -
|
|
len(filtered_tag_names)) + ' versions removed)')
|
|
tag_names = filtered_tag_names
|
|
|
|
|
|
def get_release_asset(version, verbose=True):
|
|
global releases
|
|
global is_mac_os
|
|
|
|
release_id = releases[version]['id']
|
|
if verbose:
|
|
print('getting installer for "' + version +
|
|
'" (release id ' + str(release_id) + ')...')
|
|
|
|
# find correct asset for platform
|
|
for asset in releases[version]['assets']:
|
|
if str(asset['name']).endswith('.dmg') and is_mac_os:
|
|
if verbose:
|
|
print('- binary found: ' + asset['browser_download_url'])
|
|
return asset
|
|
|
|
if verbose:
|
|
print('- binary not found')
|
|
return None
|
|
|
|
|
|
def get_release_channel(version):
|
|
global releases
|
|
|
|
full_title = releases[version]['name']
|
|
if full_title.startswith('Nightly'):
|
|
return 'nightly'
|
|
if full_title.startswith('Developer'):
|
|
return 'dev'
|
|
if full_title.startswith('Beta'):
|
|
return 'beta'
|
|
if full_title.startswith('Release'):
|
|
return 'release'
|
|
return None
|
|
|
|
|
|
def install(download_dir, path):
|
|
global is_mac_os
|
|
|
|
if is_mac_os:
|
|
print('- installing binary from DMG')
|
|
print('-> mounting "' + path + '"')
|
|
result = execute(['hdiutil', 'attach', path])
|
|
|
|
# parse out the mounted volume
|
|
volume = None
|
|
result_lines = result.splitlines()
|
|
for x in result_lines:
|
|
x = x.strip()
|
|
index = x.find('/Volumes/Brave')
|
|
if index > -1:
|
|
volume = x[index:]
|
|
break
|
|
|
|
if volume is None:
|
|
raise Exception('[ERROR] did not find "/Volumes/Brave" sub-string in mount list!\n \
|
|
Full response from "hdiutil":\n' + result)
|
|
|
|
print('-> mounted as "' + volume + '"')
|
|
|
|
# in case volumes are already mounted, remove trailing " 1" or " 2" (etc)
|
|
binary_name = volume.replace("/Volumes/", "")
|
|
binary_name = re.sub("^\\d+\\s|\\s\\d+\\s|\\s\\d+$",
|
|
"", binary_name) + '.app'
|
|
volume_path = os.path.join(volume, binary_name)
|
|
|
|
# copy binary to a temp folder
|
|
print('-> copying "' + volume_path + '" to "' + download_dir + '"')
|
|
result = execute(['cp', '-rp', volume_path, download_dir])
|
|
print('-> copy complete')
|
|
|
|
print('-> unmounting "' + volume + '"')
|
|
result = execute(['hdiutil', 'detach', volume])
|
|
|
|
return os.path.join(download_dir, binary_name)
|
|
|
|
|
|
def setup_profile_directory(args):
|
|
global is_mac_os
|
|
|
|
if is_mac_os:
|
|
print('- processing changes for profile directory')
|
|
if args.real_profile:
|
|
print('-> using real profile (`--real-profile` passed in)')
|
|
return None
|
|
|
|
profile_dir = tempdir('build-bisect-profile_')
|
|
|
|
if args.use_profile:
|
|
print('-> downloading profile: "' + args.use_profile + '"')
|
|
try:
|
|
filename = os.path.basename(args.use_profile)
|
|
query_string_index = filename.find('?')
|
|
if query_string_index > -1:
|
|
filename = filename[0:query_string_index]
|
|
download_path = os.path.join(profile_dir, filename)
|
|
download('profile', args.use_profile, download_path)
|
|
if filename.endswith('.zip'):
|
|
print('-> unzipping to ' + profile_dir)
|
|
extract_zip(download_path, profile_dir)
|
|
except Exception as e:
|
|
print('whoops- ' + str(e))
|
|
|
|
print('-> using profile directory: "' + profile_dir + '"')
|
|
|
|
return profile_dir
|
|
|
|
|
|
def get_run_cmd(install_path, profile_dir):
|
|
global is_mac_os
|
|
|
|
if is_mac_os:
|
|
run_cmd = ['open', '-a', install_path]
|
|
run_params = []
|
|
if profile_dir:
|
|
run_params = ['--args', '--user-data-dir=' + profile_dir]
|
|
return run_cmd + run_params
|
|
|
|
|
|
def test_version(args, attempt, tag):
|
|
global tag_names
|
|
|
|
# get the OS specific installer
|
|
asset = None
|
|
while len(tag_names) > 0 and not asset:
|
|
print('\nattempt ' + str(attempt) + '] getting installer for "' + tag +
|
|
'" (release id ' + str(releases[tag]['id']) + ')')
|
|
asset = get_release_asset(tag)
|
|
if not asset:
|
|
return False
|
|
|
|
if not args.demo_mode:
|
|
download_dir = tempdir('build-bisect_')
|
|
download_path = os.path.join(download_dir, asset['name'])
|
|
print('- downloading to ' + download_path)
|
|
download(tag, asset['browser_download_url'], download_path)
|
|
|
|
install_path = install(download_dir, download_path)
|
|
profile_dir = setup_profile_directory(args)
|
|
|
|
print('- running binary')
|
|
run_cmd = get_run_cmd(install_path, profile_dir)
|
|
execute(run_cmd)
|
|
|
|
first = True
|
|
while True:
|
|
if not first:
|
|
print('please type either `y` for yes or `n` for no!')
|
|
answer = raw_input('Did this version work?: y/n\n')
|
|
first = False
|
|
if answer == 'y' or answer == 'n':
|
|
break
|
|
|
|
return answer == 'y'
|
|
|
|
|
|
def get_github_token():
|
|
github_token = get_env_var('GITHUB_TOKEN')
|
|
if len(github_token) == 0:
|
|
result = execute(
|
|
['npm', 'config', 'get', 'BRAVE_GITHUB_TOKEN']).strip()
|
|
if result == 'undefined':
|
|
raise Exception('`BRAVE_GITHUB_TOKEN` value not found!')
|
|
return result
|
|
else:
|
|
return github_token
|
|
|
|
|
|
def get_nearest_index(version, index_to_get, default):
|
|
global tag_names
|
|
|
|
try:
|
|
return tag_names.index(version)
|
|
except Exception as e:
|
|
print('- value "' + version + '" not found. ' + str(e))
|
|
versions = version.split('.')
|
|
if len(versions) != 3:
|
|
return default
|
|
|
|
versions.pop()
|
|
results = [i for i in tag_names if i.startswith(
|
|
'.'.join(versions) + '.')]
|
|
if len(results) == 0:
|
|
return default
|
|
|
|
print('-> using "' + results[index_to_get])
|
|
return tag_names.index(results[index_to_get])
|
|
|
|
|
|
def find_first_broken_version(args):
|
|
global tag_names
|
|
|
|
print('bisecting: total of ' + str(len(tag_names)) + ' versions in search set')
|
|
|
|
left_index = 0
|
|
right_index = len(tag_names) - 1
|
|
|
|
if left_index >= right_index:
|
|
raise Exception('[ERROR] Not enough versions to perform search')
|
|
|
|
if args.good:
|
|
left_index = get_nearest_index(args.good, 0, left_index)
|
|
if left_index == 0:
|
|
args.good = None
|
|
else:
|
|
print('- starting at ' + tag_names[left_index])
|
|
|
|
if args.bad:
|
|
right_index = get_nearest_index(args.bad, -1, right_index)
|
|
if right_index == len(tag_names) - 1:
|
|
args.bad = None
|
|
else:
|
|
print('- ending at ' + tag_names[right_index])
|
|
|
|
if args.good or args.bad:
|
|
print('- search set narrowed down to ' + str(right_index - left_index) +
|
|
' versions using provided good/bad version(s)')
|
|
|
|
attempt_number = 1
|
|
|
|
# left should be working
|
|
if not args.good:
|
|
left_tag = tag_names[left_index]
|
|
result = test_version(args, attempt_number, left_tag)
|
|
attempt_number = attempt_number + 1
|
|
if result is False:
|
|
raise Exception('[ERROR] Version "' + left_tag +
|
|
'" is expected to work but doesn\'t')
|
|
|
|
# right should be NOT working
|
|
if not args.bad:
|
|
right_tag = tag_names[right_index]
|
|
result = test_version(args, attempt_number, right_tag)
|
|
attempt_number = attempt_number + 1
|
|
if result is True:
|
|
raise Exception('[ERROR] Version "' + right_tag +
|
|
'" is expected to fail but doesn\'t')
|
|
|
|
# perform search
|
|
works_from = left_index
|
|
fails_at = right_index
|
|
while (fails_at - works_from) > 1:
|
|
test_index = int(math.floor((left_index + right_index) / 2))
|
|
test_tag = tag_names[test_index]
|
|
|
|
if args.verbose:
|
|
print('\n[DEBUG]' +
|
|
'\nworks_from=' + tag_names[works_from] + ' (' + str(works_from) + ')' +
|
|
'\nfails_at=' + tag_names[fails_at] + ' (' + str(fails_at) + ')' +
|
|
'\nleft_index=' + tag_names[left_index] + ' (' + str(left_index) + ')' +
|
|
'\nright_index=' + tag_names[right_index] + ' (' + str(right_index) + ')' +
|
|
'\ntest_index=' + tag_names[test_index] + ' (' + str(test_index) + ')' +
|
|
'\ngap=' + str(fails_at - works_from))
|
|
|
|
result = test_version(args, attempt_number, test_tag)
|
|
|
|
if left_index == right_index:
|
|
if result:
|
|
return tag_names[fails_at], attempt_number
|
|
return test_tag, attempt_number
|
|
|
|
if result:
|
|
works_from = max(test_index, works_from)
|
|
left_index = test_index + 1
|
|
else:
|
|
fails_at = min(test_index, fails_at)
|
|
right_index = test_index - 1
|
|
|
|
attempt_number = attempt_number + 1
|
|
|
|
return tag_names[test_index], attempt_number
|
|
|
|
|
|
def main():
|
|
global tag_names
|
|
|
|
supported_platforms = ['Darwin']
|
|
|
|
if platform.system() not in supported_platforms:
|
|
print('Error: Platform \'{}\' not supported; acceptable platform(s): {}'
|
|
.format(platform.system(), ", ".join(supported_platforms)))
|
|
exit(1)
|
|
|
|
args = parse_args()
|
|
if args.real_profile and args.use_profile:
|
|
print(
|
|
'[ERROR] you can\'t use both `--fresh-profile` AND `--use-profile` at the same time.')
|
|
return 1
|
|
|
|
github_token = get_github_token()
|
|
repo = GitHub(github_token).repos(BRAVE_REPO)
|
|
|
|
get_releases(repo)
|
|
tag_names.sort(key=lambda s: map(int, s.split('.')))
|
|
|
|
filter_releases(args)
|
|
first_broken_version, attempts = find_first_broken_version(args)
|
|
print('DONE: issue first appeared in "' + str(first_broken_version) +
|
|
'" (found in ' + str(attempts) + ' attempts)')
|
|
|
|
try:
|
|
broken_index = tag_names.index(first_broken_version)
|
|
if broken_index > 0:
|
|
previous_release = tag_names[broken_index - 1]
|
|
versions = 'v' + previous_release + '..v' + first_broken_version
|
|
if args.verbose:
|
|
print(
|
|
'[INFO] finding commits using "git log --pretty=oneline ' + versions + '"')
|
|
commits = execute(
|
|
['git', 'log', '--pretty=oneline', versions]).strip()
|
|
commit_lines = commits.split('\n')
|
|
print('Commits specific to tag "v' + first_broken_version +
|
|
'" (' + str(len(commit_lines)) + ' commit(s)):')
|
|
print(commits)
|
|
except Exception as e:
|
|
print('[ERROR] ' + str(e))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
sys.exit(main())
|