diff --git a/llvm/utils/llvm-patch b/llvm/utils/llvm-patch new file mode 100755 --- /dev/null +++ b/llvm/utils/llvm-patch @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 + +""" +This script uses the Phabricator API to manage patches. It is currently +work-in-progress, please double-check the results before pushing changes. + +It requires a valid ~/.arcrc file with your API token, e.g. + +{ + "hosts": { + "https://reviews.llvm.org/api/": { + "token": "cli-thisismyapikey" + } + } +} + +To get your API token, visit https://reviews.llvm.org/conduit/login/. + +TODO: + * Support uploading patches. + * Better error handling. + * Python2 support? + * Use a better way to find raw diff IDs instead of using querydiffs. + * Support applying a stack of patches. +""" + +import argparse +import json +import os.path +import os +import subprocess +import sys +import tempfile +import re +import urllib.parse +import urllib.request +import logging +import shlex + + +def log_error_and_exit(msg): + logging.error(msg) + sys.exit(1) + + +def get_api_token(): + """Get the Phabricator API token for reviews.llvm.org from ~/.arcrc.""" + + try: + with open(os.path.expanduser(os.path.join('~', '.arcrc')), 'rt') as f: + d = json.loads(f.read()) + if 'hosts' not in d: + log_error_and_exit('No hosts in ~/.arcrc found') + + d = d['hosts'] + if 'https://reviews.llvm.org/api/' not in d: + log_error_and_exit( + 'No entry for "https://reviews.llvm.org/api/"' + ' found in ~/.arcrc') + return d['https://reviews.llvm.org/api/']['token'] + except BaseException: + log_error_and_exit('~/.arcrc not found') + + +def api_request(endpoint, values): + values['api.token'] = get_api_token() + data = urllib.parse.urlencode(values) + data = data.encode('ascii') # data should be bytes + req = urllib.request.Request( + 'https://reviews.llvm.org/api/' + endpoint, data) + with urllib.request.urlopen(req) as response: + data = json.loads(response.read()) + result = data.get('result', '') + if len(result) == 0: + log_error_and_exit( + 'Missing or empty response, error info : {}'.format( + data['error_info'])) + return result + + +def should_strip_line(s): + return s.startswith('Summary:') or s.startswith( + 'Subscribers:') or s.startswith('Tags:') + + +def apply_patch(args): + """ + Fetches the latest diff for a given differential ID and applies it to the + current working directory. + """ + revision_id = int(args.ID) + + # Make sure there are no uncommitted changes. + ret = subprocess.call(['git', 'diff-index', '--quiet', 'HEAD']) + if ret != 0: + log_error_and_exit( + 'There are uncommitted changes in the repository!\n' + 'Can only apply a patch to a clean working directory.') + + # Get list of diffs for the requested revision. + logging.info('Fetching information for D{}.'.format(revision_id)) + revision_query = api_request('differential.query', { + 'ids[0]': revision_id + }) + if len(revision_query) != 1: + log_error_and_exit( + 'Expected a single result for revision, but got {} results!'.format( + len(revision_query))) + if int(revision_query[0]['id']) != revision_id: + log_error_and_exit( + 'Got revision result for unexpected ID (expected {} but got {})!'.format( + revision_id, revision_query[0]['id'])) + + diffs = revision_query[0]['diffs'] + diff_id = diffs[0] + for i in range(1, len(diffs)): + if int(diffs[i - 1]) <= int(diffs[i]): + log_error_and_exit('Unexpected order of diff ids.') + + # Fetch raw diff for latest diff id. + logging.info('Fetching diff with ID {}.'.format(diff_id)) + rawdiff = api_request('differential.getrawdiff', { + 'diffID': diff_id + }) + + # Get commit message and strip unnecessary lines (Summary:, Subscribers: + # and Tags" ). + commit_msg = api_request('differential.getcommitmessage', { + 'revision_id': revision_id}) + commit_msg = '\n'.join( + s for s in commit_msg.splitlines() if not should_strip_line(s)) + + # Write diff to file. + diff_file, path = tempfile.mkstemp(prefix='phabdiff', text=True) + with open(path, 'wt') as diff_file: + diff_file.write(rawdiff) + + logging.info('Applying diff to working directory.') + with open(path, 'rt') as diff_file: + subprocess.check_call( + ['git', 'apply', '-3', '--intent-to-add'], stdin=diff_file) + os.remove(path) + + # Commit all changed files. + subprocess.run(['git', 'commit', '-a', '--file=-'], + input=commit_msg.encode(), check=True) + + git_user_name = subprocess.run(['git', + 'config', + '--get', + 'user.name'], + check=True, + capture_output=True).stdout.decode().strip() + logging.info('Checking if git user name matches revision author') + revision_author = api_request('user.query', + {'phids[0]': revision_query[0]['authorPHID']}) + author_name = revision_author[0]['realName'] + if git_user_name != author_name: + logging.warning( + 'Git user does not match revision author ({} != {})!'.format( + git_user_name, author_name)) + + +PHAB_ID_RE = re.compile(r'^D(\d+)$') + + +def phabricator_id(s): + m = PHAB_ID_RE.match(s) + if not m: + msg = "%r is not a Phabricator patch ID (must start with D followed by an integer)" % s + raise argparse.ArgumentTypeError(msg) + return int(m.group(1)) + + +def main(): + logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.INFO) + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers( + title='subcommands') + + apply_parser = subparsers.add_parser( + 'apply', + help='Fetch the latest diff for the given differential ID from ' + 'reviews.llvm.org and apply it to the working directory.') + apply_parser.add_argument( + 'ID', + help='Differential ID, e.g. D5678', + type=phabricator_id) + apply_parser.set_defaults(func=apply_patch) + + args = parser.parse_args() + if not hasattr(args, 'func'): + log_error_and_exit('llvm-patch: A subcommand is required') + + args.func(args) + logging.warning('This script is work-in-progress. Double-check results ' + 'before pushing changes!') + + +if __name__ == "__main__": + main()