Files
root/build-tools/merge-topic/merge-topic.py
Scott Little c80bcae82d merge-topic.py: better error handling
Signed-off-by: Scott Little <scott.little@windriver.com>
Change-Id: I42d06386d6514b732340f33d5d9ec9c4e516dd61
2025-06-11 14:33:45 -04:00

462 lines
18 KiB
Python
Executable File

#!/usr/bin/python3
#
# Copyright (c) 2021,2025 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
import argparse
import configparser
from datetime import datetime
import json
import os
import pprint
import subprocess
import sys
import urllib.parse
import xml.etree.ElementTree as ET
import requests
SUCCESS = 0
FAILURE = 1
DEFAULT_MANIFEST_FILE='default.xml'
# Cache the repo manifest file information
REPO_MANIFEST = None
def responseCorrection(content):
return content[5:]
def handleList(dargs):
pass
def etree_to_dict(elem):
""" Convert in elementTree to a dictionary
"""
d = {elem.tag: {} if elem.attrib else None}
# Handle element's children
children = list(elem)
if children:
dd = {}
for child in children:
child_dict = etree_to_dict(child)
for k, v in child_dict.items():
if k in dd:
if not isinstance(dd[k], list):
dd[k] = [dd[k]]
dd[k].append(v)
else:
dd[k] = v
d[elem.tag] = dd
# Handle element's attributes
if elem.attrib:
d[elem.tag].update((k, v) for k, v in elem.attrib.items())
# Handle element's text
text = elem.text.strip() if elem.text and elem.text.strip() else None
if text:
d[elem.tag]['#text'] = text
return d
def addGerritQuery(query_string, field_name, target_values):
""" Add a query for a specific field.
"""
if not type(target_values) is list:
target_values = [target_values]
if len(target_values) == 0:
return query_string
elif len(target_values) == 1:
return '{} {}:"{}"'.format(query_string, field_name, target_values[0])
else:
assemble = '{} ({}:"{}"'.format(query_string, field_name, target_values[0])
for val in target_values[1:]:
assemble = '{} OR {}:"{}"'.format(assemble, field_name, val)
assemble = assemble + ')'
return assemble
def gerritQuery(query):
url_query = urllib.parse.urljoin('https://' + query['gerrit'], 'changes/')
if query['verbose'] >= 1:
print('gerritQuery args: {}'.format(url_query))
# GET /changes/?q=topic:"my-topic"&o=CURRENT_REVISION&o=DOWNLOAD_COMMANDS HTTP/1.0
# GET /changes/?q=topic:"my-topic"+status:open&o=CURRENT_REVISION&o=DOWNLOAD_COMMANDS HTTP/1.0
# GET /changes/?q=topic:"my-topic"+(status:open OR status:merged)&o=CURRENT_REVISION&o=DOWNLOAD_COMMANDS HTTP/1.0
query_string = addGerritQuery('', 'topic', query['topic'])
query_string = addGerritQuery(query_string, 'status', query['status'])
query_string = addGerritQuery(query_string, 'branch', query['branch'])
if 'repo' in query:
repo = query['repo']
if repo.endswith('.git'):
repo = repo[:-len('.git')]
query_string = addGerritQuery(query_string, 'repo', repo)
if query['verbose'] >= 1:
print('gerritQuery string: {}'.format(query_string))
params = {'q': query_string,
'o': ['CURRENT_REVISION', 'DOWNLOAD_COMMANDS']}
r = requests.get(url=url_query, params=params)
content = responseCorrection(r.text)
data = json.loads(content)
if query['verbose'] >= 5:
print('gerritQuery results:')
pprint.pprint(data)
sorted_data = sorted(data, key=lambda x: x["_number"])
if query['verbose'] >= 4:
print('gerritQuery results:')
pprint.pprint(sorted_data)
return sorted_data
def truncate_ns_to_us(ts: str) -> datetime:
if '.' in ts:
base, frac = ts.split('.')
frac = (frac + '000000')[:6] # pad and truncate to 6 digits
ts = f"{base}.{frac}"
return datetime.strptime(ts, "%Y-%m-%d %H:%M:%S.%f")
def readRepoManifest(repo_root_dir, manifest_file=None):
if manifest_file:
manifest_path = os.path.join(repo_root_dir, '.repo', 'manifests', manifest_file)
else:
manifest_path = os.path.join(repo_root_dir, '.repo', 'manifest.xml')
print('Reading manifest file: {}'.format(manifest_path))
tree = ET.parse(manifest_path)
root = tree.getroot()
manifest = {}
manifest['root'] = repo_root_dir
manifest['file_name'] = manifest_file
manifest['path'] = manifest_path
manifest['remote'] = {}
manifest['project'] = {}
manifest['default'] = {}
for element in root.findall('remote'):
remote_name = element.get('name')
fetch_url = element.get('fetch')
push_url = element.get('pushurl')
review = element.get('review')
revision = element.get('revision')
manifest['remote'][remote_name]={'fetch': fetch_url, 'push_url': push_url, 'review': review, 'revision': revision}
for element in root.findall('project'):
project_name = element.get('name')
remote_name = element.get('remote')
path = element.get('path')
revision = element.get('revision')
groups = element.get('groups')
manifest['project'][project_name]={'remote': remote_name, 'path': path, 'revision': revision, 'groups': groups}
for element in root.findall('default'):
remote_name = element.get('remote')
revision = element.get('revision')
manifest['default']['remote'] = remote_name
manifest['default']['revision'] = revision
for element in root.findall('include'):
include_name = element.get('name')
include_manifest = readRepoManifest(repo_root_dir, include_name)
manifest = {**manifest, **include_manifest}
return manifest
def RepoManifestProjectList(manifest):
return list(manifest['project'].keys())
def RepoManifestRemoteList(manifest):
return list(manifest['remote'].keys())
def RepoManifestProjectInfo(manifest, project_name, use_defaults=True, abs_path=True):
project_info = None
if project_name not in manifest['project']:
return None
project_info = manifest['project'][project_name]
if use_defaults:
if project_info['remote'] is None:
project_info['remote'] = manifest['default']['remote']
if project_info['revision'] is None:
if project_info['remote'] is not None:
project_info['revision'] = manifest['remote'][project_info['remote']]['revision']
if project_info['revision'] is None:
project_info['revision'] = manifest['default']['revision']
if abs_path:
project_info['path'] = os.path.join(manifest['root'], project_info['path'])
return project_info
def RepoManifestProjectPath(manifest, project_name, abs_path=True):
project_info = RepoManifestProjectInfo(manifest, project_name, abs_path=abs_path)
if project_info is None:
return None
return project_info['path']
def extractDownloadCommand(dargs, change):
rev = change.get('revisions')
key = list(rev.keys())[0]
command = rev.get(key)
command = command.get('fetch')
command = command.get('anonymous http')
command = command.get('commands')
command = command.get(dargs['download_strategy'], None)
if not command:
raise Exception("Can't get command for {} download strategy!".format(
dargs['download_strategy']))
return command
def checkSkipChange(dargs, change_id, max_search_depth=100):
""" Determine if the change should be skipped.
Determine based on the Change-Id: in commit message.
@param dargs: Parsed dargs
@param change_id: A gerrit Change-Id to be skipped
@param max_search_depth: Limit the search depth to a certain number
to speed up things.
@return: True if the change should be skipped
"""
cmd = ['git', 'rev-list', 'HEAD', '--count', '--no-merges']
output = subprocess.check_output(
cmd
, errors="strict").strip()
rev_count = int(output)
if dargs['verbose']>= 6:
print(rev_count)
# TODO param for max_search_depth
for i in range(min(rev_count - 1, max_search_depth)):
cmd = ['git', 'rev-list', '--format=%B', '--max-count',
'1', 'HEAD~{}'.format(i)]
output = subprocess.check_output(
cmd
, errors="strict").strip()
if dargs['verbose']>= 6:
print(output)
# TODO avoid false positives, search just last occurrence
if 'Change-Id: {}'.format(change_id) in output:
print('Found {} in git log'.format(change_id))
return True
return False
def validateHandleRepoArgs(dargs):
""" Validate dargs for repositories that use Repo tool
@param dargs: Args from ArgumentParser
"""
print('Using repo root dir {}'.format(dargs['repo_root_dir']))
if not os.path.exists(dargs['repo_root_dir']):
print('{} does not exist'.format(dargs['repo_root_dir']))
return False
# print('Using gerrit {}'.format(dargs['gerrit']))
print('Using download strategy {}'.format(dargs['download_strategy']))
# print('Using review statuses {}'.format(dargs['status']))
if dargs['merge_fixer']:
if os.path.exists(dargs['merge_fixer']):
print('Using script to attempt automatic merge conflicts '
'resolution: {}'.format(dargs['merge_fixer']))
else:
print('File {} does not exist'.format(dargs['merge_fixer']))
return False
return True
def handleRepo(args):
""" Main logic for repositories that use repo tool
@param args: Args from ArgumentParser
"""
global REPO_MANIFEST
dargs = vars(args)
validateHandleRepoArgs(dargs)
tool_cwd = os.path.dirname(os.path.abspath(__file__))
REPO_MANIFEST = readRepoManifest(dargs['repo_root_dir'])
for project in RepoManifestProjectList(REPO_MANIFEST):
project_info = RepoManifestProjectInfo(REPO_MANIFEST, project)
project_path = project_info['path']
print(project_info)
git_review_info = readGitReview(project_info['path'])
if git_review_info is None:
print('Skipping {}: .gitreview missing'.format(project))
continue
if git_review_info['branch'] != project_info['revision']:
print('Skipping {}: branch mismatch between manifest and gitreview'.format(project))
continue
query = {}
query['gerrit'] = git_review_info['host']
query['repo'] = git_review_info['project']
query['branch'] = git_review_info['branch']
query['topic'] = dargs['topic']
query['status'] = 'open'
query['verbose'] = dargs['verbose']
if dargs['verbose']>= 1:
print(project, ' ', query)
query_results = gerritQuery(query)
print('Found {} matching reviews in {}'.format(len(query_results),project))
for query_result in query_results:
# Get project of the change
project = query_result.get('project')
project_name, repository_name = project.split('/')
change_id = query_result.get('change_id')
print('Detected change number {} ID {} project {} repository {}'
''.format(query_result.get('_number', ''),
change_id,
project_name,
repository_name))
download_command = extractDownloadCommand(dargs, query_result)
os.chdir(project_path)
print("Changed working directory to: {}".format(os.getcwd()))
# Check if the change should be skipped
if dargs['avoid_re_download'] and checkSkipChange(dargs, change_id):
print('Skipping {}'.format(change_id))
continue
# Apply commit
cmds = download_command.split('&&')
print('Commands to be executed {}'.format(cmds))
try:
oldenv = os.environ.copy()
env={'GIT_RERERE_AUTOUPDATE': '0'}
env = { **oldenv, **env }
for cmd in list(cmds):
cmd = cmd.strip('"')
print('Command to be executed {}'.format(cmd))
if not dargs['dry_run']:
output = subprocess.check_output(
cmd
, env=env
, errors="strict", shell=True).strip()
print('Executed: \n{}'.format(output))
except Exception as e:
pprint.pprint(e)
if dargs['merge_fixer'] and not dargs['dry_run']:
print('Using merge fixer!')
rc = runMergeFixer(dargs, project_path, tool_cwd)
return rc
else:
print('Check for unresolved merge conflict')
return False
return True
def runMergeFixer(dargs, project_path, tool_cwd):
# Run fixer
fixer = '{}'.format(os.path.join(tool_cwd, dargs['merge_fixer']))
cmd = [fixer]
fixer_rc, _ = run_cmd(cmd, shell=False, halt_on_exception=False)
# Abort in case of fixer run failure
if fixer_rc == FAILURE:
print('Fixer failed, aborting!!!')
return False
def run_cmd(cmd, shell=False, halt_on_exception=False):
# TODO improve logging, but not worth at the moment.
# LIMITATION
# Now we could automate up to doing the cherry-pick continue.
# `git cherry-pick --continue` opens a text editor and freezes terminal
# Need to figure a way to go around that.
try:
print('Running {}:\n'.format(cmd))
p1 = subprocess.Popen(
cmd,
errors="strict",
shell=shell,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True)
output, error = p1.communicate()
print('{}\n'.format(output))
found_exception = False
if p1.returncode != 0:
print('stderr: {}\n'.format(error))
found_exception = True
except Exception as e:
found_exception = True
pprint.pprint(e)
finally:
if found_exception and halt_on_exception:
exit(1)
if not found_exception:
return SUCCESS, output
return FAILURE, output
def readGitReview(gitRoot):
result = {}
config = configparser.ConfigParser()
git_review_path = os.path.join(gitRoot, '.gitreview')
if not os.path.exists(git_review_path):
return None
config.read(os.path.join(gitRoot, '.gitreview'))
result['host'] = config['gerrit']['host']
result['port'] = int(config['gerrit']['port'])
result['project'] = config['gerrit']['project']
result['branch'] = config['gerrit'].get('defaultbranch', 'master')
return result
def main():
parser = argparse.ArgumentParser(description='Tool to sync a Gerrit topic(s)',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
epilog='''Use %(prog)s subcommand --help to get help for all of parameters''')
parser.add_argument('--verbose', '-v', action='count', default=0, help='Verbosity level')
subparsers = parser.add_subparsers(title='Repository type control Commands',
help='...')
# TODO GIT
repo_parser = subparsers.add_parser('git',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
help='Command for handling a git managed project... not supported yet')
repo_parser.set_defaults(handle=handleList)
# REPO
repo_parser = subparsers.add_parser('repo',
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
help='Command for handling a repo managed project')
repo_parser.add_argument('--topic', '-t',
action='append',
help='Gerrit topic... can be specified more than once',
required=True)
repo_parser.add_argument('--repo-root-dir', '-rr',
help='Path to repo root dir',
default=os.getenv('MY_REPO_ROOT_DIR', os.getcwd()),
required=False)
repo_parser.add_argument('--manifest', '-m',
help='File name of the manifest file (not path). Otherwise use the manifest selected by the last "repo init"',
default=None,
required=False)
repo_parser.add_argument('--download-strategy', '-ds',
help='Strategy to download the patch: Pull, Cherry Pick, Branch, Checkout',
choices=['Pull', 'Cherry Pick', 'Branch', 'Checkout'],
default='Cherry Pick',
required=False)
repo_parser.add_argument('--status', '-s',
action='append',
help='Status of the review... can be specified more than once',
choices=['open', 'merged', 'abandoned'],
default=['open'],
required=False)
repo_parser.add_argument('--merge-fixer', '-mf',
help='Script to be run to attempt auto merge fixing, e.g. pick_both_merge_fixer.py',
required=False)
repo_parser.add_argument('--avoid-re-download', '-ard',
action='store_true',
help='Avoid re-downloading a commit if it already exists in the git repo.',
default=False,
required=False)
repo_parser.add_argument('--dry-run',
action='store_true',
help='''Simulate, but don't sync''',
default=False,
required=False)
repo_parser.set_defaults(handle=handleRepo)
args = parser.parse_args()
if hasattr(args, 'handle'):
rc = args.handle(args)
if not rc:
return 1
else:
parser.print_help()
return 1
if __name__ == '__main__':
exit_code = main()
sys.exit(exit_code)