diff --git a/tools/repo_pull/repo_review.py b/tools/repo_pull/repo_review.py new file mode 100755 index 000000000..9a404c818 --- /dev/null +++ b/tools/repo_pull/repo_review.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2018 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""A command line utility to post review votes to multiple CLs on Gerrit.""" + +from __future__ import print_function + +import argparse +import collections +import itertools +import json +import multiprocessing +import os +import re +import sys +import xml.dom.minidom + +try: + from urllib.parse import urlencode # PY3 +except ImportError: + from urllib import urlencode # PY2 + +try: + from urllib.request import ( + HTTPBasicAuthHandler, Request, build_opener) # PY3 +except ImportError: + from urllib2 import HTTPBasicAuthHandler, Request, build_opener # PY2 + + +def load_auth(cookie_file_path): + """Load username and password from .gitcookies and return an + HTTPBasicAuthHandler.""" + auth_handler = HTTPBasicAuthHandler() + with open(cookie_file_path, 'r') as cookie_file: + for lineno, line in enumerate(cookie_file, start=1): + if line.startswith('#HttpOnly_'): + line = line[len('#HttpOnly_'):] + if not line or line[0] == '#': + continue + row = line.split('\t') + if len(row) != 7: + continue + domain = row[0] + cookie = row[6] + sep = cookie.find('=') + if sep == -1: + continue + username = cookie[0:sep] + password = cookie[sep + 1:] + auth_handler.add_password(domain, domain, username, password) + return auth_handler + + +def _decode_xssi_json(data): + """Trim XSSI protector and decode JSON objects.""" + # Trim cross site script inclusion (XSSI) protector + data = data.decode('utf-8')[4:] + # Parse JSON objects + return json.loads(data) + + +def query_change_lists(gerrit, query_string, gitcookies, limits): + """Query change lists.""" + data = [ + ('q', query_string), + ('o', 'CURRENT_REVISION'), + ('o', 'CURRENT_COMMIT'), + ('n', str(limits)), + ] + url = gerrit + '/a/changes/?' + urlencode(data) + + auth_handler = load_auth(gitcookies) + opener = build_opener(auth_handler) + + response_file = opener.open(url) + try: + return _decode_xssi_json(response_file.read()) + finally: + response_file.close() + + +def set_review(gerrit, gitcookies, change_id, labels, message): + """Set review votes to a change list.""" + + url = '{}/a/changes/{}/revisions/current/review'.format(gerrit, change_id) + + auth_handler = load_auth(gitcookies) + opener = build_opener(auth_handler) + + data = {} + if labels: + data['labels'] = labels + if message: + data['message'] = message + data = json.dumps(data).encode('utf-8') + + headers = { + 'Content-Type': 'application/json; charset=UTF-8', + } + + request = Request(url, data, headers) + response_file = opener.open(request) + try: + res_code = response_file.getcode() + res_json = _decode_xssi_json(response_file.read()) + return (res_code, res_json) + finally: + response_file.close() + + +def _get_change_lists_from_args(args): + """Query the change lists by args.""" + return query_change_lists(args.gerrit, args.query, args.gitcookies, + args.limits) + + +def _get_labels_from_args(args): + """Collect and check labels from args.""" + if not args.label: + print('error: --label must be specified', file=sys.stderr) + sys.exit(1) + labels = {} + for (name, value) in args.label: + try: + labels[name] = int(value) + except ValueError: + print('error: Label {} takes integer, but {} is specified' + .format(name, value), file=sys.stderr) + return labels + + +def _print_change_lists(change_lists, file=sys.stdout): + """Print matching change lists for each projects.""" + change_lists = sorted( + change_lists, key=lambda change: (change['project'], change['_number'])) + + prev_project = None + print('Change Lists:', file=file) + for change in change_lists: + project = change['project'] + if project != prev_project: + print(' ', project, file=file) + prev_project = project + + change_id = change['change_id'] + revision_sha1 = change['current_revision'] + revision = change['revisions'][revision_sha1] + subject = revision['commit']['subject'] + print(' ', change_id, '--', subject, file=file) + + +def _confirm(question): + """Confirm before proceeding.""" + try: + if input(question + ' [yn] ').lower() not in {'y', 'yes'}: + print('Cancelled', file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + print('Cancelled', file=sys.stderr) + sys.exit(1) + + +def _parse_args(): + """Parse command line options.""" + parser = argparse.ArgumentParser() + + parser.add_argument('query', help='Change list query string') + parser.add_argument('-g', '--gerrit', required=True, + help='Gerrit review URL') + + parser.add_argument('--gitcookies', + default=os.path.expanduser('~/.gitcookies'), + help='Gerrit cookie file') + parser.add_argument('--limits', default=1000, + help='Max number of change lists') + + parser.add_argument('-l', '--label', nargs=2, action='append', + help='Labels to be added') + parser.add_argument('-m', '--message', help='Review message') + + return parser.parse_args() + + +_SEP_SPLIT = '=' * 79 +_SEP = '-' * 79 + + +def main(): + """Set review labels to selected change lists""" + + args = _parse_args() + + # Convert label arguments + labels = _get_labels_from_args(args) + + # Retrieve change lists + change_lists = _get_change_lists_from_args(args) + if not change_lists: + print('error: No matching change lists.', file=sys.stderr) + sys.exit(1) + + # Print matching lists + _print_change_lists(change_lists, file=sys.stdout) + + # Confirm + _confirm('Do you want to continue?') + + # Post review votes + has_error = False + for change in change_lists: + try: + res_code, res_json = set_review( + args.gerrit, args.gitcookies, change['id'], labels, + args.message) + except HTTPError as e: + res_code = e.code + res_json = None + + if res_code != 200: + has_error = True + + change_id = change['change_id'] + project = change['project'] + revision_sha1 = change['current_revision'] + revision = change['revisions'][revision_sha1] + subject = revision['commit']['subject'] + + print(_SEP_SPLIT, file=sys.stderr) + print('Project:', project, file=sys.stderr) + print('Change-Id:', change_id, file=sys.stderr) + print('Subject:', subject, file=sys.stderr) + print('HTTP status code:', res_code, file=sys.stderr) + if res_json: + print(_SEP, file=sys.stderr) + json.dump(res_json, sys.stderr, indent=4, + separators=(', ', ': ')) + print(file=sys.stderr) + print(_SEP_SPLIT, file=sys.stderr) + + if has_error: + sys.exit(1) + + +if __name__ == '__main__': + main()